diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e1f2f57..640f08641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,13 @@ jobs: - run: pnpm build - run: pnpm check-types - run: pnpm test + - run: pnpm test:core-quickstart - run: pnpm check-boundaries - run: pnpm lint - run: npm pack --dry-run working-directory: packages/dynamic-apps-builder + - run: npm pack --dry-run + working-directory: packages/dynamic-apps-core - run: npm pack --dry-run working-directory: packages/dynamic-apps - run: pnpm test:packed diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3bf620faa..1053abfbe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: run: | set -euo pipefail npm ping - for package in @rivet-dev/dynamic-apps-builder @rivet-dev/dynamic-apps; do + for package in @rivet-dev/dynamic-apps-builder @rivet-dev/dynamic-apps-core @rivet-dev/dynamic-apps; do if npm view "$package@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then echo "$package@${{ steps.release.outputs.version }} already exists" >&2 exit 1 @@ -81,6 +81,26 @@ jobs: done echo "builder version did not become visible" >&2 exit 1 + - name: Publish core + env: + NODE_AUTH_TOKEN: "" + run: >- + npm publish + .pack/rivet-dev-dynamic-apps-core-${{ steps.release.outputs.version }}.tgz + --access public + --provenance + --tag ${{ steps.release.outputs.npm_tag }} + - name: Wait for core registry visibility + run: | + set -euo pipefail + for attempt in $(seq 1 30); do + if npm view "@rivet-dev/dynamic-apps-core@${{ steps.release.outputs.version }}" version >/dev/null 2>&1; then + exit 0 + fi + sleep 10 + done + echo "core version did not become visible" >&2 + exit 1 - name: Publish main package env: # setup-node provides a dummy token value; clear it so npm uses diff --git a/benchmarks/dynamic-apps/package.json b/benchmarks/dynamic-apps/package.json index 79718ca16..a9689247c 100644 --- a/benchmarks/dynamic-apps/package.json +++ b/benchmarks/dynamic-apps/package.json @@ -19,6 +19,7 @@ "@rivet-dev/agentos-core": "0.2.15", "@rivet-dev/agentos-toolchain": "0.2.15", "@rivet-dev/dynamic-apps": "workspace:*", + "@rivet-dev/dynamic-apps-core": "workspace:*", "hono": "^4.12.9", "rivetkit": "2.3.11" }, diff --git a/benchmarks/dynamic-apps/src/edge.ts b/benchmarks/dynamic-apps/src/edge.ts index 36506772f..cfe8888f6 100644 --- a/benchmarks/dynamic-apps/src/edge.ts +++ b/benchmarks/dynamic-apps/src/edge.ts @@ -1,12 +1,13 @@ import { randomUUID } from "node:crypto"; import { availableParallelism } from "node:os"; import { appsRouter } from "@rivet-dev/dynamic-apps"; -import { Hono } from "hono"; -import { createClient } from "rivetkit/client"; import { DynamicAppsExecutor, readExecutorConfig, -} from "../../../packages/dynamic-apps/src/executor.js"; +} from "@rivet-dev/dynamic-apps-core/internal"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { createRivetReleaseStore } from "../../../packages/dynamic-apps/src/release-store.js"; import { ACTOR_BENCHMARK_APP_ID, BENCHMARK_APP_ID, @@ -66,12 +67,12 @@ export function createBenchmarkApplication(): Hono { ...process.env, DYNAMIC_APPS_TIMING_HEADERS: "1", }); - const pooled = new DynamicAppsExecutor({ + const pooled = new DynamicAppsExecutor(createRivetReleaseStore(), { ...baseConfig, executionMode: "pooled", contextPoolSize: integerEnv("BENCH_CONTEXT_POOL_SIZE", 8, 1, 128), }); - const ephemeral = new DynamicAppsExecutor({ + const ephemeral = new DynamicAppsExecutor(createRivetReleaseStore(), { ...baseConfig, executionMode: "ephemeral", contextPoolSize: 0, diff --git a/benchmarks/dynamic-apps/src/runtime-stress.ts b/benchmarks/dynamic-apps/src/runtime-stress.ts index 720257581..157dc610a 100644 --- a/benchmarks/dynamic-apps/src/runtime-stress.ts +++ b/benchmarks/dynamic-apps/src/runtime-stress.ts @@ -7,16 +7,15 @@ import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { promisify } from "node:util"; import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; -import { DynamicActorRuntime } from "../../../packages/dynamic-apps/src/actor-runtime.js"; import { + type ActiveRelease, + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, DynamicAppsExecutor, readExecutorConfig, -} from "../../../packages/dynamic-apps/src/executor.js"; +} from "@rivet-dev/dynamic-apps-core/internal"; +import { DynamicActorRuntime } from "../../../packages/dynamic-apps/src/actor-runtime.js"; import { setDynamicAppsLogHandler } from "../../../packages/dynamic-apps/src/logging.js"; -import { - DIRECT_ENTRYPOINT, - DIRECT_RUNTIME_FORMAT, -} from "../../../packages/dynamic-apps/src/runtime.js"; const execFileAsync = promisify(execFile); @@ -70,6 +69,43 @@ class FakeStatePlane { readonly calls = { resolve: 0, manifest: 0, chunk: 0, connect: 0 }; beforeChunk?: () => Promise; + async watchActiveRelease( + appId: string, + invalidate: () => void, + ): Promise<() => void> { + this.calls.connect += 1; + const listeners = this.listeners.get(appId) ?? new Set(); + listeners.add(invalidate); + this.listeners.set(appId, listeners); + return () => { + listeners.delete(invalidate); + }; + } + + async loadActiveRelease(appId: string): Promise { + this.calls.resolve += 1; + this.calls.manifest += 1; + const state = this.#state(appId); + await this.beforeChunk?.(); + this.calls.chunk += 1; + return { + appId, + release: state.artifact.release, + artifact: { + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + hash: state.artifact.hash, + bytes: new Uint8Array(state.artifact.bytes), + byteLength: state.artifact.bytes.byteLength, + usesRivetKit: false, + }, + regions: ["local"], + scaling: { minReplicas: 0, maxReplicas: 1, targetConcurrency: 32 }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; + } + readonly client = { agentOSAppsApp: { getOrCreate: (key: string[]) => this.#handle(key[0] ?? ""), @@ -381,13 +417,13 @@ async function multiAppStress( plane.set(`stress-app-${index}`, artifact); } const executor = new DynamicAppsExecutor( + plane, executorConfig({ appEntries: artifacts.length, concurrency, poolMaxTotal, poolSize, }), - plane.client as never, ); const rss = rssSampler(); const startedAt = performance.now(); @@ -440,8 +476,8 @@ async function payloadBurstStress( const plane = new FakeStatePlane(); plane.set("payload", artifact); const executor = new DynamicAppsExecutor( + plane, executorConfig({ appEntries: 2, concurrency, poolMaxTotal, poolSize }), - plane.client as never, ); const latencies: number[] = []; const rss = rssSampler(); @@ -492,8 +528,8 @@ async function invalidationStress( const plane = new FakeStatePlane(); plane.set("invalidate", before); const executor = new DynamicAppsExecutor( + plane, executorConfig({ appEntries: 2, concurrency, poolMaxTotal, poolSize }), - plane.client as never, ); let activated = false; let oldResponsesAfterActivation = 0; @@ -540,8 +576,8 @@ async function coldFanoutStress( { length: count }, () => new DynamicAppsExecutor( + plane, executorConfig({ appEntries: 1, concurrency: 1, poolMaxTotal: 0 }), - plane.client as never, ), ); const startedAt = performance.now(); @@ -574,17 +610,14 @@ async function admissionStress( plane.set("admission", artifact); const active = Math.min(4, concurrency); const queued = Math.min(8, Math.max(0, concurrency - active)); - const executor = new DynamicAppsExecutor( - { - ...executorConfig({ - appEntries: 1, - concurrency: active, - poolMaxTotal: 2, - }), - executionQueueSize: queued, - }, - plane.client as never, - ); + const executor = new DynamicAppsExecutor(plane, { + ...executorConfig({ + appEntries: 1, + concurrency: active, + poolMaxTotal: 2, + }), + executionQueueSize: queued, + }); let release = () => {}; const gate = new Promise((resolve) => { release = resolve; @@ -849,7 +882,7 @@ async function directStallStress( }), executionTimeoutMs: 50, }; - const executor = new DynamicAppsExecutor(config, plane.client as never); + const executor = new DynamicAppsExecutor(plane, config); const startedAt = performance.now(); try { await runConcurrent(requests, requests, async (index) => { @@ -875,13 +908,13 @@ async function logFloodStress(artifact: Artifact): Promise { const plane = new FakeStatePlane(); plane.set("log-flood", artifact); const executor = new DynamicAppsExecutor( + plane, executorConfig({ appEntries: 1, concurrency: 1, poolMaxTotal: 1, poolSize: 1, }), - plane.client as never, ); let delivered = 0; try { @@ -1103,13 +1136,13 @@ async function directShutdownStress( await gate; }; const executor = new DynamicAppsExecutor( + plane, executorConfig({ appEntries: 1, concurrency: requests, poolMaxTotal: 8, poolSize: 8, }), - plane.client as never, ); const outcomes = Array.from({ length: requests }, (_, index) => executor diff --git a/docs/content/docs/custom-storage.mdx b/docs/content/docs/custom-storage.mdx new file mode 100644 index 000000000..347526e13 --- /dev/null +++ b/docs/content/docs/custom-storage.mdx @@ -0,0 +1,46 @@ +--- +title: "Custom Storage" +description: "Implement durable release publication, loading, and invalidation for Dynamic Apps Core." +skill: true +--- + +Use `@rivet-dev/dynamic-apps-core` when your host owns release persistence and +notifications: + +```ts +import { createDynamicApps } from "@rivet-dev/dynamic-apps-core"; + +const dynamicApps = createDynamicApps({ + async publishRelease(input) { + // Persist input.artifact.bytes, then atomically make this release active. + await store.putArtifact(input.buildId, input.artifact.bytes); + await store.activate(input.appId, input.buildId, input); + return { appId: input.appId, release: input.buildId }; + }, + async loadActiveRelease(appId) { + // Return one coherent metadata + complete-artifact snapshot. + return store.loadActive(appId); + }, + async watchActiveRelease(appId, invalidate) { + // Resolve only after the subscription is live. + return store.subscribe(appId, invalidate); + }, +}); +``` + +## Hook guarantees + +- **Publish a release:** make the verified artifact durable before atomically + activating it. Do not resolve until a load can observe the new release. A + failed publish must leave the previous release active. +- **Load the active release:** return coherent metadata and complete bytes in + one logical operation. Core copies and independently verifies the bytes. +- **Watch for updates:** subscribe before resolving, invalidate after every + activation, and invalidate after a disconnect that may have missed events. + Duplicate invalidations are safe. + +The watcher is required. A no-op watcher is safe only when an app ID cannot +change for the entire lifetime of every serving process. + +Call `await dynamicApps.dispose()` during shutdown to release subscriptions, +build resources, cached runtimes, and agentOS contexts. diff --git a/docs/content/docs/deploy.mdx b/docs/content/docs/deploy.mdx index 6da9de963..30905a1a1 100644 --- a/docs/content/docs/deploy.mdx +++ b/docs/content/docs/deploy.mdx @@ -62,6 +62,11 @@ A failed build or incomplete artifact write never replaces the active release. A successful call returns only after the immutable artifact is persisted and activated; it does not mean a request-serving replica was warmed. +With core, this is the `publishRelease` guarantee: make the complete artifact +durable first, atomically replace the active release second, and resolve only +when `loadActiveRelease` can read it. The default adapter provides those +semantics through its per-app Rivet actor. + `appId` must contain 1–63 lowercase letters, numbers, or hyphens. Pass exactly one of `source` or `files`. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 788042c5c..3bfe0a0cb 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -5,8 +5,9 @@ skill: true --- Dynamic Apps builds user-generated HTTP applications in a sandboxed deployment -VM, stores immutable releases in a per-app Rivet actor, and serves ordinary HTTP -through agentOS in your own server process. +VM and serves ordinary HTTP through agentOS in your own server process. The +default `@rivet-dev/dynamic-apps` package stores releases in a per-app Rivet +actor; `@rivet-dev/dynamic-apps-core` lets you supply another store. Dynamic Apps is in preview and its API is subject to change. @@ -20,12 +21,16 @@ Hono server, its authentication, and the URL on which applications are mounted. Deployment and request serving are deliberately separate: ```text -deployApp -> per-app state actor -> AgentOS build VM -> immutable AOSP release +deployApp -> Dynamic Apps Core -> agentOS build VM -> publish release + -> Rivet actor (default adapter) -first HTTP request -> state actor -> verified artifact -> cached agentOS VM -cache-hit HTTP request -> headless JavaScript evaluation -> response (zero actor calls) +first HTTP request -> watch + load active release -> verified artifact -> cached agentOS VM +warm HTTP request -> headless JavaScript evaluation -> response (zero storage/actor calls) ``` +The Rivet actor is the default storage and control plane, not a serving hop for +warm requests. + `appsRouter` executes each request in a clean JavaScript context. The default mode keeps a small bounded pool of retained agentOS contexts, resetting and reinitializing each one after use. Ephemeral mode asks agentOS for a fresh diff --git a/docs/content/docs/quickstart-core.mdx b/docs/content/docs/quickstart-core.mdx new file mode 100644 index 000000000..c6e670dfc --- /dev/null +++ b/docs/content/docs/quickstart-core.mdx @@ -0,0 +1,88 @@ +--- +title: "Core Quick Start" +description: "Build and serve a Dynamic App with a development-only in-memory release store." +skill: true +--- + +[View the complete Core Quick Start example on GitHub](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-core-quickstart). + +Use Core when your application owns release storage and deployment lifecycle. +Use the standard [Dynamic Apps Quick Start](/dynamic-apps/docs/quickstart) when +you want Rivet to provide those pieces. + +## Choosing between Core and the Rivet-backed package + +| | Core | Rivet-backed package | +|-|---|---| +| Package | `@rivet-dev/dynamic-apps-core` | `@rivet-dev/dynamic-apps` | +| Build artifact storage | Provide upload and download handlers | Stored automatically | +| Cache invalidation after updates | Manual notification with `watchActiveRelease` | Handled automatically | +| Rivet namespace per app | Bring your own integration | Created and connected automatically | +| Regions and scaling | Managed by your host | Managed through Rivet deployment options | +| Lifecycle | Explicit `dispose()` | Managed by the package | +| Best for | Custom infrastructure | Batteries included and scalable | + + +This in-memory store is **development-only**. It loses releases on restart and +cannot invalidate another process. Use durable storage and cross-process +notifications in production. + + + + + + +Use Node.js 22 or newer: + +```sh +npm add @rivet-dev/dynamic-apps-core @hono/node-server hono +npm add --save-dev tsx +npm pkg set type=module +``` + + + + + +The example keeps a `Map` of active releases and a `Map` of update listeners. +Its hooks atomically publish a complete copied artifact, load a copied active +release, and register an update watcher that returns an unsubscribe function. +It then mounts the router, deploys a complete generated two-file app, and +disposes the instance during shutdown. + + + + + + + +Pass the listening host on the command line: + +```sh +node --import tsx src/server.ts --host 0.0.0.0 +``` + + + + + +```sh +curl http://localhost:3000/apps/hello/ +# Hello from Dynamic Apps Core! +``` + + + + + +The request lifecycle is compact: + +```text +agentOS build -> publishRelease +first request -> watchActiveRelease + loadActiveRelease +warm request -> cached agentOS VM (zero hooks) +``` + +Continue with [Custom Storage](/dynamic-apps/docs/custom-storage) for durable, +multi-process hooks. Use the ordinary [Quick Start](/dynamic-apps/docs/quickstart) +for the batteries-included Rivet actor-backed package. diff --git a/docs/content/docs/routing.mdx b/docs/content/docs/routing.mdx index edb805e37..ecb70598e 100644 --- a/docs/content/docs/routing.mdx +++ b/docs/content/docs/routing.mdx @@ -30,6 +30,7 @@ This serves `/:appId/*` relative to the mount. For example, `/api/items?q=1`. A bare `/apps/example` request redirects to `/apps/example/`. -There is no `createAppsRouter` or router-specific client option. `appsRouter` -creates and reuses its private control client lazily; cache-hit HTTP requests do -not call that actor. +There is no `createAppsRouter` or router-specific client option in the default +adapter. `appsRouter` creates and reuses its private control client lazily. Once +an active artifact is prepared, warm requests use only the local runtime and +isolate caches: they do not call release storage or a Rivet actor. diff --git a/docs/sidebar.json b/docs/sidebar.json index c88bc7051..ec0074867 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -9,9 +9,14 @@ "icon": "faSquareInfo" }, { - "title": "Quickstart", + "title": "Quick Start", "href": "/dynamic-apps/docs/quickstart", "icon": "faForwardFast" + }, + { + "title": "Core Quick Start", + "href": "/dynamic-apps/docs/quickstart-core", + "icon": "faForwardFast" } ] }, @@ -25,6 +30,10 @@ { "title": "Routing", "href": "/dynamic-apps/docs/routing" + }, + { + "title": "Custom Storage", + "href": "/dynamic-apps/docs/custom-storage" } ] }, diff --git a/examples/apps-core-quickstart/package.json b/examples/apps-core-quickstart/package.json new file mode 100644 index 000000000..59f5a3667 --- /dev/null +++ b/examples/apps-core-quickstart/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rivet-dev/dynamic-apps-example-core-quickstart", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "node --import tsx src/server.ts --host 0.0.0.0", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^2.0.11", + "@rivet-dev/dynamic-apps-core": "workspace:*", + "hono": "^4.12.9" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/examples/apps-core-quickstart/src/server.ts b/examples/apps-core-quickstart/src/server.ts new file mode 100644 index 000000000..a801a78fa --- /dev/null +++ b/examples/apps-core-quickstart/src/server.ts @@ -0,0 +1,98 @@ +import { serve } from "@hono/node-server"; +import { + type ActiveRelease, + createDynamicApps, +} from "@rivet-dev/dynamic-apps-core"; +import { Hono } from "hono"; + +// Development only: releases disappear on restart and updates cannot reach +// another process. Use durable storage and cross-process invalidation in production. +const active = new Map(); +const listeners = new Map void>>(); + +const dynamicApps = createDynamicApps({ + async publishRelease(input) { + const release: ActiveRelease = { + appId: input.appId, + release: input.buildId, + artifact: { + ...input.artifact, + bytes: new Uint8Array(input.artifact.bytes), + }, + regions: input.regions ?? ["local"], + scaling: { + minReplicas: input.scaling?.minReplicas ?? 0, + maxReplicas: input.scaling?.maxReplicas ?? 1, + targetConcurrency: input.scaling?.targetConcurrency ?? 8, + }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; + // The complete artifact is stored before this single active-map update. + active.set(input.appId, release); + for (const invalidate of listeners.get(input.appId) ?? []) invalidate(); + return { appId: input.appId, release: release.release }; + }, + async loadActiveRelease(appId) { + const release = active.get(appId); + return release + ? { + ...release, + regions: [...release.regions], + scaling: { ...release.scaling }, + artifact: { + ...release.artifact, + bytes: new Uint8Array(release.artifact.bytes), + }, + } + : undefined; + }, + async watchActiveRelease(appId, invalidate) { + const appListeners = listeners.get(appId) ?? new Set(); + appListeners.add(invalidate); + listeners.set(appId, appListeners); + return () => { + appListeners.delete(invalidate); + if (appListeners.size === 0) listeners.delete(appId); + }; + }, +}); + +const app = new Hono(); +app.route("/apps", dynamicApps.appsRouter); + +let server: ReturnType | undefined; +let shuttingDown = false; +const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + await dynamicApps.dispose(); + server?.close(); +}; +process.once("SIGINT", () => void shutdown()); +process.once("SIGTERM", () => void shutdown()); + +await dynamicApps.deployApp({ + appId: "hello", + files: { + "package.json": JSON.stringify({ + private: true, + type: "module", + main: "index.js", + }), + "index.js": ` + export default { + fetch() { + return new Response("Hello from Dynamic Apps Core!"); + }, + }; + `, + }, +}); + +const hostIndex = process.argv.indexOf("--host"); +const hostname = hostIndex >= 0 ? process.argv[hostIndex + 1] : "127.0.0.1"; +if (!hostname) throw new Error("--host requires a value"); +const port = Number(process.env.PORT ?? 3000); +server = serve({ fetch: app.fetch, hostname, port }); +console.log(`Dynamic Apps Core listening on http://${hostname}:${port}`); diff --git a/examples/apps-core-quickstart/tsconfig.json b/examples/apps-core-quickstart/tsconfig.json new file mode 100644 index 000000000..dbe86e31a --- /dev/null +++ b/examples/apps-core-quickstart/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/examples/apps-hello-world/fixtures/app/src/index.ts b/examples/apps-hello-world/fixtures/app/src/index.ts index 625e3a4a5..428ee0ab1 100644 --- a/examples/apps-hello-world/fixtures/app/src/index.ts +++ b/examples/apps-hello-world/fixtures/app/src/index.ts @@ -14,7 +14,7 @@ app.get("/", (c) => {

Hello from Dynamic Apps

-

This HTML is served by an HTTP app running inside a V8 isolate.

+

This HTML is served by an HTTP app running inside agentOS.

Call the JSON API

diff --git a/package.json b/package.json index 49f47e532..c12e368a8 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,14 @@ "node": ">=22.0.0" }, "scripts": { - "build": "pnpm --filter @rivet-dev/dynamic-apps-builder build && pnpm --filter @rivet-dev/dynamic-apps build", + "build": "pnpm --filter @rivet-dev/dynamic-apps-builder build && pnpm --filter @rivet-dev/dynamic-apps-core build && pnpm --filter @rivet-dev/dynamic-apps build", "check-types": "pnpm -r --if-present check-types", - "test": "pnpm --filter @rivet-dev/dynamic-apps-builder test && pnpm --filter @rivet-dev/dynamic-apps test && pnpm --filter @rivet-dev/dynamic-apps-benchmarks test", + "test": "pnpm --filter @rivet-dev/dynamic-apps-builder test && pnpm --filter @rivet-dev/dynamic-apps-core test && pnpm --filter @rivet-dev/dynamic-apps test && pnpm --filter @rivet-dev/dynamic-apps-benchmarks test", "lint": "pnpm biome check .", "fmt": "pnpm biome check --write --diagnostic-level=error .", "check-boundaries": "node scripts/check-boundaries.mjs", - "test:packed": "node scripts/test-packed.mjs" + "test:packed": "node scripts/test-packed.mjs", + "test:core-quickstart": "node scripts/test-core-quickstart.mjs" }, "devDependencies": { "@biomejs/biome": "2.4.10", diff --git a/packages/dynamic-apps-builder/package.json b/packages/dynamic-apps-builder/package.json index a3d3fec1a..f8c4e6525 100644 --- a/packages/dynamic-apps-builder/package.json +++ b/packages/dynamic-apps-builder/package.json @@ -1,6 +1,6 @@ { "name": "@rivet-dev/dynamic-apps-builder", - "version": "0.12.0-rc.1", + "version": "0.12.0-rc.2", "type": "module", "license": "Apache-2.0", "description": "Platform-owned Dynamic Apps release bundler", diff --git a/packages/dynamic-apps-builder/test/builder.test.ts b/packages/dynamic-apps-builder/test/builder.test.ts index 61789364e..f472f601c 100644 --- a/packages/dynamic-apps-builder/test/builder.test.ts +++ b/packages/dynamic-apps-builder/test/builder.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from "vitest"; import { actorRunnerSource, directRunnerSource, -} from "../../dynamic-apps/src/runtime.js"; +} from "../../dynamic-apps-core/src/runtime.js"; const execFileAsync = promisify(execFile); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); diff --git a/packages/dynamic-apps-core/README.md b/packages/dynamic-apps-core/README.md new file mode 100644 index 000000000..2c80edd90 --- /dev/null +++ b/packages/dynamic-apps-core/README.md @@ -0,0 +1,73 @@ +# `@rivet-dev/dynamic-apps-core` + +Build and serve isolated Dynamic Apps with storage you control. Core needs three +release lifecycle hooks: **publish a release**, **load the active release**, and +**watch for updates**. + +```ts +import { + createDynamicApps, + type ActiveRelease, +} from "@rivet-dev/dynamic-apps-core"; + +const active = new Map(); +const listeners = new Map void>>(); + +const dynamicApps = createDynamicApps({ + async publishRelease(input) { + const release: ActiveRelease = { + appId: input.appId, + release: input.buildId, + artifact: { + ...input.artifact, + bytes: new Uint8Array(input.artifact.bytes), + }, + regions: input.regions ?? ["local"], + scaling: { + minReplicas: input.scaling?.minReplicas ?? 0, + maxReplicas: input.scaling?.maxReplicas ?? 1, + targetConcurrency: input.scaling?.targetConcurrency ?? 8, + }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; + active.set(input.appId, release); + for (const invalidate of listeners.get(input.appId) ?? []) invalidate(); + return { appId: input.appId, release: release.release }; + }, + async loadActiveRelease(appId) { + const release = active.get(appId); + return release && { + ...release, + artifact: { + ...release.artifact, + bytes: new Uint8Array(release.artifact.bytes), + }, + }; + }, + async watchActiveRelease(appId, invalidate) { + const appListeners = listeners.get(appId) ?? new Set(); + appListeners.add(invalidate); + listeners.set(appId, appListeners); + return () => appListeners.delete(invalidate); + }, +}); +``` + +`publishRelease` must durably store the complete artifact before atomically +activating it. `watchActiveRelease` must resolve only after its subscription is +live and invalidate after updates or a connection that may have missed them. +A no-op watcher is unsafe when an app ID can change while another serving +process is running. + +Each factory instance owns its builder configuration, router, release +subscriptions, runtime cache, agentOS context pool, and cleanup timer. Await +`dynamicApps.dispose()` during shutdown. + +The default `pooled` mode leases a bounded agentOS context and resets it after +each request. `ephemeral` mode creates a fresh context per request while reusing +the immutable release VM. Use container isolation between trust domains. + +The in-memory example is development-only: it loses releases on restart and +cannot invalidate another process. Use durable object storage plus a reliable +cross-process invalidation channel in production. diff --git a/packages/dynamic-apps-core/package.json b/packages/dynamic-apps-core/package.json new file mode 100644 index 000000000..38d425a7e --- /dev/null +++ b/packages/dynamic-apps-core/package.json @@ -0,0 +1,48 @@ +{ + "name": "@rivet-dev/dynamic-apps-core", + "version": "0.12.0-rc.2", + "description": "Build and serve isolated dynamic applications with user-defined release storage.", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/dynamic-apps.git", + "directory": "packages/dynamic-apps-core" + }, + "type": "module", + "sideEffects": false, + "files": ["dist", "package.json"], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./internal": { + "import": { + "types": "./dist/internal.d.ts", + "default": "./dist/internal.js" + } + } + }, + "engines": { "node": ">=22.0.0" }, + "scripts": { + "build": "tsup src/index.ts src/internal.ts --format esm --dts --sourcemap --clean --external @rivet-dev/agentos-core --external @rivet-dev/agentos-toolchain --external @rivet-dev/dynamic-apps-builder --external @agentos-software/sh --external @agentos-software/tar", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@agentos-software/sh": "0.2.15", + "@agentos-software/tar": "0.3.5", + "@rivet-dev/agentos-core": "0.2.15", + "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/dynamic-apps-builder": "workspace:0.12.0-rc.2", + "hono": "^4.7.0" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsup": "^8.4.0", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/packages/dynamic-apps-core/src/artifact.ts b/packages/dynamic-apps-core/src/artifact.ts new file mode 100644 index 000000000..f09d8ac74 --- /dev/null +++ b/packages/dynamic-apps-core/src/artifact.ts @@ -0,0 +1,47 @@ +import { DynamicAppsError } from "./errors.js"; + +export function extractAospkgTextFile( + bytes: Uint8Array, + target: string, +): string { + const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + buffer.byteLength < 16 || + buffer[0] !== 137 || + buffer.subarray(1, 4).toString("ascii") !== "AOS" + ) { + throw new DynamicAppsError( + "agentos_apps_artifact_format_invalid", + "application artifact is not an AOSP package", + ); + } + let offset = 16 + buffer.readUInt32LE(8) + buffer.readUInt32LE(12); + while (offset + 512 <= buffer.byteLength) { + const header = buffer.subarray(offset, offset + 512); + if (header.every((value) => value === 0)) break; + const name = tarString(header.subarray(0, 100)); + const prefix = tarString(header.subarray(345, 500)); + const path = `${prefix ? `${prefix}/` : ""}${name}`.replace(/^\.\//, ""); + const sizeText = tarString(header.subarray(124, 136)).trim(); + const size = Number.parseInt(sizeText || "0", 8); + if (!Number.isSafeInteger(size) || size < 0) break; + const dataOffset = offset + 512; + const next = dataOffset + Math.ceil(size / 512) * 512; + if (next > buffer.byteLength) break; + if (path === target || path === `/${target}`) { + return new TextDecoder("utf-8", { fatal: true }).decode( + buffer.subarray(dataOffset, dataOffset + size), + ); + } + offset = next; + } + throw new DynamicAppsError( + "agentos_apps_artifact_entry_missing", + `application artifact is missing ${target}`, + ); +} + +function tarString(bytes: Uint8Array): string { + const end = bytes.indexOf(0); + return Buffer.from(end < 0 ? bytes : bytes.subarray(0, end)).toString("utf8"); +} diff --git a/packages/dynamic-apps-core/src/build.ts b/packages/dynamic-apps-core/src/build.ts new file mode 100644 index 000000000..f24d7f4dd --- /dev/null +++ b/packages/dynamic-apps-core/src/build.ts @@ -0,0 +1,792 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sh from "@agentos-software/sh"; +import tar from "@agentos-software/tar"; +import { + AgentOs, + type AgentOsOptions, + createHostDirBackend, +} from "@rivet-dev/agentos-core"; +import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; +import appsBuilder, { + appBundleManifestVersion, + appsBuilderVersion, +} from "@rivet-dev/dynamic-apps-builder"; +import { extractAospkgTextFile } from "./artifact.js"; +import { DynamicAppsError } from "./errors.js"; +import { + ACTOR_BUNDLE_PATH, + actorRunnerSource, + canonicalDeploymentHash, + DIRECT_BUNDLE_PATH, + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, + directRunnerSource, + normalizeAppPath, +} from "./runtime.js"; +import { validateAppId } from "./source.js"; +import type { + BuildAppReleaseInput, + BuildArtifactCache, + BuildConfig, + BuiltAppRelease, + DynamicAppsLogger, +} from "./types.js"; + +export const DEFAULT_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +export const DEFAULT_MAX_FILES = 2_000; +export const DEFAULT_MAX_DEPENDENCIES = 256; +export const DEFAULT_BUILD_TIMEOUT_MS = 15 * 60_000; +export const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; +export const DEFAULT_MAX_BUILD_OUTPUT_BYTES = 2 * 1024 * 1024; +export const DEFAULT_MAX_BUILD_ARTIFACT_BYTES = 64 * 1024 * 1024; +export const DEFAULT_MAX_BUILD_ARTIFACT_FILES = 4_096; +export const DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES = 32 * 1024 * 1024; +export const DEFAULT_MAX_BUILD_FILESYSTEM_BYTES = 2 * 1024 * 1024 * 1024; + +const DEFAULT_BUILD_CONFIG: BuildConfig = { + maxSourceBytes: DEFAULT_MAX_SOURCE_BYTES, + maxFiles: DEFAULT_MAX_FILES, + maxDependencies: DEFAULT_MAX_DEPENDENCIES, + buildTimeoutMs: DEFAULT_BUILD_TIMEOUT_MS, + maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, + maxBuildOutputBytes: DEFAULT_MAX_BUILD_OUTPUT_BYTES, + maxBuildArtifactBytes: DEFAULT_MAX_BUILD_ARTIFACT_BYTES, + maxBuildArtifactFiles: DEFAULT_MAX_BUILD_ARTIFACT_FILES, + maxBuildArtifactFileBytes: DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES, + maxBuildFilesystemBytes: DEFAULT_MAX_BUILD_FILESYSTEM_BYTES, +}; + +const NOOP_LOGGER: DynamicAppsLogger = { + info() {}, + error() {}, +}; + +export interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface BuildHandle { + artifactGuestPath: string; + writeFiles( + entries: Array<{ path: string; content: string | Uint8Array }>, + ): Promise>; + execArgv( + command: string, + args: string[], + options?: { + cwd?: string; + env?: Record; + timeout?: number; + captureStdio?: boolean; + }, + ): Promise; + artifactSize(): Promise; + readArtifact(): Promise; + dispose(): Promise; +} + +export interface BuildPlan { + entrypoint: string; + build: boolean; + dependencyCount: number; + hasLockfile: boolean; + usesRivetKit: boolean; +} + +export function readBuildConfig( + overrides: Partial = {}, +): BuildConfig { + const config = { ...DEFAULT_BUILD_CONFIG, ...overrides }; + for (const key of Object.keys(DEFAULT_BUILD_CONFIG) as Array< + keyof BuildConfig + >) { + const value = config[key]; + const maximum = DEFAULT_BUILD_CONFIG[key]; + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new DynamicAppsError( + "agentos_apps_invalid_config", + `${key} must be an integer between 1 and ${maximum}`, + { name: key, maximum }, + ); + } + } + return config; +} + +function fail( + code: string, + message: string, + metadata?: Record, +): never { + throw new DynamicAppsError(code, message, metadata); +} + +export function textFile( + files: Record, + path: string, +): string | undefined { + const content = files[path]; + return content ? new TextDecoder().decode(content) : undefined; +} + +export function packageExport(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const object = value as Record; + return ( + packageExport(object["."]) ?? + packageExport(object.import) ?? + packageExport(object.default) + ); +} + +export function installPackageJson( + files: Record, + plan: BuildPlan, +): Uint8Array | undefined { + const source = textFile(files, "package.json"); + if (!source || !plan.usesRivetKit) return files["package.json"]; + const value = JSON.parse(source) as { + dependencies?: Record; + devDependencies?: Record; + }; + for (const dependencies of [value.dependencies, value.devDependencies]) { + if (dependencies) delete dependencies.rivetkit; + } + return new TextEncoder().encode(JSON.stringify(value)); +} + +export function validateDeployment( + input: BuildAppReleaseInput, + limits: Pick, +): BuildPlan { + if (!input || typeof input !== "object" || !input.files) { + fail( + "agentos_apps_invalid_files", + "deployApp files must contain the complete application tree", + ); + } + const files = Object.entries(input.files); + if (files.length === 0 || files.length > limits.maxFiles) { + fail( + "agentos_apps_file_count_limit", + `deployment must contain between 1 and ${limits.maxFiles} files`, + { observed: files.length, limit: limits.maxFiles }, + ); + } + let sourceBytes = 0; + const normalizedFiles: Record = {}; + for (const [path, content] of files) { + const normalizedPath = normalizeAppPath(path); + if (normalizedFiles[normalizedPath]) { + fail( + "agentos_apps_duplicate_file_path", + `multiple deployment paths normalize to ${normalizedPath}`, + ); + } + if (!(content instanceof Uint8Array)) { + fail( + "agentos_apps_invalid_file", + `deployment file ${path} must be a Uint8Array`, + ); + } + normalizedFiles[normalizedPath] = new Uint8Array(content); + sourceBytes += content.byteLength; + } + if (sourceBytes > limits.maxSourceBytes) { + fail( + "agentos_apps_source_limit", + `deployment source is ${sourceBytes} bytes, exceeding maxSourceBytes ${limits.maxSourceBytes}`, + { observed: sourceBytes, limit: limits.maxSourceBytes }, + ); + } + input.files = normalizedFiles; + const packageJsonSource = textFile(normalizedFiles, "package.json"); + if (!packageJsonSource) { + fail( + "agentos_apps_entrypoint_not_found", + "direct applications must contain package.json and a server entrypoint", + ); + } + let packageJson: { + dependencies?: unknown; + devDependencies?: unknown; + scripts?: { build?: unknown }; + exports?: unknown; + main?: unknown; + }; + try { + packageJson = JSON.parse(packageJsonSource); + } catch (error) { + fail( + "agentos_apps_invalid_package_json", + "package.json is not valid JSON", + { error: String(error) }, + ); + } + const dependencyMaps = [ + packageJson.dependencies, + packageJson.devDependencies, + ].filter( + (value): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value), + ); + const dependencyCount = dependencyMaps.reduce( + (count, dependencies) => count + Object.keys(dependencies).length, + 0, + ); + if (dependencyCount > limits.maxDependencies) { + fail( + "agentos_apps_dependency_limit", + `deployment has ${dependencyCount} dependencies, exceeding maxDependencies ${limits.maxDependencies}`, + { observed: dependencyCount, limit: limits.maxDependencies }, + ); + } + const usesRivetKit = dependencyMaps.some( + (dependencies) => typeof dependencies.rivetkit === "string", + ); + const build = typeof packageJson.scripts?.build === "string"; + const declared = + packageExport(packageJson.exports) ?? + (typeof packageJson.main === "string" ? packageJson.main : undefined); + if (declared) { + return { + entrypoint: normalizeAppPath(declared), + build, + dependencyCount, + hasLockfile: Boolean(normalizedFiles["package-lock.json"]), + usesRivetKit, + }; + } + for (const candidate of [ + "src/index.mjs", + "src/index.js", + "index.mjs", + "index.js", + ]) { + if (normalizedFiles[candidate]) { + return { + entrypoint: candidate, + build, + dependencyCount, + hasLockfile: Boolean(normalizedFiles["package-lock.json"]), + usesRivetKit, + }; + } + } + fail( + "agentos_apps_entrypoint_not_found", + "could not infer a direct server entrypoint", + ); +} + +export function boundedOutput(value: string, maximum: number): string { + const bytes = Buffer.from(value); + if (bytes.byteLength <= maximum) return value; + return `${bytes.subarray(0, maximum).toString("utf8")}\n[truncated at ${maximum} bytes]`; +} + +export function throwCommandFailure( + kind: "install" | "build" | "pack", + command: string, + result: ExecResult, + maxOutputBytes: number, +): never { + fail( + `agentos_apps_${kind}_failed`, + `${command} failed with exit code ${result.exitCode}`, + { + exitCode: result.exitCode, + stdout: boundedOutput(result.stdout, maxOutputBytes), + stderr: boundedOutput(result.stderr, maxOutputBytes), + }, + ); +} + +function artifactResult( + buildId: string, + bytesInput: Uint8Array, + usesRivetKit: boolean, + maxBytes: number, +): BuiltAppRelease { + if (!(bytesInput instanceof Uint8Array) || bytesInput.byteLength > maxBytes) { + fail( + "agentos_apps_build_artifact_size_limit", + `cached artifact exceeds ${maxBytes} bytes`, + ); + } + const bytes = new Uint8Array(bytesInput); + extractAospkgTextFile(bytes, DIRECT_BUNDLE_PATH); + if (usesRivetKit) extractAospkgTextFile(bytes, ACTOR_BUNDLE_PATH); + return { + buildId, + artifact: { + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + hash: createHash("sha256").update(bytes).digest("hex"), + bytes, + byteLength: bytes.byteLength, + usesRivetKit, + }, + }; +} + +export async function buildAppRelease( + input: BuildAppReleaseInput, + options: { + config?: Partial; + artifactCache?: BuildArtifactCache; + logger?: DynamicAppsLogger; + } = {}, +): Promise { + validateAppId(input.appId); + const config = readBuildConfig(options.config); + const normalizedInput = { + appId: input.appId, + files: { ...input.files }, + }; + const plan = validateDeployment(normalizedInput, config); + const buildId = canonicalDeploymentHash({ + files: normalizedInput.files, + entrypoint: plan.entrypoint, + build: plan.build, + packagingIdentity: [ + `apps-builder@${appsBuilderVersion}`, + `manifest@${appBundleManifestVersion}`, + "direct@2", + `actors@${plan.usesRivetKit ? 1 : 0}`, + "esbuild-wasm@0.27.4", + ].join(";"), + }); + const cached = await options.artifactCache?.get(buildId); + if (cached !== undefined) { + return artifactResult( + buildId, + cached, + plan.usesRivetKit, + config.maxBuildArtifactBytes, + ); + } + const artifact = await buildRelease( + normalizedInput, + plan, + buildId, + config, + options.logger ?? NOOP_LOGGER, + ); + await options.artifactCache?.put(buildId, new Uint8Array(artifact)); + return artifactResult( + buildId, + artifact, + plan.usesRivetKit, + config.maxBuildArtifactBytes, + ); +} + +async function buildRelease( + input: BuildAppReleaseInput, + plan: BuildPlan, + buildId: string, + config: BuildConfig, + logger: DynamicAppsLogger, +): Promise { + const build = await createBuildVmFactory(config)(); + const startedAt = performance.now(); + const phase = (name: string) => + logger.info({ + msg: "Dynamic Apps build phase completed", + release: buildId, + phase: name, + elapsedMs: performance.now() - startedAt, + }); + let buildError: unknown; + try { + const files = Object.entries(input.files).map(([path, content]) => ({ + path: `/workspace/${normalizeAppPath(path)}`, + content: + path === "package.json" + ? (installPackageJson(input.files, plan) ?? content) + : content, + })); + files.push({ + path: "/workspace/direct-runner.mjs", + content: new TextEncoder().encode( + directRunnerSource({ + entrypoint: plan.entrypoint, + release: buildId, + maxResponseBytes: config.maxResponseBytes, + }), + ), + }); + if (plan.usesRivetKit) { + files.push({ + path: "/workspace/actor-runner.mjs", + content: new TextEncoder().encode(actorRunnerSource(plan.entrypoint)), + }); + } + const writes = await build.writeFiles(files); + const failedWrite = writes.find((entry) => !entry.success); + if (failedWrite) { + fail( + "agentos_apps_build_write_failed", + `failed to write build input ${failedWrite.path}: ${failedWrite.error ?? "unknown error"}`, + { path: failedWrite.path, error: failedWrite.error }, + ); + } + const installArgs = [ + plan.hasLockfile && !plan.usesRivetKit ? "ci" : "install", + "--install-strategy=shallow", + "--include=dev", + "--omit=optional", + "--omit=peer", + "--legacy-peer-deps", + "--no-audit", + "--no-fund", + "--maxsockets=16", + "--loglevel=error", + ]; + const install = await build.execArgv("npm", installArgs, { + cwd: "/workspace", + env: { NODE_ENV: "development", NPM_CONFIG_PRODUCTION: "false" }, + timeout: config.buildTimeoutMs, + captureStdio: true, + }); + if (install.exitCode !== 0) { + throwCommandFailure( + "install", + `npm ${installArgs[0]}`, + install, + config.maxBuildOutputBytes, + ); + } + phase("dependencies_installed"); + if (plan.build) { + const result = await build.execArgv("npm", ["run", "build"], { + cwd: "/workspace", + timeout: config.buildTimeoutMs, + captureStdio: true, + }); + if (result.exitCode !== 0) { + throwCommandFailure( + "build", + "npm run build", + result, + config.maxBuildOutputBytes, + ); + } + phase("application_built"); + } + const prune = await build.execArgv( + "npm", + [ + "prune", + "--omit=dev", + "--omit=optional", + "--omit=peer", + "--legacy-peer-deps", + ], + { + cwd: "/workspace", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (prune.exitCode !== 0) { + throwCommandFailure( + "install", + "npm prune --omit=dev --omit=optional", + prune, + config.maxBuildOutputBytes, + ); + } + const nativeAddonCheck = await build.execArgv( + "node", + [ + "-e", + 'const fs=require("node:fs"); const path=require("node:path"); const found=[]; const walk=(p)=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); if(e.isDirectory())walk(q); else if(e.name.endsWith(".node"))found.push(q)}}; walk("node_modules"); if(found.length){console.error(found.slice(0,32).join("\\n")); process.exit(42)}', + ], + { + cwd: "/workspace", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (nativeAddonCheck.exitCode === 42) { + fail( + "agentos_apps_native_addon_unsupported", + "application contains native Node addons", + { + files: boundedOutput( + nativeAddonCheck.stderr, + config.maxBuildOutputBytes, + ), + }, + ); + } + if (nativeAddonCheck.exitCode !== 0) { + throwCommandFailure( + "build", + "native addon scan", + nativeAddonCheck, + config.maxBuildOutputBytes, + ); + } + const directConfigPath = "/workspace/.agentos-app-direct-build.json"; + const configWrites = await build.writeFiles([ + { + path: directConfigPath, + content: JSON.stringify({ + version: buildId, + workspace: "/workspace", + release: "/release/direct", + entrypoint: "direct-runner.mjs", + sourceFiles: Object.keys(input.files), + usesRivetKit: false, + directIsolate: true, + stubRivetKit: plan.usesRivetKit, + maxOutputBytes: config.maxBuildArtifactBytes, + maxOutputFiles: config.maxBuildArtifactFiles, + maxFileBytes: config.maxBuildArtifactFileBytes, + }), + }, + ]); + const failedConfigWrite = configWrites.find((entry) => !entry.success); + if (failedConfigWrite) { + fail( + "agentos_apps_build_write_failed", + `failed to write Apps builder input ${failedConfigWrite.path}`, + ); + } + const directBundle = await build.execArgv( + "node", + ["/opt/agentos/bin/apps-builder", directConfigPath], + { + cwd: "/workspace", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (directBundle.exitCode !== 0) { + throwCommandFailure( + "build", + "apps-builder (direct)", + directBundle, + config.maxBuildOutputBytes, + ); + } + if (plan.usesRivetKit) { + const actorConfigPath = "/workspace/.agentos-app-actor-build.json"; + const actorConfigWrite = await build.writeFiles([ + { + path: actorConfigPath, + content: JSON.stringify({ + version: buildId, + workspace: "/workspace", + release: "/release/actor", + entrypoint: "actor-runner.mjs", + sourceFiles: Object.keys(input.files), + usesRivetKit: true, + directIsolate: false, + platformRivetKit: true, + maxOutputBytes: config.maxBuildArtifactBytes, + maxOutputFiles: config.maxBuildArtifactFiles, + maxFileBytes: config.maxBuildArtifactFileBytes, + }), + }, + ]); + if (actorConfigWrite.some((entry) => !entry.success)) { + fail( + "agentos_apps_build_write_failed", + "failed to write actor Apps builder input", + ); + } + const actorBundle = await build.execArgv( + "node", + ["/opt/agentos/bin/apps-builder", actorConfigPath], + { + cwd: "/workspace", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (actorBundle.exitCode !== 0) { + throwCommandFailure( + "build", + "apps-builder (actor)", + actorBundle, + config.maxBuildOutputBytes, + ); + } + } + phase("release_bundled"); + const validation = await build.execArgv( + "node", + [ + "-e", + `import("/release/${DIRECT_BUNDLE_PATH}").then((module)=>{if(module.dynamicAppMetadata?.format!==${JSON.stringify(DIRECT_RUNTIME_FORMAT)}||typeof module.dispatch!=="function") throw new TypeError("invalid direct app handler")}).catch((error)=>{console.error(error);process.exitCode=1})`, + ], + { + cwd: "/release", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (validation.exitCode !== 0) { + fail( + "agentos_apps_invalid_handler", + "application entrypoint could not be imported as a direct fetch handler", + { + stderr: boundedOutput(validation.stderr, config.maxBuildOutputBytes), + }, + ); + } + const rootManifestWrite = await build.writeFiles([ + { + path: "/release/agentos-package.json", + content: JSON.stringify({ name: "agentos-app", version: buildId }), + }, + ]); + if (rootManifestWrite.some((entry) => !entry.success)) { + fail( + "agentos_apps_build_write_failed", + "failed to write root application package manifest", + ); + } + phase("release_validated"); + const pack = await build.execArgv( + "tar", + [ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "-cf", + build.artifactGuestPath, + ".", + ], + { + cwd: "/release", + timeout: config.buildTimeoutMs, + captureStdio: true, + }, + ); + if (pack.exitCode !== 0) { + throwCommandFailure("pack", "tar", pack, config.maxBuildOutputBytes); + } + phase("release_archived"); + const archiveSize = await build.artifactSize(); + if ( + !Number.isSafeInteger(archiveSize) || + archiveSize < 0 || + archiveSize > config.maxBuildArtifactBytes + ) { + fail( + "agentos_apps_build_artifact_size_limit", + `built application archive is ${archiveSize} bytes, limit is ${config.maxBuildArtifactBytes}`, + ); + } + const sourceTar = Buffer.from(await build.readArtifact()); + if (sourceTar.byteLength !== archiveSize) { + fail( + "agentos_apps_build_artifact_truncated", + `build artifact contained ${sourceTar.byteLength} bytes, expected ${archiveSize}`, + ); + } + return new Uint8Array(packAospkgFromTarBytes(sourceTar).bytes); + } catch (error) { + buildError = error; + throw error; + } finally { + await build.dispose().catch((disposeError) => { + if (!buildError) throw disposeError; + logger.error({ + msg: "failed to dispose Dynamic Apps build VM after build failure", + disposeError, + }); + }); + } +} + +export function createBuildVmFactory( + config: BuildConfig, +): () => Promise { + const options: AgentOsOptions = { + defaultSoftware: false, + software: [sh, tar, appsBuilder], + permissions: { + fs: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + network: "allow", + }, + limits: { + tls: { maxBufferedBytes: 16 * 1024 * 1024 }, + jsRuntime: { v8HeapLimitMb: 1_024 }, + resources: { + maxProcesses: 64, + maxOpenFds: 2_048, + maxPreadBytes: 15 * 1024 * 1024, + maxFdWriteBytes: 16 * 1024 * 1024, + maxSocketBufferedBytes: 16 * 1024 * 1024, + maxFilesystemBytes: config.maxBuildFilesystemBytes, + }, + }, + }; + return async () => { + const outputDirectory = await mkdtemp( + join(tmpdir(), "agentos-apps-build-output-"), + ); + await chmod(outputDirectory, 0o777); + const artifactGuestPath = "/agentos-app-output/agentos-app.tar"; + const artifactHostPath = join(outputDirectory, "agentos-app.tar"); + let vm: AgentOs; + try { + vm = await AgentOs.create({ + ...options, + mounts: [ + { + path: "/agentos-app-output", + readOnly: false, + plugin: createHostDirBackend({ + hostPath: outputDirectory, + readOnly: false, + }), + }, + ], + }); + } catch (error) { + await rm(outputDirectory, { recursive: true, force: true }); + throw error; + } + return { + artifactGuestPath, + writeFiles: (...args) => vm.writeFiles(...args), + execArgv: (...args) => vm.execArgv(...args), + artifactSize: async () => (await stat(artifactHostPath)).size, + readArtifact: async () => + new Uint8Array(await readFile(artifactHostPath)), + dispose: async () => { + const results = await Promise.allSettled([ + vm.dispose(), + rm(outputDirectory, { recursive: true, force: true }), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) { + throw new AggregateError( + failures, + "failed to dispose Dynamic Apps build VM output", + ); + } + }, + }; + }; +} diff --git a/packages/dynamic-apps/src/errors.ts b/packages/dynamic-apps-core/src/errors.ts similarity index 100% rename from packages/dynamic-apps/src/errors.ts rename to packages/dynamic-apps-core/src/errors.ts diff --git a/packages/dynamic-apps/src/executor.ts b/packages/dynamic-apps-core/src/executor.ts similarity index 81% rename from packages/dynamic-apps/src/executor.ts rename to packages/dynamic-apps-core/src/executor.ts index ccf0b57a9..6046fba04 100644 --- a/packages/dynamic-apps/src/executor.ts +++ b/packages/dynamic-apps-core/src/executor.ts @@ -3,13 +3,17 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { availableParallelism, tmpdir } from "node:os"; import { join } from "node:path"; import { AgentOs } from "@rivet-dev/agentos-core"; -import { createClient } from "rivetkit/client"; -import type { AppRouteResolution } from "./actors.js"; import { DynamicAppsError } from "./errors.js"; import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; import { capConcurrencyForMemory, readCgroupMemory } from "./memory.js"; -import { ensurePrivateAppsRegistry } from "./registry.js"; import { DIRECT_BUNDLE_PATH, DIRECT_RUNTIME_FORMAT } from "./runtime.js"; +import { validateAppId } from "./source.js"; +import type { + ActiveRelease, + ReleaseInvalidation, + ReleaseLoadContext, + Unsubscribe, +} from "./types.js"; const MAX_URL_BYTES = 16 * 1024; const MAX_METHOD_BYTES = 256; @@ -51,41 +55,15 @@ export interface ExecutorConfig { logRequests: boolean; } -interface ArtifactManifest { - format: string; - hash: string; - bytes: number; - chunks: number; - chunkBytes: number; -} - -interface ReleaseActivatedEvent { - revision: number; - release: string; - artifactHash: string; - activatedAt: number; -} - -interface AppConnection { - ready: Promise; - on( - name: string, - callback: (event: ReleaseActivatedEvent) => void, - ): () => void; - onOpen(callback: () => void): () => void; - onClose(callback: () => void): () => void; - dispose(): Promise; -} - -interface AppHandle { - resolveDeployment(): Promise; - getArtifactManifest(release: string): Promise; - readArtifactChunk(release: string, index: number): Promise; - connect(): AppConnection; -} - -interface StateClient { - agentOSAppsApp: { getOrCreate(key: string[]): AppHandle }; +export interface ExecutorReleaseSource { + loadActiveRelease( + appId: string, + context: ReleaseLoadContext, + ): Promise; + watchActiveRelease( + appId: string, + invalidate: ReleaseInvalidation, + ): Promise; } interface RequestEnvelope { @@ -151,19 +129,17 @@ interface PreparedRuntime { } interface AppMapping { - resolution: AppRouteResolution; + resolution: ActiveRelease; runtime: PreparedRuntime; } interface AppCacheEntry { appId: string; - handle: AppHandle; - connection: AppConnection; - ready: Promise; + subscription: Promise; + unsubscribe?: Unsubscribe; mapping?: AppMapping; resolvePromise?: Promise; epoch: number; - highestRevision: number; lastUsedAt: number; refs: number; } @@ -299,10 +275,61 @@ export function readExecutorConfig( }; } +export function resolveExecutorConfig( + base: ExecutorConfig, + overrides: Partial = {}, +): ExecutorConfig { + const config = { ...base, ...overrides }; + if ( + config.executionMode !== "ephemeral" && + config.executionMode !== "pooled" + ) { + throw invalidExecutorConfig("executionMode"); + } + const limits: Array<[keyof ExecutorConfig, number, number]> = [ + ["contextPoolSize", 0, 128], + ["contextPoolMaxTotal", 0, 1_024], + ["contextIdleTtlMs", 1_000, 60 * 60_000], + ["contextHeapLimitMb", 8, 2_048], + ["runtimeCacheMaxEntries", 1, 1_024], + ["runtimeCacheMaxBytes", 1024 * 1024, 16 * 1024 * 1024 * 1024], + ["runtimeCacheIdleTtlMs", 1_000, 24 * 60 * 60_000], + ["memoryHighWaterPercent", 10, 95], + ["executionConcurrency", 1, 1_024], + ["executionQueueSize", 0, 100_000], + ["executionQueueWaitMs", 1, 60_000], + ["executionTimeoutMs", 1, 5 * 60_000], + ]; + for (const [name, minimum, maximum] of limits) { + const value = config[name]; + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + throw invalidExecutorConfig(String(name)); + } + } + if ( + typeof config.timingHeaders !== "boolean" || + typeof config.logRequests !== "boolean" + ) { + throw invalidExecutorConfig("timingHeaders/logRequests"); + } + return config; +} + +function invalidExecutorConfig(name: string): DynamicAppsError { + return new DynamicAppsError( + "agentos_apps_invalid_config", + `invalid Dynamic Apps executor config ${name}`, + ); +} + export class DynamicAppsExecutor { readonly config: ExecutorConfig; - readonly #client: StateClient; - readonly #ensureRegistry: boolean; + readonly #source: ExecutorReleaseSource; readonly #semaphore: Semaphore; readonly #apps = new Map(); readonly #runtimes = new Map(); @@ -313,13 +340,9 @@ export class DynamicAppsExecutor { #disposed = false; #disposePromise?: Promise; - constructor( - config: ExecutorConfig = readExecutorConfig(), - client?: StateClient, - ) { + constructor(source: ExecutorReleaseSource, config: ExecutorConfig) { this.config = config; - this.#ensureRegistry = client === undefined; - this.#client = client ?? (createClient() as unknown as StateClient); + this.#source = source; this.#semaphore = new Semaphore( config.executionConcurrency, config.executionQueueSize, @@ -358,9 +381,6 @@ export class DynamicAppsExecutor { const envelope = await measure(trace, "request-buffer", () => serializeRequest(request), ); - if (this.#ensureRegistry) { - await measure(trace, "registry-ready", ensurePrivateAppsRegistry); - } const requestedRegion = request.headers.get("x-agentos-app-region") ?? undefined; const { entry, hit } = this.#appEntry(appId); @@ -466,11 +486,12 @@ export class DynamicAppsExecutor { } async #finishDispose(): Promise { - await Promise.allSettled( - [...this.#apps.values()].map((entry) => entry.connection.dispose()), - ); + const entries = [...this.#apps.values()]; this.#apps.clear(); - await Promise.allSettled([...this.#runtimePromises.values()]); + await Promise.allSettled([ + ...entries.map((entry) => this.#releaseEntry(entry)), + ...this.#runtimePromises.values(), + ]); for (const runtime of this.#runtimes.values()) runtime.stale = true; await Promise.allSettled( [...this.#runtimes.values()].map((runtime) => @@ -483,41 +504,47 @@ export class DynamicAppsExecutor { #appEntry(appId: string): { entry: AppCacheEntry; hit: boolean } { const existing = this.#apps.get(appId); if (existing) return { entry: existing, hit: true }; - const handle = this.#client.agentOSAppsApp.getOrCreate([appId]); - const connection = handle.connect(); const entry: AppCacheEntry = { appId, - handle, - connection, - ready: connection.ready, + subscription: undefined as unknown as Promise, epoch: 0, - highestRevision: 0, lastUsedAt: Date.now(), refs: 0, }; - connection.on("releaseActivated", (event) => { - if (!validReleaseEvent(event) || event.revision <= entry.highestRevision) - return; - entry.highestRevision = event.revision; - this.#invalidateMapping(entry); - void this.#resolveAndPrepare(entry).catch(() => {}); - }); - connection.onClose(() => this.#invalidateMapping(entry)); - connection.onOpen(() => { - if (entry.mapping || entry.highestRevision > 0) { - this.#invalidateMapping(entry); - void this.#resolveAndPrepare(entry).catch(() => {}); - } - }); this.#apps.set(appId, entry); + entry.subscription = Promise.resolve() + .then(() => + this.#source.watchActiveRelease(appId, () => this.invalidate(appId)), + ) + .then(async (unsubscribe) => { + if (typeof unsubscribe !== "function") { + throw new DynamicAppsError( + "agentos_apps_invalid_subscription", + "watchActiveRelease must resolve to an unsubscribe function", + ); + } + const once = onceUnsubscribe(unsubscribe); + if (this.#disposed || this.#apps.get(appId) !== entry) { + await once(); + } else { + entry.unsubscribe = once; + } + return once; + }) + .catch((error) => { + if (this.#apps.get(appId) === entry) this.#apps.delete(appId); + throw error; + }); return { entry, hit: false }; } - #invalidateMapping(entry: AppCacheEntry): void { + invalidate(appId: string): void { + const entry = this.#apps.get(appId); + if (!entry || this.#disposed) return; + const active = entry.mapping !== undefined || entry.refs > 0; entry.epoch += 1; - const runtime = entry.mapping?.runtime; entry.mapping = undefined; - if (runtime) this.#invalidateRuntime(runtime); + if (active) void this.#resolveAndPrepare(entry).catch(() => {}); } #invalidateRuntime(runtime: PreparedRuntime): void { @@ -536,34 +563,38 @@ export class DynamicAppsExecutor { if (entry.resolvePromise !== undefined) return entry.resolvePromise; const promise = (async () => { for (;;) { + await entry.subscription; const epoch = entry.epoch; - await measureOptional(trace, "actor-connect", () => entry.ready); - const resolution = await measureOptional(trace, "actor-resolve", () => - entry.handle.resolveDeployment(), - ); - if ( - entry.epoch !== epoch || - resolution.revision < entry.highestRevision - ) - continue; - entry.highestRevision = Math.max( - entry.highestRevision, - resolution.revision, + const resolution = await measureOptional(trace, "release-load", () => + this.#source.loadActiveRelease( + entry.appId, + createReleaseLoadContext(trace), + ), ); - const runtime = await this.#prepareRuntime( - entry.appId, - entry.handle, - resolution, + if (entry.epoch !== epoch) continue; + if (!resolution) { + throw new DynamicAppsError( + "agentos_apps_not_deployed", + "app has no active direct release; call deployApp() first", + ); + } + if (resolution.appId !== entry.appId) { + throw new DynamicAppsError( + "agentos_apps_active_release_invalid", + "loadActiveRelease returned a release for a different app", + ); + } + const verifiedResolution = await measureOptional( trace, + "artifact-verify", + async () => verifyActiveRelease(resolution), ); - if ( - entry.epoch !== epoch || - resolution.revision < entry.highestRevision - ) { - this.#invalidateRuntime(runtime); - continue; + const runtime = await this.#prepareRuntime(verifiedResolution, trace); + if (entry.epoch !== epoch) continue; + if (this.#disposed || this.#apps.get(entry.appId) !== entry) { + throw disposedError(); } - const mapping = { resolution, runtime }; + const mapping = { resolution: verifiedResolution, runtime }; entry.mapping = mapping; return mapping; } @@ -577,9 +608,7 @@ export class DynamicAppsExecutor { } async #prepareRuntime( - appId: string, - handle: AppHandle, - resolution: AppRouteResolution, + resolution: ActiveRelease, trace?: RequestTrace, ): Promise { if (this.#disposed) { @@ -588,7 +617,7 @@ export class DynamicAppsExecutor { "Dynamic Apps executor is shutting down", ); } - const key = `${appId}:${resolution.release}:${resolution.artifactHash}:${DIRECT_RUNTIME_FORMAT}`; + const key = `${resolution.appId}:${resolution.release}:${resolution.artifact.hash}:${DIRECT_RUNTIME_FORMAT}`; const existing = this.#runtimes.get(key); if (existing && !existing.stale) { existing.lastUsedAt = Date.now(); @@ -596,7 +625,7 @@ export class DynamicAppsExecutor { } const pending = this.#runtimePromises.get(key); if (pending) return pending; - const promise = this.#createRuntime(key, appId, handle, resolution, trace); + const promise = this.#createRuntime(key, resolution, trace); this.#runtimePromises.set(key, promise); try { return await promise; @@ -609,58 +638,17 @@ export class DynamicAppsExecutor { async #createRuntime( key: string, - appId: string, - handle: AppHandle, - resolution: AppRouteResolution, + resolution: ActiveRelease, trace?: RequestTrace, ): Promise { - await this.#pruneCaches(true, resolution.artifactBytes); - if (resolution.artifactBytes > this.config.runtimeCacheMaxBytes) { + const artifact = resolution.artifact.bytes; + await this.#pruneCaches(true, artifact.byteLength); + if (artifact.byteLength > this.config.runtimeCacheMaxBytes) { throw new DynamicAppsError( "agentos_apps_artifact_cache_limit", "application artifact is larger than the configured runtime cache", ); } - const manifest = await measureOptional(trace, "artifact-manifest", () => - handle.getArtifactManifest(resolution.release), - ); - validateManifest(manifest, resolution); - const artifact = await measureOptional( - trace, - "artifact-download", - async () => { - const chunks: Uint8Array[] = []; - const digest = createHash("sha256"); - let bytes = 0; - for (let index = 0; index < manifest.chunks; index += 1) { - const chunk = new Uint8Array( - await handle.readArtifactChunk(resolution.release, index), - ); - bytes += chunk.byteLength; - if ( - chunk.byteLength > manifest.chunkBytes || - bytes > manifest.bytes - ) { - throw new DynamicAppsError( - "agentos_apps_artifact_chunk_invalid", - `artifact chunk ${index} has an invalid length`, - ); - } - digest.update(chunk); - chunks.push(chunk); - } - if ( - bytes !== manifest.bytes || - digest.digest("hex") !== manifest.hash - ) { - throw new DynamicAppsError( - "agentos_apps_artifact_hash_mismatch", - "downloaded artifact failed size or hash verification", - ); - } - return new Uint8Array(Buffer.concat(chunks, bytes)); - }, - ); const directory = await mkdtemp(join(tmpdir(), "dynamic-app-runtime-")); const artifactPath = join(directory, "release.aospkg"); let vm: AgentOs | undefined; @@ -700,10 +688,10 @@ export class DynamicAppsExecutor { ); const runtime: PreparedRuntime = { key, - appId, + appId: resolution.appId, release: resolution.release, - artifactHash: resolution.artifactHash, - artifactBytes: resolution.artifactBytes, + artifactHash: resolution.artifact.hash, + artifactBytes: artifact.byteLength, artifact, directory, artifactPath, @@ -742,7 +730,7 @@ export class DynamicAppsExecutor { level: "debug", source: "runtime", message: "Dynamic Apps release runtime prepared", - appId, + appId: resolution.appId, release: resolution.release, }); return runtime; @@ -754,7 +742,7 @@ export class DynamicAppsExecutor { level: "error", source: "runtime", message: "Dynamic Apps release runtime preparation failed", - appId, + appId: resolution.appId, release: resolution.release, }); throw error; @@ -1088,7 +1076,7 @@ export class DynamicAppsExecutor { this.#apps.size > this.config.runtimeCacheMaxEntries) ) { this.#apps.delete(entry.appId); - void entry.connection.dispose(); + void this.#releaseEntry(entry).catch(() => {}); } } let bytes = [...this.#runtimes.values()].reduce( @@ -1117,6 +1105,15 @@ export class DynamicAppsExecutor { } } + async #releaseEntry(entry: AppCacheEntry): Promise { + try { + const unsubscribe = entry.unsubscribe ?? (await entry.subscription); + await unsubscribe(); + } catch { + // A failed watcher must not prevent executor shutdown or cache eviction. + } + } + async #maybeDisposeRuntime(runtime: PreparedRuntime): Promise { if ( !runtime.stale || @@ -1242,37 +1239,130 @@ function evaluationValue(result: EvaluationResult, timeoutMs: number): T { ); } -function validReleaseEvent(event: unknown): event is ReleaseActivatedEvent { - if (!event || typeof event !== "object") return false; - const value = event as Partial; - return ( - Number.isInteger(value.revision) && - typeof value.release === "string" && - typeof value.artifactHash === "string" && - typeof value.activatedAt === "number" +function disposedError(): DynamicAppsError { + return new DynamicAppsError( + "agentos_apps_executor_disposed", + "Dynamic Apps executor is shutting down", ); } -function validateManifest( - manifest: ArtifactManifest, - resolution: AppRouteResolution, -): void { +function onceUnsubscribe(unsubscribe: Unsubscribe): Unsubscribe { + let promise: Promise | undefined; + return () => { + promise ??= Promise.resolve().then(unsubscribe); + return promise; + }; +} + +function createReleaseLoadContext( + trace: RequestTrace | undefined, +): ReleaseLoadContext { + return { + recordTiming(name, durationMs) { + if (!Number.isFinite(durationMs) || durationMs < 0) { + throw new DynamicAppsError( + "agentos_apps_invalid_timing", + "release load timing duration must be finite and non-negative", + ); + } + if (typeof name !== "string" || Buffer.byteLength(name) > 64) { + throw new DynamicAppsError( + "agentos_apps_invalid_timing", + "release load timing name must be at most 64 bytes", + ); + } + const normalized = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + if (!normalized) { + throw new DynamicAppsError( + "agentos_apps_invalid_timing", + "release load timing name must contain ASCII letters or digits", + ); + } + trace?.phases.set(`store-${normalized}`, durationMs); + }, + }; +} + +function verifyActiveRelease(input: ActiveRelease): ActiveRelease { + if (!input || typeof input !== "object") { + throw new DynamicAppsError( + "agentos_apps_active_release_invalid", + "loadActiveRelease returned an invalid release", + ); + } + validateAppId(input.appId); + if ( + typeof input.release !== "string" || + Buffer.byteLength(input.release) < 1 || + Buffer.byteLength(input.release) > 256 || + /[\0-\x1f\x7f]/.test(input.release) || + !Array.isArray(input.regions) || + input.regions.length === 0 || + input.regions.length > 128 || + input.regions.some( + (region) => + typeof region !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(region), + ) || + !validScaling(input.scaling) || + !Number.isSafeInteger(input.maxRequestBytes) || + input.maxRequestBytes < 1 || + !Number.isSafeInteger(input.maxResponseBytes) || + input.maxResponseBytes < 1 + ) { + throw new DynamicAppsError( + "agentos_apps_active_release_invalid", + "loadActiveRelease returned invalid release metadata", + ); + } + const artifact = input.artifact; if ( - manifest.format !== DIRECT_RUNTIME_FORMAT || - manifest.hash !== resolution.artifactHash || - manifest.bytes !== resolution.artifactBytes || - !Number.isInteger(manifest.chunks) || - manifest.chunks <= 0 || - manifest.chunks > 128 || - !Number.isInteger(manifest.chunkBytes) || - manifest.chunkBytes <= 0 || - !/^[a-f0-9]{64}$/.test(manifest.hash) + !artifact || + artifact.format !== DIRECT_RUNTIME_FORMAT || + artifact.entrypoint !== "direct-v2/main.mjs" || + !/^[a-f0-9]{64}$/.test(artifact.hash) || + !(artifact.bytes instanceof Uint8Array) || + !Number.isSafeInteger(artifact.byteLength) || + artifact.byteLength < 1 || + artifact.byteLength !== artifact.bytes.byteLength || + typeof artifact.usesRivetKit !== "boolean" ) { throw new DynamicAppsError( "agentos_apps_artifact_manifest_mismatch", - "app actor returned an invalid artifact manifest", + "loadActiveRelease returned invalid artifact metadata", ); } + const bytes = new Uint8Array(artifact.bytes); + if (createHash("sha256").update(bytes).digest("hex") !== artifact.hash) { + throw new DynamicAppsError( + "agentos_apps_artifact_hash_mismatch", + "loaded artifact failed size or hash verification", + ); + } + return { + ...input, + regions: [...input.regions], + scaling: { ...input.scaling }, + artifact: { ...artifact, bytes }, + }; +} + +function validScaling(value: ActiveRelease["scaling"]): boolean { + return ( + value !== null && + typeof value === "object" && + Number.isInteger(value.minReplicas) && + value.minReplicas >= 0 && + Number.isInteger(value.maxReplicas) && + value.maxReplicas >= 1 && + value.maxReplicas <= 128 && + value.minReplicas <= value.maxReplicas && + Number.isInteger(value.targetConcurrency) && + value.targetConcurrency >= 1 && + value.targetConcurrency <= 1_024 + ); } async function serializeRequest(request: Request): Promise { @@ -1567,15 +1657,3 @@ class Semaphore { } } } - -let defaultExecutor: DynamicAppsExecutor | undefined; - -export function getDefaultExecutor(): DynamicAppsExecutor { - defaultExecutor ??= new DynamicAppsExecutor(); - return defaultExecutor; -} - -export async function resetDefaultExecutorForTest(): Promise { - await defaultExecutor?.dispose(); - defaultExecutor = undefined; -} diff --git a/packages/dynamic-apps-core/src/factory.ts b/packages/dynamic-apps-core/src/factory.ts new file mode 100644 index 000000000..0da1e8951 --- /dev/null +++ b/packages/dynamic-apps-core/src/factory.ts @@ -0,0 +1,104 @@ +import { buildAppRelease, readBuildConfig } from "./build.js"; +import { DynamicAppsError } from "./errors.js"; +import { + DynamicAppsExecutor, + readExecutorConfig, + resolveExecutorConfig, +} from "./executor.js"; +import { createAppsRouter } from "./router.js"; +import { prepareSource } from "./source.js"; +import type { + DynamicApps, + DynamicAppsOptions, + PublishReleaseInput, +} from "./types.js"; + +export function createDynamicApps( + options: DynamicAppsOptions, +): DynamicApps { + if ( + !options || + typeof options.publishRelease !== "function" || + typeof options.loadActiveRelease !== "function" || + typeof options.watchActiveRelease !== "function" + ) { + throw new DynamicAppsError( + "agentos_apps_invalid_config", + "createDynamicApps requires publishRelease, loadActiveRelease, and watchActiveRelease hooks", + ); + } + const buildConfig = readBuildConfig(options.build); + const executorConfig = resolveExecutorConfig( + readExecutorConfig(), + options.executor, + ); + const executor = new DynamicAppsExecutor( + { + loadActiveRelease: options.loadActiveRelease, + watchActiveRelease: options.watchActiveRelease, + }, + executorConfig, + ); + const appsRouter = createAppsRouter(executor); + const inFlight = new Set>(); + let disposed = false; + let disposePromise: Promise | undefined; + + const deployApp: DynamicApps["deployApp"] = ( + input, + deployOptions, + ) => { + if (disposed) return Promise.reject(disposedError()); + const operation = (async () => { + const files = await prepareSource(input); + const built = await buildAppRelease( + { appId: input.appId, files }, + { + config: buildConfig, + artifactCache: options.artifactCache, + logger: options.logger, + }, + ); + if (disposed) throw disposedError(); + const publishInput: PublishReleaseInput = { + appId: input.appId, + buildId: built.buildId, + artifact: { + ...built.artifact, + bytes: new Uint8Array(built.artifact.bytes), + }, + regions: input.regions ? [...input.regions] : undefined, + scaling: input.scaling ? { ...input.scaling } : undefined, + createdAt: Date.now(), + }; + const result = await options.publishRelease(publishInput, deployOptions); + executor.invalidate(input.appId); + return result; + })(); + inFlight.add(operation); + void operation.finally(() => inFlight.delete(operation)).catch(() => {}); + return operation; + }; + + return { + deployApp, + appsRouter, + diagnostics: () => executor.diagnostics(), + dispose() { + if (disposePromise !== undefined) return disposePromise; + disposed = true; + disposePromise = (async () => { + await Promise.allSettled([...inFlight]); + await executor.dispose(); + })(); + return disposePromise; + }, + }; +} + +function disposedError(): DynamicAppsError { + return new DynamicAppsError( + "agentos_apps_executor_disposed", + "Dynamic Apps executor is shutting down", + ); +} diff --git a/packages/dynamic-apps-core/src/index.ts b/packages/dynamic-apps-core/src/index.ts new file mode 100644 index 000000000..f77ecac4b --- /dev/null +++ b/packages/dynamic-apps-core/src/index.ts @@ -0,0 +1,19 @@ +export { createDynamicApps } from "./factory.js"; +export type { + ActiveRelease, + AppScaling, + BuildArtifactCache, + BuildConfig, + BuiltAppRelease, + DeployAppInput, + DynamicApps, + DynamicAppsLogger, + DynamicAppsOptions, + ExecutionMode, + ExecutorConfig, + PublishReleaseInput, + ReleaseArtifact, + ReleaseInvalidation, + ReleaseLoadContext, + Unsubscribe, +} from "./types.js"; diff --git a/packages/dynamic-apps-core/src/internal.ts b/packages/dynamic-apps-core/src/internal.ts new file mode 100644 index 000000000..4cd60efee --- /dev/null +++ b/packages/dynamic-apps-core/src/internal.ts @@ -0,0 +1,38 @@ +export { buildAppRelease } from "./build.js"; +export { DynamicAppsError } from "./errors.js"; +export { + ApplicationHandlerError, + capExecutionConcurrencyForMemory, + DynamicAppsExecutor, + type ExecutorReleaseSource, + readExecutorConfig, + resolveExecutorConfig, +} from "./executor.js"; +export { + type DynamicAppsLogEvent, + type DynamicAppsLogHandler, + type DynamicAppsLogLevel, + DynamicAppsLogLineDecoder, + type DynamicAppsLogSource, + emitDynamicAppsLog, + MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES, + setDynamicAppsLogHandler, +} from "./logging.js"; +export { capConcurrencyForMemory, readCgroupMemory } from "./memory.js"; +export { type AppRequestExecutor, createAppsRouter } from "./router.js"; +export { + ACTOR_BUNDLE_PATH, + actorRunnerSource, + canonicalDeploymentHash, + DIRECT_BUNDLE_PATH, + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, + directRunnerSource, + normalizeAppPath, +} from "./runtime.js"; +export { prepareSource, validateAppId } from "./source.js"; +export type { + ActiveRelease, + ExecutionMode, + ExecutorConfig, +} from "./types.js"; diff --git a/packages/dynamic-apps-core/src/logging.ts b/packages/dynamic-apps-core/src/logging.ts new file mode 100644 index 000000000..51bfa067f --- /dev/null +++ b/packages/dynamic-apps-core/src/logging.ts @@ -0,0 +1,144 @@ +export type DynamicAppsLogLevel = "debug" | "info" | "warn" | "error"; + +export type DynamicAppsLogSource = + | "application" + | "actor" + | "build" + | "runtime"; + +export interface DynamicAppsLogEvent { + version: 1; + timestamp: number; + level: DynamicAppsLogLevel; + source: DynamicAppsLogSource; + message: string; + appId?: string; + release?: string; + requestId?: string; + actorId?: string; + stream?: "stdout" | "stderr"; + metadata?: Readonly>; +} + +export type DynamicAppsLogHandler = ( + event: Readonly, +) => void; + +type DynamicAppsLogInput = Omit; + +export const MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES = 64 * 1024; +const HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS = 60_000; + +let logHandler: DynamicAppsLogHandler | undefined; +let lastHandlerErrorDiagnosticAt = 0; + +export function setDynamicAppsLogHandler( + handler: DynamicAppsLogHandler | undefined, +): void { + logHandler = handler; +} + +/** @internal */ +export function emitDynamicAppsLog(input: DynamicAppsLogInput): void { + const handler = logHandler; + if (!handler) return; + const truncated = truncateUtf8(input.message); + const metadata = input.metadata + ? Object.freeze({ + ...input.metadata, + ...(truncated.truncated ? { truncated: true } : {}), + }) + : truncated.truncated + ? Object.freeze({ truncated: true }) + : undefined; + const event = Object.freeze({ + ...input, + version: 1 as const, + timestamp: Date.now(), + message: truncated.value, + ...(metadata ? { metadata } : {}), + }); + try { + handler(event); + } catch (error) { + const now = Date.now(); + if ( + now - lastHandlerErrorDiagnosticAt >= + HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS + ) { + lastHandlerErrorDiagnosticAt = now; + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[dynamic-apps] log handler failed: ${truncateUtf8(message).value}\n`, + ); + } + } +} + +/** Incrementally reconstructs bounded UTF-8 lines from a byte stream. */ +export class DynamicAppsLogLineDecoder { + readonly #decoder = new TextDecoder(); + readonly #emit: (message: string, truncated: boolean) => void; + #buffer = ""; + #bufferBytes = 0; + #truncated = false; + #ended = false; + + constructor(emit: (message: string, truncated: boolean) => void) { + this.#emit = emit; + } + + write(chunk: Uint8Array): void { + if (this.#ended) return; + this.#consume(this.#decoder.decode(chunk, { stream: true })); + } + + end(): void { + if (this.#ended) return; + this.#ended = true; + this.#consume(this.#decoder.decode()); + if (this.#buffer || this.#truncated) this.#flushLine(); + } + + #consume(text: string): void { + for (const character of text) { + if (character === "\n") { + this.#flushLine(); + continue; + } + if (this.#truncated) continue; + const bytes = Buffer.byteLength(character); + if (this.#bufferBytes + bytes > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { + this.#truncated = true; + continue; + } + this.#buffer += character; + this.#bufferBytes += bytes; + } + } + + #flushLine(): void { + const message = this.#buffer.endsWith("\r") + ? this.#buffer.slice(0, -1) + : this.#buffer; + this.#emit(message, this.#truncated); + this.#buffer = ""; + this.#bufferBytes = 0; + this.#truncated = false; + } +} + +function truncateUtf8(value: string): { value: string; truncated: boolean } { + if (Buffer.byteLength(value) <= MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { + return { value, truncated: false }; + } + let output = ""; + let bytes = 0; + for (const character of value) { + const size = Buffer.byteLength(character); + if (bytes + size > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) break; + output += character; + bytes += size; + } + return { value: output, truncated: true }; +} diff --git a/packages/dynamic-apps/src/memory.ts b/packages/dynamic-apps-core/src/memory.ts similarity index 84% rename from packages/dynamic-apps/src/memory.ts rename to packages/dynamic-apps-core/src/memory.ts index 92a8b065c..436143988 100644 --- a/packages/dynamic-apps/src/memory.ts +++ b/packages/dynamic-apps-core/src/memory.ts @@ -10,7 +10,8 @@ export interface CgroupMemory { export function capConcurrencyForMemory(input: { requested: number; - contextAndVmLimitMb: number; + heapLimitMb?: number; + contextAndVmLimitMb?: number; memoryHighWaterPercent: number; currentBytes: number; maxBytes: number; @@ -19,8 +20,12 @@ export function capConcurrencyForMemory(input: { (input.maxBytes * input.memoryHighWaterPercent) / 100 - MEMORY_ADMISSION_RESERVE_BYTES; const availableBytes = Math.max(0, targetBytes - input.currentBytes); + const limitMb = input.contextAndVmLimitMb ?? input.heapLimitMb; + if (limitMb === undefined) { + throw new TypeError("memory concurrency cap requires a per-request limit"); + } const perRequestBytes = - input.contextAndVmLimitMb * 1024 * 1024 + MEMORY_ADMISSION_PAYLOAD_BYTES; + limitMb * 1024 * 1024 + MEMORY_ADMISSION_PAYLOAD_BYTES; return Math.max( 1, Math.min(input.requested, Math.floor(availableBytes / perRequestBytes)), diff --git a/packages/dynamic-apps-core/src/router.ts b/packages/dynamic-apps-core/src/router.ts new file mode 100644 index 000000000..d67220055 --- /dev/null +++ b/packages/dynamic-apps-core/src/router.ts @@ -0,0 +1,161 @@ +import { randomUUID } from "node:crypto"; +import { Hono } from "hono"; +import type { BlankEnv, BlankSchema } from "hono/types"; +import { DynamicAppsError } from "./errors.js"; +import { ApplicationHandlerError } from "./executor.js"; +import { validateAppId } from "./source.js"; + +const MAX_URL_BYTES = 16 * 1024; +const MAX_METHOD_BYTES = 256; + +export interface AppRequestExecutor { + request( + appId: string, + request: Request, + requestId?: string, + ): Promise; +} + +function requestId(request: Request): string { + const provided = request.headers.get("x-request-id"); + return provided && /^[\x21-\x7e]{1,128}$/.test(provided) + ? provided + : randomUUID(); +} + +function errorCode(error: unknown): string | undefined { + if (error instanceof DynamicAppsError) return error.code; + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + return typeof error.code === "string" ? error.code : undefined; +} + +function ordinaryRoutingError(error: unknown): Response | undefined { + if (error instanceof ApplicationHandlerError) { + return new Response("Internal Server Error", { status: 500 }); + } + const code = errorCode(error); + const message = error instanceof Error ? error.message : ""; + if (code === "agentos_apps_not_deployed") { + return new Response("Dynamic App has no active release", { status: 503 }); + } + if (code === "agentos_apps_region_not_deployed") { + const region = (error as { metadata?: { requestedRegion?: unknown } }) + .metadata?.requestedRegion; + return new Response( + `Dynamic App is not deployed in requested region ${typeof region === "string" ? region : "unknown"}`, + { status: 421 }, + ); + } + if (code === "agentos_apps_no_region") { + return new Response("Dynamic App has no configured region", { + status: 503, + }); + } + if (code === "agentos_apps_request_limit") { + if (message.includes("URL")) { + return new Response("Request URL exceeds Dynamic Apps limit", { + status: 414, + }); + } + if (message.includes("method")) { + return new Response("Request method exceeds Dynamic Apps limit", { + status: 400, + }); + } + if (message.includes("header")) { + return new Response("Request headers exceed Dynamic Apps limit", { + status: 431, + }); + } + if (message.includes("body")) { + return new Response("Request body exceeds Dynamic Apps limit", { + status: 413, + }); + } + } + return undefined; +} + +function exceptionResponse(error: unknown): Response { + const ordinary = ordinaryRoutingError(error); + if (ordinary) return ordinary; + const code = errorCode(error); + const status = + code === "agentos_apps_invalid_app_id" + ? 400 + : code === "agentos_apps_not_deployed" || + code === "agentos_apps_region_not_deployed" + ? 404 + : code === "agentos_apps_request_limit" + ? 413 + : code?.startsWith("agentos_apps_") + ? 503 + : 500; + return Response.json( + { + error: { + code: code ?? "agentos_apps_internal_error", + message: + error instanceof Error + ? error.message + : "Dynamic Apps request failed", + }, + }, + { status }, + ); +} + +export function createAppsRouter( + executor: AppRequestExecutor, +): Hono { + const router: Hono = new Hono(); + const handler = async (context: { + req: { + param(name: string): string | undefined; + path: string; + routePath: string; + raw: Request; + }; + }): Promise => { + try { + const appId = context.req.param("appId") ?? ""; + validateAppId(appId); + const original = context.req.raw; + if (Buffer.byteLength(original.url) > MAX_URL_BYTES) { + return new Response("Request URL exceeds Dynamic Apps limit", { + status: 414, + }); + } + if (Buffer.byteLength(original.method) > MAX_METHOD_BYTES) { + return new Response("Request method exceeds Dynamic Apps limit", { + status: 400, + }); + } + const url = new URL(original.url); + const parameterOffset = context.req.routePath.indexOf("/:appId"); + const mountPath = + parameterOffset < 0 + ? "" + : context.req.routePath.slice(0, parameterOffset); + const applicationPath = `${mountPath}/${appId}`; + const suffix = context.req.path.startsWith(applicationPath) + ? context.req.path.slice(applicationPath.length) + : ""; + if (suffix === "") { + url.pathname = `${url.pathname}/`; + return Response.redirect(url, 308); + } + url.pathname = suffix.startsWith("/") ? suffix : `/${suffix}`; + const forwarded = new Request(url, original); + return await executor.request(appId, forwarded, requestId(original)); + } catch (error) { + return exceptionResponse(error); + } + }; + + router.all("/:appId", handler); + router.all("/:appId/*", handler); + return router; +} diff --git a/packages/dynamic-apps-core/src/runtime.ts b/packages/dynamic-apps-core/src/runtime.ts new file mode 100644 index 000000000..63303a803 --- /dev/null +++ b/packages/dynamic-apps-core/src/runtime.ts @@ -0,0 +1,156 @@ +import { createHash } from "node:crypto"; +import { posix } from "node:path"; + +const MAX_FILE_PATH_BYTES = 1_024; + +export const DIRECT_ENTRYPOINT = "direct-v2/main.mjs"; +export const DIRECT_BUNDLE_PATH = "direct/main.mjs"; +export const ACTOR_BUNDLE_PATH = "actor/main.mjs"; +export const DIRECT_RUNTIME_FORMAT = "agentos-apps-direct-v2"; + +export function normalizeAppPath(input: string): string { + if (typeof input !== "string" || input.length === 0 || input.includes("\0")) { + throw new Error( + "application file paths must be non-empty strings without NUL bytes", + ); + } + const normalized = posix.normalize(`/${input}`).slice(1); + if ( + input.startsWith("/") || + input.split("/").includes("..") || + normalized === "" || + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + Buffer.byteLength(normalized) > MAX_FILE_PATH_BYTES + ) { + throw new Error( + `application file path escapes its root: ${JSON.stringify(input)}`, + ); + } + return normalized; +} + +export function canonicalDeploymentHash(input: { + files: Record; + entrypoint: string; + build: boolean; + packagingIdentity: string; + deploymentIdentity?: string; +}): string { + const hash = createHash("sha256"); + hash.update("agentos-apps-release-v19-mounted-hono-router\0"); + const field = (value: string | Uint8Array) => { + const bytes = typeof value === "string" ? Buffer.from(value) : value; + const length = Buffer.allocUnsafe(8); + length.writeBigUInt64BE(BigInt(bytes.byteLength)); + hash.update(length); + hash.update(bytes); + }; + for (const [path, content] of Object.entries(input.files).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + field(normalizeAppPath(path)); + field(content); + } + field(normalizeAppPath(input.entrypoint)); + field(JSON.stringify({ build: input.build })); + field(input.packagingIdentity); + field(input.deploymentIdentity ?? ""); + return hash.digest("hex"); +} + +/** + * Host-controlled wrapper bundled with the application. It exports one direct + * dispatcher and never opens a socket or starts a guest process of its own. + */ +export function directRunnerSource(input: { + entrypoint: string; + release: string; + maxResponseBytes: number; +}): string { + const entrypoint = `./${normalizeAppPath(input.entrypoint)}`; + return `const dynamicAppsModuleImportStartedAt = performance.now(); +const application = await import(${JSON.stringify(entrypoint)}); +const dynamicAppsModuleImportMs = performance.now() - dynamicAppsModuleImportStartedAt; +const exported = application.default; +const appFetch = typeof exported === "function" + ? exported + : typeof exported?.fetch === "function" + ? exported.fetch.bind(exported) + : undefined; +if (!appFetch) { + throw new TypeError( + "Dynamic App entrypoint default export must be an object with fetch(request)", + ); +} + +export const dynamicAppMetadata = Object.freeze({ + format: ${JSON.stringify(DIRECT_RUNTIME_FORMAT)}, + release: ${JSON.stringify(input.release)}, +}); + +export async function dispatch(input) { + const startedAt = performance.now(); + const body = input.bodyBase64 + ? Buffer.from(input.bodyBase64, "base64") + : undefined; + const request = new Request(input.url, { + method: input.method, + headers: input.headers, + body: input.method === "GET" || input.method === "HEAD" ? undefined : body, + }); + const requestBuiltAt = performance.now(); + const response = await appFetch(request); + const handlerAt = performance.now(); + if (!(response instanceof Response)) { + throw new TypeError("Dynamic App fetch handler must return a Response"); + } + const declaredLength = Number(response.headers.get("content-length") ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > ${input.maxResponseBytes}) { + throw new RangeError("Dynamic App response exceeds the configured limit"); + } + const responseBody = new Uint8Array(await response.arrayBuffer()); + if (responseBody.byteLength > ${input.maxResponseBytes}) { + throw new RangeError("Dynamic App response exceeds the configured limit"); + } + const headers = []; + response.headers.forEach((value, name) => { + if (name !== "set-cookie") headers.push([name, value]); + }); + for (const cookie of response.headers.getSetCookie?.() ?? []) { + headers.push(["set-cookie", cookie]); + } + const serializedAt = performance.now(); + return { + status: response.status, + statusText: response.statusText, + headers, + bodyBase64: Buffer.from(responseBody).toString("base64"), + timing: { + moduleImportMs: dynamicAppsModuleImportMs, + requestBuildMs: requestBuiltAt - startedAt, + handlerMs: handlerAt - requestBuiltAt, + responseSerializeMs: serializedAt - handlerAt, + dispatcherMs: serializedAt - startedAt, + }, + }; +} +`; +} + +/** Host-owned wrapper for the app's mounted actor callback handler. */ +export function actorRunnerSource(entrypointInput: string): string { + const entrypoint = `./${normalizeAppPath(entrypointInput)}`; + return `import application from ${JSON.stringify(entrypoint)}; +const appFetch = typeof application === "function" + ? application + : typeof application?.fetch === "function" + ? application.fetch.bind(application) + : undefined; +if (!appFetch) { + throw new TypeError("Dynamic App using RivetKit must default export a fetch handler with registry.handler() mounted under /api/rivet"); +} +export const handler = appFetch; +`; +} diff --git a/packages/dynamic-apps/src/source.ts b/packages/dynamic-apps-core/src/source.ts similarity index 100% rename from packages/dynamic-apps/src/source.ts rename to packages/dynamic-apps-core/src/source.ts diff --git a/packages/dynamic-apps-core/src/types.ts b/packages/dynamic-apps-core/src/types.ts new file mode 100644 index 000000000..c673a5220 --- /dev/null +++ b/packages/dynamic-apps-core/src/types.ts @@ -0,0 +1,141 @@ +import type { Hono } from "hono"; +import type { BlankEnv, BlankSchema } from "hono/types"; + +export interface AppScaling { + minReplicas?: number; + maxReplicas?: number; + targetConcurrency?: number; +} + +interface DeployAppBase { + appId: string; + /** @deprecated Retained by the Rivet adapter for source compatibility. */ + createNamespace?: boolean; + regions?: string[]; + scaling?: AppScaling; +} + +export type DeployAppInput = + | (DeployAppBase & { source: URL; files?: never }) + | (DeployAppBase & { + files: Record; + source?: never; + }); + +export interface ReleaseArtifact { + format: "agentos-apps-direct-v2"; + entrypoint: "direct-v2/main.mjs"; + hash: string; + bytes: Uint8Array; + byteLength: number; + usesRivetKit: boolean; +} + +export interface PublishReleaseInput { + appId: string; + buildId: string; + artifact: ReleaseArtifact; + regions?: string[]; + scaling?: AppScaling; + createdAt: number; +} + +export interface ActiveRelease { + appId: string; + release: string; + artifact: ReleaseArtifact; + regions: string[]; + scaling: Required; + maxRequestBytes: number; + maxResponseBytes: number; +} + +export type ReleaseInvalidation = () => void; +export type Unsubscribe = () => void | Promise; + +export interface ReleaseLoadContext { + /** Adds a store-specific sub-phase to request timing diagnostics. */ + recordTiming(name: string, durationMs: number): void; +} + +export type ExecutionMode = "ephemeral" | "pooled"; + +export interface ExecutorConfig { + executionMode: ExecutionMode; + contextPoolSize: number; + contextPoolMaxTotal: number; + contextIdleTtlMs: number; + contextHeapLimitMb: number; + runtimeCacheMaxEntries: number; + runtimeCacheMaxBytes: number; + runtimeCacheIdleTtlMs: number; + memoryHighWaterPercent: number; + executionConcurrency: number; + executionQueueSize: number; + executionQueueWaitMs: number; + executionTimeoutMs: number; + timingHeaders: boolean; + logRequests: boolean; +} + +export interface BuildConfig { + maxSourceBytes: number; + maxFiles: number; + maxDependencies: number; + buildTimeoutMs: number; + maxResponseBytes: number; + maxBuildOutputBytes: number; + maxBuildArtifactBytes: number; + maxBuildArtifactFiles: number; + maxBuildArtifactFileBytes: number; + maxBuildFilesystemBytes: number; +} + +export interface BuildArtifactCache { + get(buildId: string): Promise; + put(buildId: string, artifact: Uint8Array): Promise; +} + +export interface BuiltAppRelease { + buildId: string; + artifact: ReleaseArtifact; +} + +export interface DynamicAppsLogger { + info(event: Record): void; + error(event: Record): void; +} + +export interface DynamicAppsOptions { + publishRelease( + input: PublishReleaseInput, + options: TDeployOptions | undefined, + ): Promise; + loadActiveRelease( + appId: string, + context: ReleaseLoadContext, + ): Promise; + watchActiveRelease( + appId: string, + invalidate: ReleaseInvalidation, + ): Promise; + executor?: Partial; + build?: Partial; + artifactCache?: BuildArtifactCache; + logger?: DynamicAppsLogger; +} + +export interface DynamicApps { + deployApp( + input: DeployAppInput, + options?: TDeployOptions, + ): Promise; + appsRouter: Hono; + diagnostics(): Record; + dispose(): Promise; +} + +export interface BuildAppReleaseInput { + appId: string; + files: Record; +} diff --git a/packages/dynamic-apps-core/tests/build.test.ts b/packages/dynamic-apps-core/tests/build.test.ts new file mode 100644 index 000000000..ec7b4ffbf --- /dev/null +++ b/packages/dynamic-apps-core/tests/build.test.ts @@ -0,0 +1,95 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; +import { afterEach, describe, expect, test } from "vitest"; +import { buildAppRelease, readBuildConfig } from "../src/build.js"; +import { DIRECT_ENTRYPOINT, DIRECT_RUNTIME_FORMAT } from "../src/runtime.js"; + +const execFileAsync = promisify(execFile); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("buildAppRelease", () => { + test("validates security ceiling overrides", () => { + expect(() => readBuildConfig({ maxFiles: 2_001 })).toThrowError( + /maximum|maxFiles|between/, + ); + expect(readBuildConfig({ maxFiles: 1 }).maxFiles).toBe(1); + }); + + test("verifies and copies cached artifacts", async () => { + const cached = await makeArtifact(); + let cacheKey = ""; + const result = await buildAppRelease( + { + appId: "demo", + files: { + "package.json": new TextEncoder().encode( + JSON.stringify({ type: "module", main: "index.js" }), + ), + "index.js": new TextEncoder().encode( + "export default { fetch() { return new Response('ok') } }", + ), + }, + }, + { + artifactCache: { + async get(buildId) { + cacheKey = buildId; + return cached; + }, + async put() { + throw new Error("cache hit must not write"); + }, + }, + }, + ); + expect(result.buildId).toBe(cacheKey); + expect(result.buildId).toMatch(/^[a-f0-9]{64}$/); + expect(result.artifact).toMatchObject({ + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + byteLength: cached.byteLength, + usesRivetKit: false, + }); + expect(result.artifact.hash).toBe( + createHash("sha256").update(cached).digest("hex"), + ); + const original = result.artifact.bytes[0]; + cached[0] = original === 0 ? 1 : 0; + expect(result.artifact.bytes[0]).toBe(original); + }); +}); + +async function makeArtifact(): Promise { + const directory = await mkdtemp(join(tmpdir(), "dynamic-apps-core-build-")); + temporaryDirectories.push(directory); + await mkdir(join(directory, "direct")); + await writeFile( + join(directory, "direct", "main.mjs"), + `export const dynamicAppMetadata = { format: ${JSON.stringify(DIRECT_RUNTIME_FORMAT)} }; +export async function dispatch() { return { status: 200, headers: [], bodyBase64: "" }; }`, + ); + await writeFile( + join(directory, "agentos-package.json"), + JSON.stringify({ name: "test-app", version: "1.0.0" }), + ); + const archive = join(directory, "app.tar"); + await execFileAsync( + "tar", + ["-cf", archive, "direct", "agentos-package.json"], + { cwd: directory }, + ); + return new Uint8Array(packAospkgFromTarBytes(await readFile(archive)).bytes); +} diff --git a/packages/dynamic-apps-core/tests/core.test.ts b/packages/dynamic-apps-core/tests/core.test.ts new file mode 100644 index 000000000..6c598bf34 --- /dev/null +++ b/packages/dynamic-apps-core/tests/core.test.ts @@ -0,0 +1,231 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; +import { afterEach, describe, expect, test } from "vitest"; +import { createDynamicApps } from "../src/index.js"; +import type { + ActiveRelease, + PublishReleaseInput, + ReleaseInvalidation, +} from "../src/types.js"; + +const execFileAsync = promisify(execFile); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("createDynamicApps", () => { + test("publishes a copied artifact and serves warm requests without hooks", async () => { + const bytes = await makeArtifact("one"); + let active: ActiveRelease | undefined; + let invalidate: ReleaseInvalidation | undefined; + let loads = 0; + let publishes = 0; + let watches = 0; + let unsubscribes = 0; + let observedPublish: PublishReleaseInput | undefined; + const dynamicApps = createDynamicApps({ + artifactCache: { + async get() { + return bytes; + }, + async put() {}, + }, + async publishRelease(input) { + publishes += 1; + observedPublish = input; + active = { + appId: input.appId, + release: `release-${publishes}`, + artifact: { + ...input.artifact, + bytes: new Uint8Array(input.artifact.bytes), + }, + regions: input.regions ?? ["local"], + scaling: { + minReplicas: 0, + maxReplicas: 128, + targetConcurrency: 8, + }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; + input.artifact.bytes[0] ^= 1; + return { release: active.release }; + }, + async loadActiveRelease() { + loads += 1; + return active; + }, + async watchActiveRelease(_appId, callback) { + watches += 1; + invalidate = callback; + return () => { + unsubscribes += 1; + }; + }, + executor: { + executionMode: "ephemeral", + timingHeaders: true, + }, + }); + try { + const deployment = await dynamicApps.deployApp({ + appId: "demo", + files: { + "package.json": JSON.stringify({ + type: "module", + main: "index.js", + }), + "index.js": "export default { fetch() {} }", + }, + }); + expect(deployment).toEqual({ release: "release-1" }); + expect(observedPublish).not.toHaveProperty("files"); + expect(active?.artifact.bytes[0]).toBe(bytes[0]); + + const first = await dynamicApps.appsRouter.request("/demo/"); + expect(await first.text()).toBe("one"); + const second = await dynamicApps.appsRouter.request("/demo/"); + expect(await second.text()).toBe("one"); + expect({ loads, watches }).toEqual({ loads: 1, watches: 1 }); + expect(first.headers.get("x-agentos-app-release")).toBe("release-1"); + + invalidate?.(); + await waitFor(() => loads === 2); + } finally { + await dynamicApps.dispose(); + } + expect(unsubscribes).toBe(1); + }); + + test("subscribes before the first release load", async () => { + const bytes = await makeArtifact("ordered"); + const order: string[] = []; + let makeReady = () => {}; + const ready = new Promise((resolve) => { + makeReady = resolve; + }); + const dynamicApps = createDynamicApps({ + async publishRelease() {}, + async watchActiveRelease() { + order.push("watch"); + await ready; + order.push("ready"); + return () => {}; + }, + async loadActiveRelease(appId) { + order.push("load"); + return release(appId, "ordered", bytes); + }, + executor: { executionMode: "ephemeral" }, + }); + try { + const response = dynamicApps.appsRouter.request("/demo/"); + await waitFor(() => order.includes("watch")); + expect(order).toEqual(["watch"]); + makeReady(); + expect(await (await response).text()).toBe("ordered"); + expect(order).toEqual(["watch", "ready", "load"]); + } finally { + makeReady(); + await dynamicApps.dispose(); + } + }); + + test("caches a verified copy of release metadata", async () => { + const bytes = await makeArtifact("copied"); + const loaded = release("demo", "before", bytes); + const dynamicApps = createDynamicApps({ + async publishRelease() {}, + async watchActiveRelease() { + return () => {}; + }, + async loadActiveRelease() { + return loaded; + }, + executor: { executionMode: "ephemeral" }, + }); + try { + const first = await dynamicApps.appsRouter.request("/demo/"); + expect(first.headers.get("x-agentos-app-release")).toBe("release-before"); + loaded.release = "release-after"; + loaded.regions[0] = "mutated"; + loaded.artifact.bytes[0] ^= 1; + const second = await dynamicApps.appsRouter.request("/demo/"); + expect(second.headers.get("x-agentos-app-release")).toBe( + "release-before", + ); + expect(await second.text()).toBe("copied"); + } finally { + await dynamicApps.dispose(); + } + }); +}); + +function release( + appId: string, + name: string, + bytes: Uint8Array, +): ActiveRelease { + return { + appId, + release: `release-${name}`, + artifact: { + format: "agentos-apps-direct-v2", + entrypoint: "direct-v2/main.mjs", + hash: createHash("sha256").update(bytes).digest("hex"), + bytes: new Uint8Array(bytes), + byteLength: bytes.byteLength, + usesRivetKit: false, + }, + regions: ["local"], + scaling: { minReplicas: 0, maxReplicas: 128, targetConcurrency: 8 }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; +} + +async function makeArtifact(body: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "dynamic-apps-core-test-")); + temporaryDirectories.push(directory); + await mkdir(join(directory, "direct")); + await writeFile( + join(directory, "direct", "main.mjs"), + `export async function dispatch() { return { + status: 200, + statusText: "OK", + headers: [], + bodyBase64: Buffer.from(${JSON.stringify(body)}).toString("base64"), + }; }`, + ); + await writeFile( + join(directory, "agentos-package.json"), + JSON.stringify({ name: "test-app", version: "1.0.0" }), + ); + const archive = join(directory, "app.tar"); + await execFileAsync( + "tar", + ["-cf", archive, "direct", "agentos-package.json"], + { cwd: directory }, + ); + return new Uint8Array(packAospkgFromTarBytes(await readFile(archive)).bytes); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + throw new Error("condition did not become true"); +} diff --git a/packages/dynamic-apps-core/tsconfig.json b/packages/dynamic-apps-core/tsconfig.json new file mode 100644 index 000000000..8b743bfdd --- /dev/null +++ b/packages/dynamic-apps-core/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src", "tests"] +} diff --git a/packages/dynamic-apps/API_CONTRACT.md b/packages/dynamic-apps/API_CONTRACT.md index 9ccfd805d..4ac1343fa 100644 --- a/packages/dynamic-apps/API_CONTRACT.md +++ b/packages/dynamic-apps/API_CONTRACT.md @@ -4,6 +4,12 @@ Status: normative rewrite contract Baseline: `@rivet-dev/dynamic-apps@0.2.15`, JJ `xuymorrq`, commit `baca1719` Scope: `appsRouter`, `deployApp`, and structured log delivery +The retained three-value surface is unchanged by the core extraction. Ordinary +deployment now prepares and builds through `@rivet-dev/dynamic-apps-core`, then +publishes through the Rivet release store. The optional injected-client path +intentionally continues to call the legacy actor `deploy` action for structural +source compatibility. + This file is the source of truth that must be written and verified against the old implementation before its internals are deleted. It deliberately does not preserve any other root export, subpath export, actor name except the private diff --git a/packages/dynamic-apps/README.md b/packages/dynamic-apps/README.md index 186d538ca..1325abbe8 100644 --- a/packages/dynamic-apps/README.md +++ b/packages/dynamic-apps/README.md @@ -1,5 +1,9 @@ # `@rivet-dev/dynamic-apps` +This package is the batteries-included, **Rivet-backed adapter** for Dynamic +Apps. For storage you control, use +[`@rivet-dev/dynamic-apps-core`](../dynamic-apps-core/README.md). + Dynamic Apps exposes three runtime values: - `deployApp(input, options?)` builds, persists, and activates an immutable app @@ -9,11 +13,10 @@ Dynamic Apps exposes three runtime values: - `setDynamicAppsLogHandler(handler)` receives structured application, actor, build, and runtime logs. -Deployment still uses the existing resource-bounded agentOS build VM, builder -package, AOSP artifact chunks, activation ordering, rollback behavior, and -`releaseActivated` event. The serving path no longer uses scaler or execution -actors. After the first resolve/download, a cache-hit request makes no actor -call. +Deployment builds through the storage-independent core, then the adapter stores +the AOSP artifact through its per-app Rivet actor with atomic activation, +rollback behavior, and a `releaseActivated` event. After the first load, a +cache-hit request makes no actor or storage call. ## Direct HTTP contract diff --git a/packages/dynamic-apps/package.json b/packages/dynamic-apps/package.json index c2cfa257c..e8bc01d3c 100644 --- a/packages/dynamic-apps/package.json +++ b/packages/dynamic-apps/package.json @@ -1,6 +1,6 @@ { "name": "@rivet-dev/dynamic-apps", - "version": "0.12.0-rc.1", + "version": "0.12.0-rc.2", "description": "Build and run user-generated HTTP applications in agentOS with durable Rivet state.", "license": "Apache-2.0", "repository": { @@ -31,15 +31,13 @@ "test": "vitest run" }, "dependencies": { - "@agentos-software/sh": "0.2.15", - "@agentos-software/tar": "0.3.5", - "@rivet-dev/agentos-core": "0.2.15", - "@rivet-dev/agentos-toolchain": "0.2.15", - "@rivet-dev/dynamic-apps-builder": "workspace:0.12.0-rc.1", + "@rivet-dev/dynamic-apps-core": "workspace:0.12.0-rc.2", "hono": "^4.7.0", "rivetkit": "2.3.11" }, "devDependencies": { + "@rivet-dev/agentos-core": "0.2.15", + "@rivet-dev/agentos-toolchain": "0.2.15", "@types/node": "^22.19.15", "tsup": "^8.4.0", "typescript": "^5.7.3", diff --git a/packages/dynamic-apps/src/actor-runtime.ts b/packages/dynamic-apps/src/actor-runtime.ts index 972c18825..505f0f5f5 100644 --- a/packages/dynamic-apps/src/actor-runtime.ts +++ b/packages/dynamic-apps/src/actor-runtime.ts @@ -12,10 +12,13 @@ import { tmpdir } from "node:os"; import { dirname, join, posix } from "node:path"; import { pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; -import { DynamicAppsError } from "./errors.js"; +import { + ACTOR_BUNDLE_PATH, + capConcurrencyForMemory, + DynamicAppsError, + readCgroupMemory, +} from "@rivet-dev/dynamic-apps-core/internal"; import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; -import { capConcurrencyForMemory, readCgroupMemory } from "./memory.js"; -import { ACTOR_BUNDLE_PATH } from "./runtime.js"; const MAX_ACTOR_FILES = 4_096; const MAX_ACTOR_FILE_BYTES = 32 * 1024 * 1024; diff --git a/packages/dynamic-apps/src/actors.ts b/packages/dynamic-apps/src/actors.ts index c38dd4457..1cc2cbca7 100644 --- a/packages/dynamic-apps/src/actors.ts +++ b/packages/dynamic-apps/src/actors.ts @@ -1,19 +1,14 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; -import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import sh from "@agentos-software/sh"; -import tar from "@agentos-software/tar"; +import type { + AppScaling, + BuildArtifactCache, +} from "@rivet-dev/dynamic-apps-core"; import { - AgentOs, - type AgentOsOptions, - createHostDirBackend, -} from "@rivet-dev/agentos-core"; -import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; -import appsBuilder, { - appBundleManifestVersion, - appsBuilderVersion, -} from "@rivet-dev/dynamic-apps-builder"; + buildAppRelease, + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, + DynamicAppsError, +} from "@rivet-dev/dynamic-apps-core/internal"; import { type AnyActorDefinition, actor, UserError } from "rivetkit"; import { db, type RawAccess } from "rivetkit/db"; import { getDefaultActorRuntime } from "./actor-runtime.js"; @@ -22,46 +17,28 @@ import { provisionAppNamespace, resolveDefaultRivetConnection, } from "./control-plane.js"; -import { DynamicAppsError } from "./errors.js"; -import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; -import { - APP_CALLBACK_SECRET_HEADER, - actorRunnerSource, - canonicalDeploymentHash, - DIRECT_BUNDLE_PATH, - DIRECT_ENTRYPOINT, - DIRECT_RUNTIME_FORMAT, - directRunnerSource, - normalizeAppPath, -} from "./runtime.js"; +import { APP_CALLBACK_SECRET_HEADER } from "./runtime.js"; import type { AppReleaseInfo, - AppScaling, + AppRouteResolution, Deployment, PreparedDeployAppInput, } from "./types.js"; -const DEFAULT_MAX_SOURCE_BYTES = 4 * 1024 * 1024; -const DEFAULT_MAX_FILES = 2_000; const DEFAULT_MAX_VERSIONS = 20; const DEFAULT_MAX_REGIONS = 8; -const DEFAULT_MAX_DEPENDENCIES = 256; -const DEFAULT_BUILD_TIMEOUT_MS = 15 * 60_000; const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; -const DEFAULT_MAX_BUILD_OUTPUT_BYTES = 2 * 1024 * 1024; -const DEFAULT_MAX_BUILD_ARTIFACT_BYTES = 64 * 1024 * 1024; -const DEFAULT_MAX_BUILD_ARTIFACT_FILES = 4_096; -const DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES = 32 * 1024 * 1024; -const DEFAULT_MAX_BUILD_FILESYSTEM_BYTES = 2 * 1024 * 1024 * 1024; const MAX_REPLICAS = 128; export const ARTIFACT_CHUNK_BYTES = 512 * 1024; +const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const MAX_ARTIFACT_CHUNKS = Math.ceil( - DEFAULT_MAX_BUILD_ARTIFACT_BYTES / ARTIFACT_CHUNK_BYTES, + MAX_ARTIFACT_BYTES / ARTIFACT_CHUNK_BYTES, ); const SOURCE_CHUNK_BYTES = 512 * 1024; const MAX_SOURCE_CHUNKS = - Math.ceil(DEFAULT_MAX_SOURCE_BYTES / SOURCE_CHUNK_BYTES) + DEFAULT_MAX_FILES; + Math.ceil((4 * 1024 * 1024) / SOURCE_CHUNK_BYTES) + 2_000; +const RELEASE_PATTERN = /^[a-f0-9]{64}$/; type AnyActorContext = { actorId: string; @@ -77,40 +54,6 @@ type AnyActorContext = { }; }; -interface ExecResult { - exitCode: number; - stdout: string; - stderr: string; -} - -interface BuildHandle { - artifactGuestPath: string; - writeFiles( - entries: Array<{ path: string; content: string | Uint8Array }>, - ): Promise>; - execArgv( - command: string, - args: string[], - options?: { - cwd?: string; - env?: Record; - timeout?: number; - captureStdio?: boolean; - }, - ): Promise; - artifactSize(): Promise; - readArtifact(): Promise; - dispose(): Promise; -} - -interface BuildPlan { - entrypoint: string; - build: boolean; - dependencyCount: number; - hasLockfile: boolean; - usesRivetKit: boolean; -} - export interface StoredAppRelease extends AppReleaseInfo { entrypoint: string; namespace: string; @@ -127,25 +70,45 @@ export interface AppState { cloudNamespace?: string | null; runnerToken?: string | null; publicToken?: string | null; + publishSequence?: number; + latestPublishSequence?: number; +} + +export interface DynamicAppsActors { + agentOSAppsApp: AnyActorDefinition; } -export interface AppRouteResolution { +interface BeginReleasePublishInput { appId: string; - release: string; - region: string; - regions: string[]; - revision: number; + buildId: string; + format: typeof DIRECT_RUNTIME_FORMAT; + entrypoint: typeof DIRECT_ENTRYPOINT; artifactHash: string; artifactBytes: number; - entrypoint: typeof DIRECT_ENTRYPOINT; - namespace: string; - scaling: Required; - maxRequestBytes: number; - maxResponseBytes: number; + usesRivetKit: boolean; + regions?: string[]; + scaling?: AppScaling; + createdAt: number; } -export interface DynamicAppsActors { - agentOSAppsApp: AnyActorDefinition; +interface BeginReleasePublishResult { + release: string; + sequence: number; + uploadRequired: boolean; + chunkBytes: number; +} + +interface WriteReleaseChunkInput { + release: string; + sequence: number; + index: number; + content: Uint8Array; +} + +interface CommitReleasePublishInput { + release: string; + sequence: number; + chunks: number; } const locks = new Map>(); @@ -175,6 +138,17 @@ function fail( throw new UserError(message, { code, metadata }); } +async function actorBoundary(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (error instanceof DynamicAppsError) { + fail(error.code, error.message, error.metadata); + } + throw error; + } +} + function positiveInteger(value: number, name: string, maximum: number): number { if (!Number.isInteger(value) || value < 1 || value > maximum) { fail( @@ -189,6 +163,12 @@ function positiveInteger(value: number, name: string, maximum: number): number { export function normalizeScaling( input: AppScaling | undefined, ): Required { + if ( + input !== undefined && + (typeof input !== "object" || input === null || Array.isArray(input)) + ) { + fail("agentos_apps_invalid_scaling", "scaling must be an object"); + } const minReplicas = input?.minReplicas ?? 0; const maxReplicas = input?.maxReplicas ?? 128; const targetConcurrency = input?.targetConcurrency ?? 8; @@ -213,6 +193,31 @@ export function normalizeScaling( return { minReplicas, maxReplicas, targetConcurrency }; } +function normalizeRegions( + regions: string[] | undefined, + fallbackRegion: string, +): string[] { + if (regions !== undefined && !Array.isArray(regions)) { + fail("agentos_apps_invalid_regions", "regions must be an array"); + } + const unique = [...new Set(regions ?? [fallbackRegion || "default"])]; + if (unique.length === 0 || unique.length > DEFAULT_MAX_REGIONS) { + fail( + "agentos_apps_invalid_regions", + `an app must have between 1 and ${DEFAULT_MAX_REGIONS} regions`, + ); + } + for (const region of unique) { + if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(region)) { + fail( + "agentos_apps_invalid_region", + `invalid region ${JSON.stringify(region)}`, + ); + } + } + return unique; +} + export async function migrateAppsTables(database: RawAccess): Promise { await database.execute(` CREATE TABLE IF NOT EXISTS agentos_apps_releases ( @@ -418,7 +423,14 @@ async function readStoredArtifact( WHERE release_id = ? ORDER BY chunk_index ASC`, release.release, ); - if (rows.length === 0 || rows.length > MAX_ARTIFACT_CHUNKS) { + const expectedChunks = Math.ceil( + release.artifactBytes / ARTIFACT_CHUNK_BYTES, + ); + if ( + rows.length !== expectedChunks || + rows.length < 1 || + rows.length > MAX_ARTIFACT_CHUNKS + ) { fail( "agentos_apps_artifact_manifest_invalid", `artifact ${release.release} has an invalid chunk count`, @@ -429,11 +441,16 @@ async function readStoredArtifact( for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; const content = row ? new Uint8Array(row.content) : undefined; + const expected = + index === rows.length - 1 + ? release.artifactBytes - index * ARTIFACT_CHUNK_BYTES + : ARTIFACT_CHUNK_BYTES; if ( !row || !content || Number(row.chunk_index) !== index || - content.byteLength !== Number(row.byte_length) + content.byteLength !== Number(row.byte_length) || + content.byteLength !== expected ) { fail( "agentos_apps_artifact_manifest_invalid", @@ -443,14 +460,9 @@ async function readStoredArtifact( bytes += content.byteLength; chunks.push(content); } - if (bytes !== release.artifactBytes) { - fail( - "agentos_apps_artifact_manifest_invalid", - `artifact ${release.release} failed its byte count`, - ); - } const artifact = new Uint8Array(Buffer.concat(chunks, bytes)); if ( + bytes !== release.artifactBytes || createHash("sha256").update(artifact).digest("hex") !== release.artifactHash ) { fail( @@ -511,703 +523,364 @@ function actorPublicEndpoint( return endpoint.toString(); } -function textFile( - files: Record, - path: string, -): string | undefined { - const content = files[path]; - return content ? new TextDecoder().decode(content) : undefined; +function validateBeginInput( + c: AnyActorContext, + input: BeginReleasePublishInput, +): string { + const appId = c.key[0]; + if (!appId || c.key.length !== 1 || input.appId !== appId) { + fail( + "agentos_apps_app_id_mismatch", + "deployApp appId must match the stable application actor key", + { appId: input.appId, actorKey: c.key }, + ); + } + if ( + input.format !== DIRECT_RUNTIME_FORMAT || + input.entrypoint !== DIRECT_ENTRYPOINT || + !RELEASE_PATTERN.test(input.buildId) || + !RELEASE_PATTERN.test(input.artifactHash) || + !Number.isSafeInteger(input.artifactBytes) || + input.artifactBytes < 1 || + input.artifactBytes > MAX_ARTIFACT_BYTES || + typeof input.usesRivetKit !== "boolean" || + !Number.isSafeInteger(input.createdAt) || + input.createdAt < 0 + ) { + fail("agentos_apps_publish_invalid", "release publish metadata is invalid"); + } + return appId; } -function packageExport(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (!value || typeof value !== "object" || Array.isArray(value)) return; - const object = value as Record; - return ( - packageExport(object["."]) ?? - packageExport(object.import) ?? - packageExport(object.default) - ); +function releaseIdFor(input: { + buildId: string; + artifactHash: string; + regions: string[]; + scaling: Required; + namespace: string; + endpoint: string; + pool: string; + usesRivetKit: boolean; +}): string { + const hash = createHash("sha256"); + hash.update("agentos-apps-rivet-release-v1\0"); + for (const value of [ + input.buildId, + input.artifactHash, + JSON.stringify(input.regions), + JSON.stringify(input.scaling), + input.namespace, + input.endpoint, + input.pool, + input.usesRivetKit ? "1" : "0", + ]) { + const bytes = Buffer.from(value); + const length = Buffer.allocUnsafe(8); + length.writeBigUInt64BE(BigInt(bytes.byteLength)); + hash.update(length); + hash.update(bytes); + } + return hash.digest("hex"); } -function installPackageJson( - files: Record, - plan: BuildPlan, -): Uint8Array | undefined { - const source = textFile(files, "package.json"); - if (!source || !plan.usesRivetKit) return files["package.json"]; - const value = JSON.parse(source) as { - dependencies?: Record; - devDependencies?: Record; - }; - for (const dependencies of [value.dependencies, value.devDependencies]) { - if (dependencies) delete dependencies.rivetkit; +async function beginReleasePublishLocked( + c: AnyActorContext, + input: BeginReleasePublishInput, +): Promise { + const appId = validateBeginInput(c, input); + const state = c.state as AppState; + const regions = normalizeRegions(input.regions, c.region); + const scaling = normalizeScaling(input.scaling); + const runtime = await provisionAppNamespace( + appId, + resolveDefaultRivetConnection(), + { + namespace: state.namespace, + cloudNamespace: state.cloudNamespace, + }, + ); + state.namespace = runtime.namespace; + state.cloudNamespace = runtime.cloudNamespace ?? null; + state.runnerToken = runtime.runnerToken ?? null; + state.publicToken = runtime.publicToken ?? null; + const releaseId = releaseIdFor({ + buildId: input.buildId, + artifactHash: input.artifactHash, + regions, + scaling, + namespace: runtime.namespace, + endpoint: runtime.endpoint, + pool: runtime.pool, + usesRivetKit: input.usesRivetKit, + }); + const releases = await listStoredReleases(c.db); + const existing = await getStoredRelease(c.db, releaseId); + const callbackSecret = input.usesRivetKit + ? existing?.callbackSecret || + releases.find((candidate) => candidate.callbackSecret)?.callbackSecret || + randomUUID() + : ""; + const sequence = (state.publishSequence ?? 0) + 1; + state.publishSequence = sequence; + state.latestPublishSequence = sequence; + const metadataMatches = + existing?.status === "ready" && + existing.entrypoint === DIRECT_ENTRYPOINT && + existing.artifactHash === input.artifactHash && + existing.artifactBytes === input.artifactBytes && + JSON.stringify(existing.regions) === JSON.stringify(regions) && + JSON.stringify(existing.scaling) === JSON.stringify(scaling) && + existing.namespace === runtime.namespace && + existing.runtimeEndpoint === runtime.endpoint && + existing.runtimePool === runtime.pool && + existing.usesRivetKit === input.usesRivetKit; + if (metadataMatches) { + return { + release: releaseId, + sequence, + uploadRequired: false, + chunkBytes: ARTIFACT_CHUNK_BYTES, + }; } - return new TextEncoder().encode(JSON.stringify(value)); + await deleteArtifactChunksBatched(c.db, releaseId); + await c.db.execute( + `INSERT INTO agentos_apps_releases ( + release_id, created_at, status, entrypoint, + artifact_hash, artifact_bytes, build_error, + regions_json, scaling_json, namespace, envoy_version, + runtime_endpoint, runtime_pool, callback_secret, uses_rivetkit + ) VALUES (?, ?, 'building', ?, ?, ?, NULL, ?, ?, ?, 1, ?, ?, ?, ?) + ON CONFLICT(release_id) DO UPDATE SET + status = 'building', entrypoint = excluded.entrypoint, + artifact_hash = excluded.artifact_hash, + artifact_bytes = excluded.artifact_bytes, build_error = NULL, + regions_json = excluded.regions_json, + scaling_json = excluded.scaling_json, + namespace = excluded.namespace, + runtime_endpoint = excluded.runtime_endpoint, + runtime_pool = excluded.runtime_pool, + callback_secret = excluded.callback_secret, + uses_rivetkit = excluded.uses_rivetkit`, + releaseId, + existing?.createdAt ?? input.createdAt, + DIRECT_ENTRYPOINT, + input.artifactHash, + input.artifactBytes, + JSON.stringify(regions), + JSON.stringify(scaling), + runtime.namespace, + runtime.endpoint, + runtime.pool, + callbackSecret, + input.usesRivetKit ? 1 : 0, + ); + return { + release: releaseId, + sequence, + uploadRequired: true, + chunkBytes: ARTIFACT_CHUNK_BYTES, + }; } -function validateDeployment( - input: PreparedDeployAppInput, - limits: { maxSourceBytes: number; maxFiles: number; maxDependencies: number }, -): BuildPlan { - if (!input || typeof input !== "object" || !input.files) { +async function writeReleaseChunk( + c: AnyActorContext, + input: WriteReleaseChunkInput, +): Promise { + const state = c.state as AppState; + if ( + !RELEASE_PATTERN.test(input.release) || + !Number.isSafeInteger(input.sequence) || + input.sequence < 1 || + input.sequence !== state.latestPublishSequence || + !Number.isSafeInteger(input.index) || + input.index < 0 || + !(input.content instanceof Uint8Array) + ) { fail( - "agentos_apps_invalid_files", - "deployApp files must contain the complete application tree", + "agentos_apps_invalid_artifact_chunk", + "release chunk metadata is invalid or superseded", ); } - const files = Object.entries(input.files); - if (files.length === 0 || files.length > limits.maxFiles) { + const release = await getStoredRelease(c.db, input.release); + if (!release || release.status !== "building") { fail( - "agentos_apps_file_count_limit", - `deployment must contain between 1 and ${limits.maxFiles} files`, - { observed: files.length, limit: limits.maxFiles }, + "agentos_apps_artifact_not_building", + `release ${input.release} is not accepting artifact chunks`, ); } - let sourceBytes = 0; - const normalizedFiles: Record = {}; - for (const [path, content] of files) { - const normalizedPath = normalizeAppPath(path); - if (normalizedFiles[normalizedPath]) { - fail( - "agentos_apps_duplicate_file_path", - `multiple deployment paths normalize to ${normalizedPath}`, - ); - } - if (!(content instanceof Uint8Array)) { - fail( - "agentos_apps_invalid_file", - `deployment file ${path} must be a string or Uint8Array`, - ); - } - normalizedFiles[normalizedPath] = content; - sourceBytes += content.byteLength; - } - if (sourceBytes > limits.maxSourceBytes) { + const chunks = Math.ceil(release.artifactBytes / ARTIFACT_CHUNK_BYTES); + const expected = + input.index === chunks - 1 + ? release.artifactBytes - input.index * ARTIFACT_CHUNK_BYTES + : ARTIFACT_CHUNK_BYTES; + if (input.index >= chunks || input.content.byteLength !== expected) { fail( - "agentos_apps_source_limit", - `deployment source is ${sourceBytes} bytes, exceeding maxSourceBytes ${limits.maxSourceBytes}`, - { observed: sourceBytes, limit: limits.maxSourceBytes }, + "agentos_apps_invalid_artifact_chunk", + `artifact chunk ${input.index} has an invalid length`, ); } - input.files = normalizedFiles; - const packageJsonSource = textFile(normalizedFiles, "package.json"); - if (!packageJsonSource) { + const content = new Uint8Array(input.content); + await c.db.execute( + `INSERT INTO agentos_apps_artifact_chunks + (release_id, chunk_index, content, byte_length) + VALUES (?, ?, ?, ?) + ON CONFLICT(release_id, chunk_index) DO UPDATE SET + content = excluded.content, byte_length = excluded.byte_length`, + input.release, + input.index, + content, + content.byteLength, + ); +} + +async function commitReleasePublishLocked( + c: AnyActorContext, + input: CommitReleasePublishInput, +): Promise { + const state = c.state as AppState; + if ( + !RELEASE_PATTERN.test(input.release) || + !Number.isSafeInteger(input.sequence) || + input.sequence !== state.latestPublishSequence + ) { fail( - "agentos_apps_entrypoint_not_found", - "direct applications must contain package.json and a server entrypoint", + "agentos_apps_publish_superseded", + "a newer release publish superseded this upload", ); } - let packageJson: { - dependencies?: unknown; - devDependencies?: unknown; - scripts?: { build?: unknown }; - exports?: unknown; - main?: unknown; - }; - try { - packageJson = JSON.parse(packageJsonSource); - } catch (error) { + const release = await getStoredRelease(c.db, input.release); + if ( + !release || + (release.status !== "building" && release.status !== "ready") + ) { fail( - "agentos_apps_invalid_package_json", - "package.json is not valid JSON", - { error: String(error) }, + "agentos_apps_artifact_not_ready", + `release ${input.release} cannot be committed`, ); } - const dependencyMaps = [ - packageJson.dependencies, - packageJson.devDependencies, - ].filter( - (value): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value), - ); - const dependencyCount = dependencyMaps.reduce( - (count, dependencies) => count + Object.keys(dependencies).length, - 0, + const expectedChunks = Math.ceil( + release.artifactBytes / ARTIFACT_CHUNK_BYTES, ); - if (dependencyCount > limits.maxDependencies) { + if ( + !Number.isSafeInteger(input.chunks) || + input.chunks !== expectedChunks || + input.chunks < 1 || + input.chunks > MAX_ARTIFACT_CHUNKS + ) { fail( - "agentos_apps_dependency_limit", - `deployment has ${dependencyCount} dependencies, exceeding maxDependencies ${limits.maxDependencies}`, - { observed: dependencyCount, limit: limits.maxDependencies }, + "agentos_apps_artifact_manifest_invalid", + "release commit has an invalid artifact chunk count", ); } - const usesRivetKit = dependencyMaps.some( - (dependencies) => typeof dependencies.rivetkit === "string", - ); - const build = typeof packageJson.scripts?.build === "string"; - const declared = - packageExport(packageJson.exports) ?? - (typeof packageJson.main === "string" ? packageJson.main : undefined); - if (declared) { - return { - entrypoint: normalizeAppPath(declared), - build, - dependencyCount, - hasLockfile: Boolean(normalizedFiles["package-lock.json"]), - usesRivetKit, - }; + await readStoredArtifact(c.db, release); + if (release.status === "ready" && state.activeRelease === release.release) { + return deploymentForRelease(c, state, release); } - for (const candidate of [ - "src/index.mjs", - "src/index.js", - "index.mjs", - "index.js", - ]) { - if (normalizedFiles[candidate]) { - return { - entrypoint: candidate, - build, - dependencyCount, - hasLockfile: Boolean(normalizedFiles["package-lock.json"]), - usesRivetKit, - }; - } - } - fail( - "agentos_apps_entrypoint_not_found", - "could not infer a direct server entrypoint", - ); -} - -function normalizeRegions( - regions: string[] | undefined, - fallbackRegion: string, - maxRegions: number, -): string[] { - const unique = [...new Set(regions ?? [fallbackRegion || "default"])]; - if (unique.length === 0 || unique.length > maxRegions) { - fail( - "agentos_apps_invalid_regions", - `an app must have between 1 and ${maxRegions} regions`, - { maxRegions }, - ); - } - for (const region of unique) { - if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(region)) { - fail( - "agentos_apps_invalid_region", - `invalid region ${JSON.stringify(region)}`, - ); - } - } - return unique; -} - -function boundedOutput(value: string, maximum: number): string { - const bytes = Buffer.from(value); - if (bytes.byteLength <= maximum) return value; - return `${bytes.subarray(0, maximum).toString("utf8")}\n[truncated at ${maximum} bytes]`; -} - -function emitBuildOutput( - appId: string, - release: string, - result: Pick, -): void { - for (const stream of ["stdout", "stderr"] as const) { - const decoder = new DynamicAppsLogLineDecoder((message, truncated) => - emitDynamicAppsLog({ - level: stream === "stdout" ? "info" : "error", - source: "build", - message, - appId, - release, - stream, - ...(truncated ? { metadata: { truncated: true } } : {}), - }), + if (release.status !== "ready") { + await c.db.execute( + `UPDATE agentos_apps_releases SET status = 'ready', build_error = NULL + WHERE release_id = ?`, + release.release, ); - decoder.write(Buffer.from(result[stream])); - decoder.end(); + release.status = "ready"; } -} - -function throwCommandFailure( - kind: "install" | "build" | "pack", - command: string, - result: ExecResult, - maxOutputBytes: number, -): never { - fail( - `agentos_apps_${kind}_failed`, - `${command} failed with exit code ${result.exitCode}`, + const appId = c.key[0]; + if (!appId) + fail("agentos_apps_invalid_app_id", "application actor key is missing"); + const runtime = await provisionAppNamespace( + appId, + resolveDefaultRivetConnection(), { - exitCode: result.exitCode, - stdout: boundedOutput(result.stdout, maxOutputBytes), - stderr: boundedOutput(result.stderr, maxOutputBytes), + namespace: state.namespace, + cloudNamespace: state.cloudNamespace, }, ); -} - -async function buildRelease( - c: AnyActorContext, - input: PreparedDeployAppInput, - plan: BuildPlan, - release: string, - config: { - createBuildVm: () => Promise; - buildTimeoutMs: number; - maxResponseBytes: number; - maxBuildOutputBytes: number; - maxBuildArtifactBytes: number; - artifactCache?: { - get(release: string): Promise; - put(release: string, artifact: Uint8Array): Promise; - }; - }, -): Promise<{ hash: string; size: number; bytes: Uint8Array }> { - const cached = await config.artifactCache?.get(release); - if (cached) { - if (cached.byteLength > config.maxBuildArtifactBytes) { - fail( - "agentos_apps_build_artifact_size_limit", - `cached artifact exceeds ${config.maxBuildArtifactBytes} bytes`, - ); - } - return { - hash: createHash("sha256").update(cached).digest("hex"), - size: cached.byteLength, - bytes: cached, - }; - } - const build = await config.createBuildVm(); - const startedAt = performance.now(); - const phase = (name: string) => { - const elapsedMs = performance.now() - startedAt; - c.log.info({ - msg: "Dynamic Apps build phase completed", - release, - phase: name, - elapsedMs, - }); - emitDynamicAppsLog({ - level: "info", - source: "build", - message: "Dynamic Apps build phase completed", - appId: input.appId, - release, - metadata: { phase: name, elapsedMs }, - }); - }; - let buildError: unknown; - try { - const files = Object.entries(input.files).map(([path, content]) => ({ - path: `/workspace/${normalizeAppPath(path)}`, - content: - path === "package.json" - ? (installPackageJson(input.files, plan) ?? content) - : content, - })); - files.push({ - path: "/workspace/direct-runner.mjs", - content: new TextEncoder().encode( - directRunnerSource({ - entrypoint: plan.entrypoint, - release, - maxResponseBytes: config.maxResponseBytes, - }), - ), - }); - if (plan.usesRivetKit) { - files.push({ - path: "/workspace/actor-runner.mjs", - content: new TextEncoder().encode(actorRunnerSource(plan.entrypoint)), - }); - } - const writes = await build.writeFiles(files); - const failedWrite = writes.find((entry) => !entry.success); - if (failedWrite) { - fail( - "agentos_apps_build_write_failed", - `failed to write build input ${failedWrite.path}: ${failedWrite.error ?? "unknown error"}`, - { path: failedWrite.path, error: failedWrite.error }, - ); - } - const installArgs = [ - plan.hasLockfile && !plan.usesRivetKit ? "ci" : "install", - "--install-strategy=shallow", - "--include=dev", - "--omit=optional", - "--omit=peer", - "--legacy-peer-deps", - "--no-audit", - "--no-fund", - "--maxsockets=16", - "--loglevel=error", - ]; - const install = await build.execArgv("npm", installArgs, { - cwd: "/workspace", - env: { NODE_ENV: "development", NPM_CONFIG_PRODUCTION: "false" }, - timeout: config.buildTimeoutMs, - captureStdio: true, - }); - emitBuildOutput(input.appId, release, install); - if (install.exitCode !== 0) { - throwCommandFailure( - "install", - `npm ${installArgs[0]}`, - install, - config.maxBuildOutputBytes, - ); - } - phase("dependencies_installed"); - if (plan.build) { - const result = await build.execArgv("npm", ["run", "build"], { - cwd: "/workspace", - timeout: config.buildTimeoutMs, - captureStdio: true, - }); - emitBuildOutput(input.appId, release, result); - if (result.exitCode !== 0) { - throwCommandFailure( - "build", - "npm run build", - result, - config.maxBuildOutputBytes, - ); - } - phase("application_built"); - } - const prune = await build.execArgv( - "npm", - [ - "prune", - "--omit=dev", - "--omit=optional", - "--omit=peer", - "--legacy-peer-deps", - ], - { - cwd: "/workspace", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, prune); - if (prune.exitCode !== 0) { - throwCommandFailure( - "install", - "npm prune --omit=dev --omit=optional", - prune, - config.maxBuildOutputBytes, - ); - } - const nativeAddonCheck = await build.execArgv( - "node", - [ - "-e", - 'const fs=require("node:fs"); const path=require("node:path"); const found=[]; const walk=(p)=>{if(!fs.existsSync(p))return; for(const e of fs.readdirSync(p,{withFileTypes:true})){const q=path.join(p,e.name); if(e.isDirectory())walk(q); else if(e.name.endsWith(".node"))found.push(q)}}; walk("node_modules"); if(found.length){console.error(found.slice(0,32).join("\\n")); process.exit(42)}', - ], - { - cwd: "/workspace", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, nativeAddonCheck); - if (nativeAddonCheck.exitCode === 42) { - fail( - "agentos_apps_native_addon_unsupported", - "application contains native Node addons", - { - files: boundedOutput( - nativeAddonCheck.stderr, - config.maxBuildOutputBytes, - ), - }, - ); - } - if (nativeAddonCheck.exitCode !== 0) { - throwCommandFailure( - "build", - "native addon scan", - nativeAddonCheck, - config.maxBuildOutputBytes, - ); - } - const directConfigPath = "/workspace/.agentos-app-direct-build.json"; - const configWrites = await build.writeFiles([ - { - path: directConfigPath, - content: JSON.stringify({ - version: release, - workspace: "/workspace", - release: "/release/direct", - entrypoint: "direct-runner.mjs", - sourceFiles: Object.keys(input.files), - usesRivetKit: plan.usesRivetKit, - directAgentOs: true, - maxOutputBytes: config.maxBuildArtifactBytes, - maxOutputFiles: DEFAULT_MAX_BUILD_ARTIFACT_FILES, - maxFileBytes: DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES, - }), - }, - ]); - const failedConfigWrite = configWrites.find((entry) => !entry.success); - if (failedConfigWrite) { - fail( - "agentos_apps_build_write_failed", - `failed to write Apps builder input ${failedConfigWrite.path}`, - ); - } - const directBundle = await build.execArgv( - "node", - ["/opt/agentos/bin/apps-builder", directConfigPath], - { - cwd: "/workspace", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, directBundle); - if (directBundle.exitCode !== 0) { - throwCommandFailure( - "build", - "apps-builder (direct)", - directBundle, - config.maxBuildOutputBytes, - ); - } - if (plan.usesRivetKit) { - const actorConfigPath = "/workspace/.agentos-app-actor-build.json"; - const actorConfigWrite = await build.writeFiles([ - { - path: actorConfigPath, - content: JSON.stringify({ - version: release, - workspace: "/workspace", - release: "/release/actor", - entrypoint: "actor-runner.mjs", - sourceFiles: Object.keys(input.files), - usesRivetKit: true, - platformRivetKit: true, - maxOutputBytes: config.maxBuildArtifactBytes, - maxOutputFiles: DEFAULT_MAX_BUILD_ARTIFACT_FILES, - maxFileBytes: DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES, - }), - }, - ]); - if (actorConfigWrite.some((entry) => !entry.success)) { - fail( - "agentos_apps_build_write_failed", - "failed to write actor Apps builder input", - ); - } - const actorBundle = await build.execArgv( - "node", - ["/opt/agentos/bin/apps-builder", actorConfigPath], - { - cwd: "/workspace", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, actorBundle); - if (actorBundle.exitCode !== 0) { - throwCommandFailure( - "build", - "apps-builder (actor)", - actorBundle, - config.maxBuildOutputBytes, - ); - } - } - phase("release_bundled"); - const validation = await build.execArgv( - "node", - [ - "-e", - `import("/release/${DIRECT_BUNDLE_PATH}").then((module)=>{if(module.dynamicAppMetadata?.format!==${JSON.stringify(DIRECT_RUNTIME_FORMAT)}||typeof module.dispatch!=="function") throw new TypeError("invalid direct app handler")}).catch((error)=>{console.error(error);process.exitCode=1})`, - ], - { - cwd: "/release", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, validation); - if (validation.exitCode !== 0) { - fail( - "agentos_apps_invalid_handler", - "application entrypoint could not be imported as a direct fetch handler", - { - stderr: boundedOutput(validation.stderr, config.maxBuildOutputBytes), - }, - ); - } - const rootManifestWrite = await build.writeFiles([ - { - path: "/release/agentos-package.json", - content: JSON.stringify({ name: "agentos-app", version: release }), - }, - ]); - if (rootManifestWrite.some((entry) => !entry.success)) { - fail( - "agentos_apps_build_write_failed", - "failed to write root application package manifest", - ); - } - phase("release_validated"); - const pack = await build.execArgv( - "tar", - [ - "--sort=name", - "--mtime=@0", - "--owner=0", - "--group=0", - "--numeric-owner", - "-cf", - build.artifactGuestPath, - ".", - ], - { - cwd: "/release", - timeout: config.buildTimeoutMs, - captureStdio: true, - }, - ); - emitBuildOutput(input.appId, release, pack); - if (pack.exitCode !== 0) { - throwCommandFailure("pack", "tar", pack, config.maxBuildOutputBytes); - } - phase("release_archived"); - const archiveSize = await build.artifactSize(); + state.namespace = runtime.namespace; + state.cloudNamespace = runtime.cloudNamespace ?? null; + state.runnerToken = runtime.runnerToken ?? null; + state.publicToken = runtime.publicToken ?? null; + if (release.usesRivetKit) { + actorPublicEndpoint(release, state); if ( - !Number.isSafeInteger(archiveSize) || - archiveSize < 0 || - archiveSize > config.maxBuildArtifactBytes + runtime.endpoint.replace(/\/$/, "") !== + release.runtimeEndpoint.replace(/\/$/, "") ) { fail( - "agentos_apps_build_artifact_size_limit", - `built application archive is ${archiveSize} bytes, limit is ${config.maxBuildArtifactBytes}`, + "agentos_apps_runtime_changed", + "the app actor Rivet endpoint does not match the deployment runtime", ); } - const sourceTar = Buffer.from(await build.readArtifact()); - if (sourceTar.byteLength !== archiveSize) { - fail( - "agentos_apps_build_artifact_truncated", - `build artifact contained ${sourceTar.byteLength} bytes, expected ${archiveSize}`, + try { + await configureAppNamespaceRunner( + c.actorId, + { + endpoint: release.runtimeEndpoint, + namespace: release.namespace, + pool: release.runtimePool, + controlToken: runtime.controlToken, + }, + release.callbackSecret, ); - } - const packed = packAospkgFromTarBytes(sourceTar).bytes; - const artifactHash = createHash("sha256").update(packed).digest("hex"); - await config.artifactCache?.put(release, new Uint8Array(packed)); - return { - hash: artifactHash, - size: packed.byteLength, - bytes: new Uint8Array(packed), - }; - } catch (error) { - buildError = error; - throw error; - } finally { - await build.dispose().catch((disposeError) => { - emitDynamicAppsLog({ - level: "error", - source: "build", - message: "failed to dispose Dynamic Apps build VM", - appId: input.appId, - release, - }); - if (!buildError) throw disposeError; + } catch (error) { c.log.error({ - msg: "failed to dispose Dynamic Apps build VM after build failure", - disposeError, + msg: "Dynamic App actor runner configuration failed", + release: release.release, + error: error instanceof Error ? error.stack : String(error), }); - }); + throw error; + } } + state.activeRelease = release.release; + state.revision += 1; + c.broadcast("releaseActivated", { + revision: state.revision, + release: release.release, + artifactHash: release.artifactHash, + activatedAt: Date.now(), + }); + const releases = await listStoredReleases(c.db); + const removable = releases + .filter((candidate) => candidate.release !== release.release) + .sort((a, b) => a.createdAt - b.createdAt); + let retained = releases.length; + while (retained > DEFAULT_MAX_VERSIONS) { + const candidate = removable.shift(); + if (!candidate) break; + await deleteArtifactChunksBatched(c.db, candidate.release); + await deleteReleaseFilesBatched(c.db, candidate.release); + await c.db.execute( + "DELETE FROM agentos_apps_releases WHERE release_id = ?", + candidate.release, + ); + retained -= 1; + } + return deploymentForRelease(c, state, release); } -function createBuildVmFactory( - maxBuildArtifactBytes: number, -): () => Promise { - const options: AgentOsOptions = { - defaultSoftware: false, - software: [sh, tar, appsBuilder], - permissions: { - fs: "allow", - childProcess: "allow", - process: "allow", - env: "allow", - network: "allow", - }, - limits: { - tls: { maxBufferedBytes: 16 * 1024 * 1024 }, - jsRuntime: { v8HeapLimitMb: 1_024 }, - resources: { - maxProcesses: 64, - maxOpenFds: 2_048, - maxPreadBytes: 15 * 1024 * 1024, - maxFdWriteBytes: 16 * 1024 * 1024, - maxSocketBufferedBytes: 16 * 1024 * 1024, - maxFilesystemBytes: Math.max( - DEFAULT_MAX_BUILD_FILESYSTEM_BYTES, - maxBuildArtifactBytes * 2, - ), - }, - }, - }; - return async () => { - const outputDirectory = await mkdtemp( - join(tmpdir(), "agentos-apps-build-output-"), - ); - await chmod(outputDirectory, 0o777); - const artifactGuestPath = "/agentos-app-output/agentos-app.tar"; - const artifactHostPath = join(outputDirectory, "agentos-app.tar"); - let vm: AgentOs; - try { - vm = await AgentOs.create({ - ...options, - mounts: [ - { - path: "/agentos-app-output", - readOnly: false, - plugin: createHostDirBackend({ - hostPath: outputDirectory, - readOnly: false, - }), - }, - ], - }); - } catch (error) { - await rm(outputDirectory, { recursive: true, force: true }); - throw error; - } - return { - artifactGuestPath, - writeFiles: (...args) => vm.writeFiles(...args), - execArgv: (...args) => vm.execArgv(...args), - artifactSize: async () => (await stat(artifactHostPath)).size, - readArtifact: async () => - new Uint8Array(await readFile(artifactHostPath)), - dispose: async () => { - const results = await Promise.allSettled([ - vm.dispose(), - rm(outputDirectory, { recursive: true, force: true }), - ]); - const failures = results.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ); - if (failures.length > 0) { - throw new AggregateError( - failures, - "failed to dispose Dynamic Apps build VM output", - ); - } - }, - }; +function deploymentForRelease( + c: AnyActorContext, + state: AppState, + release: StoredAppRelease, +): Deployment & { appActorId: string; usesRivetKit: boolean } { + const appId = c.key[0]; + if (!appId) + fail("agentos_apps_invalid_app_id", "application actor key is missing"); + return { + appId, + release: release.release, + endpoint: release.runtimeEndpoint, + namespace: release.namespace, + pool: release.runtimePool, + ...(state.publicToken ? { token: state.publicToken } : {}), + regions: [...release.regions], + appActorId: c.actorId, + usesRivetKit: release.usesRivetKit, }; } export function createAppsActors( - options: { - artifactCache?: { - get(release: string): Promise; - put(release: string, artifact: Uint8Array): Promise; - }; - } = {}, + options: { artifactCache?: BuildArtifactCache } = {}, ): DynamicAppsActors { - const createBuildVm = createBuildVmFactory(DEFAULT_MAX_BUILD_ARTIFACT_BYTES); const forwardActorRequest = async ( c: AnyActorContext, request: Request, @@ -1229,7 +902,7 @@ export function createAppsActors( try { return await getDefaultActorRuntime().request({ key: `${release.release}:${release.artifactHash}`, - appId: c.key[0] ?? "unknown", + appId: c.key[0], release: release.release, loadArtifact: () => readStoredArtifact(c.db, release), endpoint: actorPublicEndpoint(release, state), @@ -1246,8 +919,9 @@ export function createAppsActors( throw error; } }; + const agentOSAppsApp = actor({ - options: { actionTimeout: DEFAULT_BUILD_TIMEOUT_MS + 60_000 }, + options: { actionTimeout: 16 * 60_000 }, db: db({ onMigrate: migrateAppsTables }), onRequest: forwardActorRequest, createState: (): AppState => ({ @@ -1257,279 +931,103 @@ export function createAppsActors( cloudNamespace: null, runnerToken: null, publicToken: null, + publishSequence: 0, + latestPublishSequence: 0, }), actions: { - deploy: async ( + beginReleasePublish: ( + c: AnyActorContext, + input: BeginReleasePublishInput, + ) => + c.keepAwake( + actorBoundary(() => + serialized(`app:${c.actorId}`, () => + beginReleasePublishLocked(c, input), + ), + ), + ), + writeReleaseChunk: (c: AnyActorContext, input: WriteReleaseChunkInput) => + c.keepAwake( + actorBoundary(() => + serialized(`app:${c.actorId}`, () => writeReleaseChunk(c, input)), + ), + ), + commitReleasePublish: ( + c: AnyActorContext, + input: CommitReleasePublishInput, + ) => + c.keepAwake( + actorBoundary(() => + serialized(`app:${c.actorId}`, () => + commitReleasePublishLocked(c, input), + ), + ), + ), + deploy: ( c: AnyActorContext, input: PreparedDeployAppInput, ): Promise => c.keepAwake( - serialized(`app:${c.actorId}`, async () => { + actorBoundary(async () => { const appId = c.key[0]; if (!appId || c.key.length !== 1 || input.appId !== appId) { fail( "agentos_apps_app_id_mismatch", "deployApp appId must match the stable application actor key", - { appId: input.appId, actorKey: c.key }, ); } - const plan = validateDeployment(input, { - maxSourceBytes: DEFAULT_MAX_SOURCE_BYTES, - maxFiles: DEFAULT_MAX_FILES, - maxDependencies: DEFAULT_MAX_DEPENDENCIES, - }); - const state = c.state as AppState; - const runtime = await provisionAppNamespace( - appId, - resolveDefaultRivetConnection(), + const built = await buildAppRelease( + { appId, files: input.files }, { - namespace: state.namespace, - cloudNamespace: state.cloudNamespace, + artifactCache: options.artifactCache, + logger: { + info: (event) => c.log.info(event), + error: (event) => c.log.error(event), + }, }, ); - state.namespace = runtime.namespace; - state.cloudNamespace = runtime.cloudNamespace ?? null; - state.runnerToken = runtime.runnerToken ?? null; - state.publicToken = runtime.publicToken ?? null; - const regions = normalizeRegions( - input.regions, - c.region, - DEFAULT_MAX_REGIONS, - ); - const scaling = normalizeScaling(input.scaling); - const releaseId = canonicalDeploymentHash({ - files: input.files, - entrypoint: plan.entrypoint, - build: plan.build, - packagingIdentity: [ - `apps-builder@${appsBuilderVersion}`, - `manifest@${appBundleManifestVersion}`, - "direct@2", - `actors@${plan.usesRivetKit ? 1 : 0}`, - "esbuild-wasm@0.27.4", - ].join(";"), - deploymentIdentity: JSON.stringify({ - regions, - scaling, - namespace: runtime.namespace, - runtime: { - endpoint: runtime.endpoint, - pool: runtime.pool, - }, - usesRivetKit: plan.usesRivetKit, - }), - }); - const releasesBefore = await listStoredReleases(c.db); - let release = await getStoredRelease(c.db, releaseId); - const callbackSecret = plan.usesRivetKit - ? release?.callbackSecret || - releasesBefore.find((candidate) => candidate.callbackSecret) - ?.callbackSecret || - randomUUID() - : ""; - if (!release || release.status !== "ready") { - const createdAt = release?.createdAt ?? Date.now(); - await deleteArtifactChunksBatched(c.db, releaseId); - await c.db.execute( - `INSERT INTO agentos_apps_releases ( - release_id, created_at, status, entrypoint, - artifact_hash, artifact_bytes, build_error, - regions_json, scaling_json, namespace, envoy_version, - runtime_endpoint, runtime_pool, callback_secret, - uses_rivetkit - ) VALUES (?, ?, 'building', ?, '', 0, NULL, ?, ?, ?, 1, ?, ?, ?, ?) - ON CONFLICT(release_id) DO UPDATE SET - status = 'building', entrypoint = excluded.entrypoint, - artifact_hash = '', artifact_bytes = 0, build_error = NULL, - regions_json = excluded.regions_json, - scaling_json = excluded.scaling_json, - namespace = excluded.namespace, - runtime_endpoint = excluded.runtime_endpoint, - runtime_pool = excluded.runtime_pool, - callback_secret = excluded.callback_secret, - uses_rivetkit = excluded.uses_rivetkit`, - releaseId, - createdAt, - DIRECT_ENTRYPOINT, - JSON.stringify(regions), - JSON.stringify(scaling), - runtime.namespace, - runtime.endpoint, - runtime.pool, - callbackSecret, - plan.usesRivetKit ? 1 : 0, + return serialized(`app:${c.actorId}`, async () => { + const publishInput: BeginReleasePublishInput = { + appId, + buildId: built.buildId, + format: built.artifact.format, + entrypoint: built.artifact.entrypoint, + artifactHash: built.artifact.hash, + artifactBytes: built.artifact.byteLength, + usesRivetKit: built.artifact.usesRivetKit, + regions: input.regions, + scaling: input.scaling, + createdAt: Date.now(), + }; + const begin = await beginReleasePublishLocked(c, publishInput); + await deleteReleaseFilesBatched(c.db, begin.release); + await persistReleaseFilesBatched( + c.db, + begin.release, + input.files, ); - await deleteReleaseFilesBatched(c.db, releaseId); - await persistReleaseFilesBatched(c.db, releaseId, input.files); - try { - const artifact = await buildRelease(c, input, plan, releaseId, { - createBuildVm, - buildTimeoutMs: DEFAULT_BUILD_TIMEOUT_MS, - maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, - maxBuildOutputBytes: DEFAULT_MAX_BUILD_OUTPUT_BYTES, - maxBuildArtifactBytes: DEFAULT_MAX_BUILD_ARTIFACT_BYTES, - artifactCache: options.artifactCache, - }); - const chunkCount = Math.ceil( - artifact.size / ARTIFACT_CHUNK_BYTES, - ); - if (chunkCount > MAX_ARTIFACT_CHUNKS) { - fail( - "agentos_apps_artifact_chunk_limit", - `artifact requires ${chunkCount} chunks`, - ); - } - await deleteArtifactChunksBatched(c.db, releaseId); - for (let index = 0; index < chunkCount; index += 1) { - const chunk = artifact.bytes.slice( - index * ARTIFACT_CHUNK_BYTES, - (index + 1) * ARTIFACT_CHUNK_BYTES, - ); - await c.db.execute( - `INSERT INTO agentos_apps_artifact_chunks - (release_id, chunk_index, content, byte_length) - VALUES (?, ?, ?, ?)`, - releaseId, - index, - chunk, - chunk.byteLength, - ); - } - const totals = await c.db.execute<{ - bytes: number; - chunks: number; - }>( - `SELECT COALESCE(SUM(byte_length), 0) AS bytes, - COUNT(*) AS chunks FROM agentos_apps_artifact_chunks - WHERE release_id = ?`, - releaseId, - ); - if ( - Number(totals[0]?.bytes ?? 0) !== artifact.size || - Number(totals[0]?.chunks ?? 0) !== chunkCount - ) { - fail( - "agentos_apps_artifact_persist_mismatch", - "persisted artifact chunks failed length verification", - ); - } - await c.db.execute( - `UPDATE agentos_apps_releases SET status = 'ready', - artifact_hash = ?, artifact_bytes = ?, build_error = NULL - WHERE release_id = ?`, - artifact.hash, - artifact.size, - releaseId, - ); - } catch (error) { - await deleteArtifactChunksBatched(c.db, releaseId); - await c.db.execute( - `UPDATE agentos_apps_releases SET status = 'failed', - build_error = ? WHERE release_id = ?`, - error instanceof Error ? error.message : String(error), - releaseId, - ); - throw error; - } - release = await getStoredRelease(c.db, releaseId); - } else { - await c.db.execute( - `UPDATE agentos_apps_releases SET regions_json = ?, - scaling_json = ?, runtime_endpoint = ?, runtime_pool = ?, - callback_secret = ?, uses_rivetkit = ? - WHERE release_id = ?`, - JSON.stringify(regions), - JSON.stringify(scaling), - runtime.endpoint, - runtime.pool, - callbackSecret, - plan.usesRivetKit ? 1 : 0, - releaseId, + const chunks = Math.ceil( + built.artifact.byteLength / ARTIFACT_CHUNK_BYTES, ); - release = await getStoredRelease(c.db, releaseId); - } - if (!release || release.status !== "ready") { - fail( - "agentos_apps_artifact_not_ready", - "built artifact was not ready for activation", - ); - } - const previousRelease = state.activeRelease; - state.activeRelease = releaseId; - try { - if (release.usesRivetKit) { - actorPublicEndpoint(release, state); - if ( - runtime.endpoint.replace(/\/$/, "") !== - release.runtimeEndpoint.replace(/\/$/, "") - ) { - fail( - "agentos_apps_runtime_changed", - "the app actor Rivet endpoint does not match the deployment runtime", - { - expected: runtime.endpoint, - received: release.runtimeEndpoint, - }, - ); + if (begin.uploadRequired) { + for (let index = 0; index < chunks; index += 1) { + await writeReleaseChunk(c, { + release: begin.release, + sequence: begin.sequence, + index, + content: built.artifact.bytes.slice( + index * ARTIFACT_CHUNK_BYTES, + (index + 1) * ARTIFACT_CHUNK_BYTES, + ), + }); } - await configureAppNamespaceRunner( - c.actorId, - { - endpoint: release.runtimeEndpoint, - namespace: release.namespace, - pool: release.runtimePool, - controlToken: runtime.controlToken, - }, - release.callbackSecret, - ); } - } catch (error) { - state.activeRelease = previousRelease; - c.log.error({ - msg: "Dynamic App actor runner configuration failed", - release: release.release, - error: error instanceof Error ? error.stack : String(error), + return commitReleasePublishLocked(c, { + release: begin.release, + sequence: begin.sequence, + chunks, }); - if (error instanceof DynamicAppsError) { - fail(error.code, error.message, error.metadata); - } - throw error; - } - state.revision += 1; - const activatedAt = Date.now(); - c.broadcast("releaseActivated", { - revision: state.revision, - release: releaseId, - artifactHash: release.artifactHash, - activatedAt, }); - const releases = await listStoredReleases(c.db); - const removable = releases - .filter((candidate) => candidate.release !== releaseId) - .sort((a, b) => a.createdAt - b.createdAt); - let retained = releases.length; - while (retained > DEFAULT_MAX_VERSIONS) { - const candidate = removable.shift(); - if (!candidate) break; - await deleteArtifactChunksBatched(c.db, candidate.release); - await deleteReleaseFilesBatched(c.db, candidate.release); - await c.db.execute( - "DELETE FROM agentos_apps_releases WHERE release_id = ?", - candidate.release, - ); - retained -= 1; - } - return { - appId, - release: releaseId, - endpoint: runtime.endpoint, - namespace: runtime.namespace, - pool: runtime.pool, - ...(runtime.publicToken ? { token: runtime.publicToken } : {}), - regions, - appActorId: c.actorId, - usesRivetKit: release.usesRivetKit, - }; }), ), resolveDeployment: async ( @@ -1570,15 +1068,16 @@ export function createAppsActors( appId, release: release.release, region, - regions: release.regions, + regions: [...release.regions], revision: state.revision, artifactHash: release.artifactHash, artifactBytes: release.artifactBytes, entrypoint: DIRECT_ENTRYPOINT, namespace: release.namespace, - scaling: release.scaling, + scaling: { ...release.scaling }, maxRequestBytes: DEFAULT_MAX_REQUEST_BYTES, maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, + usesRivetKit: release.usesRivetKit, }; }, getArtifactManifest: async (c: AnyActorContext, releaseId: string) => { @@ -1601,7 +1100,11 @@ export function createAppsActors( ); const chunks = Number(rows[0]?.chunks ?? 0); const bytes = Number(rows[0]?.bytes ?? 0); - if (chunks > MAX_ARTIFACT_CHUNKS || bytes !== release.artifactBytes) { + if ( + chunks !== Math.ceil(bytes / ARTIFACT_CHUNK_BYTES) || + chunks > MAX_ARTIFACT_CHUNKS || + bytes !== release.artifactBytes + ) { fail( "agentos_apps_artifact_manifest_invalid", `artifact ${releaseId} failed persisted manifest validation`, diff --git a/packages/dynamic-apps/src/control-plane.ts b/packages/dynamic-apps/src/control-plane.ts index bef8df9c0..0bda1489a 100644 --- a/packages/dynamic-apps/src/control-plane.ts +++ b/packages/dynamic-apps/src/control-plane.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; +import { DynamicAppsError } from "@rivet-dev/dynamic-apps-core/internal"; import { controlFetch } from "./control-request.js"; -import { DynamicAppsError } from "./errors.js"; import { appRunnerPool, ensureServerlessRunnerConfig } from "./runtime.js"; const DEFAULT_ENDPOINT = "http://localhost:6420"; @@ -137,7 +137,12 @@ async function provisionCloudNamespace( existingCloudNamespace?: string, ): Promise { const cloudToken = process.env.RIVET_CLOUD_TOKEN; - if (!cloudToken) throw new Error("RIVET_CLOUD_TOKEN is required"); + if (!cloudToken) { + throw new DynamicAppsError( + "agentos_apps_cloud_token_required", + "RIVET_CLOUD_TOKEN is required", + ); + } const headers = controlHeaders(cloudToken); const identity = await checkedJson<{ project: string; diff --git a/packages/dynamic-apps/src/default.ts b/packages/dynamic-apps/src/default.ts new file mode 100644 index 000000000..adefa7d39 --- /dev/null +++ b/packages/dynamic-apps/src/default.ts @@ -0,0 +1,12 @@ +import { createDynamicApps } from "@rivet-dev/dynamic-apps-core"; +import { createRivetReleaseStore } from "./release-store.js"; + +const releaseStore = createRivetReleaseStore(); + +export const defaultDynamicApps = createDynamicApps({ + ...releaseStore, + logger: { + info: (event) => console.log(JSON.stringify(event)), + error: (event) => console.error(JSON.stringify(event)), + }, +}); diff --git a/packages/dynamic-apps/src/deploy.ts b/packages/dynamic-apps/src/deploy.ts index a09d51a2d..f896b52b4 100644 --- a/packages/dynamic-apps/src/deploy.ts +++ b/packages/dynamic-apps/src/deploy.ts @@ -1,7 +1,8 @@ -import { createClient } from "rivetkit/client"; -import { DynamicAppsError } from "./errors.js"; -import { ensurePrivateAppsRegistry } from "./registry.js"; -import { prepareSource } from "./source.js"; +import { + DynamicAppsError, + prepareSource, +} from "@rivet-dev/dynamic-apps-core/internal"; +import { defaultDynamicApps } from "./default.js"; import type { DeployAppInput, Deployment, @@ -27,24 +28,17 @@ export interface DeployAppOptions { }; } -let defaultClient: NonNullable | undefined; const HOST_REGISTRY_READY_TIMEOUT_MS = 15_000; const HOST_REGISTRY_RETRY_DELAY_MS = 50; -function getDefaultClient(): NonNullable { - defaultClient ??= createClient() as unknown as NonNullable< - DeployAppOptions["client"] - >; - return defaultClient; -} - export async function deployApp( input: DeployAppInput, options: DeployAppOptions = {}, ): Promise { + // The ordinary path uses core's build + release hooks. An injected structural + // client must keep calling the legacy actor action for declaration compatibility. + if (!options.client) return defaultDynamicApps.deployApp(input); const files = await prepareSource(input); - if (!options.client) await ensurePrivateAppsRegistry(); - const client = options.client ?? getDefaultClient(); const prepared: PreparedDeployAppInput = { appId: input.appId, files, @@ -52,7 +46,7 @@ export async function deployApp( scaling: input.scaling, }; const result = await deployThroughStableActor( - client.agentOSAppsApp, + options.client.agentOSAppsApp, input.appId, prepared, ); @@ -126,8 +120,3 @@ function getErrorCode(error: unknown): string | undefined { } return typeof error.code === "string" ? error.code : undefined; } - -/** @internal Test-only reset for verifying lazy client creation. */ -export function resetDefaultAppsClientForTest(): void { - defaultClient = undefined; -} diff --git a/packages/dynamic-apps/src/logging.ts b/packages/dynamic-apps/src/logging.ts index 51bfa067f..e3a0fc997 100644 --- a/packages/dynamic-apps/src/logging.ts +++ b/packages/dynamic-apps/src/logging.ts @@ -1,5 +1,11 @@ -export type DynamicAppsLogLevel = "debug" | "info" | "warn" | "error"; +import { + DynamicAppsLogLineDecoder, + emitDynamicAppsLog as emitCoreDynamicAppsLog, + MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES, + setDynamicAppsLogHandler as setCoreDynamicAppsLogHandler, +} from "@rivet-dev/dynamic-apps-core/internal"; +export type DynamicAppsLogLevel = "debug" | "info" | "warn" | "error"; export type DynamicAppsLogSource = | "application" | "actor" @@ -26,119 +32,15 @@ export type DynamicAppsLogHandler = ( type DynamicAppsLogInput = Omit; -export const MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES = 64 * 1024; -const HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS = 60_000; - -let logHandler: DynamicAppsLogHandler | undefined; -let lastHandlerErrorDiagnosticAt = 0; +export { DynamicAppsLogLineDecoder, MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES }; export function setDynamicAppsLogHandler( handler: DynamicAppsLogHandler | undefined, ): void { - logHandler = handler; + setCoreDynamicAppsLogHandler(handler); } /** @internal */ export function emitDynamicAppsLog(input: DynamicAppsLogInput): void { - const handler = logHandler; - if (!handler) return; - const truncated = truncateUtf8(input.message); - const metadata = input.metadata - ? Object.freeze({ - ...input.metadata, - ...(truncated.truncated ? { truncated: true } : {}), - }) - : truncated.truncated - ? Object.freeze({ truncated: true }) - : undefined; - const event = Object.freeze({ - ...input, - version: 1 as const, - timestamp: Date.now(), - message: truncated.value, - ...(metadata ? { metadata } : {}), - }); - try { - handler(event); - } catch (error) { - const now = Date.now(); - if ( - now - lastHandlerErrorDiagnosticAt >= - HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS - ) { - lastHandlerErrorDiagnosticAt = now; - const message = error instanceof Error ? error.message : String(error); - process.stderr.write( - `[dynamic-apps] log handler failed: ${truncateUtf8(message).value}\n`, - ); - } - } -} - -/** Incrementally reconstructs bounded UTF-8 lines from a byte stream. */ -export class DynamicAppsLogLineDecoder { - readonly #decoder = new TextDecoder(); - readonly #emit: (message: string, truncated: boolean) => void; - #buffer = ""; - #bufferBytes = 0; - #truncated = false; - #ended = false; - - constructor(emit: (message: string, truncated: boolean) => void) { - this.#emit = emit; - } - - write(chunk: Uint8Array): void { - if (this.#ended) return; - this.#consume(this.#decoder.decode(chunk, { stream: true })); - } - - end(): void { - if (this.#ended) return; - this.#ended = true; - this.#consume(this.#decoder.decode()); - if (this.#buffer || this.#truncated) this.#flushLine(); - } - - #consume(text: string): void { - for (const character of text) { - if (character === "\n") { - this.#flushLine(); - continue; - } - if (this.#truncated) continue; - const bytes = Buffer.byteLength(character); - if (this.#bufferBytes + bytes > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { - this.#truncated = true; - continue; - } - this.#buffer += character; - this.#bufferBytes += bytes; - } - } - - #flushLine(): void { - const message = this.#buffer.endsWith("\r") - ? this.#buffer.slice(0, -1) - : this.#buffer; - this.#emit(message, this.#truncated); - this.#buffer = ""; - this.#bufferBytes = 0; - this.#truncated = false; - } -} - -function truncateUtf8(value: string): { value: string; truncated: boolean } { - if (Buffer.byteLength(value) <= MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { - return { value, truncated: false }; - } - let output = ""; - let bytes = 0; - for (const character of value) { - const size = Buffer.byteLength(character); - if (bytes + size > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) break; - output += character; - bytes += size; - } - return { value: output, truncated: true }; + emitCoreDynamicAppsLog(input); } diff --git a/packages/dynamic-apps/src/release-store.ts b/packages/dynamic-apps/src/release-store.ts new file mode 100644 index 000000000..eecac9edd --- /dev/null +++ b/packages/dynamic-apps/src/release-store.ts @@ -0,0 +1,421 @@ +import { createHash } from "node:crypto"; +import type { + ActiveRelease, + AppScaling, + PublishReleaseInput, + ReleaseInvalidation, + ReleaseLoadContext, + Unsubscribe, +} from "@rivet-dev/dynamic-apps-core"; +import { + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, +} from "@rivet-dev/dynamic-apps-core/internal"; +import { createClient } from "rivetkit/client"; +import { ensurePrivateAppsRegistry } from "./registry.js"; +import type { AppRouteResolution, Deployment } from "./types.js"; + +export const ARTIFACT_CHUNK_BYTES = 512 * 1024; +const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; +const MAX_ARTIFACT_CHUNKS = Math.ceil( + MAX_ARTIFACT_BYTES / ARTIFACT_CHUNK_BYTES, +); + +interface BeginReleasePublishInput { + appId: string; + buildId: string; + format: typeof DIRECT_RUNTIME_FORMAT; + entrypoint: typeof DIRECT_ENTRYPOINT; + artifactHash: string; + artifactBytes: number; + usesRivetKit: boolean; + regions?: string[]; + scaling?: AppScaling; + createdAt: number; +} + +interface BeginReleasePublishResult { + release: string; + sequence: number; + uploadRequired: boolean; + chunkBytes: number; +} + +interface WriteReleaseChunkInput { + release: string; + sequence: number; + index: number; + content: Uint8Array; +} + +interface CommitReleasePublishInput { + release: string; + sequence: number; + chunks: number; +} + +interface ArtifactManifest { + format: string; + hash: string; + bytes: number; + chunks: number; + chunkBytes: number; +} + +interface ReleaseActivatedEvent { + revision: number; + release: string; + artifactHash: string; + activatedAt: number; +} + +interface AppConnection { + ready: Promise; + on( + name: "releaseActivated", + callback: (event: ReleaseActivatedEvent) => void, + ): () => void; + onOpen(callback: () => void): () => void; + onClose(callback: () => void): () => void; + dispose(): Promise; +} + +interface AppReleaseHandle { + beginReleasePublish( + input: BeginReleasePublishInput, + ): Promise; + writeReleaseChunk(input: WriteReleaseChunkInput): Promise; + commitReleasePublish( + input: CommitReleasePublishInput, + ): Promise; + resolveDeployment(): Promise; + getArtifactManifest(release: string): Promise; + readArtifactChunk(release: string, index: number): Promise; + connect(): AppConnection; +} + +interface ReleaseActorGroup { + get?(key: string | string[]): AppReleaseHandle; + getOrCreate(key: string | string[]): AppReleaseHandle; +} + +interface ReleaseStoreClient { + agentOSAppsApp: ReleaseActorGroup; +} + +interface DriverEntry { + appId: string; + handle?: AppReleaseHandle; + connection?: AppConnection; + ready: Promise; + listenerRemovers: Array<() => void>; + disposed: boolean; + unsubscribe?: Unsubscribe; +} + +export interface RivetReleaseStore { + publishRelease(input: PublishReleaseInput): Promise; + loadActiveRelease( + appId: string, + context: ReleaseLoadContext, + ): Promise; + watchActiveRelease( + appId: string, + invalidate: ReleaseInvalidation, + ): Promise; +} + +export function createRivetReleaseStore( + clientInput?: ReleaseStoreClient, +): RivetReleaseStore { + let client = clientInput; + const drivers = new Map(); + const getClient = () => + (client ??= createClient() as unknown as ReleaseStoreClient); + + const publishRelease = async ( + input: PublishReleaseInput, + ): Promise => { + await ensurePrivateAppsRegistry(); + const group = getClient().agentOSAppsApp; + const driver = drivers.get(input.appId); + let handle: AppReleaseHandle; + let usedExisting = false; + if (driver) { + await driver.ready; + if (!driver.handle) throw new Error("release driver has no actor handle"); + handle = driver.handle; + } else if (group.get) { + handle = group.get([input.appId]); + usedExisting = true; + } else { + handle = group.getOrCreate([input.appId]); + } + const beginInput: BeginReleasePublishInput = { + appId: input.appId, + buildId: input.buildId, + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + artifactHash: input.artifact.hash, + artifactBytes: input.artifact.byteLength, + usesRivetKit: input.artifact.usesRivetKit, + regions: input.regions, + scaling: input.scaling, + createdAt: input.createdAt, + }; + let begin: BeginReleasePublishResult; + try { + begin = await handle.beginReleasePublish(beginInput); + } catch (error) { + if (!usedExisting || !isActorNotFound(error)) throw error; + handle = group.getOrCreate([input.appId]); + begin = await handle.beginReleasePublish(beginInput); + } + if ( + !/^[a-f0-9]{64}$/.test(begin.release) || + !Number.isSafeInteger(begin.sequence) || + begin.sequence < 1 || + typeof begin.uploadRequired !== "boolean" || + begin.chunkBytes !== ARTIFACT_CHUNK_BYTES + ) { + throw new Error("app actor returned invalid release upload metadata"); + } + const chunks = Math.ceil(input.artifact.byteLength / begin.chunkBytes); + if (chunks < 1 || chunks > MAX_ARTIFACT_CHUNKS) { + throw new Error("application artifact requires an invalid chunk count"); + } + if (begin.uploadRequired) { + for (let index = 0; index < chunks; index += 1) { + await handle.writeReleaseChunk({ + release: begin.release, + sequence: begin.sequence, + index, + content: input.artifact.bytes.slice( + index * begin.chunkBytes, + (index + 1) * begin.chunkBytes, + ), + }); + } + } + const result = await handle.commitReleasePublish({ + release: begin.release, + sequence: begin.sequence, + chunks, + }); + return projectDeployment(result); + }; + + const loadActiveRelease = async ( + appId: string, + context: ReleaseLoadContext, + ): Promise => { + const driver = drivers.get(appId); + if (!driver) + throw new Error("release load requires an active subscription"); + await timed(context, "actor-connect", () => driver.ready); + const handle = driver.handle; + if (!handle) throw new Error("release driver has no actor handle"); + let resolution: AppRouteResolution; + try { + resolution = await timed(context, "actor-resolve", () => + handle.resolveDeployment(), + ); + } catch (error) { + if (getErrorCode(error) === "agentos_apps_not_deployed") return undefined; + throw error; + } + const manifest = await timed(context, "artifact-manifest", () => + handle.getArtifactManifest(resolution.release), + ); + validateManifest(manifest, resolution); + const bytes = await timed(context, "artifact-download", async () => { + const chunks: Uint8Array[] = []; + const digest = createHash("sha256"); + let total = 0; + for (let index = 0; index < manifest.chunks; index += 1) { + const content = new Uint8Array( + await handle.readArtifactChunk(resolution.release, index), + ); + const expected = + index === manifest.chunks - 1 + ? manifest.bytes - index * manifest.chunkBytes + : manifest.chunkBytes; + if (content.byteLength !== expected) { + throw new Error(`artifact chunk ${index} has an invalid length`); + } + total += content.byteLength; + digest.update(content); + chunks.push(content); + } + if (total !== manifest.bytes || digest.digest("hex") !== manifest.hash) { + throw new Error("downloaded artifact failed size or hash verification"); + } + return new Uint8Array(Buffer.concat(chunks, total)); + }); + return { + appId: resolution.appId, + release: resolution.release, + artifact: { + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + hash: resolution.artifactHash, + bytes, + byteLength: bytes.byteLength, + usesRivetKit: resolution.usesRivetKit, + }, + regions: [...resolution.regions], + scaling: { ...resolution.scaling }, + maxRequestBytes: resolution.maxRequestBytes, + maxResponseBytes: resolution.maxResponseBytes, + }; + }; + + const watchActiveRelease = async ( + appId: string, + invalidate: ReleaseInvalidation, + ): Promise => { + await ensurePrivateAppsRegistry(); + const existing = drivers.get(appId); + if (existing) { + await existing.ready; + if (!existing.unsubscribe) + throw new Error("release driver is unavailable"); + return existing.unsubscribe; + } + const entry: DriverEntry = { + appId, + ready: undefined as unknown as Promise, + listenerRemovers: [], + disposed: false, + }; + drivers.set(appId, entry); + entry.ready = connectDriver( + entry, + getClient().agentOSAppsApp, + invalidate, + ).catch(async (error) => { + if (drivers.get(appId) === entry) drivers.delete(appId); + for (const remove of entry.listenerRemovers.splice(0)) remove(); + await entry.connection?.dispose().catch(() => {}); + entry.connection = undefined; + entry.handle = undefined; + throw error; + }); + await entry.ready; + let unsubscribePromise: Promise | undefined; + const unsubscribe = () => { + unsubscribePromise ??= (async () => { + entry.disposed = true; + if (drivers.get(appId) === entry) drivers.delete(appId); + for (const remove of entry.listenerRemovers.splice(0)) remove(); + await entry.connection?.dispose(); + })(); + return unsubscribePromise; + }; + entry.unsubscribe = unsubscribe; + return unsubscribe; + }; + + return { publishRelease, loadActiveRelease, watchActiveRelease }; +} + +async function connectDriver( + entry: DriverEntry, + group: ReleaseActorGroup, + invalidate: ReleaseInvalidation, +): Promise { + const attempt = async (handle: AppReleaseHandle): Promise => { + const connection = handle.connect(); + entry.handle = handle; + entry.connection = connection; + let ready = false; + entry.listenerRemovers.push( + connection.on("releaseActivated", () => invalidate()), + connection.onClose(() => invalidate()), + connection.onOpen(() => { + if (ready) invalidate(); + }), + ); + await connection.ready; + ready = true; + }; + if (group.get) { + try { + await attempt(group.get([entry.appId])); + return; + } catch (error) { + for (const remove of entry.listenerRemovers.splice(0)) remove(); + await entry.connection?.dispose().catch(() => {}); + entry.connection = undefined; + entry.handle = undefined; + if (!isActorNotFound(error)) throw error; + } + } + await attempt(group.getOrCreate([entry.appId])); +} + +function validateManifest( + manifest: ArtifactManifest, + resolution: AppRouteResolution, +): void { + if ( + manifest.format !== DIRECT_RUNTIME_FORMAT || + manifest.hash !== resolution.artifactHash || + manifest.bytes !== resolution.artifactBytes || + !/^[a-f0-9]{64}$/.test(manifest.hash) || + !Number.isSafeInteger(manifest.bytes) || + manifest.bytes < 1 || + manifest.bytes > MAX_ARTIFACT_BYTES || + !Number.isSafeInteger(manifest.chunks) || + manifest.chunks < 1 || + manifest.chunks > MAX_ARTIFACT_CHUNKS || + manifest.chunkBytes !== ARTIFACT_CHUNK_BYTES || + manifest.chunks !== Math.ceil(manifest.bytes / manifest.chunkBytes) + ) { + throw new Error("app actor returned an invalid artifact manifest"); + } +} + +async function timed( + context: ReleaseLoadContext, + name: string, + operation: () => Promise, +): Promise { + const startedAt = performance.now(); + try { + return await operation(); + } finally { + context.recordTiming(name, performance.now() - startedAt); + } +} + +function projectDeployment(input: Deployment): Deployment { + return { + appId: input.appId, + release: input.release, + endpoint: input.endpoint, + namespace: input.namespace, + pool: input.pool, + ...(input.token ? { token: input.token } : {}), + regions: [...input.regions], + }; +} + +function isActorNotFound(error: unknown): boolean { + const code = getErrorCode(error); + return ( + code === "actor_not_found" || + (code === "not_found" && + typeof error === "object" && + error !== null && + "group" in error && + error.group === "actor") + ); +} + +function getErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + return typeof error.code === "string" ? error.code : undefined; +} diff --git a/packages/dynamic-apps/src/router.ts b/packages/dynamic-apps/src/router.ts index 60644caa4..cd52d0dca 100644 --- a/packages/dynamic-apps/src/router.ts +++ b/packages/dynamic-apps/src/router.ts @@ -1,161 +1,11 @@ -import { randomUUID } from "node:crypto"; -import { Hono } from "hono"; +import type { Hono } from "hono"; import type { BlankEnv, BlankSchema } from "hono/types"; -import { DynamicAppsError } from "./errors.js"; -import { ApplicationHandlerError, getDefaultExecutor } from "./executor.js"; +import { defaultDynamicApps } from "./default.js"; import { handlePrivateAppsRegistry } from "./registry.js"; -import { validateAppId } from "./source.js"; const PRIVATE_REGISTRY_SENTINEL = "x-agentos-app-registry-dispatch"; -const MAX_URL_BYTES = 16 * 1024; -const MAX_METHOD_BYTES = 256; -let requestOverride: - | ((appId: string, request: Request, requestId: string) => Promise) - | undefined; - -function requestId(request: Request): string { - const provided = request.headers.get("x-request-id"); - return provided && /^[\x21-\x7e]{1,128}$/.test(provided) - ? provided - : randomUUID(); -} - -function errorCode(error: unknown): string | undefined { - if (error instanceof DynamicAppsError) return error.code; - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - return typeof error.code === "string" ? error.code : undefined; -} - -function ordinaryRoutingError(error: unknown): Response | undefined { - if (error instanceof ApplicationHandlerError) { - return new Response("Internal Server Error", { status: 500 }); - } - const code = errorCode(error); - const message = error instanceof Error ? error.message : ""; - if (code === "agentos_apps_not_deployed") { - return new Response("Dynamic App has no active release", { status: 503 }); - } - if (code === "agentos_apps_region_not_deployed") { - const region = (error as { metadata?: { requestedRegion?: unknown } }) - .metadata?.requestedRegion; - return new Response( - `Dynamic App is not deployed in requested region ${typeof region === "string" ? region : "unknown"}`, - { status: 421 }, - ); - } - if (code === "agentos_apps_no_region") { - return new Response("Dynamic App has no configured region", { - status: 503, - }); - } - if (code === "agentos_apps_request_limit") { - if (message.includes("URL")) { - return new Response("Request URL exceeds Dynamic Apps limit", { - status: 414, - }); - } - if (message.includes("method")) { - return new Response("Request method exceeds Dynamic Apps limit", { - status: 400, - }); - } - if (message.includes("header")) { - return new Response("Request headers exceed Dynamic Apps limit", { - status: 431, - }); - } - if (message.includes("body")) { - return new Response("Request body exceeds Dynamic Apps limit", { - status: 413, - }); - } - } - return undefined; -} - -function exceptionResponse(error: unknown): Response { - const ordinary = ordinaryRoutingError(error); - if (ordinary) return ordinary; - const code = errorCode(error); - const status = - code === "agentos_apps_invalid_app_id" - ? 400 - : code === "agentos_apps_not_deployed" || - code === "agentos_apps_region_not_deployed" - ? 404 - : code === "agentos_apps_request_limit" - ? 413 - : code?.startsWith("agentos_apps_") - ? 503 - : 500; - return Response.json( - { - error: { - code: code ?? "agentos_apps_internal_error", - message: - error instanceof Error - ? error.message - : "Dynamic Apps request failed", - }, - }, - { status }, - ); -} - -const router: Hono = new Hono(); - -const handler = async (context: { - req: { - param(name: string): string | undefined; - path: string; - routePath: string; - raw: Request; - }; -}): Promise => { - try { - const appId = context.req.param("appId") ?? ""; - validateAppId(appId); - const original = context.req.raw; - if (Buffer.byteLength(original.url) > MAX_URL_BYTES) { - return new Response("Request URL exceeds Dynamic Apps limit", { - status: 414, - }); - } - if (Buffer.byteLength(original.method) > MAX_METHOD_BYTES) { - return new Response("Request method exceeds Dynamic Apps limit", { - status: 400, - }); - } - const url = new URL(original.url); - const parameterOffset = context.req.routePath.indexOf("/:appId"); - const mountPath = - parameterOffset < 0 - ? "" - : context.req.routePath.slice(0, parameterOffset); - const applicationPath = `${mountPath}/${appId}`; - const suffix = context.req.path.startsWith(applicationPath) - ? context.req.path.slice(applicationPath.length) - : ""; - if (suffix === "") { - url.pathname = `${url.pathname}/`; - return Response.redirect(url, 308); - } - url.pathname = suffix.startsWith("/") ? suffix : `/${suffix}`; - const forwarded = new Request(url, original); - const id = requestId(original); - return await (requestOverride - ? requestOverride(appId, forwarded, id) - : getDefaultExecutor().request(appId, forwarded, id)); - } catch (error) { - return exceptionResponse(error); - } -}; - -router.all("/:appId", handler); -router.all("/:appId/*", handler); +const router = defaultDynamicApps.appsRouter; const honoFetch = router.fetch.bind(router); router.fetch = (async (request: Request, ...rest: unknown[]) => { if (request.headers.get(PRIVATE_REGISTRY_SENTINEL) === "1") { @@ -172,14 +22,3 @@ router.fetch = (async (request: Request, ...rest: unknown[]) => { }) as typeof router.fetch; export const appsRouter: Hono = router; - -/** @internal Test and benchmark seam; not exported from the package root. */ -export function setRouterRequestOverride( - override?: ( - appId: string, - request: Request, - requestId: string, - ) => Promise, -): void { - requestOverride = override; -} diff --git a/packages/dynamic-apps/src/runtime.ts b/packages/dynamic-apps/src/runtime.ts index e51087551..52056b27e 100644 --- a/packages/dynamic-apps/src/runtime.ts +++ b/packages/dynamic-apps/src/runtime.ts @@ -1,15 +1,9 @@ import { createHash } from "node:crypto"; -import { posix } from "node:path"; import { controlFetch } from "./control-request.js"; -const MAX_FILE_PATH_BYTES = 1_024; const MAX_ENGINE_RESPONSE_BYTES = 1024 * 1024; const MAX_ENGINE_DATACENTERS = 128; -export const DIRECT_ENTRYPOINT = "direct-v2/main.mjs"; -export const DIRECT_BUNDLE_PATH = "direct/main.mjs"; -export const ACTOR_BUNDLE_PATH = "actor/main.mjs"; -export const DIRECT_RUNTIME_FORMAT = "agentos-apps-direct-v2"; export const APP_CALLBACK_SECRET_HEADER = "x-agentos-app-callback-token"; /** Stable compatibility pool retained in deployApp's result. */ @@ -18,153 +12,6 @@ export function appRunnerPool(appId: string): string { return `agentos-apps-${suffix}`; } -export function normalizeAppPath(input: string): string { - if (typeof input !== "string" || input.length === 0 || input.includes("\0")) { - throw new Error( - "application file paths must be non-empty strings without NUL bytes", - ); - } - const normalized = posix.normalize(`/${input}`).slice(1); - if ( - input.startsWith("/") || - input.split("/").includes("..") || - normalized === "" || - normalized === "." || - normalized === ".." || - normalized.startsWith("../") || - Buffer.byteLength(normalized) > MAX_FILE_PATH_BYTES - ) { - throw new Error( - `application file path escapes its root: ${JSON.stringify(input)}`, - ); - } - return normalized; -} - -export function canonicalDeploymentHash(input: { - files: Record; - entrypoint: string; - build: boolean; - packagingIdentity: string; - deploymentIdentity?: string; -}): string { - const hash = createHash("sha256"); - hash.update("agentos-apps-release-v19-mounted-hono-router\0"); - const field = (value: string | Uint8Array) => { - const bytes = typeof value === "string" ? Buffer.from(value) : value; - const length = Buffer.allocUnsafe(8); - length.writeBigUInt64BE(BigInt(bytes.byteLength)); - hash.update(length); - hash.update(bytes); - }; - for (const [path, content] of Object.entries(input.files).sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, - )) { - field(normalizeAppPath(path)); - field(content); - } - field(normalizeAppPath(input.entrypoint)); - field(JSON.stringify({ build: input.build })); - field(input.packagingIdentity); - field(input.deploymentIdentity ?? ""); - return hash.digest("hex"); -} - -/** - * Host-controlled wrapper bundled with the application. It exports one direct - * dispatcher and never opens a socket or starts a guest process of its own. - */ -export function directRunnerSource(input: { - entrypoint: string; - release: string; - maxResponseBytes: number; -}): string { - const entrypoint = `./${normalizeAppPath(input.entrypoint)}`; - return `const dynamicAppsModuleImportStartedAt = performance.now(); -const application = await import(${JSON.stringify(entrypoint)}); -const dynamicAppsModuleImportMs = performance.now() - dynamicAppsModuleImportStartedAt; -const exported = application.default; -const appFetch = typeof exported === "function" - ? exported - : typeof exported?.fetch === "function" - ? exported.fetch.bind(exported) - : undefined; -if (!appFetch) { - throw new TypeError( - "Dynamic App entrypoint default export must be an object with fetch(request)", - ); -} - -export const dynamicAppMetadata = Object.freeze({ - format: ${JSON.stringify(DIRECT_RUNTIME_FORMAT)}, - release: ${JSON.stringify(input.release)}, -}); - -export async function dispatch(input) { - const startedAt = performance.now(); - const body = input.bodyBase64 - ? Buffer.from(input.bodyBase64, "base64") - : undefined; - const request = new Request(input.url, { - method: input.method, - headers: input.headers, - body: input.method === "GET" || input.method === "HEAD" ? undefined : body, - }); - const requestBuiltAt = performance.now(); - const response = await appFetch(request); - const handlerAt = performance.now(); - if (!(response instanceof Response)) { - throw new TypeError("Dynamic App fetch handler must return a Response"); - } - const declaredLength = Number(response.headers.get("content-length") ?? 0); - if (Number.isFinite(declaredLength) && declaredLength > ${input.maxResponseBytes}) { - throw new RangeError("Dynamic App response exceeds the configured limit"); - } - const responseBody = new Uint8Array(await response.arrayBuffer()); - if (responseBody.byteLength > ${input.maxResponseBytes}) { - throw new RangeError("Dynamic App response exceeds the configured limit"); - } - const headers = []; - response.headers.forEach((value, name) => { - if (name !== "set-cookie") headers.push([name, value]); - }); - for (const cookie of response.headers.getSetCookie?.() ?? []) { - headers.push(["set-cookie", cookie]); - } - const serializedAt = performance.now(); - return { - status: response.status, - statusText: response.statusText, - headers, - bodyBase64: Buffer.from(responseBody).toString("base64"), - timing: { - moduleImportMs: dynamicAppsModuleImportMs, - requestBuildMs: requestBuiltAt - startedAt, - handlerMs: handlerAt - requestBuiltAt, - responseSerializeMs: serializedAt - handlerAt, - dispatcherMs: serializedAt - startedAt, - }, - }; -} -`; -} - -/** Host-owned wrapper for the app's mounted actor callback handler. */ -export function actorRunnerSource(entrypointInput: string): string { - const entrypoint = `./${normalizeAppPath(entrypointInput)}`; - return `import application from ${JSON.stringify(entrypoint)}; -const appFetch = typeof application === "function" - ? application - : typeof application?.fetch === "function" - ? application.fetch.bind(application) - : undefined; -if (!appFetch) { - throw new TypeError("Dynamic App using RivetKit must default export a fetch handler with registry.handler() mounted under /api/rivet"); -} -export const handler = appFetch; -`; -} - async function readBoundedText(response: Response): Promise { if (!response.body) return ""; const reader = response.body.getReader(); diff --git a/packages/dynamic-apps/src/types.ts b/packages/dynamic-apps/src/types.ts index 8f15db4f5..a29cee11f 100644 --- a/packages/dynamic-apps/src/types.ts +++ b/packages/dynamic-apps/src/types.ts @@ -1,29 +1,7 @@ -export interface AppScaling { - minReplicas?: number; - maxReplicas?: number; - targetConcurrency?: number; -} - -interface DeployAppBase { - /** Stable URL-safe identifier used for routing and namespace isolation. */ - appId: string; - /** @deprecated Every application is now isolated in its own namespace. */ - createNamespace?: boolean; - regions?: string[]; - scaling?: AppScaling; -} +import type { AppScaling, DeployAppInput } from "@rivet-dev/dynamic-apps-core"; +import type { DIRECT_ENTRYPOINT } from "@rivet-dev/dynamic-apps-core/internal"; -export type DeployAppInput = - | (DeployAppBase & { - /** Local application directory. */ - source: URL; - files?: never; - }) - | (DeployAppBase & { - /** Complete generated application tree. */ - files: Record; - source?: never; - }); +export type { AppScaling, DeployAppInput }; export interface Deployment { appId: string; @@ -54,3 +32,19 @@ export interface PreparedDeployAppInput { regions?: string[]; scaling?: AppScaling; } + +export interface AppRouteResolution { + appId: string; + release: string; + region: string; + regions: string[]; + revision: number; + artifactHash: string; + artifactBytes: number; + entrypoint: typeof DIRECT_ENTRYPOINT; + namespace: string; + scaling: Required; + maxRequestBytes: number; + maxResponseBytes: number; + usesRivetKit: boolean; +} diff --git a/packages/dynamic-apps/tests/direct.test.ts b/packages/dynamic-apps/tests/direct.test.ts index 682cdaa75..9a9b0809e 100644 --- a/packages/dynamic-apps/tests/direct.test.ts +++ b/packages/dynamic-apps/tests/direct.test.ts @@ -1,42 +1,59 @@ import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; +import type { ExecutorConfig } from "@rivet-dev/dynamic-apps-core"; +import { + canonicalDeploymentHash, + capExecutionConcurrencyForMemory, + createAppsRouter, + DIRECT_ENTRYPOINT, + DIRECT_RUNTIME_FORMAT, + DynamicAppsExecutor, + directRunnerSource, + type ExecutorReleaseSource, + normalizeAppPath, + prepareSource, + readExecutorConfig, +} from "@rivet-dev/dynamic-apps-core/internal"; import { Hono } from "hono"; import { afterEach, describe, expect, test } from "vitest"; import { actorWorkerEnvironment, DynamicActorRuntime, } from "../src/actor-runtime.js"; -import type { AppRouteResolution } from "../src/actors.js"; import { forwardActorCallbackRequest } from "../src/actors.js"; import { resolveDefaultRivetConnection } from "../src/control-plane.js"; import { deployApp } from "../src/deploy.js"; -import { - capExecutionConcurrencyForMemory, - DynamicAppsExecutor, - type ExecutorConfig, - readExecutorConfig, -} from "../src/executor.js"; import { type DynamicAppsLogEvent, setDynamicAppsLogHandler, } from "../src/logging.js"; -import { appsRouter, setRouterRequestOverride } from "../src/router.js"; -import { - canonicalDeploymentHash, - DIRECT_ENTRYPOINT, - DIRECT_RUNTIME_FORMAT, - directRunnerSource, - normalizeAppPath, -} from "../src/runtime.js"; -import { prepareSource } from "../src/source.js"; const execFileAsync = promisify(execFile); +let requestOverride: + | ((appId: string, request: Request, requestId: string) => Promise) + | undefined; +const appsRouter = createAppsRouter({ + request(appId, request, requestId = randomUUID()) { + if (!requestOverride) throw new Error("missing test request executor"); + return requestOverride(appId, request, requestId); + }, +}); +const setRouterRequestOverride = ( + override?: ( + appId: string, + request: Request, + requestId: string, + ) => Promise, +) => { + requestOverride = override; +}; + afterEach(() => { setRouterRequestOverride(); setDynamicAppsLogHandler(undefined); @@ -358,8 +375,8 @@ describe("direct agentOS execution", () => { }, }); const executor = new DynamicAppsExecutor( + fake.source, executorConfig("pooled"), - fake.client, ); const outcome = executor .request("demo", new Request("http://example.test/shutdown")) @@ -394,7 +411,7 @@ describe("direct agentOS execution", () => { ); const fake = fakeStateClient(artifact); const config = { ...executorConfig("pooled"), executionTimeoutMs: 50 }; - const executor = new DynamicAppsExecutor(config, fake.client); + const executor = new DynamicAppsExecutor(fake.source, config); try { const outcome = await Promise.race([ executor.request("demo", new Request("http://example.test/stall")).then( @@ -419,8 +436,8 @@ describe("direct agentOS execution", () => { const artifact = await makeArtifact("binary"); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( + fake.source, executorConfig("pooled"), - fake.client, ); const input = Uint8Array.from( { length: 65_537 }, @@ -449,7 +466,7 @@ describe("direct agentOS execution", () => { ] as const)("starts every request clean in %s mode", async (mode) => { const artifact = await makeArtifact("one"); const fake = fakeStateClient(artifact); - const executor = new DynamicAppsExecutor(executorConfig(mode), fake.client); + const executor = new DynamicAppsExecutor(fake.source, executorConfig(mode)); try { const first = await executor.request( "demo", @@ -489,8 +506,8 @@ describe("direct agentOS execution", () => { const artifact = await makeArtifact("reuse"); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( + fake.source, executorConfig("pooled"), - fake.client, ); try { for (let index = 0; index < 20; index += 1) { @@ -517,14 +534,11 @@ describe("direct agentOS execution", () => { test("admits a request before buffering its body", async () => { const artifact = await makeArtifact("admission"); const fake = fakeStateClient(artifact); - const executor = new DynamicAppsExecutor( - { - ...executorConfig("pooled"), - executionConcurrency: 1, - executionQueueSize: 0, - }, - fake.client, - ); + const executor = new DynamicAppsExecutor(fake.source, { + ...executorConfig("pooled"), + executionConcurrency: 1, + executionQueueSize: 0, + }); let releaseFirstBody = () => {}; const firstBodyGate = new Promise((resolve) => { releaseFirstBody = resolve; @@ -582,14 +596,11 @@ describe("direct agentOS execution", () => { artifacts.map((artifact, index) => [`app-${index}`, artifact] as const), ), ); - const executor = new DynamicAppsExecutor( - { - ...executorConfig("pooled"), - runtimeCacheMaxEntries: artifacts.length, - contextPoolMaxTotal: 4, - } as ExecutorConfig, - fake.client, - ); + const executor = new DynamicAppsExecutor(fake.source, { + ...executorConfig("pooled"), + runtimeCacheMaxEntries: artifacts.length, + contextPoolMaxTotal: 4, + } as ExecutorConfig); try { for (let index = 0; index < artifacts.length; index += 1) { const response = await executor.request( @@ -618,8 +629,8 @@ export async function dispatch(input) { ); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( + fake.source, executorConfig("ephemeral"), - fake.client, ); try { const response = await executor.request( @@ -645,10 +656,10 @@ export async function dispatch(input) { }`, ); const fake = fakeStateClient(artifact); - const executor = new DynamicAppsExecutor( - { ...executorConfig("ephemeral"), logRequests: true }, - fake.client, - ); + const executor = new DynamicAppsExecutor(fake.source, { + ...executorConfig("ephemeral"), + logRequests: true, + }); try { const response = await executor.request( "demo", @@ -1271,117 +1282,65 @@ function fakeStateClient( options: { beforeChunk?: () => Promise } = {}, ) { const calls = { resolve: 0, manifest: 0, chunk: 0 }; - const resolution: AppRouteResolution = { - appId: "demo", - release: artifact.release, - region: "local", - regions: ["local"], - revision: 1, - artifactHash: artifact.hash, - artifactBytes: artifact.bytes.byteLength, - entrypoint: DIRECT_ENTRYPOINT, - namespace: "test", - scaling: { minReplicas: 0, maxReplicas: 128, targetConcurrency: 8 }, - maxRequestBytes: 1024 * 1024, - maxResponseBytes: 4 * 1024 * 1024, - }; - const client = { - agentOSAppsApp: { - getOrCreate() { - return { - async resolveDeployment() { - calls.resolve += 1; - return resolution; - }, - async getArtifactManifest() { - calls.manifest += 1; - return { - format: DIRECT_RUNTIME_FORMAT, - hash: artifact.hash, - bytes: artifact.bytes.byteLength, - chunks: 1, - chunkBytes: artifact.bytes.byteLength, - }; - }, - async readArtifactChunk() { - calls.chunk += 1; - await options.beforeChunk?.(); - return artifact.bytes; - }, - connect() { - return { - ready: Promise.resolve(), - connStatus: "connected", - on: () => () => {}, - onOpen: () => () => {}, - onClose: () => () => {}, - dispose: async () => {}, - }; - }, - }; - }, + const source: ExecutorReleaseSource = { + async watchActiveRelease() { + return () => {}; + }, + async loadActiveRelease(appId: string) { + calls.resolve += 1; + calls.manifest += 1; + await options.beforeChunk?.(); + calls.chunk += 1; + return { + appId, + release: artifact.release, + artifact: { + format: DIRECT_RUNTIME_FORMAT, + entrypoint: DIRECT_ENTRYPOINT, + hash: artifact.hash, + bytes: new Uint8Array(artifact.bytes), + byteLength: artifact.bytes.byteLength, + usesRivetKit: false, + }, + regions: ["local"], + scaling: { minReplicas: 0, maxReplicas: 128, targetConcurrency: 8 }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; }, }; - return { client, calls }; + return { source, calls }; } function fakeMultiStateClient(artifacts: Map) { const calls = { resolve: 0, manifest: 0, chunk: 0 }; - const client = { - agentOSAppsApp: { - getOrCreate(key: string[]) { - const appId = key[0] ?? ""; - const artifact = artifacts.get(appId); - if (!artifact) throw new Error(`unknown test app ${appId}`); - const resolution: AppRouteResolution = { - appId, - release: artifact.release, - region: "local", - regions: ["local"], - revision: 1, - artifactHash: artifact.hash, - artifactBytes: artifact.bytes.byteLength, + const source: ExecutorReleaseSource = { + async watchActiveRelease() { + return () => {}; + }, + async loadActiveRelease(appId: string) { + const artifact = artifacts.get(appId); + if (!artifact) throw new Error(`unknown test app ${appId}`); + calls.resolve += 1; + calls.manifest += 1; + calls.chunk += 1; + return { + appId, + release: artifact.release, + artifact: { + format: DIRECT_RUNTIME_FORMAT, entrypoint: DIRECT_ENTRYPOINT, - namespace: "test", - scaling: { - minReplicas: 0, - maxReplicas: 128, - targetConcurrency: 8, - }, - maxRequestBytes: 1024 * 1024, - maxResponseBytes: 4 * 1024 * 1024, - }; - return { - async resolveDeployment() { - calls.resolve += 1; - return resolution; - }, - async getArtifactManifest() { - calls.manifest += 1; - return { - format: DIRECT_RUNTIME_FORMAT, - hash: artifact.hash, - bytes: artifact.bytes.byteLength, - chunks: 1, - chunkBytes: artifact.bytes.byteLength, - }; - }, - async readArtifactChunk() { - calls.chunk += 1; - return artifact.bytes; - }, - connect() { - return { - ready: Promise.resolve(), - on: () => () => {}, - onOpen: () => () => {}, - onClose: () => () => {}, - dispose: async () => {}, - }; - }, - }; - }, + hash: artifact.hash, + bytes: new Uint8Array(artifact.bytes), + byteLength: artifact.bytes.byteLength, + usesRivetKit: false, + }, + regions: ["local"], + scaling: { minReplicas: 0, maxReplicas: 128, targetConcurrency: 8 }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; }, }; - return { client, calls }; + return { source, calls }; } diff --git a/packages/dynamic-apps/tests/release-store.test.ts b/packages/dynamic-apps/tests/release-store.test.ts new file mode 100644 index 000000000..43c6c3e33 --- /dev/null +++ b/packages/dynamic-apps/tests/release-store.test.ts @@ -0,0 +1,202 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, test } from "vitest"; +import { + ARTIFACT_CHUNK_BYTES, + createRivetReleaseStore, +} from "../src/release-store.js"; + +const previousRuntimeMode = process.env.RIVETKIT_RUNTIME_MODE; + +afterEach(() => { + if (previousRuntimeMode === undefined) { + delete process.env.RIVETKIT_RUNTIME_MODE; + } else { + process.env.RIVETKIT_RUNTIME_MODE = previousRuntimeMode; + } +}); + +describe("Rivet release store", () => { + test("publishes sequential chunks and returns only Deployment fields", async () => { + process.env.RIVETKIT_RUNTIME_MODE = "serverless"; + const bytes = Uint8Array.from( + { length: ARTIFACT_CHUNK_BYTES + 7 }, + (_, index) => index % 251, + ); + const writes: Array<{ index: number; bytes: number }> = []; + const handle = { + async beginReleasePublish() { + return { + release: "a".repeat(64), + sequence: 1, + uploadRequired: true, + chunkBytes: ARTIFACT_CHUNK_BYTES, + }; + }, + async writeReleaseChunk(input: { index: number; content: Uint8Array }) { + writes.push({ index: input.index, bytes: input.content.byteLength }); + }, + async commitReleasePublish() { + return { + appId: "demo", + release: "a".repeat(64), + endpoint: "https://example.test", + namespace: "demo", + pool: "pool", + token: "public", + regions: ["local"], + appActorId: "actor", + usesRivetKit: false, + }; + }, + }; + const store = createRivetReleaseStore({ + agentOSAppsApp: { + get: () => handle as never, + getOrCreate: () => handle as never, + }, + }); + const result = await store.publishRelease({ + appId: "demo", + buildId: "b".repeat(64), + artifact: { + format: "agentos-apps-direct-v2", + entrypoint: "direct-v2/main.mjs", + hash: createHash("sha256").update(bytes).digest("hex"), + bytes, + byteLength: bytes.byteLength, + usesRivetKit: false, + }, + createdAt: Date.now(), + }); + expect(writes).toEqual([ + { index: 0, bytes: ARTIFACT_CHUNK_BYTES }, + { index: 1, bytes: 7 }, + ]); + expect(Object.keys(result)).toEqual([ + "appId", + "release", + "endpoint", + "namespace", + "pool", + "token", + "regions", + ]); + }); + + test("subscribes, downloads, verifies, and releases a driver", async () => { + process.env.RIVETKIT_RUNTIME_MODE = "serverless"; + const bytes = new Uint8Array([1, 2, 3, 4]); + const hash = createHash("sha256").update(bytes).digest("hex"); + let disposed = 0; + const handle = { + connect() { + return { + ready: Promise.resolve(), + on: () => () => {}, + onOpen: () => () => {}, + onClose: () => () => {}, + async dispose() { + disposed += 1; + }, + }; + }, + async resolveDeployment() { + return { + appId: "demo", + release: "c".repeat(64), + region: "local", + regions: ["local"], + revision: 1, + artifactHash: hash, + artifactBytes: bytes.byteLength, + entrypoint: "direct-v2/main.mjs" as const, + namespace: "demo", + scaling: { minReplicas: 0, maxReplicas: 1, targetConcurrency: 1 }, + maxRequestBytes: 1024, + maxResponseBytes: 1024, + usesRivetKit: false, + }; + }, + async getArtifactManifest() { + return { + format: "agentos-apps-direct-v2", + hash, + bytes: bytes.byteLength, + chunks: 1, + chunkBytes: ARTIFACT_CHUNK_BYTES, + }; + }, + async readArtifactChunk() { + return bytes; + }, + }; + const store = createRivetReleaseStore({ + agentOSAppsApp: { + get: () => handle as never, + getOrCreate: () => handle as never, + }, + }); + const unsubscribe = await store.watchActiveRelease("demo", () => {}); + const timings: string[] = []; + const release = await store.loadActiveRelease("demo", { + recordTiming: (name) => timings.push(name), + }); + expect(release?.artifact.bytes).toEqual(bytes); + expect(timings).toEqual([ + "actor-connect", + "actor-resolve", + "artifact-manifest", + "artifact-download", + ]); + await unsubscribe(); + await unsubscribe(); + expect(disposed).toBe(1); + }); + + test("cleans up a failed connection and allows a later retry", async () => { + process.env.RIVETKIT_RUNTIME_MODE = "serverless"; + let attempts = 0; + let disposed = 0; + let listenersRemoved = 0; + const handle = { + connect() { + attempts += 1; + const succeeds = attempts > 1; + return { + ready: succeeds + ? Promise.resolve() + : Promise.reject(new Error("connection failed")), + on: () => () => { + listenersRemoved += 1; + }, + onOpen: () => () => { + listenersRemoved += 1; + }, + onClose: () => () => { + listenersRemoved += 1; + }, + async dispose() { + disposed += 1; + }, + }; + }, + }; + const store = createRivetReleaseStore({ + agentOSAppsApp: { + get: () => handle as never, + getOrCreate: () => handle as never, + }, + }); + await expect(store.watchActiveRelease("demo", () => {})).rejects.toThrow( + "connection failed", + ); + expect({ disposed, listenersRemoved }).toEqual({ + disposed: 1, + listenersRemoved: 3, + }); + const unsubscribe = await store.watchActiveRelease("demo", () => {}); + await unsubscribe(); + expect(attempts).toBe(2); + expect(disposed).toBe(2); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d4b56ee6..2e205d1e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@rivet-dev/dynamic-apps': specifier: workspace:* version: link:../../packages/dynamic-apps + '@rivet-dev/dynamic-apps-core': + specifier: workspace:* + version: link:../../packages/dynamic-apps-core hono: specifier: ^4.12.9 version: 4.13.3 @@ -93,6 +96,28 @@ importers: specifier: ^5.7.3 version: 5.9.3 + examples/apps-core-quickstart: + dependencies: + '@hono/node-server': + specifier: ^2.0.11 + version: 2.1.1(hono@4.13.3) + '@rivet-dev/dynamic-apps-core': + specifier: workspace:* + version: link:../../packages/dynamic-apps-core + hono: + specifier: ^4.12.9 + version: 4.13.3 + devDependencies: + '@types/node': + specifier: ^22.19.15 + version: 22.20.1 + tsx: + specifier: ^4.20.6 + version: 4.23.12 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + examples/apps-hello-world: dependencies: '@hono/node-server': @@ -117,21 +142,9 @@ importers: packages/dynamic-apps: dependencies: - '@agentos-software/sh': - specifier: 0.2.15 - version: 0.2.15 - '@agentos-software/tar': - specifier: 0.3.5 - version: 0.3.5 - '@rivet-dev/agentos-core': - specifier: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - version: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3) - '@rivet-dev/agentos-toolchain': - specifier: 0.2.15 - version: 0.2.15 - '@rivet-dev/dynamic-apps-builder': - specifier: workspace:0.12.0-rc.1 - version: link:../dynamic-apps-builder + '@rivet-dev/dynamic-apps-core': + specifier: workspace:0.12.0-rc.2 + version: link:../dynamic-apps-core hono: specifier: ^4.7.0 version: 4.13.3 @@ -139,6 +152,12 @@ importers: specifier: 2.3.11 version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.3) devDependencies: + '@rivet-dev/agentos-core': + specifier: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + version: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3) + '@rivet-dev/agentos-toolchain': + specifier: 0.2.15 + version: 0.2.15 '@types/node': specifier: ^22.19.15 version: 22.20.1 @@ -177,6 +196,40 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@22.20.1) + packages/dynamic-apps-core: + dependencies: + '@agentos-software/sh': + specifier: 0.2.15 + version: 0.2.15 + '@agentos-software/tar': + specifier: 0.3.5 + version: 0.3.5 + '@rivet-dev/agentos-core': + specifier: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + version: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3) + '@rivet-dev/agentos-toolchain': + specifier: 0.2.15 + version: 0.2.15 + '@rivet-dev/dynamic-apps-builder': + specifier: workspace:0.12.0-rc.2 + version: link:../dynamic-apps-builder + hono: + specifier: ^4.7.0 + version: 4.13.3 + devDependencies: + '@types/node': + specifier: ^22.19.15 + version: 22.20.1 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.26)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.1) + tests/e2e/dynamic-apps: dependencies: '@rivet-dev/dynamic-apps': diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index d4d810172..6ba0e7bcb 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -27,33 +27,68 @@ function assert(condition, message) { const mainPackage = JSON.parse( await read("packages/dynamic-apps/package.json"), ); -assert( - !mainPackage.dependencies?.["@rivet-dev/agentos"], - "Dynamic Apps must use agentOS core rather than the actor package", +const corePackage = JSON.parse( + await read("packages/dynamic-apps-core/package.json"), ); assert( - mainPackage.dependencies?.["@rivet-dev/agentos-core"] === "0.2.15", - "agentOS core must remain a pinned implementation dependency", + !corePackage.dependencies?.["@rivet-dev/agentos"] && + corePackage.dependencies?.["@rivet-dev/agentos-core"] === "0.2.15" && + corePackage.dependencies?.["@rivet-dev/agentos-toolchain"] === "0.2.15", + "core must use exact pinned agentOS Core implementation dependencies", ); assert( - !mainPackage.dependencies?.["isolated-vm"], - "isolated-vm must not be a direct runtime dependency", + !corePackage.dependencies?.["isolated-vm"], + "core must use agentOS contexts rather than isolated-vm", ); assert( - !mainPackage.peerDependencies?.["@rivet-dev/agentos-core"], + !corePackage.peerDependencies?.["@rivet-dev/agentos-core"], "agentOS core must not be a peer dependency", ); assert( - mainPackage.dependencies?.["@rivet-dev/dynamic-apps-builder"] === + corePackage.dependencies?.["@rivet-dev/dynamic-apps-builder"] === + `workspace:${corePackage.version}` || + corePackage.dependencies?.["@rivet-dev/dynamic-apps-builder"] === + corePackage.version, + "core must use the exact matching builder version", +); +assert( + mainPackage.dependencies?.["@rivet-dev/dynamic-apps-core"] === `workspace:${mainPackage.version}` || - mainPackage.dependencies?.["@rivet-dev/dynamic-apps-builder"] === + mainPackage.dependencies?.["@rivet-dev/dynamic-apps-core"] === mainPackage.version, - "the builder must use the exact matching workspace version", + "the adapter must use the exact matching core version", ); +for (const dependency of [ + "@agentos-software/sh", + "@agentos-software/tar", + "@rivet-dev/agentos", + "@rivet-dev/agentos-core", + "@rivet-dev/agentos-toolchain", + "@rivet-dev/dynamic-apps-builder", + "isolated-vm", +]) { + assert( + !mainPackage.dependencies?.[dependency], + `adapter production dependency leaked from core: ${dependency}`, + ); +} +assert( + !corePackage.dependencies?.rivetkit && !corePackage.devDependencies?.rivetkit, + "core must not depend on RivetKit", +); +for (const path of await walk("packages/dynamic-apps-core/src/")) { + if (!path.endsWith(".ts")) continue; + const source = await read(path); + assert( + !/^\s*import[^\n]*["']rivetkit(?:\/[^"']*)?["']/m.test(source), + `${path} imports RivetKit`, + ); +} for (const path of await walk("examples/")) { if (!path.endsWith(".ts") && !path.endsWith("package.json")) continue; const source = await read(path); + if (path.startsWith("examples/apps-core-quickstart/")) continue; assert( !source.includes('from "@rivet-dev/agentos"') && !source.includes('"@rivet-dev/agentos":'), @@ -65,7 +100,8 @@ const actors = await read("packages/dynamic-apps/src/actors.ts"); for (const identity of [ "agentOSAppsApp: AnyActorDefinition;", "const agentOSAppsApp = actor({", - '"/opt/agentos/bin/apps-builder"', + "beginReleasePublish:", + "commitReleasePublish:", ]) { assert( actors.includes(identity), @@ -73,9 +109,11 @@ for (const identity of [ ); } -const runtime = await read("packages/dynamic-apps/src/runtime.ts"); +const runtime = await read("packages/dynamic-apps-core/src/runtime.ts"); assert( - runtime.includes('hash.update("agentos-apps-release-v17-direct-actors\\0")'), + runtime.includes( + 'hash.update("agentos-apps-release-v19-mounted-hono-router\\0")', + ), "release hash domain changed", ); @@ -95,6 +133,13 @@ assert( logging.includes("export interface DynamicAppsLogEvent"), "structured logging public surface is missing", ); +const coreIndex = await read("packages/dynamic-apps-core/src/index.ts"); +assert( + coreIndex.includes("createDynamicApps") && + !coreIndex.includes("buildAppRelease") && + !coreIndex.includes("DynamicAppsExecutor"), + "core root must export the factory without internal build/executor values", +); const builderManifest = JSON.parse( await read("packages/dynamic-apps-builder/agentos-package.json"), @@ -115,4 +160,20 @@ for (const [index, source] of (await Promise.all(declarations)).entries()) { ); } +const coreDeclarations = (await walk("packages/dynamic-apps-core/dist/")) + .filter((path) => path.endsWith(".d.ts")) + .map((path) => read(path)); +for (const [index, source] of (await Promise.all(coreDeclarations)).entries()) { + assert( + !source.includes('from "rivetkit') && !source.includes("from 'rivetkit"), + `core declaration ${index + 1} leaks RivetKit`, + ); +} +for (const [index, source] of (await Promise.all(declarations)).entries()) { + assert( + !source.includes("@rivet-dev/dynamic-apps-core/internal"), + `adapter declaration ${index + 1} leaks core internal types`, + ); +} + console.log("Dynamic Apps package boundaries are valid."); diff --git a/scripts/set-release-version.mjs b/scripts/set-release-version.mjs index 3660716c3..3efb23220 100644 --- a/scripts/set-release-version.mjs +++ b/scripts/set-release-version.mjs @@ -12,7 +12,11 @@ async function update(path, transform) { await update("packages/dynamic-apps-builder/package.json", (value) => { value.version = version; }); -await update("packages/dynamic-apps/package.json", (value) => { +await update("packages/dynamic-apps-core/package.json", (value) => { value.version = version; value.dependencies["@rivet-dev/dynamic-apps-builder"] = version; }); +await update("packages/dynamic-apps/package.json", (value) => { + value.version = version; + value.dependencies["@rivet-dev/dynamic-apps-core"] = version; +}); diff --git a/scripts/test-core-quickstart.mjs b/scripts/test-core-quickstart.mjs new file mode 100644 index 000000000..b24868b10 --- /dev/null +++ b/scripts/test-core-quickstart.mjs @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; + +const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not allocate a Core Quick Start test port")); + return; + } + server.close(() => resolve(address.port)); + }); +}); + +const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "examples/apps-core-quickstart/src/server.ts", + "--host", + "0.0.0.0", + ], + { + stdio: ["ignore", "pipe", "inherit"], + env: { ...process.env, PORT: String(port) }, + }, +); +let output = ""; +child.stdout.on("data", (chunk) => { + output += chunk; +}); + +try { + const deadline = Date.now() + 5 * 60_000; + for (;;) { + if (child.exitCode !== null) { + throw new Error(`Core Quick Start exited early with ${child.exitCode}`); + } + try { + const response = await fetch(`http://127.0.0.1:${port}/apps/hello/`); + if ( + response.ok && + (await response.text()) === "Hello from Dynamic Apps Core!" + ) { + break; + } + } catch {} + if (Date.now() >= deadline) { + throw new Error(`Core Quick Start did not become ready:\n${output}`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} finally { + if (child.exitCode === null) { + child.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolve(undefined); + }); + }); + } +} diff --git a/scripts/test-packed.mjs b/scripts/test-packed.mjs index fc012b5e1..f8e1b0e1f 100644 --- a/scripts/test-packed.mjs +++ b/scripts/test-packed.mjs @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { access, mkdir, @@ -21,6 +22,7 @@ await rm(packDirectory, { recursive: true, force: true }); await mkdir(packDirectory, { recursive: true }); for (const packagePath of [ "packages/dynamic-apps-builder", + "packages/dynamic-apps-core", "packages/dynamic-apps", ]) { await execFileAsync( @@ -38,10 +40,18 @@ const builderTarball = join( const mainTarball = join( packDirectory, tarballs.find( - (name) => name.includes("dynamic-apps-") && !name.includes("builder"), + (name) => + name.includes("dynamic-apps-") && + !name.includes("builder") && + !name.includes("core"), ) ?? "missing", ); +const coreTarball = join( + packDirectory, + tarballs.find((name) => name.includes("dynamic-apps-core")) ?? "missing", +); await access(builderTarball); +await access(coreTarball); await access(mainTarball); const fixture = await mkdtemp(join(tmpdir(), "dynamic-apps-packed-")); @@ -53,6 +63,7 @@ await writeFile( dependencies: { "@rivet-dev/dynamic-apps": `file:${mainTarball}`, "@rivet-dev/dynamic-apps-builder": `file:${builderTarball}`, + "@rivet-dev/dynamic-apps-core": `file:${coreTarball}`, }, }), ); @@ -67,12 +78,23 @@ const builderRoot = join( "node_modules/@rivet-dev/dynamic-apps-builder", ); const mainRoot = join(fixture, "node_modules/@rivet-dev/dynamic-apps"); +const coreRoot = join(fixture, "node_modules/@rivet-dev/dynamic-apps-core"); const builder = await import(pathToFileURL(join(builderRoot, "dist/index.js"))); if (basename(builder.default.packagePath) !== "package.aospkg") { throw new Error("packed builder did not export package.aospkg"); } await access(builder.default.packagePath); +const core = await import(pathToFileURL(join(coreRoot, "dist/index.js"))); +if ( + JSON.stringify(Object.keys(core).sort()) !== + JSON.stringify(["createDynamicApps"]) +) { + throw new Error( + `packed core package has unexpected exports: ${Object.keys(core)}`, + ); +} + const main = await import(pathToFileURL(join(mainRoot, "dist/index.js"))); const exports = Object.keys(main).sort(); if ( @@ -84,7 +106,7 @@ if ( main.setDynamicAppsLogHandler(() => {}); main.setDynamicAppsLogHandler(undefined); -for (const packageRoot of [builderRoot, mainRoot]) { +for (const packageRoot of [builderRoot, coreRoot, mainRoot]) { const manifest = JSON.parse( await readFile(join(packageRoot, "package.json"), "utf8"), ); @@ -154,6 +176,80 @@ if (typeof directModule.dispatch !== "function") { throw new Error("packed direct builder did not emit an ESM dispatcher"); } +const coreArtifactDirectory = join(fixture, "core-artifact"); +const coreArtifactArchive = join(fixture, "core-artifact.tar"); +await mkdir(join(coreArtifactDirectory, "direct"), { recursive: true }); +await writeFile( + join(coreArtifactDirectory, "direct/main.mjs"), + await readFile(join(release, "main.mjs"), "utf8"), +); +await writeFile( + join(coreArtifactDirectory, "agentos-package.json"), + JSON.stringify({ name: "packed-core-smoke", version: "1.0.0" }), +); +await execFileAsync( + "tar", + ["-cf", coreArtifactArchive, "direct", "agentos-package.json"], + { cwd: coreArtifactDirectory }, +); +const { packAospkgFromTarBytes } = await import( + pathToFileURL( + join( + repositoryRoot, + "packages/dynamic-apps-core/node_modules/@rivet-dev/agentos-toolchain/dist/index.js", + ), + ) +); +const coreArtifact = new Uint8Array( + packAospkgFromTarBytes(await readFile(coreArtifactArchive)).bytes, +); +let activeRelease; +const dynamicApps = core.createDynamicApps({ + artifactCache: { + async get() { + return coreArtifact; + }, + async put() {}, + }, + async publishRelease(input) { + activeRelease = { + appId: input.appId, + release: "packed-1", + artifact: { + ...input.artifact, + bytes: new Uint8Array(input.artifact.bytes), + hash: createHash("sha256").update(input.artifact.bytes).digest("hex"), + }, + regions: ["local"], + scaling: { minReplicas: 0, maxReplicas: 1, targetConcurrency: 1 }, + maxRequestBytes: 1024 * 1024, + maxResponseBytes: 4 * 1024 * 1024, + }; + }, + async loadActiveRelease() { + return activeRelease; + }, + async watchActiveRelease() { + return () => {}; + }, + executor: { executionMode: "ephemeral" }, +}); +try { + await dynamicApps.deployApp({ + appId: "hello", + files: { + "package.json": JSON.stringify({ type: "module", main: "index.js" }), + "index.js": "export default { fetch() { return new Response('ok') } }", + }, + }); + const response = await dynamicApps.appsRouter.request("/hello/"); + if (response.status !== 200) { + throw new Error(`packed core smoke returned HTTP ${response.status}`); + } +} finally { + await dynamicApps.dispose(); +} + const actorWorkspace = join(fixture, "actor-builder-smoke"); const actorRelease = join(fixture, "actor-builder-release"); await mkdir(actorWorkspace, { recursive: true }); @@ -193,5 +289,5 @@ if (typeof actorModule.registry?.handler !== "function") { } console.log( - `Verified ${basename(builderTarball)} and ${basename(mainTarball)}.`, + `Verified ${basename(builderTarball)}, ${basename(coreTarball)}, and ${basename(mainTarball)}.`, );