From 1cca789db7a98e72217993e5e9a6778a557399d8 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 31 Aug 2026 02:43:50 -0700 Subject: [PATCH] fix: run dynamic app actors inside AgentOS --- Dockerfile | 6 + benchmarks/dynamic-apps/package.json | 20 +- benchmarks/dynamic-apps/src/deploy-fixture.ts | 8 +- benchmarks/dynamic-apps/src/edge.ts | 19 +- benchmarks/dynamic-apps/src/load.ts | 2 +- benchmarks/dynamic-apps/src/runtime-stress.ts | 8 +- docs/content/docs/authentication.mdx | 2 - docs/content/docs/deploy.mdx | 11 +- docs/content/docs/index.mdx | 92 +- docs/content/docs/multiplayer.mdx | 19 + docs/content/docs/quickstart-core.mdx | 4 +- docs/content/docs/quickstart.mdx | 4 +- docs/content/docs/realtime.mdx | 22 - docs/content/docs/reference.mdx | 19 - docs/content/docs/routing.mdx | 16 +- docs/content/docs/sqlite.mdx | 20 + docs/content/docs/state-and-data.mdx | 49 - docs/content/docs/static-websites.mdx | 21 + docs/content/docs/workflows.mdx | 19 + docs/sidebar.json | 24 +- .../apps-ai-builder/fixtures/app/package.json | 5 + .../apps-ai-builder/fixtures/app/src/index.ts | 23 +- examples/apps-ai-builder/package.json | 2 +- examples/apps-ai-builder/src/server.ts | 21 +- examples/apps-core-quickstart/package.json | 2 +- examples/apps-hello-world/package.json | 4 +- examples/apps-hello-world/src/server.ts | 8 +- .../fixtures/app/package.json | 12 + .../fixtures/app/src/index.ts | 44 + examples/apps-multiplayer/package.json | 22 + examples/apps-multiplayer/src/client.ts | 32 + examples/apps-multiplayer/src/server.ts | 21 + examples/apps-multiplayer/tsconfig.json | 12 + .../apps-sqlite/fixtures/app/package.json | 12 + .../apps-sqlite/fixtures/app/src/index.ts | 47 + examples/apps-sqlite/package.json | 22 + examples/apps-sqlite/src/client.ts | 29 + examples/apps-sqlite/src/server.ts | 21 + examples/apps-sqlite/tsconfig.json | 12 + .../fixtures/app/package.json | 10 + .../fixtures/app/public/app.js | 2 + .../fixtures/app/public/index.html | 16 + .../fixtures/app/public/styles.css | 5 + .../fixtures/app/src/index.ts | 28 + examples/apps-static-website/package.json | 20 + examples/apps-static-website/src/server.ts | 18 + examples/apps-static-website/tsconfig.json | 12 + .../apps-workflows/fixtures/app/package.json | 12 + .../apps-workflows/fixtures/app/src/index.ts | 48 + examples/apps-workflows/package.json | 22 + examples/apps-workflows/src/client.ts | 34 + examples/apps-workflows/src/server.ts | 21 + examples/apps-workflows/tsconfig.json | 12 + .../dynamic-apps-builder/cli/apps-builder.mjs | 21 +- packages/dynamic-apps-builder/package.json | 5 +- .../dynamic-apps-builder/test/builder.test.ts | 105 +- packages/dynamic-apps-core/package.json | 6 +- packages/dynamic-apps-core/src/artifact.ts | 4 +- packages/dynamic-apps-core/src/build.ts | 50 +- packages/dynamic-apps-core/src/executor.ts | 74 +- packages/dynamic-apps-core/src/factory.ts | 4 +- packages/dynamic-apps-core/src/router.ts | 20 +- packages/dynamic-apps-core/src/runtime.ts | 33 +- packages/dynamic-apps-core/src/source.ts | 16 +- packages/dynamic-apps-core/src/types.ts | 2 +- packages/dynamic-apps-core/tests/core.test.ts | 2 +- packages/dynamic-apps/API_CONTRACT.md | 109 +- packages/dynamic-apps/README.md | 77 +- packages/dynamic-apps/package.json | 4 +- packages/dynamic-apps/src/actor-runtime.ts | 1016 +++++------------ packages/dynamic-apps/src/actors.ts | 157 +-- packages/dynamic-apps/src/control-plane.ts | 24 +- packages/dynamic-apps/src/deploy.ts | 4 +- packages/dynamic-apps/src/index.ts | 1 + packages/dynamic-apps/src/registry.ts | 36 +- packages/dynamic-apps/src/release-store.ts | 12 +- packages/dynamic-apps/src/router.ts | 25 +- packages/dynamic-apps/src/runtime.ts | 2 +- packages/dynamic-apps/src/skills.ts | 151 +++ packages/dynamic-apps/tests/direct.test.ts | 144 ++- .../tests/fixtures/legacy-0.2.15.json | 16 +- .../dynamic-apps/tests/release-store.test.ts | 10 +- pnpm-lock.yaml | 281 +++-- pnpm-workspace.yaml | 2 +- scripts/check-boundaries.mjs | 6 +- scripts/resolve-release.mjs | 2 +- specs/agentos-inline-runtime-and-logging.md | 61 +- specs/dynamic-apps-core.md | 25 +- tests/e2e/dynamic-apps/package.json | 8 +- tests/e2e/dynamic-apps/src/run.ts | 18 +- tests/e2e/dynamic-apps/src/sanity.ts | 32 +- tests/e2e/dynamic-apps/src/verify.ts | 11 + 92 files changed, 2087 insertions(+), 1485 deletions(-) create mode 100644 docs/content/docs/multiplayer.mdx delete mode 100644 docs/content/docs/realtime.mdx delete mode 100644 docs/content/docs/reference.mdx create mode 100644 docs/content/docs/sqlite.mdx delete mode 100644 docs/content/docs/state-and-data.mdx create mode 100644 docs/content/docs/static-websites.mdx create mode 100644 docs/content/docs/workflows.mdx create mode 100644 examples/apps-multiplayer/fixtures/app/package.json create mode 100644 examples/apps-multiplayer/fixtures/app/src/index.ts create mode 100644 examples/apps-multiplayer/package.json create mode 100644 examples/apps-multiplayer/src/client.ts create mode 100644 examples/apps-multiplayer/src/server.ts create mode 100644 examples/apps-multiplayer/tsconfig.json create mode 100644 examples/apps-sqlite/fixtures/app/package.json create mode 100644 examples/apps-sqlite/fixtures/app/src/index.ts create mode 100644 examples/apps-sqlite/package.json create mode 100644 examples/apps-sqlite/src/client.ts create mode 100644 examples/apps-sqlite/src/server.ts create mode 100644 examples/apps-sqlite/tsconfig.json create mode 100644 examples/apps-static-website/fixtures/app/package.json create mode 100644 examples/apps-static-website/fixtures/app/public/app.js create mode 100644 examples/apps-static-website/fixtures/app/public/index.html create mode 100644 examples/apps-static-website/fixtures/app/public/styles.css create mode 100644 examples/apps-static-website/fixtures/app/src/index.ts create mode 100644 examples/apps-static-website/package.json create mode 100644 examples/apps-static-website/src/server.ts create mode 100644 examples/apps-static-website/tsconfig.json create mode 100644 examples/apps-workflows/fixtures/app/package.json create mode 100644 examples/apps-workflows/fixtures/app/src/index.ts create mode 100644 examples/apps-workflows/package.json create mode 100644 examples/apps-workflows/src/client.ts create mode 100644 examples/apps-workflows/src/server.ts create mode 100644 examples/apps-workflows/tsconfig.json create mode 100644 packages/dynamic-apps/src/skills.ts diff --git a/Dockerfile b/Dockerfile index 7d26a63b0..061e06de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,16 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/dynamic-apps/package.json packages/dynamic-apps/package.json +COPY packages/dynamic-apps-core/package.json packages/dynamic-apps-core/package.json COPY packages/dynamic-apps-builder/package.json packages/dynamic-apps-builder/package.json COPY benchmarks/dynamic-apps/package.json benchmarks/dynamic-apps/package.json COPY examples/apps-ai-builder/package.json examples/apps-ai-builder/package.json COPY examples/apps-hello-world/package.json examples/apps-hello-world/package.json +COPY examples/apps-core-quickstart/package.json examples/apps-core-quickstart/package.json +COPY examples/apps-multiplayer/package.json examples/apps-multiplayer/package.json +COPY examples/apps-sqlite/package.json examples/apps-sqlite/package.json +COPY examples/apps-static-website/package.json examples/apps-static-website/package.json +COPY examples/apps-workflows/package.json examples/apps-workflows/package.json COPY tests/e2e/dynamic-apps/package.json tests/e2e/dynamic-apps/package.json RUN pnpm install --frozen-lockfile diff --git a/benchmarks/dynamic-apps/package.json b/benchmarks/dynamic-apps/package.json index a9689247c..9179f275f 100644 --- a/benchmarks/dynamic-apps/package.json +++ b/benchmarks/dynamic-apps/package.json @@ -4,20 +4,20 @@ "private": true, "type": "module", "scripts": { - "benchmark:local": "node --import tsx src/local.ts --host 0.0.0.0", - "benchmark:cloud-stress": "node --import tsx src/cloud-stress.ts", - "benchmark:stress": "node --expose-gc --import tsx src/runtime-stress.ts", - "benchmark:suite": "node --import tsx src/suite.ts", - "deploy:fixture": "node --import tsx src/deploy-fixture.ts", - "load": "node --import tsx src/load.ts", - "start": "node --import tsx src/server.ts --host 0.0.0.0", + "benchmark:local": "npx tsx src/local.ts --host 0.0.0.0", + "benchmark:cloud-stress": "npx tsx src/cloud-stress.ts", + "benchmark:stress": "npx tsx --expose-gc src/runtime-stress.ts", + "benchmark:suite": "npx tsx src/suite.ts", + "deploy:fixture": "npx tsx src/deploy-fixture.ts", + "load": "npx tsx src/load.ts", + "start": "npx tsx src/server.ts --host 0.0.0.0", "check-types": "tsc --noEmit", - "test": "node --import tsx --test src/load.test.ts" + "test": "npx tsx --test src/load.test.ts" }, "dependencies": { "@hono/node-server": "^2.0.11", - "@rivet-dev/agentos-core": "0.2.15", - "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/agentos-core": "0.2.16-rc.3", + "@rivet-dev/agentos-toolchain": "0.2.16-rc.3", "@rivet-dev/dynamic-apps": "workspace:*", "@rivet-dev/dynamic-apps-core": "workspace:*", "hono": "^4.12.9", diff --git a/benchmarks/dynamic-apps/src/deploy-fixture.ts b/benchmarks/dynamic-apps/src/deploy-fixture.ts index 05bcc4be4..7bc18731d 100644 --- a/benchmarks/dynamic-apps/src/deploy-fixture.ts +++ b/benchmarks/dynamic-apps/src/deploy-fixture.ts @@ -6,7 +6,7 @@ import { } from "./fixture.js"; const actorClient = createClient() as unknown as { - agentOSAppsApp: { + dynamicAppsApp: { getOrCreate(key: string[]): ActorHandle; getForId(actorId: string): ActorHandle; }; @@ -16,10 +16,10 @@ interface ActorHandle { } const actorId = process.env.BENCH_APP_ACTOR_ID; const actor = actorId - ? actorClient.agentOSAppsApp.getForId(actorId) - : actorClient.agentOSAppsApp.getOrCreate([BENCHMARK_APP_ID]); + ? actorClient.dynamicAppsApp.getForId(actorId) + : actorClient.dynamicAppsApp.getOrCreate([BENCHMARK_APP_ID]); const deploymentClient = { - agentOSAppsApp: { getOrCreate: () => actor }, + dynamicAppsApp: { getOrCreate: () => actor }, } as unknown as BenchmarkDeploymentClient; const deployment = await deployBenchmarkFixture(deploymentClient); const resolvedActorId = await actor.resolve(); diff --git a/benchmarks/dynamic-apps/src/edge.ts b/benchmarks/dynamic-apps/src/edge.ts index cfe8888f6..b79aea369 100644 --- a/benchmarks/dynamic-apps/src/edge.ts +++ b/benchmarks/dynamic-apps/src/edge.ts @@ -22,7 +22,7 @@ interface AppHandle { } interface StateClient { - agentOSAppsApp: { + dynamicAppsApp: { getOrCreate(key: string[]): AppHandle; }; } @@ -84,7 +84,7 @@ export function createBenchmarkApplication(): Hono { | undefined; let actorApplicationClient: ActorApplicationClient | undefined; const getActorApplicationDeployment = () => { - actorApplicationDeployment ??= deployActorBenchmarkFixture(client) + actorApplicationDeployment ??= deployActorBenchmarkFixture() .then((deployment) => { actorApplicationResolvedDeployment = deployment; return deployment; @@ -101,12 +101,7 @@ export function createBenchmarkApplication(): Hono { ) as unknown as ActorApplicationClient; return actorApplicationClient; }; - const privateRegistry = (request: Request) => { - const headers = new Headers(request.headers); - headers.set("x-agentos-app-registry-dispatch", "1"); - return appsRouter.fetch(new Request(request, { headers })); - }; - app.all("/api/rivet/*", (c) => privateRegistry(c.req.raw)); + app.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); app.all("/bench/noop", () => { const startedAt = performance.now(); @@ -148,7 +143,7 @@ export function createBenchmarkApplication(): Hono { app.all("/bench/actor/resolve", async () => { const startedAt = performance.now(); - const handle = client.agentOSAppsApp.getOrCreate([BENCHMARK_APP_ID]); + const handle = client.dynamicAppsApp.getOrCreate([BENCHMARK_APP_ID]); const actorId = await handle.resolve(); const response = Response.json({ ok: true, actorId }); response.headers.set( @@ -159,7 +154,7 @@ export function createBenchmarkApplication(): Hono { }); app.all("/bench/actor/action", async () => { const startedAt = performance.now(); - const resolution = await client.agentOSAppsApp + const resolution = await client.dynamicAppsApp .getOrCreate([BENCHMARK_APP_ID]) .resolveDeployment(); const response = Response.json({ ok: true, resolution }); @@ -245,7 +240,7 @@ export function createBenchmarkApplication(): Hono { }); app.get("/bench/setup", async () => { try { - const deployment = await client.agentOSAppsApp + const deployment = await client.dynamicAppsApp .getOrCreate([BENCHMARK_APP_ID]) .resolveDeployment(); return Response.json({ status: "ready", deployment }); @@ -254,7 +249,7 @@ export function createBenchmarkApplication(): Hono { typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; - if (code === "agentos_apps_not_deployed") { + if (code === "dynamic_apps_not_deployed") { return Response.json({ status: "not-started" }, { status: 404 }); } throw error; diff --git a/benchmarks/dynamic-apps/src/load.ts b/benchmarks/dynamic-apps/src/load.ts index d2a0c9eab..e8abd3292 100644 --- a/benchmarks/dynamic-apps/src/load.ts +++ b/benchmarks/dynamic-apps/src/load.ts @@ -147,7 +147,7 @@ export async function runLoadTest( { signal: AbortSignal.timeout(config.timeoutMs), headers: { - "user-agent": "agentos-apps-load-test", + "user-agent": "dynamic-apps-load-test", ...(config.token ? { "x-rivet-token": config.token } : {}), }, }, diff --git a/benchmarks/dynamic-apps/src/runtime-stress.ts b/benchmarks/dynamic-apps/src/runtime-stress.ts index 157dc610a..05c691d3d 100644 --- a/benchmarks/dynamic-apps/src/runtime-stress.ts +++ b/benchmarks/dynamic-apps/src/runtime-stress.ts @@ -107,7 +107,7 @@ class FakeStatePlane { } readonly client = { - agentOSAppsApp: { + dynamicAppsApp: { getOrCreate: (key: string[]) => this.#handle(key[0] ?? ""), }, }; @@ -641,7 +641,7 @@ async function admissionStress( .then( () => "ok" as const, (error: unknown) => - isErrorCode(error, "agentos_apps_no_capacity") + isErrorCode(error, "dynamic_apps_no_capacity") ? ("rejected" as const) : Promise.reject(error), ), @@ -841,7 +841,7 @@ async function actorChurnStress( await response.arrayBuffer(); completed += 1; } catch (error) { - if (!isErrorCode(error, "agentos_apps_no_capacity")) throw error; + if (!isErrorCode(error, "dynamic_apps_no_capacity")) throw error; rejected += 1; } }); @@ -1204,7 +1204,7 @@ async function actorMemoryStress( assert.equal(Number(await response.text()), allocationBytes); completed += 1; } catch (error) { - if (!isErrorCode(error, "agentos_apps_no_capacity")) throw error; + if (!isErrorCode(error, "dynamic_apps_no_capacity")) throw error; rejected += 1; } }); diff --git a/docs/content/docs/authentication.mdx b/docs/content/docs/authentication.mdx index ad0799f02..02fc6e54e 100644 --- a/docs/content/docs/authentication.mdx +++ b/docs/content/docs/authentication.mdx @@ -3,8 +3,6 @@ title: "Authentication" description: "Authenticate requests to deployed Dynamic Apps with Hono middleware, route guards, and application-level authorization before handlers run." --- -## Authentication - Use normal Hono middleware to authenticate requests before they reach deployed apps: diff --git a/docs/content/docs/deploy.mdx b/docs/content/docs/deploy.mdx index 30905a1a1..041ea6ee8 100644 --- a/docs/content/docs/deploy.mdx +++ b/docs/content/docs/deploy.mdx @@ -38,8 +38,9 @@ await deployApp({ The direct entrypoint must default-export a function or an object with `fetch(request)`. Code runs inside agentOS with filesystem, process, environment, and network permissions, and supported Node builtins are -available. Static-only directories and native addons are not supported in this -release candidate. +available. Directories that contain only static files are rejected; see +[Static Websites](/dynamic-apps/docs/static-websites). Native addons are not +supported. ## Build repair and rollback @@ -58,6 +59,10 @@ for (let attempt = 0; attempt < 3; attempt++) { } ``` +Include `webServerSkill` and `rivetActorsSkill` from `@rivet-dev/dynamic-apps` +in the model prompt. They describe the supported TypeScript server layout and +Rivet actor integration. [View the complete AI App Builder example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-ai-builder). + 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. @@ -88,7 +93,7 @@ await deployApp({ | Option | Default | Meaning | | --- | --- | --- | | `regions` | State actor's current region | Stored compatibility metadata; it does not move direct HTTP execution | -| `createNamespace` | — | Deprecated compatibility option; every app always receives its own stable namespace | +| `createNamespace` | none | Deprecated compatibility option; every app always receives its own stable namespace | | `scaling.minReplicas` | `0` | App-defined actor runner setting; compatibility metadata for direct HTTP | | `scaling.maxReplicas` | `128` | App-defined actor runner setting; compatibility metadata for direct HTTP | | `scaling.targetConcurrency` | `8` | App-defined actor runner setting; compatibility metadata for direct HTTP | diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 3bfe0a0cb..872b4bbfc 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,45 +1,89 @@ --- title: "Dynamic Apps" -description: "Build user-generated HTTP apps and serve them through bounded agentOS VMs." +description: "Deploy user-generated applications in isolated agentOS VMs with SQLite, workflows, multiplayer, and static sites out of the box." skill: true --- -Dynamic Apps builds user-generated HTTP applications in a sandboxed deployment -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 runs user-generated HTTP applications inside your own Node.js +server. Each app runs in an isolated agentOS VM and can add durable SQLite +state, workflows, realtime multiplayer, and static sites. Dynamic Apps is in preview and its API is subject to change. + + + Start the host server, deploy a generated app, and visit it. + + + Deploy files or a directory, repair build errors, and configure apps. + + + Serve an HTML, CSS, and JavaScript site. + + + Store durable relational data in an actor-owned database. + + + Share realtime state between every connected client. + + + Run durable multi-step jobs that sleep and resume. + + + ## Architecture -**Dynamic Apps is a library, not a hosted app deployment platform.** You own the -Hono server, its authentication, and the URL on which applications are mounted. +**Dynamic Apps is a library, not a hosted app deployment platform.** You own +the Hono server, its authentication, and the URL on which applications are +mounted. + +Requests reach your Hono server, where `appsRouter` executes them in a cached +agentOS VM holding the app's active release. Warm requests never touch storage +or a Rivet actor. + + + + + + + + + OS + + + + + Request + Agent · Browser · API -Deployment and request serving are deliberately separate: + -```text -deployApp -> Dynamic Apps Core -> agentOS build VM -> publish release - -> Rivet actor (default adapter) + + Your Hono server + + appsRouter + auth · route -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. + + agentOS VM + + + User's app + serves the response + -`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 -context on every request while reusing the immutable release VM. +Deployment is separate from serving. `deployApp()` builds the files in a +sandboxed agentOS build VM and publishes an immutable release. 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. -An application may additionally export a RivetKit registry. Those app-defined -actors use normal Rivet routing for durable state, actions, events, and -connections, while the same application's ordinary HTTP handler still runs -through headless agentOS evaluation. +An app may also export a RivetKit registry. Those app-defined actors use normal +Rivet routing for durable state, actions, events, and connections, while the +same app's ordinary HTTP handler still runs through the agentOS VM. agentOS provides the filesystem, process, environment, and network permission diff --git a/docs/content/docs/multiplayer.mdx b/docs/content/docs/multiplayer.mdx new file mode 100644 index 000000000..65a134353 --- /dev/null +++ b/docs/content/docs/multiplayer.mdx @@ -0,0 +1,19 @@ +--- +title: "Multiplayer" +description: "Share realtime state between every client connected to an app." +--- + +An actor holds the shared state for one room and broadcasts events to every +connected client. [View the complete example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-multiplayer). + +The server is the generated app. The client shows how your own system connects +to the actors inside it. + + + + + + +Dynamic Apps does not wrap RivetKit's action, event, or connection APIs. See +[Events](https://rivet.dev/actors/docs/events/) and +[Connections](https://rivet.dev/actors/docs/connections/) in Rivet Actors. diff --git a/docs/content/docs/quickstart-core.mdx b/docs/content/docs/quickstart-core.mdx index c6e670dfc..ab14bdfa0 100644 --- a/docs/content/docs/quickstart-core.mdx +++ b/docs/content/docs/quickstart-core.mdx @@ -1,5 +1,5 @@ --- -title: "Core Quick Start" +title: "Core Quickstart" description: "Build and serve a Dynamic App with a development-only in-memory release store." skill: true --- @@ -59,7 +59,7 @@ 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 +npx tsx src/server.ts --host 0.0.0.0 ``` diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx index 2e113f419..9b3e92714 100644 --- a/docs/content/docs/quickstart.mdx +++ b/docs/content/docs/quickstart.mdx @@ -33,7 +33,7 @@ served by `appsRouter` through a cached agentOS VM. Run the server normally: ```sh -node --import tsx src/server.ts +npx tsx src/server.ts ``` @@ -46,7 +46,7 @@ agent, an upload endpoint, or another trusted control-plane process. ```sh -node --import tsx src/deploy.ts +npx tsx src/deploy.ts ``` diff --git a/docs/content/docs/realtime.mdx b/docs/content/docs/realtime.mdx deleted file mode 100644 index 6c34f1cb8..000000000 --- a/docs/content/docs/realtime.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Realtime Events" -description: "Subscribe to events from an app-defined RivetKit actor." ---- - -App-defined actors expose the standard RivetKit realtime client. Subscribe to -an event on a connected actor handle: - -```ts -const counter = await client.counter.getOrCreate(["main"]).connect(); - -const unsubscribe = counter.on("changed", (count) => { - console.log("count changed", count); -}); - -await counter.add(1); -unsubscribe(); -await counter.dispose(); -``` - -Dynamic Apps packages the actor registry and configures its stable runner pool; -it does not wrap or replace RivetKit's action, event, or connection APIs. diff --git a/docs/content/docs/reference.mdx b/docs/content/docs/reference.mdx deleted file mode 100644 index 75cd82ec8..000000000 --- a/docs/content/docs/reference.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: "Reference" -description: "Build and repair Dynamic Apps with AI-generated files, deployment diagnostics, and a practical roadmap for extending generated backends." ---- - -## Build Apps with AI - -Give an agent the app requirements, let it generate the project files, and pass -those files to `deployApp()`. If the build returns TypeScript diagnostics, give -them back to the agent and deploy its repaired files again. - -[View the complete AI App Builder example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-ai-builder). - -## Planned Improvements - -- Automatically include agent skills based on the - [Actors Learn guides](https://rivet.dev/actors/learn/) for better app generation. -- Billing API for tracking and charging for app usage. -- Built-in error reporting for generated apps. diff --git a/docs/content/docs/routing.mdx b/docs/content/docs/routing.mdx index ecb70598e..6bebd4cf2 100644 --- a/docs/content/docs/routing.mdx +++ b/docs/content/docs/routing.mdx @@ -5,8 +5,7 @@ skill: true --- The package root exports one router. Mount ordinary application traffic at any -prefix and explicitly dispatch the private `/api/rivet` callback to the same -router: +prefix and forward the private `/api/rivet/*` callback to the same router: ```ts import { appsRouter } from "@rivet-dev/dynamic-apps"; @@ -14,14 +13,7 @@ import { Hono } from "hono"; const server = new Hono(); -const dispatchRegistry = (request: Request) => { - const headers = new Headers(request.headers); - headers.set("x-agentos-app-registry-dispatch", "1"); - return appsRouter.fetch(new Request(request, { headers })); -}; - -server.all("/api/rivet", (c) => dispatchRegistry(c.req.raw)); -server.all("/api/rivet/*", (c) => dispatchRegistry(c.req.raw)); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); server.route("/apps", appsRouter); ``` @@ -30,7 +22,3 @@ 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 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/content/docs/sqlite.mdx b/docs/content/docs/sqlite.mdx new file mode 100644 index 000000000..00b201659 --- /dev/null +++ b/docs/content/docs/sqlite.mdx @@ -0,0 +1,20 @@ +--- +title: "SQLite" +description: "Store durable relational data in an actor-owned SQLite database." +--- + +Apps that depend on `rivetkit` can define actors. Each actor owns its own +SQLite database, so a generated app gets durable relational data without any +extra infrastructure. [View the complete example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-sqlite). + +The server is the generated app. The client shows how your own system connects +to the actors inside it. + + + + + + +`deployApp()` returns the endpoint, namespace, pool, and token the ordinary +RivetKit client needs. See [SQLite in Rivet Actors](https://rivet.dev/actors/docs/sqlite/) +for the full database API. diff --git a/docs/content/docs/state-and-data.mdx b/docs/content/docs/state-and-data.mdx deleted file mode 100644 index 589e1bc8d..000000000 --- a/docs/content/docs/state-and-data.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: "State & Actors" -description: "Add durable RivetKit actors while ordinary HTTP runs through agentOS." ---- - -An application can combine ordinary HTTP with app-defined RivetKit actors. -Declare `rivetkit` in the application's dependencies, export its registry, and -keep the normal `registry.start()` call: - -```ts -import { actor, event, setup } from "rivetkit"; - -const counter = actor({ - state: { count: 0 }, - events: { changed: event() }, - actions: { - add(c, amount = 1) { - c.state.count += amount; - c.broadcast("changed", c.state.count); - return c.state.count; - }, - }, -}); - -export const registry = setup({ use: { counter } }); -registry.start(); - -export default () => new Response("ordinary HTTP still runs locally"); -``` - -`deployApp()` returns the stable namespace and runner-pool names needed by the -ordinary RivetKit client: - -```ts -import { createClient } from "rivetkit/client"; - -const deployment = await deployApp({ appId: "counter", source }); -const client = createClient({ - namespace: deployment.namespace, - poolName: deployment.pool, -}); - -const handle = await client.counter.getOrCreate(["main"]).connect(); -console.log(await handle.add(1)); -``` - -Actor actions, state, events, connections, and streaming actor responses use -normal Rivet routing. Requests to the application's default HTTP export still -use the cached agentOS direct-request path. diff --git a/docs/content/docs/static-websites.mdx b/docs/content/docs/static-websites.mdx new file mode 100644 index 000000000..f265d6136 --- /dev/null +++ b/docs/content/docs/static-websites.mdx @@ -0,0 +1,21 @@ +--- +title: "Static Websites" +description: "Serve an HTML, CSS, and JavaScript site from a Dynamic App." +--- + +An app is a directory with a `package.json` and a server entrypoint. A static +site is the same directory plus a `public/` folder and a handler that serves +it. [View the complete example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-static-website). + + + + + + + +Deploy the directory and open `/apps/static-website/`: + + + +Directories that contain only static files are rejected. Every app needs a +`package.json` and an entrypoint that default-exports a `fetch` handler. diff --git a/docs/content/docs/workflows.mdx b/docs/content/docs/workflows.mdx new file mode 100644 index 000000000..9f595ac71 --- /dev/null +++ b/docs/content/docs/workflows.mdx @@ -0,0 +1,19 @@ +--- +title: "Workflows" +description: "Run durable multi-step jobs that sleep, scale to zero, and resume." +--- + +An actor's `run` workflow executes when the actor is created. Each step is +durable, so the job survives restarts and the app scales to zero while it +sleeps. [View the complete example](https://github.com/rivet-dev/dynamic-apps/tree/main/examples/apps-workflows). + +The server is the generated app. The client shows how your own system connects +to the actors inside it. + + + + + + +See [Workflows in Rivet Actors](https://rivet.dev/actors/docs/workflows/) for +steps, loops, queues, and error handling. diff --git a/docs/sidebar.json b/docs/sidebar.json index ec0074867..2616afda2 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -9,12 +9,12 @@ "icon": "faSquareInfo" }, { - "title": "Quick Start", + "title": "Quickstart", "href": "/dynamic-apps/docs/quickstart", "icon": "faForwardFast" }, { - "title": "Core Quick Start", + "title": "Core Quickstart", "href": "/dynamic-apps/docs/quickstart-core", "icon": "faForwardFast" } @@ -41,16 +41,20 @@ "title": "Capabilities", "pages": [ { - "title": "State & Actors", - "href": "/dynamic-apps/docs/state-and-data" + "title": "Static Websites", + "href": "/dynamic-apps/docs/static-websites" }, { - "title": "Realtime Events", - "href": "/dynamic-apps/docs/realtime" + "title": "SQLite", + "href": "/dynamic-apps/docs/sqlite" }, { - "title": "Collecting logs", - "href": "/dynamic-apps/docs/logging" + "title": "Multiplayer", + "href": "/dynamic-apps/docs/multiplayer" + }, + { + "title": "Workflows", + "href": "/dynamic-apps/docs/workflows" } ] }, @@ -62,8 +66,8 @@ "href": "/dynamic-apps/docs/authentication" }, { - "title": "Reference", - "href": "/dynamic-apps/docs/reference" + "title": "Collecting logs", + "href": "/dynamic-apps/docs/logging" } ] } diff --git a/examples/apps-ai-builder/fixtures/app/package.json b/examples/apps-ai-builder/fixtures/app/package.json index f866b0c16..7174e192b 100644 --- a/examples/apps-ai-builder/fixtures/app/package.json +++ b/examples/apps-ai-builder/fixtures/app/package.json @@ -8,7 +8,12 @@ "build": "tsc", "check-types": "tsc --noEmit" }, + "dependencies": { + "@hono/node-server": "2.0.11", + "hono": "4.12.9" + }, "devDependencies": { + "@types/node": "22.19.15", "typescript": "5.7.3" } } diff --git a/examples/apps-ai-builder/fixtures/app/src/index.ts b/examples/apps-ai-builder/fixtures/app/src/index.ts index c369cd297..88dc11adf 100644 --- a/examples/apps-ai-builder/fixtures/app/src/index.ts +++ b/examples/apps-ai-builder/fixtures/app/src/index.ts @@ -1,8 +1,15 @@ -export default { - fetch(request: Request) { - return Response.json({ - message: "Replace this seed with the generated application.", - path: new URL(request.url).pathname, - }); - }, -}; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; + +const app = new Hono(); + +app.get("/", (context) => + context.json({ + message: "Replace this seed with the generated application.", + }), +); + +serve({ + fetch: app.fetch, + port: Number(process.env.PORT ?? 3000), +}); diff --git a/examples/apps-ai-builder/package.json b/examples/apps-ai-builder/package.json index cc05f174d..cab81a21e 100644 --- a/examples/apps-ai-builder/package.json +++ b/examples/apps-ai-builder/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node --import tsx src/server.ts", + "start": "npx tsx src/server.ts", "check-types": "tsc --noEmit" }, "dependencies": { diff --git a/examples/apps-ai-builder/src/server.ts b/examples/apps-ai-builder/src/server.ts index 509a0a684..fd08a9800 100644 --- a/examples/apps-ai-builder/src/server.ts +++ b/examples/apps-ai-builder/src/server.ts @@ -1,7 +1,12 @@ import { readFile } from "node:fs/promises"; import { anthropic } from "@ai-sdk/anthropic"; import { serve } from "@hono/node-server"; -import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { + appsRouter, + deployApp, + rivetActorsSkill, + webServerSkill, +} from "@rivet-dev/dynamic-apps"; import { generateText } from "ai"; import { Hono } from "hono"; @@ -53,9 +58,11 @@ async function revise( model: anthropic(process.env.AI_MODEL ?? "claude-sonnet-4-5"), maxOutputTokens: 8_000, prompt: [ + // These skills tell the model how to structure, build, and serve the generated code. + webServerSkill, + rivetActorsSkill, 'Return JSON only as {"files":{"path":"content"}}.', `You may edit only: ${editablePaths.join(", ")}.`, - "The app must export a default object with fetch(request) returning a Web Response.", `User request: ${prompt}`, diagnostics ? `Previous build diagnostics:\n${diagnostics}` : "", `Current files:\n${JSON.stringify(files)}`, @@ -80,7 +87,7 @@ async function generateApp(appId: string, prompt: string) { error !== null && "code" in error && typeof error.code === "string" && - error.code.startsWith("agentos_apps_"); + error.code.startsWith("dynamic_apps_"); if (!appsError || attempt === maxRepairs) { throw error; } @@ -101,13 +108,7 @@ async function generateApp(appId: string, prompt: string) { } const server = new Hono(); -const dispatchRegistry = (request: Request) => { - const headers = new Headers(request.headers); - headers.set("x-agentos-app-registry-dispatch", "1"); - return appsRouter.fetch(new Request(request, { headers })); -}; -server.all("/api/rivet", (c) => dispatchRegistry(c.req.raw)); -server.all("/api/rivet/*", (c) => dispatchRegistry(c.req.raw)); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); // An agent or any other part of the system can call this route. A generic // deployment endpoint could accept multipart files; this example generates the diff --git a/examples/apps-core-quickstart/package.json b/examples/apps-core-quickstart/package.json index 59f5a3667..e0ae2bb2d 100644 --- a/examples/apps-core-quickstart/package.json +++ b/examples/apps-core-quickstart/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node --import tsx src/server.ts --host 0.0.0.0", + "start": "npx tsx src/server.ts --host 0.0.0.0", "check-types": "tsc --noEmit" }, "dependencies": { diff --git a/examples/apps-hello-world/package.json b/examples/apps-hello-world/package.json index 70e05afa7..993787fd1 100644 --- a/examples/apps-hello-world/package.json +++ b/examples/apps-hello-world/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "start": "node --import tsx src/server.ts", - "deploy": "node --import tsx src/deploy.ts", + "start": "npx tsx src/server.ts", + "deploy": "npx tsx src/deploy.ts", "check-types": "tsc --noEmit" }, "dependencies": { diff --git a/examples/apps-hello-world/src/server.ts b/examples/apps-hello-world/src/server.ts index 811fdead4..b13cf5934 100644 --- a/examples/apps-hello-world/src/server.ts +++ b/examples/apps-hello-world/src/server.ts @@ -4,13 +4,7 @@ import { Hono } from "hono"; const server = new Hono(); -const dispatchRegistry = (request: Request) => { - const headers = new Headers(request.headers); - headers.set("x-agentos-app-registry-dispatch", "1"); - return appsRouter.fetch(new Request(request, { headers })); -}; -server.all("/api/rivet", (c) => dispatchRegistry(c.req.raw)); -server.all("/api/rivet/*", (c) => dispatchRegistry(c.req.raw)); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); // Mount every deployed application at /apps/:appId. server.route("/apps", appsRouter); diff --git a/examples/apps-multiplayer/fixtures/app/package.json b/examples/apps-multiplayer/fixtures/app/package.json new file mode 100644 index 000000000..7da6914f1 --- /dev/null +++ b/examples/apps-multiplayer/fixtures/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "multiplayer-room-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@hono/node-server": "2.1.1", + "hono": "4.13.3", + "rivetkit": "2.3.11" + } +} diff --git a/examples/apps-multiplayer/fixtures/app/src/index.ts b/examples/apps-multiplayer/fixtures/app/src/index.ts new file mode 100644 index 000000000..9be651b42 --- /dev/null +++ b/examples/apps-multiplayer/fixtures/app/src/index.ts @@ -0,0 +1,44 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { actor, event, setup } from "rivetkit"; + +type Position = { x: number; y: number }; + +// One actor per room. State is shared by every connected client. +const room = actor({ + state: { players: {} as Record }, + events: { changed: event() }, + actions: { + join(c, player: string) { + c.state.players[player] ??= { x: 0, y: 0 }; + c.broadcast("changed", c.state.players); + return c.state.players; + }, + move(c, player: string, x: number, y: number) { + c.state.players[player] = { x, y }; + c.broadcast("changed", c.state.players); + return c.state.players; + }, + }, +}); + +export const registry = setup({ use: { room } }); + +const app = new Hono(); +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); +app.get("/", (c) => + c.json({ message: "Use the RivetKit client to join a room." }), +); + +// Dynamic Apps runs the actors in serverless mode and waits for this listener. +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: Number(process.env.PORT), hostname: "0.0.0.0" }, + () => resolve(), + ); + server.once("error", reject); + }); +} + +export default app; diff --git a/examples/apps-multiplayer/package.json b/examples/apps-multiplayer/package.json new file mode 100644 index 000000000..dad4ae8f8 --- /dev/null +++ b/examples/apps-multiplayer/package.json @@ -0,0 +1,22 @@ +{ + "name": "@rivet-dev/dynamic-apps-example-multiplayer", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "npx tsx src/server.ts", + "client": "npx tsx src/client.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^2.0.11", + "@rivet-dev/dynamic-apps": "workspace:*", + "hono": "^4.12.9", + "rivetkit": "2.3.11" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/examples/apps-multiplayer/src/client.ts b/examples/apps-multiplayer/src/client.ts new file mode 100644 index 000000000..a8abb7473 --- /dev/null +++ b/examples/apps-multiplayer/src/client.ts @@ -0,0 +1,32 @@ +import type { deployApp } from "@rivet-dev/dynamic-apps"; +import { createClient } from "rivetkit/client"; +import type { registry } from "../fixtures/app/src/index.js"; + +// Deploy the app through the host server and read back its connection details. +const host = process.env.HOST_URL ?? "http://localhost:3000"; +const response = await fetch(`${host}/deploy/multiplayer-room`, { + method: "POST", +}); +if (!response.ok) throw new Error(`deploy failed: ${await response.text()}`); +const deployment = (await response.json()) as Awaited< + ReturnType +>; + +// docs:start client +const client = createClient({ + endpoint: deployment.endpoint, + namespace: deployment.namespace, + poolName: deployment.pool, + token: deployment.token, +}); + +// Open a realtime connection and receive every broadcast from the room. +const room = client.room.getOrCreate(["lobby"]).connect(); +room.on("changed", (players) => console.log("players", players)); + +await room.join("alice"); +await room.move("alice", 4, 8); +await room.dispose(); +// docs:end client + +await client.dispose(); diff --git a/examples/apps-multiplayer/src/server.ts b/examples/apps-multiplayer/src/server.ts new file mode 100644 index 000000000..32e26acca --- /dev/null +++ b/examples/apps-multiplayer/src/server.ts @@ -0,0 +1,21 @@ +import { serve } from "@hono/node-server"; +import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { Hono } from "hono"; + +const server = new Hono(); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); + +// An agent or upload endpoint would pass generated files here. This example +// deploys its checked-in fixture and returns the deployment to the caller. +server.post("/deploy/:name", async (c) => + c.json( + await deployApp({ + appId: c.req.param("name"), + source: new URL("../fixtures/app/", import.meta.url), + }), + ), +); + +server.route("/apps", appsRouter); + +serve({ fetch: server.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/apps-multiplayer/tsconfig.json b/examples/apps-multiplayer/tsconfig.json new file mode 100644 index 000000000..888653f31 --- /dev/null +++ b/examples/apps-multiplayer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "fixtures/app/src"] +} diff --git a/examples/apps-sqlite/fixtures/app/package.json b/examples/apps-sqlite/fixtures/app/package.json new file mode 100644 index 000000000..52522ca5a --- /dev/null +++ b/examples/apps-sqlite/fixtures/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "sqlite-notes-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@hono/node-server": "2.1.1", + "hono": "4.13.3", + "rivetkit": "2.3.11" + } +} diff --git a/examples/apps-sqlite/fixtures/app/src/index.ts b/examples/apps-sqlite/fixtures/app/src/index.ts new file mode 100644 index 000000000..553a9efe7 --- /dev/null +++ b/examples/apps-sqlite/fixtures/app/src/index.ts @@ -0,0 +1,47 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { actor, setup } from "rivetkit"; +import { db } from "rivetkit/db"; + +// Each actor owns its own SQLite database. +const notes = actor({ + db: db({ + async onMigrate(database) { + await database.execute(` + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body TEXT NOT NULL + ) + `); + }, + }), + actions: { + async add(c, body: string) { + await c.db.execute("INSERT INTO notes (body) VALUES (?)", body); + }, + async list(c) { + return c.db.execute("SELECT id, body FROM notes ORDER BY id"); + }, + }, +}); + +export const registry = setup({ use: { notes } }); + +const app = new Hono(); +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); +app.get("/", (c) => + c.json({ message: "Use the RivetKit client to add notes." }), +); + +// Dynamic Apps runs the actors in serverless mode and waits for this listener. +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: Number(process.env.PORT), hostname: "0.0.0.0" }, + () => resolve(), + ); + server.once("error", reject); + }); +} + +export default app; diff --git a/examples/apps-sqlite/package.json b/examples/apps-sqlite/package.json new file mode 100644 index 000000000..84bdb1dbe --- /dev/null +++ b/examples/apps-sqlite/package.json @@ -0,0 +1,22 @@ +{ + "name": "@rivet-dev/dynamic-apps-example-sqlite", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "npx tsx src/server.ts", + "client": "npx tsx src/client.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^2.0.11", + "@rivet-dev/dynamic-apps": "workspace:*", + "hono": "^4.12.9", + "rivetkit": "2.3.11" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/examples/apps-sqlite/src/client.ts b/examples/apps-sqlite/src/client.ts new file mode 100644 index 000000000..245c65114 --- /dev/null +++ b/examples/apps-sqlite/src/client.ts @@ -0,0 +1,29 @@ +import type { deployApp } from "@rivet-dev/dynamic-apps"; +import { createClient } from "rivetkit/client"; +import type { registry } from "../fixtures/app/src/index.js"; + +// Deploy the app through the host server and read back its connection details. +const host = process.env.HOST_URL ?? "http://localhost:3000"; +const response = await fetch(`${host}/deploy/sqlite-notes`, { + method: "POST", +}); +if (!response.ok) throw new Error(`deploy failed: ${await response.text()}`); +const deployment = (await response.json()) as Awaited< + ReturnType +>; + +// docs:start client +// Connect to the actors inside the app's own Rivet namespace. +const client = createClient({ + endpoint: deployment.endpoint, + namespace: deployment.namespace, + poolName: deployment.pool, + token: deployment.token, +}); + +const notes = client.notes.getOrCreate(["shared"]); +await notes.add("Hello from the RivetKit client"); +console.log(await notes.list()); +// docs:end client + +await client.dispose(); diff --git a/examples/apps-sqlite/src/server.ts b/examples/apps-sqlite/src/server.ts new file mode 100644 index 000000000..32e26acca --- /dev/null +++ b/examples/apps-sqlite/src/server.ts @@ -0,0 +1,21 @@ +import { serve } from "@hono/node-server"; +import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { Hono } from "hono"; + +const server = new Hono(); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); + +// An agent or upload endpoint would pass generated files here. This example +// deploys its checked-in fixture and returns the deployment to the caller. +server.post("/deploy/:name", async (c) => + c.json( + await deployApp({ + appId: c.req.param("name"), + source: new URL("../fixtures/app/", import.meta.url), + }), + ), +); + +server.route("/apps", appsRouter); + +serve({ fetch: server.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/apps-sqlite/tsconfig.json b/examples/apps-sqlite/tsconfig.json new file mode 100644 index 000000000..888653f31 --- /dev/null +++ b/examples/apps-sqlite/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "fixtures/app/src"] +} diff --git a/examples/apps-static-website/fixtures/app/package.json b/examples/apps-static-website/fixtures/app/package.json new file mode 100644 index 000000000..2119d84bf --- /dev/null +++ b/examples/apps-static-website/fixtures/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "static-website-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "hono": "4.13.3" + } +} diff --git a/examples/apps-static-website/fixtures/app/public/app.js b/examples/apps-static-website/fixtures/app/public/app.js new file mode 100644 index 000000000..f304d933a --- /dev/null +++ b/examples/apps-static-website/fixtures/app/public/app.js @@ -0,0 +1,2 @@ +document.querySelector("#status").textContent = + "Served from a Dynamic Apps release inside agentOS."; diff --git a/examples/apps-static-website/fixtures/app/public/index.html b/examples/apps-static-website/fixtures/app/public/index.html new file mode 100644 index 000000000..4c9fae5c6 --- /dev/null +++ b/examples/apps-static-website/fixtures/app/public/index.html @@ -0,0 +1,16 @@ + + + + + + Static site on Dynamic Apps + + + +
+

Static sites scale to zero too.

+

Loading JavaScript…

+
+ + + diff --git a/examples/apps-static-website/fixtures/app/public/styles.css b/examples/apps-static-website/fixtures/app/public/styles.css new file mode 100644 index 000000000..0bc9f4b40 --- /dev/null +++ b/examples/apps-static-website/fixtures/app/public/styles.css @@ -0,0 +1,5 @@ +body { + font-family: system-ui, sans-serif; + margin: 4rem auto; + max-width: 40rem; +} diff --git a/examples/apps-static-website/fixtures/app/src/index.ts b/examples/apps-static-website/fixtures/app/src/index.ts new file mode 100644 index 000000000..f56526ae6 --- /dev/null +++ b/examples/apps-static-website/fixtures/app/src/index.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { Hono } from "hono"; + +const contentTypes: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +}; + +const app = new Hono(); + +// Serve every file under public/. "/" maps to public/index.html. The build +// bundles this entrypoint next to public/, so paths resolve from the bundle. +app.get("/*", async (c) => { + const path = c.req.path.endsWith("/") + ? `${c.req.path}index.html` + : c.req.path; + const type = contentTypes[path.slice(path.lastIndexOf("."))]; + if (!type || path.includes("..")) return c.notFound(); + try { + const file = await readFile(new URL(`./public${path}`, import.meta.url)); + return c.body(file, 200, { "content-type": type }); + } catch { + return c.notFound(); + } +}); + +export default app; diff --git a/examples/apps-static-website/package.json b/examples/apps-static-website/package.json new file mode 100644 index 000000000..3e6e165b2 --- /dev/null +++ b/examples/apps-static-website/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rivet-dev/dynamic-apps-example-static-website", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "npx tsx src/server.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^2.0.11", + "@rivet-dev/dynamic-apps": "workspace:*", + "hono": "^4.12.9" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/examples/apps-static-website/src/server.ts b/examples/apps-static-website/src/server.ts new file mode 100644 index 000000000..d08f4a02a --- /dev/null +++ b/examples/apps-static-website/src/server.ts @@ -0,0 +1,18 @@ +import { serve } from "@hono/node-server"; +import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { Hono } from "hono"; + +const server = new Hono(); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); +server.route("/apps", appsRouter); + +// docs:start deploy +// Deploy the site directory. Its package.json and src/index.ts serve public/. +await deployApp({ + appId: "static-website", + source: new URL("../fixtures/app/", import.meta.url), +}); +// docs:end deploy + +serve({ fetch: server.fetch, port: Number(process.env.PORT ?? 3000) }); +console.log("Open http://localhost:3000/apps/static-website/"); diff --git a/examples/apps-static-website/tsconfig.json b/examples/apps-static-website/tsconfig.json new file mode 100644 index 000000000..888653f31 --- /dev/null +++ b/examples/apps-static-website/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "fixtures/app/src"] +} diff --git a/examples/apps-workflows/fixtures/app/package.json b/examples/apps-workflows/fixtures/app/package.json new file mode 100644 index 000000000..fd1f12ca2 --- /dev/null +++ b/examples/apps-workflows/fixtures/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "order-workflow-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@hono/node-server": "2.1.1", + "hono": "4.13.3", + "rivetkit": "2.3.11" + } +} diff --git a/examples/apps-workflows/fixtures/app/src/index.ts b/examples/apps-workflows/fixtures/app/src/index.ts new file mode 100644 index 000000000..3b5439e93 --- /dev/null +++ b/examples/apps-workflows/fixtures/app/src/index.ts @@ -0,0 +1,48 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { actor, setup } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; + +type Status = "placed" | "paid" | "shipped" | "delivered"; + +// The workflow runs when the actor is created. Each step is durable, so the +// actor can sleep, scale to zero, and resume exactly where it left off. +const order = actor({ + state: { status: "placed" as Status }, + actions: { + status: (c) => c.state.status, + }, + run: workflow(async (wf) => { + await wf.step("charge", async (c) => { + c.state.status = "paid"; + }); + await wf.step("ship", async (c) => { + c.state.status = "shipped"; + }); + await wf.sleep("in transit", 2_000); + await wf.step("deliver", async (c) => { + c.state.status = "delivered"; + }); + }), +}); + +export const registry = setup({ use: { order } }); + +const app = new Hono(); +app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); +app.get("/", (c) => + c.json({ message: "Use the RivetKit client to read orders." }), +); + +// Dynamic Apps runs the actors in serverless mode and waits for this listener. +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: Number(process.env.PORT), hostname: "0.0.0.0" }, + () => resolve(), + ); + server.once("error", reject); + }); +} + +export default app; diff --git a/examples/apps-workflows/package.json b/examples/apps-workflows/package.json new file mode 100644 index 000000000..aa1d61911 --- /dev/null +++ b/examples/apps-workflows/package.json @@ -0,0 +1,22 @@ +{ + "name": "@rivet-dev/dynamic-apps-example-workflows", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "npx tsx src/server.ts", + "client": "npx tsx src/client.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^2.0.11", + "@rivet-dev/dynamic-apps": "workspace:*", + "hono": "^4.12.9", + "rivetkit": "2.3.11" + }, + "devDependencies": { + "@types/node": "^22.19.15", + "tsx": "^4.20.6", + "typescript": "^5.7.3" + } +} diff --git a/examples/apps-workflows/src/client.ts b/examples/apps-workflows/src/client.ts new file mode 100644 index 000000000..192041117 --- /dev/null +++ b/examples/apps-workflows/src/client.ts @@ -0,0 +1,34 @@ +import type { deployApp } from "@rivet-dev/dynamic-apps"; +import { createClient } from "rivetkit/client"; +import type { registry } from "../fixtures/app/src/index.js"; + +// Deploy the app through the host server and read back its connection details. +const host = process.env.HOST_URL ?? "http://localhost:3000"; +const response = await fetch(`${host}/deploy/order-workflow`, { + method: "POST", +}); +if (!response.ok) throw new Error(`deploy failed: ${await response.text()}`); +const deployment = (await response.json()) as Awaited< + ReturnType +>; + +// docs:start client +const client = createClient({ + endpoint: deployment.endpoint, + namespace: deployment.namespace, + poolName: deployment.pool, + token: deployment.token, +}); + +// Creating the actor starts its workflow. Poll until it finishes. +const order = client.order.getOrCreate(["order-1042"]); +let status = await order.status(); +while (status !== "delivered") { + console.log("status", status); + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await order.status(); +} +console.log("status", status); +// docs:end client + +await client.dispose(); diff --git a/examples/apps-workflows/src/server.ts b/examples/apps-workflows/src/server.ts new file mode 100644 index 000000000..32e26acca --- /dev/null +++ b/examples/apps-workflows/src/server.ts @@ -0,0 +1,21 @@ +import { serve } from "@hono/node-server"; +import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { Hono } from "hono"; + +const server = new Hono(); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); + +// An agent or upload endpoint would pass generated files here. This example +// deploys its checked-in fixture and returns the deployment to the caller. +server.post("/deploy/:name", async (c) => + c.json( + await deployApp({ + appId: c.req.param("name"), + source: new URL("../fixtures/app/", import.meta.url), + }), + ), +); + +server.route("/apps", appsRouter); + +serve({ fetch: server.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/apps-workflows/tsconfig.json b/examples/apps-workflows/tsconfig.json new file mode 100644 index 000000000..888653f31 --- /dev/null +++ b/examples/apps-workflows/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "fixtures/app/src"] +} diff --git a/packages/dynamic-apps-builder/cli/apps-builder.mjs b/packages/dynamic-apps-builder/cli/apps-builder.mjs index bc56d19fc..406fc782e 100755 --- a/packages/dynamic-apps-builder/cli/apps-builder.mjs +++ b/packages/dynamic-apps-builder/cli/apps-builder.mjs @@ -340,8 +340,10 @@ function nodeFileSystemPlugin() { if ( config.usesRivetKit && (extension === ".js" || + extension === ".cjs" || extension === ".mjs" || - extension === ".ts") + extension === ".ts" || + extension === ".cts") ) { // RivetKit deliberately hides this optional dependency from // ordinary bundlers. Apps always select the WASM runtime, so @@ -349,10 +351,19 @@ function nodeFileSystemPlugin() { // leaving the native and engine-CLI fallbacks unreachable. const source = contents.toString("utf8"); contents = Buffer.from( - source.replaceAll( - 'import(["@rivetkit", "rivetkit-wasm"].join("/"))', - 'import("@rivetkit/rivetkit-wasm")', - ), + source + .replace( + /import\(\s*\[\s*["']@rivetkit["']\s*,\s*["']rivetkit-wasm["']\s*\]\.join\(\s*["']\/["']\s*\)\s*\)/gu, + 'import("@rivetkit/rivetkit-wasm")', + ) + .replace( + /require\(\s*\[\s*["']@rivetkit["']\s*,\s*["']rivetkit-wasm["']\s*\]\.join\(\s*["']\/["']\s*\)\s*\)/gu, + 'require("@rivetkit/rivetkit-wasm")', + ) + .replace( + "const { getEnginePath } = await loadEngineCli();", + 'if (!config.startEngine) throw new Error("local Engine disabled"); const { getEnginePath } = await loadEngineCli();', + ), ); } return { diff --git a/packages/dynamic-apps-builder/package.json b/packages/dynamic-apps-builder/package.json index f8c4e6525..c1b207183 100644 --- a/packages/dynamic-apps-builder/package.json +++ b/packages/dynamic-apps-builder/package.json @@ -34,12 +34,13 @@ "test": "vitest run test/ --passWithNoTests" }, "dependencies": { + "@rivetkit/rivetkit-wasm": "2.3.11", "esbuild-wasm": "0.27.4", "rivetkit": "2.3.11" }, "devDependencies": { - "@rivet-dev/agentos-core": "0.2.15", - "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/agentos-core": "0.2.16-rc.3", + "@rivet-dev/agentos-toolchain": "0.2.16-rc.3", "@types/node": "^22.19.15", "typescript": "^5.7.3", "vitest": "^2.1.9" diff --git a/packages/dynamic-apps-builder/test/builder.test.ts b/packages/dynamic-apps-builder/test/builder.test.ts index f472f601c..1efc76504 100644 --- a/packages/dynamic-apps-builder/test/builder.test.ts +++ b/packages/dynamic-apps-builder/test/builder.test.ts @@ -1,6 +1,7 @@ -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -19,7 +20,7 @@ const builder = join(packageRoot, "cli", "apps-builder.mjs"); describe("apps-builder", () => { test("emits an executable Node ESM dispatcher with Node builtins", async () => { - const root = await mkdtemp(join(tmpdir(), "agentos-apps-direct-builder-")); + const root = await mkdtemp(join(tmpdir(), "dynamic-apps-direct-builder-")); const workspace = join(root, "workspace"); const release = join(root, "release"); await mkdir(workspace, { recursive: true }); @@ -74,7 +75,7 @@ describe("apps-builder", () => { }); test("bundles real RivetKit and invokes it inside agentOS", async () => { - const root = await mkdtemp(join(tmpdir(), "agentos-apps-rivetkit-")); + const root = await mkdtemp(join(tmpdir(), "dynamic-apps-rivetkit-")); const workspace = join(root, "workspace"); const release = join(root, "release"); await mkdir(workspace, { recursive: true }); @@ -140,7 +141,7 @@ describe("apps-builder", () => { }, 30_000); test("rejects native Node addons in direct agentOS bundles", async () => { - const root = await mkdtemp(join(tmpdir(), "agentos-apps-native-addon-")); + const root = await mkdtemp(join(tmpdir(), "dynamic-apps-native-addon-")); const workspace = join(root, "workspace"); const release = join(root, "release"); await mkdir(workspace, { recursive: true }); @@ -173,7 +174,7 @@ describe("apps-builder", () => { }); test("emits a minimal executable TypeScript release with static assets", async () => { - const root = await mkdtemp(join(tmpdir(), "agentos-apps-builder-")); + const root = await mkdtemp(join(tmpdir(), "dynamic-apps-builder-")); const workspace = join(root, "workspace"); const release = join(root, "release"); await mkdir(join(workspace, "src"), { recursive: true }); @@ -282,8 +283,8 @@ describe("apps-builder", () => { }); }); - test("emits a small platform-linked actor fetch bundle", async () => { - const root = await mkdtemp(join(tmpdir(), "agentos-apps-actor-builder-")); + test("emits a self-contained AgentOS RivetKit WASM actor bundle", async () => { + const root = await mkdtemp(join(tmpdir(), "dynamic-apps-actor-builder-")); const workspace = join(root, "workspace"); const release = join(root, "release"); await mkdir(join(workspace, "src"), { recursive: true }); @@ -294,10 +295,15 @@ describe("apps-builder", () => { await writeFile( join(workspace, "src", "index.mjs"), [ + 'import http from "node:http";', 'import { actor, setup } from "rivetkit";', "const counter = actor({ state: { count: 0 } });", "const registry = setup({ use: { counter } });", - "export default { fetch: (request) => registry.handler(request) };", + "http.createServer(async (incoming, outgoing) => {", + " const chunks = []; for await (const chunk of incoming) chunks.push(Buffer.from(chunk));", + " const response = await registry.handler(new Request(new URL(incoming.url ?? '/', 'http://actor.test'), { method: incoming.method, headers: incoming.headers, body: incoming.method === 'GET' || incoming.method === 'HEAD' ? undefined : Buffer.concat(chunks) }));", + " outgoing.statusCode = response.status; response.headers.forEach((value, name) => outgoing.setHeader(name, value)); outgoing.end(Buffer.from(await response.arrayBuffer()));", + "}).listen(Number(process.env.PORT), '0.0.0.0');", ].join("\n"), ); const configPath = join(root, "config.json"); @@ -310,26 +316,87 @@ describe("apps-builder", () => { version: "actor-test", sourceFiles: ["src/index.mjs"], usesRivetKit: true, - platformRivetKit: true, - maxOutputBytes: 1024 * 1024, + directAgentOs: true, + maxOutputBytes: 16 * 1024 * 1024, maxOutputFiles: 16, - maxFileBytes: 1024 * 1024, + maxFileBytes: 8 * 1024 * 1024, }), ); await execFileAsync(process.execPath, [builder, configPath]); const paths = await listFiles(release); - expect(paths).toEqual([ - "agentos-package.json", - "main.mjs", - "manifest.json", - ]); + expect(paths).toEqual( + expect.arrayContaining([ + "agentos-package.json", + "main.mjs", + "manifest.json", + ]), + ); + expect( + paths.some( + (path) => + path.startsWith("modules/rivetkit-") && path.endsWith(".wasm"), + ), + ).toBe(true); const source = await readFile(join(release, "main.mjs"), "utf8"); - expect(source).toContain('from"rivetkit"'); - expect(Buffer.byteLength(source)).toBeLessThan(32 * 1024); - }); + expect(source).not.toContain("DYNAMIC_APPS_ACTOR_RPC"); + expect(source).not.toContain("/.agentos/ready"); + expect(source).not.toContain('from"rivetkit"'); + expect(Buffer.byteLength(source)).toBeGreaterThan(256 * 1024); + const port = await freePort(); + const child = spawn(process.execPath, [join(release, "main.mjs")], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PORT: String(port), + RIVETKIT_RUNTIME: "wasm", + RIVETKIT_RUNTIME_MODE: "serverless", + }, + }); + let stderr = ""; + child.stdout.resume(); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + await new Promise((resolveReady, rejectReady) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + rejectReady(new Error(`actor bundle did not start: ${stderr}`)); + }, 5_000); + const poll = setInterval(() => { + void fetch(`http://127.0.0.1:${port}/.agentos/ready`) + .then(() => { + clearInterval(poll); + clearTimeout(timeout); + resolveReady(); + }) + .catch(() => {}); + }, 20); + child.once("exit", (code) => { + clearInterval(poll); + clearTimeout(timeout); + rejectReady(new Error(`actor bundle exited ${code}: ${stderr}`)); + }); + }); + child.kill("SIGKILL"); + }, 15_000); }); +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("failed to allocate port"); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + async function listFiles(root: string): Promise { const paths: string[] = []; const walk = async (directory: string) => { diff --git a/packages/dynamic-apps-core/package.json b/packages/dynamic-apps-core/package.json index 38d425a7e..8559c4256 100644 --- a/packages/dynamic-apps-core/package.json +++ b/packages/dynamic-apps-core/package.json @@ -32,10 +32,10 @@ "test": "vitest run" }, "dependencies": { - "@agentos-software/sh": "0.2.15", + "@agentos-software/sh": "0.2.16-rc.3", "@agentos-software/tar": "0.3.5", - "@rivet-dev/agentos-core": "0.2.15", - "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/agentos-core": "0.2.16-rc.3", + "@rivet-dev/agentos-toolchain": "0.2.16-rc.3", "@rivet-dev/dynamic-apps-builder": "workspace:0.12.0-rc.2", "hono": "^4.7.0" }, diff --git a/packages/dynamic-apps-core/src/artifact.ts b/packages/dynamic-apps-core/src/artifact.ts index f09d8ac74..5c18750be 100644 --- a/packages/dynamic-apps-core/src/artifact.ts +++ b/packages/dynamic-apps-core/src/artifact.ts @@ -11,7 +11,7 @@ export function extractAospkgTextFile( buffer.subarray(1, 4).toString("ascii") !== "AOS" ) { throw new DynamicAppsError( - "agentos_apps_artifact_format_invalid", + "dynamic_apps_artifact_format_invalid", "application artifact is not an AOSP package", ); } @@ -36,7 +36,7 @@ export function extractAospkgTextFile( offset = next; } throw new DynamicAppsError( - "agentos_apps_artifact_entry_missing", + "dynamic_apps_artifact_entry_missing", `application artifact is missing ${target}`, ); } diff --git a/packages/dynamic-apps-core/src/build.ts b/packages/dynamic-apps-core/src/build.ts index f24d7f4dd..9ec74a6ce 100644 --- a/packages/dynamic-apps-core/src/build.ts +++ b/packages/dynamic-apps-core/src/build.ts @@ -109,7 +109,7 @@ export function readBuildConfig( const maximum = DEFAULT_BUILD_CONFIG[key]; if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { throw new DynamicAppsError( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", `${key} must be an integer between 1 and ${maximum}`, { name: key, maximum }, ); @@ -167,14 +167,14 @@ export function validateDeployment( ): BuildPlan { if (!input || typeof input !== "object" || !input.files) { fail( - "agentos_apps_invalid_files", + "dynamic_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", + "dynamic_apps_file_count_limit", `deployment must contain between 1 and ${limits.maxFiles} files`, { observed: files.length, limit: limits.maxFiles }, ); @@ -185,13 +185,13 @@ export function validateDeployment( const normalizedPath = normalizeAppPath(path); if (normalizedFiles[normalizedPath]) { fail( - "agentos_apps_duplicate_file_path", + "dynamic_apps_duplicate_file_path", `multiple deployment paths normalize to ${normalizedPath}`, ); } if (!(content instanceof Uint8Array)) { fail( - "agentos_apps_invalid_file", + "dynamic_apps_invalid_file", `deployment file ${path} must be a Uint8Array`, ); } @@ -200,7 +200,7 @@ export function validateDeployment( } if (sourceBytes > limits.maxSourceBytes) { fail( - "agentos_apps_source_limit", + "dynamic_apps_source_limit", `deployment source is ${sourceBytes} bytes, exceeding maxSourceBytes ${limits.maxSourceBytes}`, { observed: sourceBytes, limit: limits.maxSourceBytes }, ); @@ -209,7 +209,7 @@ export function validateDeployment( const packageJsonSource = textFile(normalizedFiles, "package.json"); if (!packageJsonSource) { fail( - "agentos_apps_entrypoint_not_found", + "dynamic_apps_entrypoint_not_found", "direct applications must contain package.json and a server entrypoint", ); } @@ -224,7 +224,7 @@ export function validateDeployment( packageJson = JSON.parse(packageJsonSource); } catch (error) { fail( - "agentos_apps_invalid_package_json", + "dynamic_apps_invalid_package_json", "package.json is not valid JSON", { error: String(error) }, ); @@ -242,7 +242,7 @@ export function validateDeployment( ); if (dependencyCount > limits.maxDependencies) { fail( - "agentos_apps_dependency_limit", + "dynamic_apps_dependency_limit", `deployment has ${dependencyCount} dependencies, exceeding maxDependencies ${limits.maxDependencies}`, { observed: dependencyCount, limit: limits.maxDependencies }, ); @@ -280,7 +280,7 @@ export function validateDeployment( } } fail( - "agentos_apps_entrypoint_not_found", + "dynamic_apps_entrypoint_not_found", "could not infer a direct server entrypoint", ); } @@ -298,7 +298,7 @@ export function throwCommandFailure( maxOutputBytes: number, ): never { fail( - `agentos_apps_${kind}_failed`, + `dynamic_apps_${kind}_failed`, `${command} failed with exit code ${result.exitCode}`, { exitCode: result.exitCode, @@ -316,7 +316,7 @@ function artifactResult( ): BuiltAppRelease { if (!(bytesInput instanceof Uint8Array) || bytesInput.byteLength > maxBytes) { fail( - "agentos_apps_build_artifact_size_limit", + "dynamic_apps_build_artifact_size_limit", `cached artifact exceeds ${maxBytes} bytes`, ); } @@ -433,7 +433,7 @@ async function buildRelease( const failedWrite = writes.find((entry) => !entry.success); if (failedWrite) { fail( - "agentos_apps_build_write_failed", + "dynamic_apps_build_write_failed", `failed to write build input ${failedWrite.path}: ${failedWrite.error ?? "unknown error"}`, { path: failedWrite.path, error: failedWrite.error }, ); @@ -518,7 +518,7 @@ async function buildRelease( ); if (nativeAddonCheck.exitCode === 42) { fail( - "agentos_apps_native_addon_unsupported", + "dynamic_apps_native_addon_unsupported", "application contains native Node addons", { files: boundedOutput( @@ -546,9 +546,8 @@ async function buildRelease( release: "/release/direct", entrypoint: "direct-runner.mjs", sourceFiles: Object.keys(input.files), - usesRivetKit: false, - directIsolate: true, - stubRivetKit: plan.usesRivetKit, + usesRivetKit: plan.usesRivetKit, + directAgentOs: true, maxOutputBytes: config.maxBuildArtifactBytes, maxOutputFiles: config.maxBuildArtifactFiles, maxFileBytes: config.maxBuildArtifactFileBytes, @@ -558,7 +557,7 @@ async function buildRelease( const failedConfigWrite = configWrites.find((entry) => !entry.success); if (failedConfigWrite) { fail( - "agentos_apps_build_write_failed", + "dynamic_apps_build_write_failed", `failed to write Apps builder input ${failedConfigWrite.path}`, ); } @@ -591,8 +590,7 @@ async function buildRelease( entrypoint: "actor-runner.mjs", sourceFiles: Object.keys(input.files), usesRivetKit: true, - directIsolate: false, - platformRivetKit: true, + directAgentOs: true, maxOutputBytes: config.maxBuildArtifactBytes, maxOutputFiles: config.maxBuildArtifactFiles, maxFileBytes: config.maxBuildArtifactFileBytes, @@ -601,7 +599,7 @@ async function buildRelease( ]); if (actorConfigWrite.some((entry) => !entry.success)) { fail( - "agentos_apps_build_write_failed", + "dynamic_apps_build_write_failed", "failed to write actor Apps builder input", ); } @@ -638,7 +636,7 @@ async function buildRelease( ); if (validation.exitCode !== 0) { fail( - "agentos_apps_invalid_handler", + "dynamic_apps_invalid_handler", "application entrypoint could not be imported as a direct fetch handler", { stderr: boundedOutput(validation.stderr, config.maxBuildOutputBytes), @@ -653,7 +651,7 @@ async function buildRelease( ]); if (rootManifestWrite.some((entry) => !entry.success)) { fail( - "agentos_apps_build_write_failed", + "dynamic_apps_build_write_failed", "failed to write root application package manifest", ); } @@ -687,14 +685,14 @@ async function buildRelease( archiveSize > config.maxBuildArtifactBytes ) { fail( - "agentos_apps_build_artifact_size_limit", + "dynamic_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", + "dynamic_apps_build_artifact_truncated", `build artifact contained ${sourceTar.byteLength} bytes, expected ${archiveSize}`, ); } @@ -741,7 +739,7 @@ export function createBuildVmFactory( }; return async () => { const outputDirectory = await mkdtemp( - join(tmpdir(), "agentos-apps-build-output-"), + join(tmpdir(), "dynamic-apps-build-output-"), ); await chmod(outputDirectory, 0o777); const artifactGuestPath = "/agentos-app-output/agentos-app.tar"; diff --git a/packages/dynamic-apps-core/src/executor.ts b/packages/dynamic-apps-core/src/executor.ts index 6046fba04..af92f20ea 100644 --- a/packages/dynamic-apps-core/src/executor.ts +++ b/packages/dynamic-apps-core/src/executor.ts @@ -156,7 +156,7 @@ export function readExecutorConfig( const executionMode = env.DYNAMIC_APPS_EXECUTION_MODE ?? "pooled"; if (executionMode !== "ephemeral" && executionMode !== "pooled") { throw new DynamicAppsError( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", "DYNAMIC_APPS_EXECUTION_MODE must be ephemeral or pooled", ); } @@ -322,7 +322,7 @@ export function resolveExecutorConfig( function invalidExecutorConfig(name: string): DynamicAppsError { return new DynamicAppsError( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", `invalid Dynamic Apps executor config ${name}`, ); } @@ -362,7 +362,7 @@ export class DynamicAppsExecutor { ): Promise { if (this.#disposed) { throw new DynamicAppsError( - "agentos_apps_executor_disposed", + "dynamic_apps_executor_disposed", "Dynamic Apps executor is shutting down", ); } @@ -396,7 +396,7 @@ export class DynamicAppsExecutor { !mapping.resolution.regions.includes(requestedRegion) ) { throw new DynamicAppsError( - "agentos_apps_region_not_deployed", + "dynamic_apps_region_not_deployed", `app is not deployed in requested region ${requestedRegion}`, { requestedRegion, regions: mapping.resolution.regions }, ); @@ -519,7 +519,7 @@ export class DynamicAppsExecutor { .then(async (unsubscribe) => { if (typeof unsubscribe !== "function") { throw new DynamicAppsError( - "agentos_apps_invalid_subscription", + "dynamic_apps_invalid_subscription", "watchActiveRelease must resolve to an unsubscribe function", ); } @@ -574,13 +574,13 @@ export class DynamicAppsExecutor { if (entry.epoch !== epoch) continue; if (!resolution) { throw new DynamicAppsError( - "agentos_apps_not_deployed", + "dynamic_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", + "dynamic_apps_active_release_invalid", "loadActiveRelease returned a release for a different app", ); } @@ -613,7 +613,7 @@ export class DynamicAppsExecutor { ): Promise { if (this.#disposed) { throw new DynamicAppsError( - "agentos_apps_executor_disposed", + "dynamic_apps_executor_disposed", "Dynamic Apps executor is shutting down", ); } @@ -645,7 +645,7 @@ export class DynamicAppsExecutor { await this.#pruneCaches(true, artifact.byteLength); if (artifact.byteLength > this.config.runtimeCacheMaxBytes) { throw new DynamicAppsError( - "agentos_apps_artifact_cache_limit", + "dynamic_apps_artifact_cache_limit", "application artifact is larger than the configured runtime cache", ); } @@ -722,7 +722,7 @@ export class DynamicAppsExecutor { } if (this.#disposed) { throw new DynamicAppsError( - "agentos_apps_executor_disposed", + "dynamic_apps_executor_disposed", "Dynamic Apps executor is shutting down", ); } @@ -853,7 +853,7 @@ export class DynamicAppsExecutor { } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new DynamicAppsError( - "agentos_apps_execution_cancelled", + "dynamic_apps_execution_cancelled", "application execution was cancelled", ); } @@ -1224,13 +1224,13 @@ function evaluationValue(result: EvaluationResult, timeoutMs: number): T { } if (result.outcome === "timed_out") { throw new DynamicAppsError( - "agentos_apps_execution_timeout", + "dynamic_apps_execution_timeout", `application execution exceeded ${timeoutMs}ms`, ); } if (result.outcome === "cancelled") { throw new DynamicAppsError( - "agentos_apps_execution_cancelled", + "dynamic_apps_execution_cancelled", "application execution was cancelled", ); } @@ -1241,7 +1241,7 @@ function evaluationValue(result: EvaluationResult, timeoutMs: number): T { function disposedError(): DynamicAppsError { return new DynamicAppsError( - "agentos_apps_executor_disposed", + "dynamic_apps_executor_disposed", "Dynamic Apps executor is shutting down", ); } @@ -1261,13 +1261,13 @@ function createReleaseLoadContext( recordTiming(name, durationMs) { if (!Number.isFinite(durationMs) || durationMs < 0) { throw new DynamicAppsError( - "agentos_apps_invalid_timing", + "dynamic_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", + "dynamic_apps_invalid_timing", "release load timing name must be at most 64 bytes", ); } @@ -1277,7 +1277,7 @@ function createReleaseLoadContext( .replace(/^-|-$/g, ""); if (!normalized) { throw new DynamicAppsError( - "agentos_apps_invalid_timing", + "dynamic_apps_invalid_timing", "release load timing name must contain ASCII letters or digits", ); } @@ -1289,7 +1289,7 @@ function createReleaseLoadContext( function verifyActiveRelease(input: ActiveRelease): ActiveRelease { if (!input || typeof input !== "object") { throw new DynamicAppsError( - "agentos_apps_active_release_invalid", + "dynamic_apps_active_release_invalid", "loadActiveRelease returned an invalid release", ); } @@ -1313,7 +1313,7 @@ function verifyActiveRelease(input: ActiveRelease): ActiveRelease { input.maxResponseBytes < 1 ) { throw new DynamicAppsError( - "agentos_apps_active_release_invalid", + "dynamic_apps_active_release_invalid", "loadActiveRelease returned invalid release metadata", ); } @@ -1330,14 +1330,14 @@ function verifyActiveRelease(input: ActiveRelease): ActiveRelease { typeof artifact.usesRivetKit !== "boolean" ) { throw new DynamicAppsError( - "agentos_apps_artifact_manifest_mismatch", + "dynamic_apps_artifact_manifest_mismatch", "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", + "dynamic_apps_artifact_hash_mismatch", "loaded artifact failed size or hash verification", ); } @@ -1368,7 +1368,7 @@ function validScaling(value: ActiveRelease["scaling"]): boolean { async function serializeRequest(request: Request): Promise { if (Buffer.byteLength(request.url) > MAX_URL_BYTES) { throw new DynamicAppsError( - "agentos_apps_request_limit", + "dynamic_apps_request_limit", "request URL exceeds limit", ); } @@ -1377,7 +1377,7 @@ async function serializeRequest(request: Request): Promise { !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(request.method) ) { throw new DynamicAppsError( - "agentos_apps_request_limit", + "dynamic_apps_request_limit", "request method is invalid or exceeds limit", ); } @@ -1389,11 +1389,7 @@ async function serializeRequest(request: Request): Promise { for (const name of [...HOP_BY_HOP_HEADERS, ...connectionTokens]) { headers.delete(name); } - for (const name of [ - "x-rivet-token", - "x-agentos-app-region", - "x-agentos-app-registry-dispatch", - ]) { + for (const name of ["x-rivet-token", "x-agentos-app-region"]) { headers.delete(name); } const pairs = [...headers.entries()]; @@ -1429,7 +1425,7 @@ function responseFromEnvelope( typeof envelope.bodyBase64 !== "string" ) { throw new DynamicAppsError( - "agentos_apps_invalid_response", + "dynamic_apps_invalid_response", "application returned an invalid response envelope", ); } @@ -1440,7 +1436,7 @@ function responseFromEnvelope( const body = Buffer.from(envelope.bodyBase64, "base64"); if (body.byteLength > MAX_RESPONSE_BODY_BYTES) { throw new DynamicAppsError( - "agentos_apps_response_limit", + "dynamic_apps_response_limit", "application response exceeds limit", ); } @@ -1459,8 +1455,8 @@ function validateHeaderPairs( if (pairs.length > MAX_HEADER_PAIRS) { throw new DynamicAppsError( kind === "request" - ? "agentos_apps_request_limit" - : "agentos_apps_response_header_limit", + ? "dynamic_apps_request_limit" + : "dynamic_apps_response_header_limit", `${kind} headers exceed pair limit`, ); } @@ -1473,7 +1469,7 @@ function validateHeaderPairs( typeof pair[1] !== "string" ) { throw new DynamicAppsError( - "agentos_apps_invalid_response", + "dynamic_apps_invalid_response", `${kind} contains an invalid header pair`, ); } @@ -1482,8 +1478,8 @@ function validateHeaderPairs( if (bytes > MAX_HEADER_BYTES) { throw new DynamicAppsError( kind === "request" - ? "agentos_apps_request_limit" - : "agentos_apps_response_header_limit", + ? "dynamic_apps_request_limit" + : "dynamic_apps_response_header_limit", `${kind} headers exceed byte limit`, ); } @@ -1504,7 +1500,7 @@ async function readBoundedBody( if (bytes > limit) { await reader.cancel(); throw new DynamicAppsError( - "agentos_apps_request_limit", + "dynamic_apps_request_limit", "request body exceeds limit", ); } @@ -1551,7 +1547,7 @@ function integerEnv( const value = Number(env[name] ?? fallback); if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { throw new DynamicAppsError( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", `${name} must be an integer between ${minimum} and ${maximum}`, ); } @@ -1609,7 +1605,7 @@ class Semaphore { } if (this.#queue.length >= this.#maxQueued) { throw new DynamicAppsError( - "agentos_apps_no_capacity", + "dynamic_apps_no_capacity", "Dynamic Apps execution queue is full", ); } @@ -1635,7 +1631,7 @@ class Semaphore { if (offset >= 0) this.#queue.splice(offset, 1); item.reject( new DynamicAppsError( - "agentos_apps_no_capacity", + "dynamic_apps_no_capacity", `Dynamic Apps execution queue exceeded ${this.#waitMs}ms`, ), ); diff --git a/packages/dynamic-apps-core/src/factory.ts b/packages/dynamic-apps-core/src/factory.ts index 0da1e8951..1ce15e5c4 100644 --- a/packages/dynamic-apps-core/src/factory.ts +++ b/packages/dynamic-apps-core/src/factory.ts @@ -23,7 +23,7 @@ export function createDynamicApps( typeof options.watchActiveRelease !== "function" ) { throw new DynamicAppsError( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", "createDynamicApps requires publishRelease, loadActiveRelease, and watchActiveRelease hooks", ); } @@ -98,7 +98,7 @@ export function createDynamicApps( function disposedError(): DynamicAppsError { return new DynamicAppsError( - "agentos_apps_executor_disposed", + "dynamic_apps_executor_disposed", "Dynamic Apps executor is shutting down", ); } diff --git a/packages/dynamic-apps-core/src/router.ts b/packages/dynamic-apps-core/src/router.ts index d67220055..06c7b058b 100644 --- a/packages/dynamic-apps-core/src/router.ts +++ b/packages/dynamic-apps-core/src/router.ts @@ -37,10 +37,10 @@ function ordinaryRoutingError(error: unknown): Response | undefined { } const code = errorCode(error); const message = error instanceof Error ? error.message : ""; - if (code === "agentos_apps_not_deployed") { + if (code === "dynamic_apps_not_deployed") { return new Response("Dynamic App has no active release", { status: 503 }); } - if (code === "agentos_apps_region_not_deployed") { + if (code === "dynamic_apps_region_not_deployed") { const region = (error as { metadata?: { requestedRegion?: unknown } }) .metadata?.requestedRegion; return new Response( @@ -48,12 +48,12 @@ function ordinaryRoutingError(error: unknown): Response | undefined { { status: 421 }, ); } - if (code === "agentos_apps_no_region") { + if (code === "dynamic_apps_no_region") { return new Response("Dynamic App has no configured region", { status: 503, }); } - if (code === "agentos_apps_request_limit") { + if (code === "dynamic_apps_request_limit") { if (message.includes("URL")) { return new Response("Request URL exceeds Dynamic Apps limit", { status: 414, @@ -83,20 +83,20 @@ function exceptionResponse(error: unknown): Response { if (ordinary) return ordinary; const code = errorCode(error); const status = - code === "agentos_apps_invalid_app_id" + code === "dynamic_apps_invalid_app_id" ? 400 - : code === "agentos_apps_not_deployed" || - code === "agentos_apps_region_not_deployed" + : code === "dynamic_apps_not_deployed" || + code === "dynamic_apps_region_not_deployed" ? 404 - : code === "agentos_apps_request_limit" + : code === "dynamic_apps_request_limit" ? 413 - : code?.startsWith("agentos_apps_") + : code?.startsWith("dynamic_apps_") ? 503 : 500; return Response.json( { error: { - code: code ?? "agentos_apps_internal_error", + code: code ?? "dynamic_apps_internal_error", message: error instanceof Error ? error.message diff --git a/packages/dynamic-apps-core/src/runtime.ts b/packages/dynamic-apps-core/src/runtime.ts index 63303a803..84fc495e8 100644 --- a/packages/dynamic-apps-core/src/runtime.ts +++ b/packages/dynamic-apps-core/src/runtime.ts @@ -6,7 +6,7 @@ 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 const DIRECT_RUNTIME_FORMAT = "dynamic-apps-direct-v2"; export function normalizeAppPath(input: string): string { if (typeof input !== "string" || input.length === 0 || input.includes("\0")) { @@ -39,7 +39,7 @@ export function canonicalDeploymentHash(input: { deploymentIdentity?: string; }): string { const hash = createHash("sha256"); - hash.update("agentos-apps-release-v19-mounted-hono-router\0"); + hash.update("dynamic-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); @@ -71,7 +71,15 @@ export function directRunnerSource(input: { }): string { const entrypoint = `./${normalizeAppPath(input.entrypoint)}`; return `const dynamicAppsModuleImportStartedAt = performance.now(); -const application = await import(${JSON.stringify(entrypoint)}); +const dynamicAppsPreviousRuntimeMode = process.env.RIVETKIT_RUNTIME_MODE; +delete process.env.RIVETKIT_RUNTIME_MODE; +let application; +try { + application = await import(${JSON.stringify(entrypoint)}); +} finally { + if (dynamicAppsPreviousRuntimeMode === undefined) delete process.env.RIVETKIT_RUNTIME_MODE; + else process.env.RIVETKIT_RUNTIME_MODE = dynamicAppsPreviousRuntimeMode; +} const dynamicAppsModuleImportMs = performance.now() - dynamicAppsModuleImportStartedAt; const exported = application.default; const appFetch = typeof exported === "function" @@ -139,18 +147,15 @@ export async function dispatch(input) { `; } -/** Host-owned wrapper for the app's mounted actor callback handler. */ +/** Initializes RivetKit WASM, then starts the application's own HTTP server. */ 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; + return `import { readFile } from "node:fs/promises"; +import initializeRivetKit from "@rivetkit/rivetkit-wasm"; + +const wasmUrl = new URL(__AGENTOS_RIVETKIT_WASM_PATH__, import.meta.url); +await initializeRivetKit({ module_or_path: await readFile(wasmUrl) }); +await import(${JSON.stringify(entrypoint)}); +console.log("DYNAMIC_APPS_SERVER_READY:" + (process.env.DYNAMIC_APPS_READY_NONCE ?? "")); `; } diff --git a/packages/dynamic-apps-core/src/source.ts b/packages/dynamic-apps-core/src/source.ts index 6142e41ac..741cbec2f 100644 --- a/packages/dynamic-apps-core/src/source.ts +++ b/packages/dynamic-apps-core/src/source.ts @@ -13,7 +13,7 @@ const APP_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/; export function validateAppId(appId: string): void { if (!APP_ID_PATTERN.test(appId)) { throw new DynamicAppsError( - "agentos_apps_invalid_app_id", + "dynamic_apps_invalid_app_id", "appId must be 1-63 lowercase letters, digits, or hyphens, beginning with a letter or digit", { appId }, ); @@ -29,21 +29,21 @@ function enforceFileBounds( state.bytes += content.byteLength; if (state.files > DEFAULT_MAX_FILES) { throw new DynamicAppsError( - "agentos_apps_file_limit", + "dynamic_apps_file_limit", `application contains more than maxFiles ${DEFAULT_MAX_FILES}; reduce the source tree`, { limit: DEFAULT_MAX_FILES }, ); } if (content.byteLength > DEFAULT_MAX_FILE_BYTES) { throw new DynamicAppsError( - "agentos_apps_file_size_limit", + "dynamic_apps_file_size_limit", `${path} is ${content.byteLength} bytes, exceeding maxFileBytes ${DEFAULT_MAX_FILE_BYTES}; reduce the file size`, { path, observed: content.byteLength, limit: DEFAULT_MAX_FILE_BYTES }, ); } if (state.bytes > DEFAULT_MAX_SOURCE_BYTES) { throw new DynamicAppsError( - "agentos_apps_source_limit", + "dynamic_apps_source_limit", `application source exceeds maxSourceBytes ${DEFAULT_MAX_SOURCE_BYTES}; reduce the source tree`, { observed: state.bytes, limit: DEFAULT_MAX_SOURCE_BYTES }, ); @@ -53,7 +53,7 @@ function enforceFileBounds( async function loadDirectory(source: URL): Promise> { if (source.protocol !== "file:") { throw new DynamicAppsError( - "agentos_apps_invalid_source", + "dynamic_apps_invalid_source", "deployApp source must be a file: directory URL", { protocol: source.protocol }, ); @@ -62,7 +62,7 @@ async function loadDirectory(source: URL): Promise> { const rootStat = await lstat(root); if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { throw new DynamicAppsError( - "agentos_apps_invalid_source", + "dynamic_apps_invalid_source", "deployApp source must reference a real directory, not a symlink", ); } @@ -78,7 +78,7 @@ async function loadDirectory(source: URL): Promise> { const entryStat = await lstat(path); if (entryStat.isSymbolicLink()) { throw new DynamicAppsError( - "agentos_apps_source_symlink", + "dynamic_apps_source_symlink", `application source contains unsupported symlink ${relative}`, { path: relative }, ); @@ -91,7 +91,7 @@ async function loadDirectory(source: URL): Promise> { } if (!entryStat.isFile()) { throw new DynamicAppsError( - "agentos_apps_source_file_type", + "dynamic_apps_source_file_type", `application source contains unsupported file type ${relative}`, { path: relative }, ); diff --git a/packages/dynamic-apps-core/src/types.ts b/packages/dynamic-apps-core/src/types.ts index c673a5220..e03b92e8f 100644 --- a/packages/dynamic-apps-core/src/types.ts +++ b/packages/dynamic-apps-core/src/types.ts @@ -23,7 +23,7 @@ export type DeployAppInput = }); export interface ReleaseArtifact { - format: "agentos-apps-direct-v2"; + format: "dynamic-apps-direct-v2"; entrypoint: "direct-v2/main.mjs"; hash: string; bytes: Uint8Array; diff --git a/packages/dynamic-apps-core/tests/core.test.ts b/packages/dynamic-apps-core/tests/core.test.ts index 6c598bf34..8a746bf12 100644 --- a/packages/dynamic-apps-core/tests/core.test.ts +++ b/packages/dynamic-apps-core/tests/core.test.ts @@ -182,7 +182,7 @@ function release( appId, release: `release-${name}`, artifact: { - format: "agentos-apps-direct-v2", + format: "dynamic-apps-direct-v2", entrypoint: "direct-v2/main.mjs", hash: createHash("sha256").update(bytes).digest("hex"), bytes: new Uint8Array(bytes), diff --git a/packages/dynamic-apps/API_CONTRACT.md b/packages/dynamic-apps/API_CONTRACT.md index 4ac1343fa..c3f5b3651 100644 --- a/packages/dynamic-apps/API_CONTRACT.md +++ b/packages/dynamic-apps/API_CONTRACT.md @@ -89,7 +89,7 @@ interface PreparedDeployAppInput { interface DeployAppOptions { client?: { - agentOSAppsApp: { + dynamicAppsApp: { get?(key: string | string[]): { deploy( input: PreparedDeployAppInput, @@ -198,16 +198,16 @@ export. stored as `direct/main.mjs`; supported Node builtins resolve inside agentOS and the bundle is validated before activation. - Native Node addons and static-only output are rejected in the first preview. -- A declared `rivetkit` dependency enables a second, platform-linked - `actor/main.mjs` bundle. RivetKit itself is supplied by the host rather than - installed into every app artifact. Both bundles must validate before the - release can activate. +- A declared `rivetkit` dependency enables a second self-contained + `actor/main.mjs` bundle with RivetKit's WebAssembly runtime. The actor bundle + runs inside agentOS; RivetKit and uploaded app code are not imported into the + host process. Both bundles must validate before the release can activate. ### Rivet and actor call - The app state actor key is exactly `[input.appId]` and its client property - remains `agentOSAppsApp` so the structural injected-client contract works. -- If an injected client implements `agentOSAppsApp.get`, deployment first calls + remains `dynamicAppsApp` so the structural injected-client contract works. +- If an injected client implements `dynamicAppsApp.get`, deployment first calls `get([input.appId]).deploy(preparedInput)` so an existing stable actor is resolved without a datacenter creation constraint. Only an actor-scoped `not_found` falls back to @@ -248,7 +248,7 @@ export. metadata for direct HTTP and has no deleted scaler/replica effect. - `endpoint`, `namespace`, deterministic `pool`, and an optional namespace-scoped publishable `token` are returned. The pool is - `agentos-apps-${sha256(appId).slice(0, 16)}`. Direct HTTP does not execute in + `dynamic-apps-${sha256(appId).slice(0, 16)}`. Direct HTTP does not execute in that pool. Actor-enabled apps use it as their stable app actor runner pool. - `createNamespace` remains source-compatible but is deprecated and ignored. @@ -326,7 +326,7 @@ Thrown or rejected errors caught by the router use the existing JSON shape: ```json { "error": { - "code": "agentos_apps_error_code", + "code": "dynamic_apps_error_code", "message": "message" } } @@ -336,11 +336,11 @@ The router preserves this exception mapping: | Error | HTTP status | | --- | ---: | -| `agentos_apps_invalid_app_id` | 400 | -| `agentos_apps_not_deployed` | 404 | -| `agentos_apps_region_not_deployed` | 404 | -| `agentos_apps_request_limit` | 413 | -| other `agentos_apps_*` | 503 | +| `dynamic_apps_invalid_app_id` | 400 | +| `dynamic_apps_not_deployed` | 404 | +| `dynamic_apps_region_not_deployed` | 404 | +| `dynamic_apps_request_limit` | 413 | +| other `dynamic_apps_*` | 503 | | unknown/untyped error | 500 | Replica identity/count/cold-start headers and benchmark timing headers are not @@ -349,38 +349,47 @@ part of the retained API. ## App-defined RivetKit actor contract An application that declares `rivetkit` mounts `registry.handler()` at -`/api/rivet` and `/api/rivet/*` in its default exported fetch router. It must -not call `registry.start()` or `serve()` because Dynamic Apps owns the HTTP -listener. The same mounted router serves ordinary direct requests. +`/api/rivet/*` in its default exported fetch router. When +`RIVETKIT_RUNTIME_MODE=serverless`, it must also listen on the numeric `PORT` +provided by Dynamic Apps. The same router serves ordinary direct requests. The +server entrypoint should await its listening callback. Dynamic Apps treats +successful module evaluation as readiness and does not poll an application +health route. On activation, deployment configures the returned `namespace` and `pool` with -an authenticated serverless callback to the private `agentOSAppsApp` actor. +an authenticated serverless callback to the private `dynamicAppsApp` actor. Failure to configure that runner rolls the active pointer back. The callback accepts only Rivet Engine traffic with the per-app secret and only the active actor-enabled release. -The callback lazily verifies and extracts `actor/main.mjs`, then caches one -worker thread per active release. The worker uses the platform's pinned -RivetKit native runtime and the app namespace/pool connection. Actor -state, actions, events, connections, request/response streaming, backpressure, -and cancellation use the ordinary RivetKit protocol. Worker entries are -bounded by count, V8 heap, idle TTL, callback body size, and container memory -pressure. They do not receive the deployment actor's control credential. +The callback lazily verifies and mounts the release package in agentOS, then +starts `actor/main.mjs` with `RIVETKIT_RUNTIME=wasm` and +`RIVETKIT_RUNTIME_MODE=serverless`. Actor state, actions, events, and +connections use the ordinary RivetKit protocol. The retained runtime count is +an idle-cache target, not a callback admission limit; active callbacks are not +rejected or evicted because the target is full. Guest runtimes do not receive +the deployment actor's control credential. + +Rivet Engine's `/start` response is an SSE control stream. It contains the +exact encoded runner ID/protocol version once, then keepalive pings, and stays +open for the serverless runner lifespan; actor requests travel separately over +RivetKit's outbound WebSocket. The host forwards the guest server's response +through agentOS's native VM HTTP streaming API and propagates cancellation. The +runner response is not mocked or serialized through process stdio. Runner configuration uses the ordinary `RIVET_ENDPOINT` credential by default. `DYNAMIC_APPS_CONTROL_TOKEN` can provide a separate token with datacenter-list and runner-config create/update permissions; it is used only for those control -calls and is never included in the actor worker environment. +calls and is never included in the sandboxed actor environment. -The actor worker uses `RIVET_PUBLIC_ENDPOINT` when present. If +The sandboxed actor runtime uses `RIVET_PUBLIC_ENDPOINT` when present. If `RIVET_ENDPOINT` contains credentials and no public endpoint is configured, actor-enabled activation fails instead of forwarding the host secret. URL auth is split into explicit endpoint, namespace, and publishable-token fields before -the worker creates its RivetKit registry. +the guest creates its RivetKit registry. -Actor bundle execution shares a process with the host and is not a mutually -hostile-code security boundary. Direct HTTP remains inside agentOS and never -enters the actor worker. +Both direct and actor bundle execution use agentOS as the hostile-code security +boundary. Uploaded application modules are never evaluated by host Node. ## Deliberate runtime narrowing @@ -421,29 +430,29 @@ execution-limit failures return the JSON exception shape; this is an explicit replacement for the old mid-stream failure behavior. An oversized response header/status envelope fails with -`agentos_apps_response_header_limit` in the JSON exception shape. The agentOS +`dynamic_apps_response_header_limit` in the JSON exception shape. The agentOS request ABI enforces the logical body and header maxima before and after base64 expansion. The retained deploy/source/control error codes are locked by golden tests, -including `agentos_apps_invalid_app_id`, `agentos_apps_invalid_source`, -`agentos_apps_source_symlink`, `agentos_apps_source_file_type`, -`agentos_apps_file_limit`, `agentos_apps_file_size_limit`, -`agentos_apps_source_limit`, `agentos_apps_invalid_files`, -`agentos_apps_file_count_limit`, `agentos_apps_duplicate_file_path`, -`agentos_apps_invalid_file`, `agentos_apps_entrypoint_not_found`, -`agentos_apps_invalid_package_json`, `agentos_apps_dependency_limit`, -`agentos_apps_invalid_regions`, `agentos_apps_invalid_region`, -`agentos_apps_invalid_scaling`, `agentos_apps_invalid_config`, -`agentos_apps_app_id_mismatch`, `agentos_apps_namespace_changed`, -`agentos_apps_build_write_failed`, `agentos_apps_install_failed`, -`agentos_apps_build_failed`, `agentos_apps_pack_failed`, -`agentos_apps_build_artifact_size_limit`, -`agentos_apps_build_artifact_truncated`, -`agentos_apps_native_addon_unsupported`, -`agentos_apps_control_response_limit`, -`agentos_apps_namespace_lookup_failed`, -`agentos_apps_namespace_create_failed`, and `host_registry_not_ready`. +including `dynamic_apps_invalid_app_id`, `dynamic_apps_invalid_source`, +`dynamic_apps_source_symlink`, `dynamic_apps_source_file_type`, +`dynamic_apps_file_limit`, `dynamic_apps_file_size_limit`, +`dynamic_apps_source_limit`, `dynamic_apps_invalid_files`, +`dynamic_apps_file_count_limit`, `dynamic_apps_duplicate_file_path`, +`dynamic_apps_invalid_file`, `dynamic_apps_entrypoint_not_found`, +`dynamic_apps_invalid_package_json`, `dynamic_apps_dependency_limit`, +`dynamic_apps_invalid_regions`, `dynamic_apps_invalid_region`, +`dynamic_apps_invalid_scaling`, `dynamic_apps_invalid_config`, +`dynamic_apps_app_id_mismatch`, `dynamic_apps_namespace_changed`, +`dynamic_apps_build_write_failed`, `dynamic_apps_install_failed`, +`dynamic_apps_build_failed`, `dynamic_apps_pack_failed`, +`dynamic_apps_build_artifact_size_limit`, +`dynamic_apps_build_artifact_truncated`, +`dynamic_apps_native_addon_unsupported`, +`dynamic_apps_control_response_limit`, +`dynamic_apps_namespace_lookup_failed`, +`dynamic_apps_namespace_create_failed`, and `host_registry_not_ready`. ## Mandatory verification and deletion gates diff --git a/packages/dynamic-apps/README.md b/packages/dynamic-apps/README.md index 1325abbe8..7e1356543 100644 --- a/packages/dynamic-apps/README.md +++ b/packages/dynamic-apps/README.md @@ -38,12 +38,14 @@ bodies are not supported. ## App-defined actors An app that declares `rivetkit` mounts the registry handler in its normal fetch -router. Dynamic Apps owns the listener, so the application must not call -`registry.start()` or `serve()`: +router and starts an HTTP server on `PORT` when it runs in serverless mode. +Dynamic Apps waits for the server entrypoint's listening callback before +forwarding callbacks: ```ts import { actor, setup } from "rivetkit"; import { Hono } from "hono"; +import { serve } from "@hono/node-server"; const counter = actor({ state: { value: 0 }, @@ -58,9 +60,25 @@ const registry = setup({ use: { counter } }); const app = new Hono(); app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); app.all("*", () => new Response("ok")); +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { + fetch: app.fetch, + port: Number(process.env.PORT), + hostname: "0.0.0.0", + }, + resolve, + ); + server.once("error", reject); + }); +} export default app; ``` +Awaiting Hono's listening callback makes module completion the readiness signal; +Dynamic Apps does not poll an application health route. + Use the unchanged `deployApp` result to create the app client: ```ts @@ -99,16 +117,20 @@ receiver that accepts child-namespace lifecycle callbacks; Dynamic Apps appends `/api/rivet` to that origin. Actor requests follow the normal Rivet Engine path. The app's serverless -callback loads its verified actor bundle into a bounded process-local worker -thread and uses the host's pinned RivetKit native runtime. State, actions, -events, connections, and streaming actor responses are handled by RivetKit; -ordinary HTTP for the same app still uses the agentOS evaluation path. +callback mounts its verified artifact in agentOS and runs the bundled RivetKit +WebAssembly runtime in serverless mode. State, actions, events, connections, +and streaming actor responses are handled by RivetKit inside the sandbox; +ordinary HTTP for the same app uses the agentOS evaluation path. + +Rivet Engine requires `/api/rivet/start` to return a long-lived SSE control +stream containing the real runner-init packet and keepalive pings. Actor traffic +does not travel in this response; it uses RivetKit's outbound WebSocket. Dynamic +Apps forwards the server's response with agentOS's native VM HTTP stream, +including backpressure and cancellation. No app code executes in the host. -Callback admission happens before the request body is read. The worker cache -is a strict process limit across active and idle app bundles; a new bundle is -rejected with `agentos_apps_no_capacity` when every worker slot is busy. -Existing bundles continue sharing their worker up to the callback concurrency -limit. +Dynamic Apps does not impose a separate actor-callback concurrency gate. The +configured actor runtime count controls retained idle entries only; active +AgentOS runtimes are not rejected or evicted because that target is full. ## Host integration @@ -121,14 +143,7 @@ import { Hono } from "hono"; const server = new Hono(); -const dispatchRegistry = (request: Request) => { - const headers = new Headers(request.headers); - headers.set("x-agentos-app-registry-dispatch", "1"); - return appsRouter.fetch(new Request(request, { headers })); -}; - -server.all("/api/rivet", (c) => dispatchRegistry(c.req.raw)); -server.all("/api/rivet/*", (c) => dispatchRegistry(c.req.raw)); +server.all("/api/rivet/*", (c) => appsRouter.fetch(c.req.raw)); server.route("/apps", appsRouter); serve({ fetch: server.fetch, port: 3000 }); @@ -180,21 +195,18 @@ number of retained contexts remain idle afterward. | `DYNAMIC_APPS_EXECUTION_TIMEOUT_MS` | `30000` | | `DYNAMIC_APPS_ACTOR_WORKER_MAX_ENTRIES` | `4` | | `DYNAMIC_APPS_ACTOR_WORKER_HEAP_LIMIT_MB` | `96` | -| `DYNAMIC_APPS_ACTOR_WORKER_START_TIMEOUT_MS` | `10000` | +| `DYNAMIC_APPS_ACTOR_WORKER_START_TIMEOUT_MS` | `30000` | | `DYNAMIC_APPS_ACTOR_WORKER_IDLE_TTL_MS` | `30000` | -| `DYNAMIC_APPS_ACTOR_START_PAYLOAD_MAX_BYTES` | `1048576` | -| `DYNAMIC_APPS_ACTOR_REQUEST_CONCURRENCY` | `64` | -| `DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_SIZE` | `128` | -| `DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_WAIT_MS` | `5000` | +| `DYNAMIC_APPS_ACTOR_START_PAYLOAD_MAX_BYTES` | `16777216` | | `DYNAMIC_APPS_ACTOR_REQUEST_TIMEOUT_MS` | `30000` | -Inside a finite cgroup, execution concurrency, both context-pool limits, and -the actor-worker limit are upper bounds. At startup they are reduced when +Inside a finite cgroup, direct execution concurrency and both direct +context-pool limits are upper bounds. At startup they are reduced when necessary to keep the configured heap cost below `DYNAMIC_APPS_MEMORY_HIGH_WATER_PERCENT`, with host and payload headroom. They are unchanged when the cgroup has no finite memory limit. Effective direct -concurrency appears as `executionConcurrency` in executor diagnostics, and the -effective actor limit appears as `workerLimit` in actor diagnostics. +concurrency appears as `executionConcurrency` in executor diagnostics. The +actor `workerLimit` diagnostic is the retained-idle target, not admission. `DYNAMIC_APPS_CONTROL_TOKEN` optionally overrides only the token used by Dynamic Apps Engine control-plane requests when running against a local or @@ -223,7 +235,7 @@ setDynamicAppsLogHandler((event) => { }); ``` -The handler receives application stdout/stderr, actor worker output, build +The handler receives application stdout/stderr, sandboxed actor output, build phases, and runtime events. Enqueue into a logging SDK rather than performing a blocking network request in the callback. @@ -236,7 +248,6 @@ Successful pooled contexts are reset and reinitialized; failed, timed-out, or aborted contexts are deleted. agentOS supplies the filesystem, process, environment, and network permission -boundary for direct HTTP. App actor code still runs in a bounded worker thread -inside the host process. Run one trust domain per container and keep the host -and agentOS runtime patched; do not treat actor workers as a boundary for -mutually hostile tenants. +boundary for both direct HTTP and app-defined actors. RivetKit actors use the +bundled WASM runtime; uploaded app code is never imported into the Dynamic Apps +host process. diff --git a/packages/dynamic-apps/package.json b/packages/dynamic-apps/package.json index e8bc01d3c..4b1848c9d 100644 --- a/packages/dynamic-apps/package.json +++ b/packages/dynamic-apps/package.json @@ -36,8 +36,8 @@ "rivetkit": "2.3.11" }, "devDependencies": { - "@rivet-dev/agentos-core": "0.2.15", - "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/agentos-core": "0.2.16-rc.3", + "@rivet-dev/agentos-toolchain": "0.2.16-rc.3", "@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 505f0f5f5..c595b522d 100644 --- a/packages/dynamic-apps/src/actor-runtime.ts +++ b/packages/dynamic-apps/src/actor-runtime.ts @@ -1,28 +1,21 @@ -import { createHash } from "node:crypto"; -import { - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { createRequire } from "node:module"; +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, posix } from "node:path"; -import { pathToFileURL } from "node:url"; -import { Worker } from "node:worker_threads"; -import { - ACTOR_BUNDLE_PATH, - capConcurrencyForMemory, - DynamicAppsError, - readCgroupMemory, -} from "@rivet-dev/dynamic-apps-core/internal"; -import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; - -const MAX_ACTOR_FILES = 4_096; -const MAX_ACTOR_FILE_BYTES = 32 * 1024 * 1024; -const MAX_ACTOR_ARTIFACT_BYTES = 64 * 1024 * 1024; +import { join } from "node:path"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import { emitDynamicAppsLog } from "./logging.js"; + +const ACTOR_HTTP_PORT = 3000; +const HOP_BY_HOP_HEADERS = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +] as const; interface ActorRuntimeConfig { maxEntries: number; @@ -31,9 +24,6 @@ interface ActorRuntimeConfig { idleTtlMs: number; maxStartPayloadBytes: number; memoryHighWaterPercent: number; - requestConcurrency: number; - requestQueueSize: number; - requestQueueWaitMs: number; requestTimeoutMs: number; } @@ -48,87 +38,47 @@ export interface ActorRuntimeRequest { request: Request; } -interface PendingRequest { - resolve(response: Response): void; - reject(error: unknown): void; - controller?: ReadableStreamDefaultController; - waitingAck: boolean; - settled: boolean; - abort?: () => void; - releaseAdmission(): void; - timeout?: ReturnType; -} - interface RuntimeEntry { key: string; appId: string; release: string; directory: string; - worker: Worker; - ready: Promise; - pending: Map; + vm: AgentOs; + pid: number; active: number; lastUsedAt: number; disposed: boolean; - nextRequestId: number; - logDisposers: Array<() => void>; } -type WorkerMessage = - | { type: "ready" } - | { type: "head"; id: string; status: number; headers: [string, string][] } - | { type: "chunk"; id: string; chunk: Uint8Array } - | { type: "end"; id: string } - | { type: "error"; id?: string; message: string }; - +/** Runs RivetKit callbacks in cached agentOS VMs using its native HTTP stream. */ export class DynamicActorRuntime { readonly config: ActorRuntimeConfig; readonly #entries = new Map(); readonly #creating = new Map>(); - readonly #admission: ActorAdmission; readonly #timer: ReturnType; - #workerReservations = 0; #disposed = false; #disposePromise?: Promise; constructor(env: NodeJS.ProcessEnv = process.env) { - const requestedMaxEntries = integerEnv( - env, - "DYNAMIC_APPS_ACTOR_WORKER_MAX_ENTRIES", - 4, - 1, - 1_024, - ); - const heapLimitMb = integerEnv( - env, - "DYNAMIC_APPS_ACTOR_WORKER_HEAP_LIMIT_MB", - 96, - 16, - 2_048, - ); - const memoryHighWaterPercent = integerEnv( - env, - "DYNAMIC_APPS_MEMORY_HIGH_WATER_PERCENT", - 70, - 10, - 95, - ); - const cgroupMemory = readCgroupMemory(); this.config = { - maxEntries: cgroupMemory - ? capConcurrencyForMemory({ - requested: requestedMaxEntries, - contextAndVmLimitMb: heapLimitMb, - memoryHighWaterPercent, - currentBytes: cgroupMemory.currentBytes, - maxBytes: cgroupMemory.maxBytes, - }) - : requestedMaxEntries, - heapLimitMb, + maxEntries: integerEnv( + env, + "DYNAMIC_APPS_ACTOR_WORKER_MAX_ENTRIES", + 4, + 1, + 1_024, + ), + heapLimitMb: integerEnv( + env, + "DYNAMIC_APPS_ACTOR_WORKER_HEAP_LIMIT_MB", + 96, + 16, + 2_048, + ), startTimeoutMs: integerEnv( env, "DYNAMIC_APPS_ACTOR_WORKER_START_TIMEOUT_MS", - 10_000, + 30_000, 10, 5 * 60_000, ), @@ -142,31 +92,16 @@ export class DynamicActorRuntime { maxStartPayloadBytes: integerEnv( env, "DYNAMIC_APPS_ACTOR_START_PAYLOAD_MAX_BYTES", - 1024 * 1024, - 1, 16 * 1024 * 1024, - ), - memoryHighWaterPercent, - requestConcurrency: integerEnv( - env, - "DYNAMIC_APPS_ACTOR_REQUEST_CONCURRENCY", - 64, 1, - 4_096, - ), - requestQueueSize: integerEnv( - env, - "DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_SIZE", - 128, - 0, - 100_000, + 64 * 1024 * 1024, ), - requestQueueWaitMs: integerEnv( + memoryHighWaterPercent: integerEnv( env, - "DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_WAIT_MS", - 5_000, - 1, - 60_000, + "DYNAMIC_APPS_MEMORY_HIGH_WATER_PERCENT", + 70, + 10, + 95, ), requestTimeoutMs: integerEnv( env, @@ -176,11 +111,6 @@ export class DynamicActorRuntime { 5 * 60_000, ), }; - this.#admission = new ActorAdmission( - this.config.requestConcurrency, - this.config.requestQueueSize, - this.config.requestQueueWaitMs, - ); this.#timer = setInterval( () => void this.#prune(), Math.min(this.config.idleTtlMs, 30_000), @@ -189,92 +119,77 @@ export class DynamicActorRuntime { } async request(input: ActorRuntimeRequest): Promise { - await this.#admission.acquire(); - let admissionHandedOff = false; + const body = await readBoundedBody( + input.request.body, + this.config.maxStartPayloadBytes, + ); + if (!body) + return new Response("RivetKit actor start payload exceeds limit", { + status: 413, + }); + const entry = await this.#entry(input); + const request = new Request(input.request.url, { + method: input.request.method, + headers: input.request.headers, + body: + input.request.method === "GET" || input.request.method === "HEAD" + ? undefined + : Buffer.from(body), + }); + let head: Awaited>; try { - const body = await readBoundedBody( - input.request.body, - this.config.maxStartPayloadBytes, - ); - if (!body) { - return new Response("RivetKit actor start payload exceeds limit", { - status: 413, - }); - } - console.info( - JSON.stringify({ - msg: "Dynamic App actor callback received", - method: input.request.method, - path: new URL(input.request.url).pathname, - bodyBytes: body.byteLength, - contentLength: input.request.headers.get("content-length"), - }), + head = await withTimeout( + entry.vm.fetchStreamStart(ACTOR_HTTP_PORT, request), + this.config.requestTimeoutMs, + `Dynamic App actor response headers exceeded ${this.config.requestTimeoutMs}ms`, ); - const entry = await this.#entry(input); - try { - await entry.ready; - } catch (error) { - entry.active = Math.max(0, entry.active - 1); - entry.lastUsedAt = Date.now(); - if (this.#entries.size > this.config.maxEntries) void this.#prune(); - throw error; - } - const id = String(++entry.nextRequestId); - return new Promise((resolve, reject) => { - const pending: PendingRequest = { - resolve, - reject, - waitingAck: false, - settled: false, - releaseAdmission: () => this.#admission.release(), - }; - const cancel = () => entry.worker.postMessage({ type: "cancel", id }); - pending.abort = cancel; - entry.pending.set(id, pending); - admissionHandedOff = true; - pending.timeout = setTimeout(() => { - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor request timed out", - appId: entry.appId, - release: entry.release, - }); - this.#failEntry( - entry, - new Error( - `Dynamic App actor request exceeded ${this.config.requestTimeoutMs}ms`, - ), - ); - }, this.config.requestTimeoutMs); + } catch (error) { + this.#release(entry); + this.#failEntry(entry, error); + throw error; + } + + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + input.request.signal.removeEventListener("abort", cancel); + this.#release(entry); + }; + const cancel = () => { + if (settled) return; + void entry.vm.fetchStreamCancel(head.streamId).catch(() => {}); + settle(); + }; + input.request.signal.addEventListener("abort", cancel, { once: true }); + if (input.request.signal.aborted) cancel(); + + const stream = new ReadableStream({ + pull: async (controller) => { + if (settled) return controller.close(); try { - entry.worker.postMessage({ - type: "request", - id, - method: input.request.method, - url: input.request.url, - headers: [...input.request.headers.entries()], - body, - }); - input.request.signal.addEventListener("abort", cancel, { - once: true, - }); - if (input.request.signal.aborted) cancel(); + const chunk = await entry.vm.fetchStreamRead(head.streamId); + if (chunk.body.byteLength > 0) controller.enqueue(chunk.body); + if (chunk.done) { + controller.close(); + settle(); + } } catch (error) { - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor worker transport failed", - appId: entry.appId, - release: entry.release, - }); - this.#settle(entry, id); - reject(error); + controller.error(error); + settle(); } - }); - } finally { - if (!admissionHandedOff) this.#admission.release(); - } + }, + cancel: async () => { + if (settled) return; + await entry.vm.fetchStreamCancel(head.streamId).catch(() => {}); + settle(); + }, + }); + return new Response(stream, { + status: head.status, + statusText: head.statusText, + headers: proxyResponseHeaders(head.headers), + }); } async invalidate(key: string): Promise { @@ -286,18 +201,16 @@ export class DynamicActorRuntime { diagnostics(): Record { const entries = [...this.#entries.values()]; + const active = entries.reduce((sum, entry) => sum + entry.active, 0); return { workerLimit: this.config.maxEntries, entries: entries.length, creating: this.#creating.size, - workerReservations: this.#workerReservations, - activeRequests: entries.reduce((sum, entry) => sum + entry.active, 0), - pendingRequests: entries.reduce( - (sum, entry) => sum + entry.pending.size, - 0, - ), - admittedRequests: this.#admission.active, - queuedRequests: this.#admission.queued, + workerReservations: 0, + activeRequests: active, + pendingRequests: active, + admittedRequests: active, + queuedRequests: 0, }; } @@ -305,16 +218,13 @@ export class DynamicActorRuntime { if (this.#disposePromise !== undefined) return this.#disposePromise; this.#disposed = true; clearInterval(this.#timer); - this.#admission.dispose(); this.#disposePromise = this.#finishDispose(); return this.#disposePromise; } async #finishDispose(): Promise { - while (this.#creating.size > 0) { + while (this.#creating.size > 0) await Promise.allSettled([...this.#creating.values()]); - await new Promise((resolve) => setImmediate(resolve)); - } await Promise.allSettled( [...this.#entries.values()].map((entry) => this.#disposeEntry(entry)), ); @@ -325,319 +235,179 @@ export class DynamicActorRuntime { if (this.#disposed) throw new Error("Dynamic App actor runtime disposed"); const existing = this.#entries.get(input.key); if (existing && !existing.disposed) return this.#lease(existing); - const pending = this.#creating.get(input.key); - if (pending) return this.#lease(await pending); - const reservation = this.#reserveWorker(); - const promise = reservation.then(async () => { - const entry = await this.#createEntry(input); - if (this.#disposed) { - await this.#disposeEntry(entry); - throw new Error( - "Dynamic App actor runtime disposed during worker creation", - ); - } - return entry; - }); + const creating = this.#creating.get(input.key); + if (creating) return this.#lease(await creating); + const promise = this.#createEntry(input); this.#creating.set(input.key, promise); - let reserved = true; try { const entry = await promise; - this.#creating.delete(input.key); - this.#workerReservations = Math.max(0, this.#workerReservations - 1); - reserved = false; if (this.#disposed) { await this.#disposeEntry(entry); - throw new Error( - "Dynamic App actor runtime disposed during worker creation", - ); + throw new Error("Dynamic App actor runtime disposed during creation"); } this.#entries.set(input.key, entry); this.#lease(entry); await this.#prune(entry.key); return entry; } finally { - if (reserved) { - this.#workerReservations = Math.max(0, this.#workerReservations - 1); - } - if (this.#creating.get(input.key) === promise) { + if (this.#creating.get(input.key) === promise) this.#creating.delete(input.key); - } } } - #reserveWorker(): Promise { - if (this.#disposed) throw new Error("Dynamic App actor runtime disposed"); - if ( - this.#entries.size + this.#workerReservations < - this.config.maxEntries - ) { - this.#workerReservations += 1; - return Promise.resolve(); - } - const idle = [...this.#entries.values()] - .filter((entry) => entry.active === 0 && !entry.disposed) - .sort((a, b) => a.lastUsedAt - b.lastUsedAt)[0]; - if (!idle) { - throw new DynamicAppsError( - "agentos_apps_no_capacity", - `Dynamic Apps actor worker limit ${this.config.maxEntries} is busy`, - ); - } - this.#entries.delete(idle.key); - this.#workerReservations += 1; - return this.#disposeEntry(idle); - } - #lease(entry: RuntimeEntry): RuntimeEntry { - if (entry.disposed) { - throw new Error( - "Dynamic App actor worker was disposed before request lease", - ); - } - entry.active += 1; + if (entry.disposed) + throw new Error("Dynamic App actor runtime was disposed"); + entry.active++; entry.lastUsedAt = Date.now(); return entry; } + #release(entry: RuntimeEntry): void { + entry.active = Math.max(0, entry.active - 1); + entry.lastUsedAt = Date.now(); + if (this.#entries.size > this.config.maxEntries) void this.#prune(); + } + async #createEntry(input: ActorRuntimeRequest): Promise { - const directory = await mkdtemp(join(tmpdir(), "dynamic-app-actor-")); + const directory = await mkdtemp( + join(tmpdir(), "dynamic-app-actor-agentos-"), + ); + const artifactPath = join(directory, "release.aospkg"); + let vm: AgentOs | undefined; try { - const files = extractActorFiles(await input.loadArtifact()); - for (const [path, bytes] of files) { - const target = join(directory, ...path.split("/")); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, bytes, { mode: 0o600 }); - } - const rivetkitPackage = await resolvePlatformRivetKitPackage(); - await mkdir(join(directory, "node_modules"), { recursive: true }); - await symlink( - rivetkitPackage, - join(directory, "node_modules", "rivetkit"), - "dir", - ); - const entrypoint = pathToFileURL(join(directory, "main.mjs")).href; - let readyResolve = () => {}; - let readyReject = (_error: unknown) => {}; - const ready = new Promise((resolve, reject) => { - readyResolve = resolve; - readyReject = reject; + await chmod(directory, 0o700); + await writeFile(artifactPath, await input.loadArtifact(), { + mode: 0o600, }); - const worker = new Worker( - new URL( - `data:text/javascript,${encodeURIComponent(ACTOR_WORKER_SOURCE)}`, - ), - { - workerData: { entrypoint }, - env: actorWorkerEnvironment(input), - resourceLimits: { - maxOldGenerationSizeMb: this.config.heapLimitMb, - maxYoungGenerationSizeMb: Math.max( - 4, - Math.min(32, Math.floor(this.config.heapLimitMb / 4)), - ), + vm = await AgentOs.create({ + sidecar: { kind: "shared", pool: "dynamic-apps-actors" }, + defaultSoftware: false, + loopbackExemptPorts: loopbackExemptPorts(input.endpoint), + mounts: [ + { + path: "/app", + readOnly: true, + plugin: { + id: "agentos_packages", + config: { + kind: "tar", + tarPath: artifactPath, + root: "/", + readOnly: true, + }, + }, }, - stdout: true, - stderr: true, + ], + permissions: { + fs: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + network: "allow", }, - ); - const entry: RuntimeEntry = { + limits: { jsRuntime: { v8HeapLimitMb: this.config.heapLimitMb } }, + }); + const pendingLogs: Array<["stdout" | "stderr", Uint8Array]> = []; + const readyNonce = randomUUID(); + const readyMarker = `DYNAMIC_APPS_SERVER_READY:${readyNonce}`; + let readyBuffer = ""; + let resolveReady = () => {}; + let rejectReady = (_error: unknown) => {}; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + let entry: RuntimeEntry | undefined; + const process = await vm.process.spawn("node", ["/app/actor/main.mjs"], { + cwd: "/app/actor", + env: stringEnvironment({ + ...actorWorkerEnvironment(input), + DYNAMIC_APPS_READY_NONCE: readyNonce, + }), + onStdout: (data) => { + readyBuffer = `${readyBuffer}${new TextDecoder().decode(data)}`.slice( + -4_096, + ); + if (readyBuffer.includes(readyMarker)) resolveReady(); + if (entry) this.#logGuest(entry, "stdout", data); + else pendingLogs.push(["stdout", data]); + }, + onStderr: (data) => + entry + ? this.#logGuest(entry, "stderr", data) + : pendingLogs.push(["stderr", data]), + }); + entry = { key: input.key, appId: input.appId ?? "unknown", release: input.release ?? "unknown", directory, - worker, - ready, - pending: new Map(), + vm, + pid: process.pid, active: 0, lastUsedAt: Date.now(), disposed: false, - nextRequestId: 0, - logDisposers: [], }; - for (const stream of ["stdout", "stderr"] as const) { - const output = worker[stream]; - const decoder = new DynamicAppsLogLineDecoder((message, truncated) => - emitDynamicAppsLog({ - level: stream === "stdout" ? "info" : "error", - source: "actor", - message, - appId: input.appId ?? "unknown", - release: input.release ?? "unknown", - stream, - ...(truncated ? { metadata: { truncated: true } } : {}), - }), - ); - const onData = (chunk: Uint8Array) => decoder.write(chunk); - output.on("data", onData); - entry.logDisposers.push(() => { - output.off("data", onData); - decoder.end(); - }); - } - let startupSettled = false; - const startupTimer = setTimeout(() => { - if (startupSettled || entry.disposed) return; - startupSettled = true; + for (const [stream, data] of pendingLogs) + this.#logGuest(entry, stream, data); + const processExit = vm.process.wait(process.pid); + void processExit.then((exit) => { const error = new Error( - `Dynamic App actor worker startup exceeded ${this.config.startTimeoutMs}ms`, + `Dynamic App agentOS actor process exited with ${exit.exitCode ?? 1}`, ); - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor worker startup timed out", - appId: entry.appId, - release: entry.release, - }); - readyReject(error); - this.#failEntry(entry, error); - }, this.config.startTimeoutMs); - startupTimer.unref?.(); - const finishStartup = () => { - if (startupSettled) return; - startupSettled = true; - clearTimeout(startupTimer); - }; - worker.on("message", (message: WorkerMessage) => { - if (message.type === "ready") { - finishStartup(); - readyResolve(); - return; - } - if (message.type === "error" && !message.id) { - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor worker reported a transport error", - appId: entry.appId, - release: entry.release, - }); - finishStartup(); - readyReject(new Error(message.message)); - this.#failEntry(entry, new Error(message.message)); - return; - } - if (message.id) this.#handleMessage(entry, message); - }); - worker.once("error", (error) => { - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor worker failed", - appId: entry.appId, - release: entry.release, - }); - finishStartup(); - readyReject(error); + rejectReady(error); + if (!entry || entry.disposed) return; this.#failEntry(entry, error); }); - worker.once("exit", (code) => { - finishStartup(); - if (!entry.disposed) { - emitDynamicAppsLog({ - level: "error", - source: "runtime", - message: "Dynamic App actor worker exited", - appId: entry.appId, - release: entry.release, - metadata: { exitCode: code }, - }); - const error = new Error( - `Dynamic App actor worker exited with ${code}`, - ); - readyReject(error); - this.#failEntry(entry, error); - } - }); + await withTimeout( + ready, + this.config.startTimeoutMs, + `Dynamic App agentOS actor startup exceeded ${this.config.startTimeoutMs}ms`, + ); return entry; } catch (error) { - await rm(directory, { recursive: true, force: true }); + await vm?.dispose().catch(() => {}); + await rm(directory, { recursive: true, force: true }).catch(() => {}); throw error; } } - #handleMessage(entry: RuntimeEntry, message: WorkerMessage): void { - if (!("id" in message) || !message.id) return; - const pending = entry.pending.get(message.id); - if (!pending || pending.settled) return; - if (message.type === "head") { - if (pending.timeout) { - clearTimeout(pending.timeout); - pending.timeout = undefined; - } - const stream = new ReadableStream({ - start: (controller) => { - pending.controller = controller; - }, - pull: () => { - if (!pending.waitingAck) return; - pending.waitingAck = false; - entry.worker.postMessage({ type: "ack", id: message.id }); - }, - cancel: () => { - entry.worker.postMessage({ type: "cancel", id: message.id }); - this.#settle(entry, message.id); - }, - }); - pending.resolve( - new Response(stream, { - status: message.status, - headers: message.headers, - }), - ); - return; - } - if (message.type === "chunk") { - pending.controller?.enqueue(new Uint8Array(message.chunk)); - if ((pending.controller?.desiredSize ?? 1) > 0) { - entry.worker.postMessage({ type: "ack", id: message.id }); - } else { - pending.waitingAck = true; - } - return; - } - if (message.type === "end") { - pending.controller?.close(); - this.#settle(entry, message.id); - return; - } - if (message.type === "error") { - const error = new Error(message.message); - if (pending.controller) pending.controller.error(error); - else pending.reject(error); - this.#settle(entry, message.id); - } - } - - #settle(entry: RuntimeEntry, id: string): void { - const pending = entry.pending.get(id); - if (!pending || pending.settled) return; - pending.settled = true; - entry.pending.delete(id); - if (pending.timeout) clearTimeout(pending.timeout); - pending.releaseAdmission(); - entry.active = Math.max(0, entry.active - 1); - entry.lastUsedAt = Date.now(); - if (this.#entries.size > this.config.maxEntries) void this.#prune(); - } - #failEntry(entry: RuntimeEntry, error: unknown): void { this.#entries.delete(entry.key); - for (const [id, pending] of entry.pending) { - if (pending.controller) pending.controller.error(error); - else pending.reject(error); - this.#settle(entry, id); - } + emitDynamicAppsLog({ + level: "error", + source: "actor", + message: + error instanceof Error ? (error.stack ?? error.message) : String(error), + appId: entry.appId, + release: entry.release, + }); void this.#disposeEntry(entry); } + #logGuest( + entry: Pick, + stream: "stdout" | "stderr", + data: Uint8Array, + ): void { + emitDynamicAppsLog({ + level: stream === "stdout" ? "info" : "error", + source: "actor", + message: new TextDecoder().decode(data).slice(0, 64 * 1024), + appId: entry.appId, + release: entry.release, + stream, + }); + } + async #prune(protectedKey?: string): Promise { if (this.#disposed) return; const now = Date.now(); const memoryPressure = await this.#memoryPressure(); - const entries = [...this.#entries.values()].sort( + for (const entry of [...this.#entries.values()].sort( (a, b) => a.lastUsedAt - b.lastUsedAt, - ); - for (const entry of entries) { + )) { if ( entry.key !== protectedKey && entry.active === 0 && @@ -674,21 +444,15 @@ export class DynamicActorRuntime { async #disposeEntry(entry: RuntimeEntry): Promise { if (entry.disposed) return; entry.disposed = true; - for (const [id, pending] of entry.pending) { - pending.reject(new Error("Dynamic App actor worker disposed")); - pending.controller?.error(new Error("Dynamic App actor worker disposed")); - this.#settle(entry, id); - } - for (const dispose of entry.logDisposers.splice(0)) dispose(); const results = await Promise.allSettled([ - entry.worker.terminate(), + entry.vm.dispose(), rm(entry.directory, { recursive: true, force: true }), ]); if (results.some((result) => result.status === "rejected")) { emitDynamicAppsLog({ level: "error", source: "runtime", - message: "Dynamic App actor worker disposal failed", + message: "Dynamic App agentOS actor disposal failed", appId: entry.appId, release: entry.release, }); @@ -696,6 +460,21 @@ export class DynamicActorRuntime { } } +function proxyResponseHeaders( + input: Headers | Iterable, +): Headers { + const headers = new Headers(); + if (input instanceof Headers) { + input.forEach((value, name) => { + headers.append(name, value); + }); + } else { + for (const [name, value] of input) headers.append(name, value); + } + for (const name of HOP_BY_HOP_HEADERS) headers.delete(name); + return headers; +} + async function readBoundedBody( body: ReadableStream | null, limit: number, @@ -720,40 +499,22 @@ async function readBoundedBody( } } -let platformRivetKitPackage: Promise | undefined; - -function resolvePlatformRivetKitPackage(): Promise { - platformRivetKitPackage ??= (async () => { - const hostRequire = createRequire(import.meta.url); - const rivetkitEntry = hostRequire.resolve("rivetkit"); - return findPackageRoot(rivetkitEntry); - })(); - return platformRivetKitPackage; -} - -async function findPackageRoot(entrypoint: string): Promise { - let directory = dirname(entrypoint); - for (;;) { - try { - const value = JSON.parse( - await readFile(join(directory, "package.json"), "utf8"), - ) as { name?: unknown }; - if (typeof value.name === "string" && value.name) return directory; - } catch (error) { - if ( - typeof error !== "object" || - error === null || - !("code" in error) || - error.code !== "ENOENT" - ) { - throw error; - } - } - const parent = dirname(directory); - if (parent === directory) { - throw new Error(`Could not find package root for ${entrypoint}`); - } - directory = parent; +async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); } } @@ -768,100 +529,52 @@ export function actorWorkerEnvironment( const endpointToken = endpoint.password ? decodeURIComponent(endpoint.password) : undefined; - if (endpointNamespace && endpointNamespace !== input.namespace) { + if (endpointNamespace && endpointNamespace !== input.namespace) throw new Error( `Dynamic App actor endpoint namespace ${endpointNamespace} does not match ${input.namespace}`, ); - } endpoint.username = ""; endpoint.password = ""; return { NODE_ENV: "production", - RIVETKIT_RUNTIME: "native", + PORT: String(ACTOR_HTTP_PORT), + RIVETKIT_RUNTIME: "wasm", RIVETKIT_RUNTIME_MODE: "serverless", RIVET_ENDPOINT: endpoint.toString().replace(/\/$/u, ""), RIVET_NAMESPACE: input.namespace, ...(endpointToken ? { RIVET_TOKEN: endpointToken } : {}), RIVET_POOL: input.pool, - RIVET_RUNNER: input.pool, - RIVET_RUNNER_POOL: input.pool, RIVET_ENVOY_VERSION: String(actorEnvoyVersion(input.key)), + ...(process.env.AGENTOS_DEBUG_HTTP_BRIDGE + ? { AGENTOS_DEBUG_HTTP_BRIDGE: process.env.AGENTOS_DEBUG_HTTP_BRIDGE } + : {}), }; } -function actorEnvoyVersion(key: string): number { - return ( - createHash("sha256").update(key).digest().readUInt32BE(0) & 0x7fffffff || 1 +function stringEnvironment(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); } -function extractActorFiles(artifact: Uint8Array): Map { - const buffer = Buffer.from( - artifact.buffer, - artifact.byteOffset, - artifact.byteLength, - ); +function loopbackExemptPorts(endpoint: string): number[] { + const url = new URL(endpoint); if ( - buffer.byteLength < 16 || - buffer[0] !== 137 || - buffer.subarray(1, 4).toString("ascii") !== "AOS" + url.hostname !== "127.0.0.1" && + url.hostname !== "localhost" && + url.hostname !== "[::1]" ) { - throw new Error("Dynamic App actor artifact is not an AOSP package"); + return []; } - let offset = 16 + buffer.readUInt32LE(8) + buffer.readUInt32LE(12); - let total = 0; - const output = new Map(); - 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 size = Number.parseInt( - tarString(header.subarray(124, 136)) || "0", - 8, - ); - const type = String.fromCharCode(header[156] ?? 0); - if (!Number.isSafeInteger(size) || size < 0) { - throw new Error("Dynamic App actor artifact has an invalid tar entry"); - } - const dataOffset = offset + 512; - const next = dataOffset + Math.ceil(size / 512) * 512; - if (next > buffer.byteLength) { - throw new Error("Dynamic App actor artifact is truncated"); - } - if (path.startsWith("actor/") && (type === "\0" || type === "0")) { - const relative = posix.normalize(path.slice("actor/".length)); - if ( - !relative || - relative === "." || - relative === ".." || - relative.startsWith("../") || - posix.isAbsolute(relative) || - size > MAX_ACTOR_FILE_BYTES - ) { - throw new Error("Dynamic App actor artifact contains an invalid path"); - } - total += size; - if (total > MAX_ACTOR_ARTIFACT_BYTES || output.size >= MAX_ACTOR_FILES) { - throw new Error("Dynamic App actor artifact exceeds extraction limits"); - } - output.set( - relative, - new Uint8Array(buffer.subarray(dataOffset, dataOffset + size)), - ); - } - offset = next; - } - if (!output.has(ACTOR_BUNDLE_PATH.slice("actor/".length))) { - throw new Error("Dynamic App artifact has no actor bundle"); - } - return output; + return [Number(url.port || (url.protocol === "https:" ? 443 : 80))]; } -function tarString(bytes: Uint8Array): string { - const end = bytes.indexOf(0); - return Buffer.from(end < 0 ? bytes : bytes.subarray(0, end)).toString("utf8"); +function actorEnvoyVersion(key: string): number { + return ( + createHash("sha256").update(key).digest().readUInt32BE(0) & 0x7fffffff || 1 + ); } function integerEnv( @@ -872,160 +585,11 @@ function integerEnv( maximum: number, ): number { const value = Number(env[name] ?? fallback); - if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`); - } return value; } -class ActorAdmission { - readonly capacity: number; - readonly #maxQueued: number; - readonly #waitMs: number; - #active = 0; - #disposed = false; - #queue: Array<{ resolve(): void; reject(error: unknown): void }> = []; - - constructor(capacity: number, maxQueued: number, waitMs: number) { - this.capacity = capacity; - this.#maxQueued = maxQueued; - this.#waitMs = waitMs; - } - - get active(): number { - return this.#active; - } - - get queued(): number { - return this.#queue.length; - } - - async acquire(): Promise { - if (this.#disposed) throw new Error("actor runtime disposed"); - if (this.#active < this.capacity) { - this.#active += 1; - return; - } - if (this.#queue.length >= this.#maxQueued) { - throw new DynamicAppsError( - "agentos_apps_no_capacity", - "Dynamic Apps actor callback queue is full", - ); - } - await new Promise((resolve, reject) => { - let settled = false; - const item = { - resolve: () => { - if (settled) return; - settled = true; - clearTimeout(timer); - this.#active += 1; - resolve(); - }, - reject: (error: unknown) => { - if (settled) return; - settled = true; - clearTimeout(timer); - reject(error); - }, - }; - const timer = setTimeout(() => { - const offset = this.#queue.indexOf(item); - if (offset >= 0) this.#queue.splice(offset, 1); - item.reject( - new DynamicAppsError( - "agentos_apps_no_capacity", - `Dynamic Apps actor callback queue exceeded ${this.#waitMs}ms`, - ), - ); - }, this.#waitMs); - this.#queue.push(item); - }); - } - - release(): void { - if (this.#active <= 0) return; - this.#active -= 1; - this.#queue.shift()?.resolve(); - } - - dispose(): void { - this.#disposed = true; - for (const item of this.#queue.splice(0)) { - item.reject(new Error("actor runtime disposed")); - } - } -} - -const ACTOR_WORKER_SOURCE = ` -import { parentPort, workerData } from "node:worker_threads"; -if (!parentPort) throw new Error("Dynamic App actor worker has no parent port"); -const { handler } = await import(workerData.entrypoint); -if (typeof handler !== "function") throw new TypeError("Dynamic App actor fetch handler is invalid"); -const requests = new Map(); -const acknowledgements = new Map(); -const waitForAck = (id) => new Promise((resolve) => acknowledgements.set(id, resolve)); -const finishAck = (id) => { const resolve = acknowledgements.get(id); acknowledgements.delete(id); resolve?.(); }; -const describeError = (error) => { - if (!(error instanceof Error)) return String(error); - const details = [error.stack || error.message]; - let cause = error.cause; - for (let depth = 0; cause !== undefined && depth < 4; depth += 1) { - details.push("Caused by: " + (cause instanceof Error ? cause.stack || cause.message : String(cause))); - cause = cause instanceof Error ? cause.cause : undefined; - } - return details.join("\\n"); -}; -parentPort.on("message", (message) => { - if (message.type === "ack") { finishAck(message.id); return; } - if (message.type === "cancel") { - requests.get(message.id)?.abort(new Error("host cancelled actor callback")); - finishAck(message.id); - return; - } - if (message.type !== "request") return; - void (async () => { - const controller = new AbortController(); - requests.set(message.id, controller); - try { - const response = await handler(new Request(message.url, { - method: message.method, - headers: message.headers, - body: message.method === "GET" || message.method === "HEAD" ? undefined : message.body, - signal: controller.signal, - })); - parentPort.postMessage({ - type: "head", - id: message.id, - status: response.status, - headers: [...response.headers.entries()], - }); - if (response.body) { - const reader = response.body.getReader(); - try { - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - const bytes = new Uint8Array(chunk.value); - parentPort.postMessage({ type: "chunk", id: message.id, chunk: bytes }, [bytes.buffer]); - await waitForAck(message.id); - } - } finally { - reader.releaseLock(); - } - } - parentPort.postMessage({ type: "end", id: message.id }); - } catch (error) { - parentPort.postMessage({ type: "error", id: message.id, message: describeError(error) }); - } finally { - requests.delete(message.id); - finishAck(message.id); - } - })(); -}); -parentPort.postMessage({ type: "ready" }); -`; - let defaultActorRuntime: DynamicActorRuntime | undefined; export function getDefaultActorRuntime(): DynamicActorRuntime { diff --git a/packages/dynamic-apps/src/actors.ts b/packages/dynamic-apps/src/actors.ts index 1cc2cbca7..de5da8bb7 100644 --- a/packages/dynamic-apps/src/actors.ts +++ b/packages/dynamic-apps/src/actors.ts @@ -30,7 +30,9 @@ const DEFAULT_MAX_REGIONS = 8; const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_REPLICAS = 128; -export const ARTIFACT_CHUNK_BYTES = 512 * 1024; +// Keep the raw payload comfortably below the Engine action-message limit after +// Uint8Array JSON/base64 serialization and protocol framing. +export const ARTIFACT_CHUNK_BYTES = 128 * 1024; const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const MAX_ARTIFACT_CHUNKS = Math.ceil( MAX_ARTIFACT_BYTES / ARTIFACT_CHUNK_BYTES, @@ -65,6 +67,7 @@ export interface StoredAppRelease extends AppReleaseInfo { export interface AppState { activeRelease: string | null; + activatingRelease?: string | null; namespace: string | null; revision: number; cloudNamespace?: string | null; @@ -75,7 +78,7 @@ export interface AppState { } export interface DynamicAppsActors { - agentOSAppsApp: AnyActorDefinition; + dynamicAppsApp: AnyActorDefinition; } interface BeginReleasePublishInput { @@ -152,7 +155,7 @@ async function actorBoundary(run: () => Promise): Promise { function positiveInteger(value: number, name: string, maximum: number): number { if (!Number.isInteger(value) || value < 1 || value > maximum) { fail( - "agentos_apps_invalid_config", + "dynamic_apps_invalid_config", `${name} must be an integer between 1 and ${maximum}`, { name, maximum }, ); @@ -167,7 +170,7 @@ export function normalizeScaling( input !== undefined && (typeof input !== "object" || input === null || Array.isArray(input)) ) { - fail("agentos_apps_invalid_scaling", "scaling must be an object"); + fail("dynamic_apps_invalid_scaling", "scaling must be an object"); } const minReplicas = input?.minReplicas ?? 0; const maxReplicas = input?.maxReplicas ?? 128; @@ -178,7 +181,7 @@ export function normalizeScaling( minReplicas > MAX_REPLICAS ) { fail( - "agentos_apps_invalid_scaling", + "dynamic_apps_invalid_scaling", `scaling.minReplicas must be an integer between 0 and ${MAX_REPLICAS}`, ); } @@ -186,7 +189,7 @@ export function normalizeScaling( positiveInteger(targetConcurrency, "scaling.targetConcurrency", 1_024); if (minReplicas > maxReplicas) { fail( - "agentos_apps_invalid_scaling", + "dynamic_apps_invalid_scaling", "scaling.minReplicas cannot exceed scaling.maxReplicas", ); } @@ -198,19 +201,19 @@ function normalizeRegions( fallbackRegion: string, ): string[] { if (regions !== undefined && !Array.isArray(regions)) { - fail("agentos_apps_invalid_regions", "regions must be an array"); + fail("dynamic_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", + "dynamic_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", + "dynamic_apps_invalid_region", `invalid region ${JSON.stringify(region)}`, ); } @@ -220,7 +223,7 @@ function normalizeRegions( export async function migrateAppsTables(database: RawAccess): Promise { await database.execute(` - CREATE TABLE IF NOT EXISTS agentos_apps_releases ( + CREATE TABLE IF NOT EXISTS dynamic_apps_releases ( release_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, status TEXT NOT NULL, @@ -238,7 +241,7 @@ export async function migrateAppsTables(database: RawAccess): Promise { uses_rivetkit INTEGER NOT NULL DEFAULT 0 CHECK (uses_rivetkit IN (0, 1)) ); - CREATE TABLE IF NOT EXISTS agentos_apps_release_files ( + CREATE TABLE IF NOT EXISTS dynamic_apps_release_files ( release_id TEXT NOT NULL, path TEXT NOT NULL, chunk_index INTEGER NOT NULL, @@ -246,28 +249,28 @@ export async function migrateAppsTables(database: RawAccess): Promise { byte_length INTEGER NOT NULL, PRIMARY KEY (release_id, path, chunk_index) ); - CREATE TABLE IF NOT EXISTS agentos_apps_artifact_chunks ( + CREATE TABLE IF NOT EXISTS dynamic_apps_artifact_chunks ( release_id TEXT NOT NULL, chunk_index INTEGER NOT NULL, content BLOB NOT NULL, byte_length INTEGER NOT NULL, PRIMARY KEY (release_id, chunk_index) ); - CREATE INDEX IF NOT EXISTS idx_agentos_apps_releases_created_at - ON agentos_apps_releases(created_at); + CREATE INDEX IF NOT EXISTS idx_dynamic_apps_releases_created_at + ON dynamic_apps_releases(created_at); `); const columns = await database.execute<{ name: string }>( - "PRAGMA table_info(agentos_apps_releases)", + "PRAGMA table_info(dynamic_apps_releases)", ); if (!columns.some((column) => column.name === "callback_secret")) { await database.execute( - `ALTER TABLE agentos_apps_releases + `ALTER TABLE dynamic_apps_releases ADD COLUMN callback_secret TEXT NOT NULL DEFAULT ''`, ); } if (!columns.some((column) => column.name === "uses_rivetkit")) { await database.execute( - `ALTER TABLE agentos_apps_releases + `ALTER TABLE dynamic_apps_releases ADD COLUMN uses_rivetkit INTEGER NOT NULL DEFAULT 0 CHECK (uses_rivetkit IN (0, 1))`, ); @@ -280,21 +283,21 @@ async function deleteReleaseFilesBatched( ): Promise { for (let batch = 0; batch < MAX_SOURCE_CHUNKS; batch += 1) { const rows = await database.execute<{ chunks: number }>( - `SELECT COUNT(*) AS chunks FROM agentos_apps_release_files + `SELECT COUNT(*) AS chunks FROM dynamic_apps_release_files WHERE release_id = ?`, releaseId, ); if (Number(rows[0]?.chunks ?? 0) === 0) return; await database.execute( - `DELETE FROM agentos_apps_release_files WHERE rowid IN ( - SELECT rowid FROM agentos_apps_release_files + `DELETE FROM dynamic_apps_release_files WHERE rowid IN ( + SELECT rowid FROM dynamic_apps_release_files WHERE release_id = ? ORDER BY path, chunk_index LIMIT 1 )`, releaseId, ); } fail( - "agentos_apps_source_cleanup_limit", + "dynamic_apps_source_cleanup_limit", `source cleanup exceeded ${MAX_SOURCE_CHUNKS} bounded batches`, ); } @@ -315,7 +318,7 @@ async function persistReleaseFilesBatched( (index + 1) * SOURCE_CHUNK_BYTES, ); await database.execute( - `INSERT INTO agentos_apps_release_files + `INSERT INTO dynamic_apps_release_files (release_id, path, chunk_index, content, byte_length) VALUES (?, ?, ?, ?, ?)`, releaseId, @@ -334,21 +337,21 @@ async function deleteArtifactChunksBatched( ): Promise { for (let batch = 0; batch < MAX_ARTIFACT_CHUNKS; batch += 1) { const rows = await database.execute<{ chunks: number }>( - `SELECT COUNT(*) AS chunks FROM agentos_apps_artifact_chunks + `SELECT COUNT(*) AS chunks FROM dynamic_apps_artifact_chunks WHERE release_id = ?`, releaseId, ); if (Number(rows[0]?.chunks ?? 0) === 0) return; await database.execute( - `DELETE FROM agentos_apps_artifact_chunks WHERE rowid IN ( - SELECT rowid FROM agentos_apps_artifact_chunks + `DELETE FROM dynamic_apps_artifact_chunks WHERE rowid IN ( + SELECT rowid FROM dynamic_apps_artifact_chunks WHERE release_id = ? ORDER BY chunk_index LIMIT 1 )`, releaseId, ); } fail( - "agentos_apps_artifact_cleanup_limit", + "dynamic_apps_artifact_cleanup_limit", `artifact cleanup exceeded ${MAX_ARTIFACT_CHUNKS} bounded batches`, ); } @@ -394,7 +397,7 @@ async function getStoredRelease( releaseId: string, ): Promise { const rows = await database.execute( - "SELECT * FROM agentos_apps_releases WHERE release_id = ?", + "SELECT * FROM dynamic_apps_releases WHERE release_id = ?", releaseId, ); return rows[0] ? releaseFromRow(rows[0]) : undefined; @@ -404,7 +407,7 @@ async function listStoredReleases( database: RawAccess, ): Promise { const rows = await database.execute( - "SELECT * FROM agentos_apps_releases ORDER BY created_at ASC", + "SELECT * FROM dynamic_apps_releases ORDER BY created_at ASC", ); return rows.map(releaseFromRow); } @@ -419,7 +422,7 @@ async function readStoredArtifact( byte_length: number; }>( `SELECT chunk_index, content, byte_length - FROM agentos_apps_artifact_chunks + FROM dynamic_apps_artifact_chunks WHERE release_id = ? ORDER BY chunk_index ASC`, release.release, ); @@ -432,7 +435,7 @@ async function readStoredArtifact( rows.length > MAX_ARTIFACT_CHUNKS ) { fail( - "agentos_apps_artifact_manifest_invalid", + "dynamic_apps_artifact_manifest_invalid", `artifact ${release.release} has an invalid chunk count`, ); } @@ -453,7 +456,7 @@ async function readStoredArtifact( content.byteLength !== expected ) { fail( - "agentos_apps_artifact_manifest_invalid", + "dynamic_apps_artifact_manifest_invalid", `artifact ${release.release} contains an invalid chunk`, ); } @@ -466,7 +469,7 @@ async function readStoredArtifact( createHash("sha256").update(artifact).digest("hex") !== release.artifactHash ) { fail( - "agentos_apps_artifact_hash_mismatch", + "dynamic_apps_artifact_hash_mismatch", `artifact ${release.release} failed hash verification`, ); } @@ -515,7 +518,7 @@ function actorPublicEndpoint( const endpoint = new URL(raw); if (!publicEndpoint && (endpoint.username || endpoint.password)) { throw new DynamicAppsError( - "agentos_apps_public_endpoint_required", + "dynamic_apps_public_endpoint_required", "RIVET_PUBLIC_ENDPOINT is required to run app actors when RIVET_ENDPOINT contains credentials", ); } @@ -530,7 +533,7 @@ function validateBeginInput( const appId = c.key[0]; if (!appId || c.key.length !== 1 || input.appId !== appId) { fail( - "agentos_apps_app_id_mismatch", + "dynamic_apps_app_id_mismatch", "deployApp appId must match the stable application actor key", { appId: input.appId, actorKey: c.key }, ); @@ -547,7 +550,7 @@ function validateBeginInput( !Number.isSafeInteger(input.createdAt) || input.createdAt < 0 ) { - fail("agentos_apps_publish_invalid", "release publish metadata is invalid"); + fail("dynamic_apps_publish_invalid", "release publish metadata is invalid"); } return appId; } @@ -563,7 +566,7 @@ function releaseIdFor(input: { usesRivetKit: boolean; }): string { const hash = createHash("sha256"); - hash.update("agentos-apps-rivet-release-v1\0"); + hash.update("dynamic-apps-rivet-release-v1\0"); for (const value of [ input.buildId, input.artifactHash, @@ -644,7 +647,7 @@ async function beginReleasePublishLocked( } await deleteArtifactChunksBatched(c.db, releaseId); await c.db.execute( - `INSERT INTO agentos_apps_releases ( + `INSERT INTO dynamic_apps_releases ( release_id, created_at, status, entrypoint, artifact_hash, artifact_bytes, build_error, regions_json, scaling_json, namespace, envoy_version, @@ -697,14 +700,14 @@ async function writeReleaseChunk( !(input.content instanceof Uint8Array) ) { fail( - "agentos_apps_invalid_artifact_chunk", + "dynamic_apps_invalid_artifact_chunk", "release chunk metadata is invalid or superseded", ); } const release = await getStoredRelease(c.db, input.release); if (!release || release.status !== "building") { fail( - "agentos_apps_artifact_not_building", + "dynamic_apps_artifact_not_building", `release ${input.release} is not accepting artifact chunks`, ); } @@ -715,13 +718,13 @@ async function writeReleaseChunk( : ARTIFACT_CHUNK_BYTES; if (input.index >= chunks || input.content.byteLength !== expected) { fail( - "agentos_apps_invalid_artifact_chunk", + "dynamic_apps_invalid_artifact_chunk", `artifact chunk ${input.index} has an invalid length`, ); } const content = new Uint8Array(input.content); await c.db.execute( - `INSERT INTO agentos_apps_artifact_chunks + `INSERT INTO dynamic_apps_artifact_chunks (release_id, chunk_index, content, byte_length) VALUES (?, ?, ?, ?) ON CONFLICT(release_id, chunk_index) DO UPDATE SET @@ -744,7 +747,7 @@ async function commitReleasePublishLocked( input.sequence !== state.latestPublishSequence ) { fail( - "agentos_apps_publish_superseded", + "dynamic_apps_publish_superseded", "a newer release publish superseded this upload", ); } @@ -754,7 +757,7 @@ async function commitReleasePublishLocked( (release.status !== "building" && release.status !== "ready") ) { fail( - "agentos_apps_artifact_not_ready", + "dynamic_apps_artifact_not_ready", `release ${input.release} cannot be committed`, ); } @@ -768,7 +771,7 @@ async function commitReleasePublishLocked( input.chunks > MAX_ARTIFACT_CHUNKS ) { fail( - "agentos_apps_artifact_manifest_invalid", + "dynamic_apps_artifact_manifest_invalid", "release commit has an invalid artifact chunk count", ); } @@ -778,7 +781,7 @@ async function commitReleasePublishLocked( } if (release.status !== "ready") { await c.db.execute( - `UPDATE agentos_apps_releases SET status = 'ready', build_error = NULL + `UPDATE dynamic_apps_releases SET status = 'ready', build_error = NULL WHERE release_id = ?`, release.release, ); @@ -786,7 +789,7 @@ async function commitReleasePublishLocked( } const appId = c.key[0]; if (!appId) - fail("agentos_apps_invalid_app_id", "application actor key is missing"); + fail("dynamic_apps_invalid_app_id", "application actor key is missing"); const runtime = await provisionAppNamespace( appId, resolveDefaultRivetConnection(), @@ -800,17 +803,36 @@ async function commitReleasePublishLocked( state.runnerToken = runtime.runnerToken ?? null; state.publicToken = runtime.publicToken ?? null; if (release.usesRivetKit) { - actorPublicEndpoint(release, state); + const publicEndpoint = actorPublicEndpoint(release, state); if ( runtime.endpoint.replace(/\/$/, "") !== release.runtimeEndpoint.replace(/\/$/, "") ) { fail( - "agentos_apps_runtime_changed", + "dynamic_apps_runtime_changed", "the app actor Rivet endpoint does not match the deployment runtime", ); } + state.activatingRelease = release.release; try { + const metadataResponse = await getDefaultActorRuntime().request({ + key: `${release.release}:${release.artifactHash}`, + appId, + release: release.release, + loadArtifact: () => readStoredArtifact(c.db, release), + endpoint: publicEndpoint, + namespace: release.namespace, + pool: release.runtimePool, + request: new Request("http://dynamic-app.internal/api/rivet/metadata", { + headers: { "user-agent": "RivetEngine/prewarm" }, + }), + }); + if (!metadataResponse.ok) { + throw new Error( + `Dynamic App actor metadata prewarm returned HTTP ${metadataResponse.status}`, + ); + } + await metadataResponse.arrayBuffer(); await configureAppNamespaceRunner( c.actorId, { @@ -822,6 +844,7 @@ async function commitReleasePublishLocked( release.callbackSecret, ); } catch (error) { + state.activatingRelease = null; c.log.error({ msg: "Dynamic App actor runner configuration failed", release: release.release, @@ -831,6 +854,7 @@ async function commitReleasePublishLocked( } } state.activeRelease = release.release; + state.activatingRelease = null; state.revision += 1; c.broadcast("releaseActivated", { revision: state.revision, @@ -849,7 +873,7 @@ async function commitReleasePublishLocked( await deleteArtifactChunksBatched(c.db, candidate.release); await deleteReleaseFilesBatched(c.db, candidate.release); await c.db.execute( - "DELETE FROM agentos_apps_releases WHERE release_id = ?", + "DELETE FROM dynamic_apps_releases WHERE release_id = ?", candidate.release, ); retained -= 1; @@ -864,7 +888,7 @@ function deploymentForRelease( ): Deployment & { appActorId: string; usesRivetKit: boolean } { const appId = c.key[0]; if (!appId) - fail("agentos_apps_invalid_app_id", "application actor key is missing"); + fail("dynamic_apps_invalid_app_id", "application actor key is missing"); return { appId, release: release.release, @@ -888,8 +912,9 @@ export function createAppsActors( const callbackPath = normalizeActorCallbackPath(request); if (!callbackPath) return new Response("Not Found", { status: 404 }); const state = c.state as AppState; - const release = state.activeRelease - ? await getStoredRelease(c.db, state.activeRelease) + const routedRelease = state.activatingRelease ?? state.activeRelease; + const release = routedRelease + ? await getStoredRelease(c.db, routedRelease) : undefined; if (!release || release.status !== "ready" || !release.usesRivetKit) { return new Response("Dynamic App has no active actor registry", { @@ -920,7 +945,7 @@ export function createAppsActors( } }; - const agentOSAppsApp = actor({ + const dynamicAppsApp = actor({ options: { actionTimeout: 16 * 60_000 }, db: db({ onMigrate: migrateAppsTables }), onRequest: forwardActorRequest, @@ -972,7 +997,7 @@ export function createAppsActors( const appId = c.key[0]; if (!appId || c.key.length !== 1 || input.appId !== appId) { fail( - "agentos_apps_app_id_mismatch", + "dynamic_apps_app_id_mismatch", "deployApp appId must match the stable application actor key", ); } @@ -1038,7 +1063,7 @@ export function createAppsActors( const appId = c.key[0]; if (!appId) { fail( - "agentos_apps_invalid_app_id", + "dynamic_apps_invalid_app_id", "application actor key is missing", ); } @@ -1051,19 +1076,19 @@ export function createAppsActors( release.entrypoint !== DIRECT_ENTRYPOINT ) { fail( - "agentos_apps_not_deployed", + "dynamic_apps_not_deployed", "app has no active direct release; call app.deploy() first", ); } if (requestedRegion && !release.regions.includes(requestedRegion)) { fail( - "agentos_apps_region_not_deployed", + "dynamic_apps_region_not_deployed", `app is not deployed in requested region ${requestedRegion}`, { requestedRegion, regions: release.regions }, ); } const region = requestedRegion ?? release.regions[0]; - if (!region) fail("agentos_apps_no_region", "active app has no region"); + if (!region) fail("dynamic_apps_no_region", "active app has no region"); return { appId, release: release.release, @@ -1088,14 +1113,14 @@ export function createAppsActors( release.entrypoint !== DIRECT_ENTRYPOINT ) { fail( - "agentos_apps_artifact_not_ready", + "dynamic_apps_artifact_not_ready", `artifact for release ${releaseId} is not ready`, ); } const rows = await c.db.execute<{ chunks: number; bytes: number }>( `SELECT COUNT(*) AS chunks, COALESCE(SUM(byte_length), 0) AS bytes - FROM agentos_apps_artifact_chunks WHERE release_id = ?`, + FROM dynamic_apps_artifact_chunks WHERE release_id = ?`, releaseId, ); const chunks = Number(rows[0]?.chunks ?? 0); @@ -1106,7 +1131,7 @@ export function createAppsActors( bytes !== release.artifactBytes ) { fail( - "agentos_apps_artifact_manifest_invalid", + "dynamic_apps_artifact_manifest_invalid", `artifact ${releaseId} failed persisted manifest validation`, ); } @@ -1129,7 +1154,7 @@ export function createAppsActors( index >= MAX_ARTIFACT_CHUNKS ) { fail( - "agentos_apps_invalid_artifact_chunk", + "dynamic_apps_invalid_artifact_chunk", `artifact chunk index must be between 0 and ${MAX_ARTIFACT_CHUNKS - 1}`, ); } @@ -1137,7 +1162,7 @@ export function createAppsActors( content: Uint8Array; byte_length: number; }>( - `SELECT content, byte_length FROM agentos_apps_artifact_chunks + `SELECT content, byte_length FROM dynamic_apps_artifact_chunks WHERE release_id = ? AND chunk_index = ?`, releaseId, index, @@ -1145,7 +1170,7 @@ export function createAppsActors( const row = rows[0]; if (!row) { fail( - "agentos_apps_artifact_chunk_not_found", + "dynamic_apps_artifact_chunk_not_found", `artifact chunk ${index} for release ${releaseId} was not found`, ); } @@ -1155,7 +1180,7 @@ export function createAppsActors( content.byteLength > ARTIFACT_CHUNK_BYTES ) { fail( - "agentos_apps_artifact_chunk_invalid", + "dynamic_apps_artifact_chunk_invalid", `artifact chunk ${index} failed length validation`, ); } @@ -1172,7 +1197,7 @@ export function createAppsActors( }, }, }); - return { agentOSAppsApp }; + return { dynamicAppsApp }; } /** @internal Preserves callback streaming until runtime admission. */ diff --git a/packages/dynamic-apps/src/control-plane.ts b/packages/dynamic-apps/src/control-plane.ts index 0bda1489a..58d7555c8 100644 --- a/packages/dynamic-apps/src/control-plane.ts +++ b/packages/dynamic-apps/src/control-plane.ts @@ -36,7 +36,7 @@ async function readBoundedJson(response: Response): Promise { if (bytes > MAX_CONTROL_RESPONSE_BYTES) { await reader.cancel("Dynamic Apps control response limit exceeded"); throw new DynamicAppsError( - "agentos_apps_control_response_limit", + "dynamic_apps_control_response_limit", `Rivet control response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`, { limit: MAX_CONTROL_RESPONSE_BYTES }, ); @@ -139,7 +139,7 @@ async function provisionCloudNamespace( const cloudToken = process.env.RIVET_CLOUD_TOKEN; if (!cloudToken) { throw new DynamicAppsError( - "agentos_apps_cloud_token_required", + "dynamic_apps_cloud_token_required", "RIVET_CLOUD_TOKEN is required", ); } @@ -150,7 +150,7 @@ async function provisionCloudNamespace( }>( cloudUrl("/tokens/api/inspect"), { headers }, - "agentos_apps_cloud_token_invalid", + "dynamic_apps_cloud_token_invalid", "Rivet Cloud token inspection", ); const displayName = appNamespaceDisplayName(appId, connection.namespace); @@ -170,7 +170,7 @@ async function provisionCloudNamespace( }>( url, { headers }, - "agentos_apps_namespace_lookup_failed", + "dynamic_apps_namespace_lookup_failed", "Rivet Cloud namespace lookup", ); cloudNamespace = listed.namespaces.find( @@ -195,7 +195,7 @@ async function provisionCloudNamespace( headers, body: JSON.stringify({ displayName }), }, - "agentos_apps_namespace_create_failed", + "dynamic_apps_namespace_create_failed", "Rivet Cloud namespace creation", ); cloudNamespace = created.namespace.name; @@ -207,25 +207,25 @@ async function provisionCloudNamespace( }>( cloudUrl(namespacePath, identity.organization), { headers }, - "agentos_apps_namespace_lookup_failed", + "dynamic_apps_namespace_lookup_failed", "Rivet Cloud namespace resolution", ), checkedJson<{ token: string }>( cloudUrl(`${namespacePath}/tokens/access`, identity.organization), { method: "POST", headers }, - "agentos_apps_namespace_token_failed", + "dynamic_apps_namespace_token_failed", "Rivet Cloud access token creation", ), checkedJson<{ token: string }>( cloudUrl(`${namespacePath}/tokens/secret`, identity.organization), { method: "POST", headers }, - "agentos_apps_namespace_token_failed", + "dynamic_apps_namespace_token_failed", "Rivet Cloud runner token creation", ), checkedJson<{ token: string }>( cloudUrl(`${namespacePath}/tokens/publishable`, identity.organization), { method: "POST", headers }, - "agentos_apps_namespace_token_failed", + "dynamic_apps_namespace_token_failed", "Rivet Cloud publishable token creation", ), ]); @@ -264,7 +264,7 @@ export async function provisionAppNamespace( }); if (!response.ok) { throw new DynamicAppsError( - "agentos_apps_namespace_lookup_failed", + "dynamic_apps_namespace_lookup_failed", `Rivet namespace lookup failed with HTTP ${response.status}`, { status: response.status }, ); @@ -289,7 +289,7 @@ export async function provisionAppNamespace( ); if (!response.ok && !(await lookup())) { throw new DynamicAppsError( - "agentos_apps_namespace_create_failed", + "dynamic_apps_namespace_create_failed", `Rivet namespace creation failed with HTTP ${response.status}`, { status: response.status }, ); @@ -366,7 +366,7 @@ export async function configureAppNamespaceRunner( }); } catch (error) { throw new DynamicAppsError( - "agentos_apps_runner_config_failed", + "dynamic_apps_runner_config_failed", `Rivet runner configuration failed for namespace ${runtime.namespace} and pool ${runtime.pool}`, { namespace: runtime.namespace, diff --git a/packages/dynamic-apps/src/deploy.ts b/packages/dynamic-apps/src/deploy.ts index f896b52b4..13eabb5e9 100644 --- a/packages/dynamic-apps/src/deploy.ts +++ b/packages/dynamic-apps/src/deploy.ts @@ -24,7 +24,7 @@ interface DeploymentActorGroup { export interface DeployAppOptions { /** An ordinary RivetKit client. The default client is created lazily. */ client?: { - agentOSAppsApp: DeploymentActorGroup; + dynamicAppsApp: DeploymentActorGroup; }; } @@ -46,7 +46,7 @@ export async function deployApp( scaling: input.scaling, }; const result = await deployThroughStableActor( - options.client.agentOSAppsApp, + options.client.dynamicAppsApp, input.appId, prepared, ); diff --git a/packages/dynamic-apps/src/index.ts b/packages/dynamic-apps/src/index.ts index 70efb4ac3..ed0b55fcf 100644 --- a/packages/dynamic-apps/src/index.ts +++ b/packages/dynamic-apps/src/index.ts @@ -7,3 +7,4 @@ export { setDynamicAppsLogHandler, } from "./logging.js"; export { appsRouter } from "./router.js"; +export { rivetActorsSkill, webServerSkill } from "./skills.js"; diff --git a/packages/dynamic-apps/src/registry.ts b/packages/dynamic-apps/src/registry.ts index 76db8c375..60524243c 100644 --- a/packages/dynamic-apps/src/registry.ts +++ b/packages/dynamic-apps/src/registry.ts @@ -1,10 +1,42 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { setup } from "rivetkit"; import { createAppsActors } from "./actors.js"; +const artifactCacheDirectory = process.env.DYNAMIC_APPS_E2E_ARTIFACT_CACHE; + export const privateAppsRegistry = setup({ - use: createAppsActors() as unknown as Record< + // Artifact chunks are binary deployment traffic and expand during RivetKit + // action serialization. The default 64 KiB action limit is too small. + maxIncomingMessageSize: 1024 * 1024, + use: createAppsActors({ + artifactCache: artifactCacheDirectory + ? { + async get(release) { + try { + return new Uint8Array( + await readFile( + join(artifactCacheDirectory, `${release}.aospkg`), + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return undefined; + throw error; + } + }, + async put(release, artifact) { + await mkdir(artifactCacheDirectory, { recursive: true }); + const target = join(artifactCacheDirectory, `${release}.aospkg`); + const temporary = `${target}.${process.pid}.tmp`; + await writeFile(temporary, artifact); + await rename(temporary, target); + }, + } + : undefined, + }) as unknown as Record< string, - ReturnType["agentOSAppsApp"] + ReturnType["dynamicAppsApp"] >, }); diff --git a/packages/dynamic-apps/src/release-store.ts b/packages/dynamic-apps/src/release-store.ts index eecac9edd..55076b4db 100644 --- a/packages/dynamic-apps/src/release-store.ts +++ b/packages/dynamic-apps/src/release-store.ts @@ -15,7 +15,9 @@ import { createClient } from "rivetkit/client"; import { ensurePrivateAppsRegistry } from "./registry.js"; import type { AppRouteResolution, Deployment } from "./types.js"; -export const ARTIFACT_CHUNK_BYTES = 512 * 1024; +// Keep the raw payload comfortably below the Engine action-message limit after +// Uint8Array JSON/base64 serialization and protocol framing. +export const ARTIFACT_CHUNK_BYTES = 128 * 1024; const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const MAX_ARTIFACT_CHUNKS = Math.ceil( MAX_ARTIFACT_BYTES / ARTIFACT_CHUNK_BYTES, @@ -100,7 +102,7 @@ interface ReleaseActorGroup { } interface ReleaseStoreClient { - agentOSAppsApp: ReleaseActorGroup; + dynamicAppsApp: ReleaseActorGroup; } interface DriverEntry { @@ -137,7 +139,7 @@ export function createRivetReleaseStore( input: PublishReleaseInput, ): Promise => { await ensurePrivateAppsRegistry(); - const group = getClient().agentOSAppsApp; + const group = getClient().dynamicAppsApp; const driver = drivers.get(input.appId); let handle: AppReleaseHandle; let usedExisting = false; @@ -221,7 +223,7 @@ export function createRivetReleaseStore( handle.resolveDeployment(), ); } catch (error) { - if (getErrorCode(error) === "agentos_apps_not_deployed") return undefined; + if (getErrorCode(error) === "dynamic_apps_not_deployed") return undefined; throw error; } const manifest = await timed(context, "artifact-manifest", () => @@ -291,7 +293,7 @@ export function createRivetReleaseStore( drivers.set(appId, entry); entry.ready = connectDriver( entry, - getClient().agentOSAppsApp, + getClient().dynamicAppsApp, invalidate, ).catch(async (error) => { if (drivers.get(appId) === entry) drivers.delete(appId); diff --git a/packages/dynamic-apps/src/router.ts b/packages/dynamic-apps/src/router.ts index cd52d0dca..af89fbe9a 100644 --- a/packages/dynamic-apps/src/router.ts +++ b/packages/dynamic-apps/src/router.ts @@ -1,24 +1,15 @@ -import type { Hono } from "hono"; +import { Hono } from "hono"; import type { BlankEnv, BlankSchema } from "hono/types"; import { defaultDynamicApps } from "./default.js"; import { handlePrivateAppsRegistry } from "./registry.js"; -const PRIVATE_REGISTRY_SENTINEL = "x-agentos-app-registry-dispatch"; +const router = new Hono(); -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") { - const headers = new Headers(request.headers); - headers.delete(PRIVATE_REGISTRY_SENTINEL); - const cleanRequest = new Request(request, { headers }); - const path = new URL(cleanRequest.url).pathname.replace(/\/+$/, "") || "/"; - if (path === "/api/rivet" || path.startsWith("/api/rivet/")) { - return handlePrivateAppsRegistry(cleanRequest); - } - return new Response("Not Found", { status: 404 }); - } - return honoFetch(request, ...rest); -}) as typeof router.fetch; +router.all("/api/rivet/*", (context, next) => { + const path = new URL(context.req.raw.url).pathname; + if (!path.startsWith("/api/rivet/")) return next(); + return handlePrivateAppsRegistry(context.req.raw); +}); +router.route("/", defaultDynamicApps.appsRouter); export const appsRouter: Hono = router; diff --git a/packages/dynamic-apps/src/runtime.ts b/packages/dynamic-apps/src/runtime.ts index 52056b27e..3e0aeb5c8 100644 --- a/packages/dynamic-apps/src/runtime.ts +++ b/packages/dynamic-apps/src/runtime.ts @@ -9,7 +9,7 @@ export const APP_CALLBACK_SECRET_HEADER = "x-agentos-app-callback-token"; /** Stable compatibility pool retained in deployApp's result. */ export function appRunnerPool(appId: string): string { const suffix = createHash("sha256").update(appId).digest("hex").slice(0, 16); - return `agentos-apps-${suffix}`; + return `dynamic-apps-${suffix}`; } async function readBoundedText(response: Response): Promise { diff --git a/packages/dynamic-apps/src/skills.ts b/packages/dynamic-apps/src/skills.ts new file mode 100644 index 000000000..db5f584a9 --- /dev/null +++ b/packages/dynamic-apps/src/skills.ts @@ -0,0 +1,151 @@ +/** Instructions and a complete starter for a buildable TypeScript HTTP app. */ +export const webServerSkill = `Build a Node.js web server in TypeScript. Start from this complete project and modify it for the user's request. + +package.json +~~~json +{ + "name": "generated-web-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@hono/node-server": "2.0.11", + "hono": "4.12.9" + }, + "devDependencies": { + "@types/node": "22.19.15", + "typescript": "5.7.3" + } +} +~~~ + +tsconfig.json +~~~json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src"] +} +~~~ + +src/index.ts +~~~ts +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; + +const app = new Hono(); + +app.get("/", (context) => + context.json({ ok: true, message: "Hello from Dynamic Apps" }), +); + +serve({ + fetch: app.fetch, + port: Number(process.env.PORT ?? 3000), +}); +~~~ + +Keep the build script as "tsc" and the entrypoint as dist/index.js. The deployment runs npm run build, so invalid generated TypeScript returns compiler diagnostics that can be used to repair the files.`; + +/** Instructions and a complete starter for a TypeScript app with Rivet actors. */ +export const rivetActorsSkill = `Build a Node.js TypeScript web server with Rivet Actors. Start from this complete project and modify the actor state, actions, and HTTP routes for the user's request. + +package.json +~~~json +{ + "name": "generated-rivet-actors-app", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@hono/node-server": "2.0.11", + "hono": "4.12.9", + "rivetkit": "2.3.11" + }, + "devDependencies": { + "@types/node": "22.19.15", + "typescript": "5.7.3" + } +} +~~~ + +tsconfig.json +~~~json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src"] +} +~~~ + +src/index.ts +~~~ts +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { actor, event, setup } from "rivetkit"; + +const counter = actor({ + state: { count: 0 }, + events: { countChanged: event() }, + actions: { + increment(context, amount: number = 1) { + context.state.count += amount; + context.broadcast("countChanged", context.state.count); + return context.state.count; + }, + getCount(context) { + return context.state.count; + } + } +}); + +export const registry = setup({ use: { counter } }); + +const app = new Hono(); +app.all("/api/rivet/*", (context) => registry.handler(context.req.raw)); +app.get("/", (context) => + context.json({ ok: true, message: "Rivet Actors server is running" }), +); + +serve({ + fetch: app.fetch, + port: Number(process.env.PORT ?? 3000), +}); +~~~ + +Do not call registry.start(): the Hono server owns the HTTP listener. Keep the build script as "tsc" so deployment failures include TypeScript diagnostics. The example above works without reading external documentation. + +Optional reference links: +- Rivet Actors: https://rivet.dev/actors/docs/ +- Node.js quickstart: https://rivet.dev/actors/docs/quickstart/backend/ +- Hono integration: https://rivet.dev/actors/docs/general/http-server/ +- State: https://rivet.dev/actors/docs/state/ +- Actions: https://rivet.dev/actors/docs/actions/ +- Events: https://rivet.dev/actors/docs/events/ +- SQLite: https://rivet.dev/actors/docs/sqlite/`; diff --git a/packages/dynamic-apps/tests/direct.test.ts b/packages/dynamic-apps/tests/direct.test.ts index 9a9b0809e..a7135750b 100644 --- a/packages/dynamic-apps/tests/direct.test.ts +++ b/packages/dynamic-apps/tests/direct.test.ts @@ -105,7 +105,7 @@ describe("retained public surface", () => { const response = await appsRouter.request("/INVALID/"); expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ - error: { code: "agentos_apps_invalid_app_id" }, + error: { code: "dynamic_apps_invalid_app_id" }, }); expect(calls).toBe(0); }); @@ -124,7 +124,7 @@ describe("retained public surface", () => { }, { client: { - agentOSAppsApp: { + dynamicAppsApp: { getOrCreate(value) { key = value; return { @@ -135,7 +135,7 @@ describe("retained public surface", () => { release: "release-1", endpoint: "https://api.example.test", namespace: "app-demo", - pool: "agentos-apps-demo", + pool: "dynamic-apps-demo", token: "pk_demo", regions: ["us-west"], appActorId: "actor-1", @@ -173,7 +173,7 @@ describe("retained public surface", () => { }, { client: { - agentOSAppsApp: { + dynamicAppsApp: { get() { calls.push("get"); return { @@ -205,7 +205,7 @@ describe("retained public surface", () => { }, { client: { - agentOSAppsApp: { + dynamicAppsApp: { get() { calls.push("get"); return { @@ -279,7 +279,7 @@ function deploymentResult(_input: unknown) { release: "release-1", endpoint: "https://api.example.test", namespace: "app-demo", - pool: "agentos-apps-demo", + pool: "dynamic-apps-demo", token: "pk_demo", regions: ["default"], appActorId: "actor-1", @@ -578,7 +578,7 @@ describe("direct agentOS execution", () => { releaseFirstBody(); expect((await first).status).toBe(200); expect(await secondError).toMatchObject({ - code: "agentos_apps_no_capacity", + code: "dynamic_apps_no_capacity", }); } finally { releaseFirstBody(); @@ -749,7 +749,7 @@ export default { fetch: () => new Response("ok") }; } }); - test("bounds concurrent actor workers as well as idle cache entries", async () => { + test("does not reject active actor runtimes when the idle cache target is full", async () => { const artifact = await makeActorArtifact(` export default { async fetch() { @@ -761,7 +761,7 @@ export default { const runtime = new DynamicActorRuntime({ DYNAMIC_APPS_ACTOR_WORKER_MAX_ENTRIES: "4", }); - const outcomes = Array.from({ length: 16 }, (_, index) => + const outcomes = Array.from({ length: 6 }, (_, index) => runtime .request({ key: `bounded-${index}`, @@ -779,15 +779,14 @@ export default { ), ); try { - await new Promise((resolve) => setTimeout(resolve, 50)); - const diagnostics = runtime.diagnostics(); - expect(diagnostics.entries + diagnostics.creating).toBeLessThanOrEqual(4); + expect(await Promise.all(outcomes)).toHaveLength(6); + await waitFor(() => runtime.diagnostics().entries <= 4); } finally { await Promise.allSettled(outcomes); await runtime.dispose(); await artifact.dispose(); } - }, 5_000); + }, 60_000); test("times out an actor handler that blocks its worker", async () => { const artifact = await makeActorArtifact(` @@ -817,7 +816,7 @@ export default { .then( () => "response" as const, (error: unknown) => - String(error).includes("request exceeded") + String(error).includes("response headers exceeded") ? ("timeout" as const) : Promise.reject(error), ), @@ -886,7 +885,9 @@ export default { expect(pulls).toBe(1); }); - test("preserves actor worker error stacks and causes", async () => { + test("logs actor server error stacks and causes", async () => { + const events: Readonly[] = []; + setDynamicAppsLogHandler((event) => events.push(event)); const artifact = await makeActorArtifact(` export default { async fetch() { @@ -896,25 +897,29 @@ export default { `); const runtime = new DynamicActorRuntime(); try { - await expect( - runtime.request({ - key: "worker-error", - loadArtifact: async () => artifact.bytes, - endpoint: "http://example.test", - namespace: "test", - pool: "default", - request: new Request("http://example.test/start", { - method: "POST", - }), - }), - ).rejects.toThrow(/outer failure[\s\S]*Caused by:[\s\S]*inner failure/u); + const response = await runtime.request({ + key: "worker-error", + loadArtifact: async () => artifact.bytes, + endpoint: "http://example.test", + namespace: "test", + pool: "default", + request: new Request("http://example.test/start", { method: "POST" }), + }); + expect(response.status).toBe(500); + await response.arrayBuffer(); + await waitFor(() => + events.some((event) => event.message.includes("outer failure")), + ); + const messages = events.map((event) => event.message).join("\n"); + expect(messages).toContain("outer failure"); + expect(messages).toContain("inner failure"); } finally { await runtime.dispose(); await artifact.dispose(); } }); - test("admits actor callback bodies before reading them", async () => { + test("does not gate concurrent actor callback bodies", async () => { const artifact = await makeActorArtifact(` export default { async fetch() { @@ -922,11 +927,7 @@ export default { }, }; `); - const runtime = new DynamicActorRuntime({ - DYNAMIC_APPS_ACTOR_REQUEST_CONCURRENCY: "4", - DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_SIZE: "4", - DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_WAIT_MS: "1000", - }); + const runtime = new DynamicActorRuntime(); let release = () => {}; const gate = new Promise((resolve) => { release = resolve; @@ -954,16 +955,16 @@ export default { ), ); try { - await waitFor(() => pulls >= 4); + await waitFor(() => pulls >= 16); await new Promise((resolve) => setTimeout(resolve, 20)); - expect(pulls).toBe(4); + expect(pulls).toBe(16); } finally { release(); await Promise.allSettled(outcomes); await runtime.dispose(); await artifact.dispose(); } - }); + }, 60_000); test("stops reading a callback body when it crosses the limit", async () => { const runtime = new DynamicActorRuntime({ @@ -1052,27 +1053,21 @@ export default { const outcome = await Promise.race([ Promise.all( Array.from({ length: 8 }, async (_, index) => { - try { - const response = await runtime.request({ - key: `churn-${index}`, - loadArtifact: async () => artifact.bytes, - endpoint: "http://example.test", - namespace: "test", - pool: "default", - request: new Request(`http://example.test/${index}`, { - method: "POST", - }), - }); - expect(await response.text()).toBe("ok"); - } catch (error) { - expect(error).toMatchObject({ - code: "agentos_apps_no_capacity", - }); - } + const response = await runtime.request({ + key: `churn-${index}`, + loadArtifact: async () => artifact.bytes, + endpoint: "http://example.test", + namespace: "test", + pool: "default", + request: new Request(`http://example.test/${index}`, { + method: "POST", + }), + }); + expect(await response.text()).toBe("ok"); }), ).then(() => "complete" as const), new Promise<"hung">((resolve) => - setTimeout(() => resolve("hung"), 1_000), + setTimeout(() => resolve("hung"), 30_000), ), ]); expect(outcome).toBe("complete"); @@ -1085,7 +1080,7 @@ export default { await runtime.dispose(); await artifact.dispose(); } - }, 5_000); + }, 60_000); test("attributes actor worker stdout and stderr", async () => { const events: Readonly[] = []; @@ -1249,12 +1244,47 @@ async function makeActorArtifact(source: string): Promise { await writeFile(join(directory, "actor", "application.mjs"), source); await writeFile( join(directory, "actor", "main.mjs"), - `const { default: application } = await import("./application.mjs"); + `import http from "node:http"; +const { default: application } = await import("./application.mjs"); const fetch = typeof application === "function" ? application : application?.fetch?.bind(application); if (typeof fetch !== "function") throw new TypeError("invalid test fetch handler"); -export const handler = fetch; +const port = Number(process.env.DYNAMIC_APPS_ACTOR_PORT ?? 3000); +http.createServer(async (incoming, outgoing) => { + try { + if (incoming.url === "/.agentos/ready") { + outgoing.writeHead(200); + outgoing.end("ok"); + return; + } + const chunks = await new Promise((resolve, reject) => { + const value = []; + incoming.on("data", (chunk) => value.push(Buffer.from(chunk))); + incoming.once("end", () => resolve(value)); + incoming.once("error", reject); + }); + const request = new Request(new URL(incoming.url ?? "/", "http://actor.test"), { + method: incoming.method, + headers: incoming.headers, + body: incoming.method === "GET" || incoming.method === "HEAD" ? undefined : Buffer.concat(chunks), + }); + const response = await fetch(request); + outgoing.statusCode = response.status; + response.headers.forEach((value, name) => outgoing.setHeader(name, value)); + outgoing.flushHeaders(); + if (response.body) { + for await (const chunk of response.body) { + if (!outgoing.write(Buffer.from(chunk))) await new Promise((resolve) => outgoing.once("drain", resolve)); + } + } + outgoing.end(); + } catch (error) { + console.error(error); + if (!outgoing.headersSent) outgoing.writeHead(500); + if (!outgoing.writableEnded) outgoing.end("Internal Server Error"); + } +}).listen(port, "0.0.0.0"); `, ); await writeFile( diff --git a/packages/dynamic-apps/tests/fixtures/legacy-0.2.15.json b/packages/dynamic-apps/tests/fixtures/legacy-0.2.15.json index 1a59108f6..ca48f36b1 100644 --- a/packages/dynamic-apps/tests/fixtures/legacy-0.2.15.json +++ b/packages/dynamic-apps/tests/fixtures/legacy-0.2.15.json @@ -1,18 +1,18 @@ { "sourcePackages": { - "@rivet-dev/agentos-apps": "0.2.15", + "@rivet-dev/dynamic-apps": "0.2.15", "@agentos-software/apps-builder": "0.2.15" }, "sourceRevision": "d7026219dec75a886a11d89857c68327a6f9b94a", "actorKeys": [ - "agentOSAppsApp", - "agentOSAppsScaler", - "agentOSAppsReplica" + "dynamicAppsApp", + "dynamicAppsScaler", + "dynamicAppsReplica" ], "sqliteTables": [ - "agentos_apps_releases", - "agentos_apps_release_files", - "agentos_apps_artifact_chunks" + "dynamic_apps_releases", + "dynamic_apps_release_files", + "dynamic_apps_artifact_chunks" ], "filesBase64": { "index.html": "PGgxPmxlZ2FjeSBmaXh0dXJlPC9oMT4K", @@ -20,6 +20,6 @@ }, "entrypoint": "index.html", "packagingIdentity": "apps-builder@0.2.15;manifest@1;bundle@2;esbuild-wasm@0.27.4;rivetkit-adapter@6", - "deploymentIdentity": "{\"regions\":[\"us-west\"],\"scaling\":{\"minReplicas\":0,\"maxReplicas\":128,\"targetConcurrency\":8},\"namespace\":\"legacy-fixture\",\"runtime\":{\"endpoint\":\"https://api.rivet.dev\",\"pool\":\"agentos-apps-legacy\"},\"usesRivetKit\":false}", + "deploymentIdentity": "{\"regions\":[\"us-west\"],\"scaling\":{\"minReplicas\":0,\"maxReplicas\":128,\"targetConcurrency\":8},\"namespace\":\"legacy-fixture\",\"runtime\":{\"endpoint\":\"https://api.rivet.dev\",\"pool\":\"dynamic-apps-legacy\"},\"usesRivetKit\":false}", "releaseId": "6e9dae258355860189a434505e9f0a8a89dacbdb2b8cf62423166d95611aa069" } diff --git a/packages/dynamic-apps/tests/release-store.test.ts b/packages/dynamic-apps/tests/release-store.test.ts index 43c6c3e33..b7cebacba 100644 --- a/packages/dynamic-apps/tests/release-store.test.ts +++ b/packages/dynamic-apps/tests/release-store.test.ts @@ -50,7 +50,7 @@ describe("Rivet release store", () => { }, }; const store = createRivetReleaseStore({ - agentOSAppsApp: { + dynamicAppsApp: { get: () => handle as never, getOrCreate: () => handle as never, }, @@ -59,7 +59,7 @@ describe("Rivet release store", () => { appId: "demo", buildId: "b".repeat(64), artifact: { - format: "agentos-apps-direct-v2", + format: "dynamic-apps-direct-v2", entrypoint: "direct-v2/main.mjs", hash: createHash("sha256").update(bytes).digest("hex"), bytes, @@ -119,7 +119,7 @@ describe("Rivet release store", () => { }, async getArtifactManifest() { return { - format: "agentos-apps-direct-v2", + format: "dynamic-apps-direct-v2", hash, bytes: bytes.byteLength, chunks: 1, @@ -131,7 +131,7 @@ describe("Rivet release store", () => { }, }; const store = createRivetReleaseStore({ - agentOSAppsApp: { + dynamicAppsApp: { get: () => handle as never, getOrCreate: () => handle as never, }, @@ -182,7 +182,7 @@ describe("Rivet release store", () => { }, }; const store = createRivetReleaseStore({ - agentOSAppsApp: { + dynamicAppsApp: { get: () => handle as never, getOrCreate: () => handle as never, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e205d1e3..a3cbea9dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@rivet-dev/agentos-core': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-core': 0.2.16-rc.3 '@rivetkit/engine-cli': 2.3.11 '@rivetkit/react': 2.3.11 '@rivetkit/rivetkit-wasm': 2.3.11 @@ -34,11 +34,11 @@ importers: specifier: ^2.0.11 version: 2.1.1(hono@4.13.3) '@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) + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3(@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 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@rivet-dev/dynamic-apps': specifier: workspace:* version: link:../../packages/dynamic-apps @@ -140,6 +140,103 @@ importers: specifier: ^5.7.3 version: 5.9.3 + examples/apps-multiplayer: + dependencies: + '@hono/node-server': + specifier: ^2.0.11 + version: 2.1.1(hono@4.13.3) + '@rivet-dev/dynamic-apps': + specifier: workspace:* + version: link:../../packages/dynamic-apps + hono: + specifier: ^4.12.9 + version: 4.13.3 + rivetkit: + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.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-sqlite: + dependencies: + '@hono/node-server': + specifier: ^2.0.11 + version: 2.1.1(hono@4.13.3) + '@rivet-dev/dynamic-apps': + specifier: workspace:* + version: link:../../packages/dynamic-apps + hono: + specifier: ^4.12.9 + version: 4.13.3 + rivetkit: + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.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-static-website: + dependencies: + '@hono/node-server': + specifier: ^2.0.11 + version: 2.1.1(hono@4.13.3) + '@rivet-dev/dynamic-apps': + specifier: workspace:* + version: link:../../packages/dynamic-apps + 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-workflows: + dependencies: + '@hono/node-server': + specifier: ^2.0.11 + version: 2.1.1(hono@4.13.3) + '@rivet-dev/dynamic-apps': + specifier: workspace:* + version: link:../../packages/dynamic-apps + hono: + specifier: ^4.12.9 + version: 4.13.3 + rivetkit: + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.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 + packages/dynamic-apps: dependencies: '@rivet-dev/dynamic-apps-core': @@ -153,11 +250,11 @@ importers: 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) + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3(@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 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@types/node': specifier: ^22.19.15 version: 22.20.1 @@ -173,6 +270,9 @@ importers: packages/dynamic-apps-builder: dependencies: + '@rivetkit/rivetkit-wasm': + specifier: 2.3.11 + version: 2.3.11 esbuild-wasm: specifier: 0.27.4 version: 0.27.4 @@ -181,11 +281,11 @@ importers: 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) + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3(@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 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@types/node': specifier: ^22.19.15 version: 22.20.1 @@ -199,17 +299,17 @@ importers: packages/dynamic-apps-core: dependencies: '@agentos-software/sh': - specifier: 0.2.15 - version: 0.2.15 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@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) + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3(@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 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@rivet-dev/dynamic-apps-builder': specifier: workspace:0.12.0-rc.2 version: link:../dynamic-apps-builder @@ -243,8 +343,8 @@ importers: version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.3) devDependencies: '@rivet-dev/agentos-toolchain': - specifier: 0.2.15 - version: 0.2.15 + specifier: 0.2.16-rc.3 + version: 0.2.16-rc.3 '@rivetkit/engine-cli': specifier: 2.3.11 version: 2.3.11 @@ -275,8 +375,8 @@ packages: '@agentos-software/codex-cli@0.3.4': resolution: {integrity: sha512-SAw3EOTa90dJLgEVVoE7JJIxHwticzdD9cEM9v00gpxhxC9vdLkeBva6rgWIUB9+qD1JEYBvUCyNkIpD2kT5YQ==} - '@agentos-software/common@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-hTMKv/gtvxlcMuLIdCi1mWVNZcIDdnbltKnNH2EEYyAEcOk7OnAuY6eEclGrLrRfojEbWr4M8JBTzd3tRLtPoA==} + '@agentos-software/common@0.2.16-rc.3': + resolution: {integrity: sha512-2+8ui1qqMKj8kDvnNz27334tC84He0YViF+3g0O+/dMiF+sPvayCjqpfiqX9BqvlT2+5mWzFWSMzVV4Gg9aBLQ==} '@agentos-software/coreutils@0.3.4': resolution: {integrity: sha512-tGd0gQjUjHnm+5KgOBwidwUtlkKnl9biQuD7X2sZl15VOhYqUJpnyLF33h/nF3r9WtOTclSiLj/dc2xCxmAXnw==} @@ -296,8 +396,8 @@ packages: '@agentos-software/gzip@0.3.4': resolution: {integrity: sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg==} - '@agentos-software/manifest@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-pW2lqsiiclos9b0ymoFYKZ/rEvL7wq+QthUeILWH6iYw3FQF+/LE2NSFjf/9ptPNmFYfP+wPO4640cz7NU+i6w==} + '@agentos-software/manifest@0.2.16-rc.3': + resolution: {integrity: sha512-gJwJ85o3K7TFDK6RQjw+cjMUauxHOMGypoR/JDiytP1nHbp/thHVaXc95U7gSEt94sy2nh6KUK+W0Wict/sspQ==} '@agentos-software/opencode@0.2.7': resolution: {integrity: sha512-lZspCiMgM0+kPAA08CEvyNy8lfBx463wObKpAwbYyaVvc0KfGvbAPS6VmnuZQWmaBmJJuRMtSo14nmb8XCD16g==} @@ -310,8 +410,8 @@ packages: '@agentos-software/sed@0.3.4': resolution: {integrity: sha512-J10nZnZmme2SvXK5WMK2unQlOVncMQVUCS20GZB579a2gNoayLJYHGvfJ9a4+42wOHgDKL1u74byWlydCkEyOQ==} - '@agentos-software/sh@0.2.15': - resolution: {integrity: sha512-ksyZBJb2yiRPPAWvYGBzsauuGU9eTmR5J/xeneS+MlrKFsQol6JOC6Yhc12YwqT4vlpTypJ1i5ZMDfXKzP3aLA==} + '@agentos-software/sh@0.2.16-rc.3': + resolution: {integrity: sha512-ho2diVXYd/L9YaXVievU0/8ecD3r/kQZ5EiM5SLoIvwi7OWdZpAc/zi2AdGHV8MRbM8mSiSRM3dPFhRDGb/krA==} '@agentos-software/tar@0.3.5': resolution: {integrity: sha512-hSf6PY4q1luIomFSDgVHxVDDIPdAVZxdgwrQFEYH4aIE6TXX9qatVmzR36uYLWpnFGAZTR6srREGoTnmOGV4Lg==} @@ -1286,70 +1386,64 @@ packages: pyodide: optional: true - '@rivet-dev/agentos-core@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-7jqJEu+tUhi/M6qrAKhUaPaP5ZD9jv1V2/tEoaNrKg2j4duekWUpf8p1aNT8v3V7vLhMmImvmLDDLwiC+QigWw==} + '@rivet-dev/agentos-core@0.2.16-rc.3': + resolution: {integrity: sha512-EUFOUBa3YWhiZBTGhb4Vpv+tG6978KW2I+2JFWqB3fjSbG/82MYhbjxfxoCc3idQEGfCKtm9IOQSzpdb6Q6ufw==} - '@rivet-dev/agentos-runtime-core@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-7QVeEvIoP7Q7hkzeetw9Hl7qeZOjVREWf425OTyX4AwSK44FbUq7kzu8wfH5IJHJ8ha2kcOsazKxNbP6eBnYuQ==} + '@rivet-dev/agentos-runtime-core@0.2.16-rc.3': + resolution: {integrity: sha512-4n4w5dCmOTcy4UhIYEy2xCZFXlBtYFeh2CA5B0WjB1QGs2cFcIB3Jpg7FAz6wBO5GX968/tsUGJ1Ut5J8L3fKw==} - '@rivet-dev/agentos-runtime-sidecar-darwin-arm64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-L8NN5RQA/8VZB406gw9uyWUAakNAF15YMpoO2ssX+FabXavEdja77XvRYcRSq4bBPO+tEad7IOJWfBZcmEeoOw==} + '@rivet-dev/agentos-runtime-sidecar-darwin-arm64@0.2.16-rc.3': + resolution: {integrity: sha512-fy0IpWgMwdJujwqNAY6Y+AhYw4b/+4v+0CYxeeUF1peCrIEPCPlaF2V6Xpc5ljcW8mpxiVumbvU8UxbkwsJolw==} engines: {node: '>=20'} cpu: [arm64] os: [darwin] - '@rivet-dev/agentos-runtime-sidecar-darwin-x64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-iSB0oRLMcPuuHca2y5e1vEVq02IRXYQ5R7A189F/Hj7v0UUFUnyRRLfnanc/0RPs9Zxiqq8nf/7WhfoWU1aJEQ==} + '@rivet-dev/agentos-runtime-sidecar-darwin-x64@0.2.16-rc.3': + resolution: {integrity: sha512-NiOyfMBaIqXqrg7rhdcp9wK+iWCEW9oQob0R/r4iF9Kit2uyXP2GZBFXQ4g+T6K7KTr9wEG33+fF1mLNA9a7rA==} engines: {node: '>=20'} cpu: [x64] os: [darwin] - '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-wrc8yS1bfpMowIeG8Ge9FDNy/BuXuSGnarW4cqsrSThzAIJVy4HcK3itH+lAXOgDxgJnUqYjCFk5Xk1l3aATtA==} + '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu@0.2.16-rc.3': + resolution: {integrity: sha512-hTIkuc12FmjetKDAkuYrr3NGsjuLMeA1docsD0jxUxrfvT3APX1rLqUORfqZpnykqzEnbUtQJMu8zP3QTDvHoA==} engines: {node: '>=20'} cpu: [arm64] os: [linux] - '@rivet-dev/agentos-runtime-sidecar-linux-x64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-vUGoVLr2mQXsrN6R+jASBvc4px/D7FR51dxY/vrBXAdlZRsIqCu8t6Mu5JPt/DiySJxvEawF8pMvhRlUWzg+gA==} - engines: {node: '>=20'} - cpu: [x64] - os: [linux] - - '@rivet-dev/agentos-runtime-sidecar@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-3eXrQlDQgIfcbmcM1qvVmpNdA8Bew9FZ60kiFAwNhWd4KM5Tc4NmkZ19g3sVwjD35MpbU4pLjwhrpCDTzDoe0g==} + '@rivet-dev/agentos-runtime-sidecar@0.2.16-rc.3': + resolution: {integrity: sha512-0IQs0gOY8Y48im3lCTFI06UJ0RZDlFeFfe/MTiTQg1O7WWqRXMZe/oJdX4AD6vLJQLPuoI8rGSgOAMJYWngwMg==} engines: {node: '>=20'} - '@rivet-dev/agentos-sidecar-darwin-arm64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-2vbHXKR2+Zk/eXI4/kzZqP2ts6bcyyTl4LLnJuU2U1DzIAAJC35uqZu3v5V4moTWciweXHnwiPtSzf+QMQlvrw==} + '@rivet-dev/agentos-sidecar-darwin-arm64@0.2.16-rc.3': + resolution: {integrity: sha512-wlnQP+Pu+DfEtb/cHda4mYyR/dKqX600kP0JILjh3rYKmhH3wWr/e/+bkLNMpTH2MrjcLNjxSUNW0Wwt1MGddg==} engines: {node: '>=20'} cpu: [arm64] os: [darwin] - '@rivet-dev/agentos-sidecar-darwin-x64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-+Reu0fWkO/2D/2lqTEutX52Cz3Hv+x9PTGu5ZM6z832529/SIXTgl0BFykPpTbPDJhVpFBS7piId/FQXpQmphQ==} + '@rivet-dev/agentos-sidecar-darwin-x64@0.2.16-rc.3': + resolution: {integrity: sha512-UV9Nmu6ziHXQEuXMioeCZyc1emzVETwTZwF5J64O4vp/YbmGfu6TPKnEnCRUs3XyqOoh5cPRJvo0WRSzg1GtgA==} engines: {node: '>=20'} cpu: [x64] os: [darwin] - '@rivet-dev/agentos-sidecar-linux-arm64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-majX/SiINTMpqp+URlWBJohvxO4QVHOKbQulHlpVsRVZtRg1/zymuQJY+KdWEGCvYbmW0mvmrLUU8vu7Uk8L2Q==} + '@rivet-dev/agentos-sidecar-linux-arm64-gnu@0.2.16-rc.3': + resolution: {integrity: sha512-D6fBNJz+i6cnm/AAx9pUo5m+uIKjkGP5Cl/ltU86VKsva49zXDH8f+GbnYr5OCQb+yAdoRgRHAnlrdsC1f6IVw==} engines: {node: '>=20'} cpu: [arm64] os: [linux] - '@rivet-dev/agentos-sidecar-linux-x64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-EIQUdVPLIEizhQa4sRBA3maXvEUomBV23dPfSaV21VFTcVD6uF9HrM/fzwPZQ5Eklksv0+EgY8+fuA1opZropg==} + '@rivet-dev/agentos-sidecar-linux-x64-gnu@0.2.16-rc.3': + resolution: {integrity: sha512-82bvLR2mj7AAaCexfkyWkUmnW3jfplfM1NgFZV3PMXs5tlwuK1SrkX+Js70OZKmc45vwMUZJha3cgxOxelBTgQ==} engines: {node: '>=20'} cpu: [x64] os: [linux] - '@rivet-dev/agentos-sidecar@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - resolution: {integrity: sha512-MCbw4zTgpjCwXP543KtHJG8N/yhe2UvmpE5UV7SjoJGwpC55Ln5Z7J6BhSfK83dShvPr0IhemZ5djWa3fpQW5g==} + '@rivet-dev/agentos-sidecar@0.2.16-rc.3': + resolution: {integrity: sha512-yJshB14bF938+MyB2dsa8wXxr+xKc+9t8CDQsnMZUi956RMjEJsPsqEsf62sN93o/KLBj9itgxNkafZQygQ2qA==} engines: {node: '>=20'} - '@rivet-dev/agentos-toolchain@0.2.15': - resolution: {integrity: sha512-DcjNMIvXijTNGolTVee+9+rf/G//4P7QK+IssTET8cmYqByDBl2/XDSsocRhnu1giFIxSJTFw5AwxtxO7hiAtw==} + '@rivet-dev/agentos-toolchain@0.2.16-rc.3': + resolution: {integrity: sha512-ZtL3NhK/4QGmkOoudWl21+Kzn2wFlyV6VkS2NqGBzbtGnJweWoAhFhD9eq3dZ0HvqL0WBn2r7Dy/2PX7Ki0Q5w==} hasBin: true '@rivetkit/bare-ts@0.6.2': @@ -2591,10 +2685,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isolated-vm@6.2.0: - resolution: {integrity: sha512-UuSlxSHWt2QuJ5WvBhzlIJx2VVZN/a44SqBbEZFKNdvuSyhOvhmyDo8SQ+njVbhnh/njoL/aW0bUTiFYlpweGQ==} - engines: {node: '>=22.0.0'} - isomorphic-timers-promises@1.0.1: resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} engines: {node: '>=10'} @@ -2780,10 +2870,6 @@ packages: resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} hasBin: true - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-stdlib-browser@1.3.1: resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} engines: {node: '>=10'} @@ -3620,7 +3706,7 @@ snapshots: '@agentos-software/codex-cli@0.3.4': {} - '@agentos-software/common@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@agentos-software/common@0.2.16-rc.3': dependencies: '@agentos-software/coreutils': 0.3.4 '@agentos-software/diffutils': 0.3.4 @@ -3643,7 +3729,7 @@ snapshots: '@agentos-software/gzip@0.3.4': {} - '@agentos-software/manifest@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': {} + '@agentos-software/manifest@0.2.16-rc.3': {} '@agentos-software/opencode@0.2.7': {} @@ -3662,7 +3748,7 @@ snapshots: '@agentos-software/sed@0.3.4': {} - '@agentos-software/sh@0.2.15': {} + '@agentos-software/sh@0.2.16-rc.3': {} '@agentos-software/tar@0.3.5': {} @@ -4532,24 +4618,23 @@ snapshots: dependencies: '@secure-exec/core': 0.2.1 - '@rivet-dev/agentos-core@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-core@0.2.16-rc.3(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)': dependencies: '@agentclientprotocol/sdk': 0.16.1(zod@4.4.3) '@agentos-software/claude-code': 0.2.7 '@agentos-software/codex-cli': 0.3.4 - '@agentos-software/common': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@agentos-software/manifest': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@agentos-software/common': 0.2.16-rc.3 + '@agentos-software/manifest': 0.2.16-rc.3 '@agentos-software/opencode': 0.2.7 '@agentos-software/pi': 0.2.7(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) '@aws-sdk/client-s3': 3.1113.0 - '@rivet-dev/agentos-runtime-core': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-sidecar': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-runtime-core': 0.2.16-rc.3 + '@rivet-dev/agentos-sidecar': 0.2.16-rc.3 '@rivetkit/bare-ts': 0.6.2 '@xterm/headless': 6.0.0 better-sqlite3: 12.11.1 croner: 10.0.1 googleapis: 144.0.0 - isolated-vm: 6.2.0 long-timeout: 0.1.1 minimatch: 10.2.6 zod: 4.4.3 @@ -4563,51 +4648,47 @@ snapshots: - utf-8-validate - ws - '@rivet-dev/agentos-runtime-core@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-runtime-core@0.2.16-rc.3': dependencies: - '@rivet-dev/agentos-runtime-sidecar': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-runtime-sidecar': 0.2.16-rc.3 '@rivetkit/bare-ts': 0.6.2 zod: 4.4.3 - '@rivet-dev/agentos-runtime-sidecar-darwin-arm64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': - optional: true - - '@rivet-dev/agentos-runtime-sidecar-darwin-x64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-runtime-sidecar-darwin-arm64@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-runtime-sidecar-darwin-x64@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-runtime-sidecar-linux-x64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-runtime-sidecar@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-runtime-sidecar@0.2.16-rc.3': optionalDependencies: - '@rivet-dev/agentos-runtime-sidecar-darwin-arm64': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-runtime-sidecar-darwin-x64': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-runtime-sidecar-linux-x64-gnu': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-runtime-sidecar-darwin-arm64': 0.2.16-rc.3 + '@rivet-dev/agentos-runtime-sidecar-darwin-x64': 0.2.16-rc.3 + '@rivet-dev/agentos-runtime-sidecar-linux-arm64-gnu': 0.2.16-rc.3 - '@rivet-dev/agentos-sidecar-darwin-arm64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-sidecar-darwin-arm64@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-sidecar-darwin-x64@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-sidecar-darwin-x64@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-sidecar-linux-arm64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-sidecar-linux-arm64-gnu@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-sidecar-linux-x64-gnu@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-sidecar-linux-x64-gnu@0.2.16-rc.3': optional: true - '@rivet-dev/agentos-sidecar@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': + '@rivet-dev/agentos-sidecar@0.2.16-rc.3': optionalDependencies: - '@rivet-dev/agentos-sidecar-darwin-arm64': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-sidecar-darwin-x64': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-sidecar-linux-arm64-gnu': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - '@rivet-dev/agentos-sidecar-linux-x64-gnu': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-sidecar-darwin-arm64': 0.2.16-rc.3 + '@rivet-dev/agentos-sidecar-darwin-x64': 0.2.16-rc.3 + '@rivet-dev/agentos-sidecar-linux-arm64-gnu': 0.2.16-rc.3 + '@rivet-dev/agentos-sidecar-linux-x64-gnu': 0.2.16-rc.3 - '@rivet-dev/agentos-toolchain@0.2.15': + '@rivet-dev/agentos-toolchain@0.2.16-rc.3': dependencies: '@rivetkit/bare-ts': 0.6.2 @@ -5889,10 +5970,6 @@ snapshots: isexe@2.0.0: {} - isolated-vm@6.2.0: - dependencies: - node-gyp-build: 4.8.4 - isomorphic-timers-promises@1.0.1: {} jose@6.2.9: {} @@ -6043,8 +6120,6 @@ snapshots: detect-libc: 2.1.2 optional: true - node-gyp-build@4.8.4: {} - node-stdlib-browser@1.3.1: dependencies: assert: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4ca301ace..32c71d9ba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ onlyBuiltDependencies: - esbuild overrides: - '@rivet-dev/agentos-core': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + '@rivet-dev/agentos-core': 0.2.16-rc.3 '@rivetkit/engine-cli': 2.3.11 '@rivetkit/react': 2.3.11 '@rivetkit/rivetkit-wasm': 2.3.11 diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 6ba0e7bcb..f594d7b56 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -98,8 +98,8 @@ for (const path of await walk("examples/")) { const actors = await read("packages/dynamic-apps/src/actors.ts"); for (const identity of [ - "agentOSAppsApp: AnyActorDefinition;", - "const agentOSAppsApp = actor({", + "dynamicAppsApp: AnyActorDefinition;", + "const dynamicAppsApp = actor({", "beginReleasePublish:", "commitReleasePublish:", ]) { @@ -112,7 +112,7 @@ for (const identity of [ const runtime = await read("packages/dynamic-apps-core/src/runtime.ts"); assert( runtime.includes( - 'hash.update("agentos-apps-release-v19-mounted-hono-router\\0")', + 'hash.update("dynamic-apps-release-v19-mounted-hono-router\\0")', ), "release hash domain changed", ); diff --git a/scripts/resolve-release.mjs b/scripts/resolve-release.mjs index e0b7bd6aa..49a6e74d5 100644 --- a/scripts/resolve-release.mjs +++ b/scripts/resolve-release.mjs @@ -13,7 +13,7 @@ const args = Object.fromEntries( let version = args.version; if (version === "legacy") { const [{ stdout: main }, { stdout: builder }] = await Promise.all([ - execFileAsync("npm", ["view", "@rivet-dev/agentos-apps@latest", "version"]), + execFileAsync("npm", ["view", "@rivet-dev/dynamic-apps@latest", "version"]), execFileAsync("npm", [ "view", "@agentos-software/apps-builder@latest", diff --git a/specs/agentos-inline-runtime-and-logging.md b/specs/agentos-inline-runtime-and-logging.md index b4d6d733b..af8597232 100644 --- a/specs/agentos-inline-runtime-and-logging.md +++ b/specs/agentos-inline-runtime-and-logging.md @@ -11,7 +11,7 @@ through the agentOS library's headless JavaScript API in the Compute process; they must not start an execution actor, guest process, HTTP listener, or nested Node server. -Keep the existing deployment implementation and durable `agentOSAppsApp` +Keep the existing deployment implementation and durable `dynamicAppsApp` state actor. It remains responsible for building releases in an agentOS build VM, persisting release artifacts in actor SQLite, activation, rollback, and release invalidation events. @@ -21,7 +21,7 @@ application, actor, build, and runtime logs to stdout or its logging provider. ```text deployApp - -> agentOSAppsApp[appId] + -> dynamicAppsApp[appId] -> existing agentOS build VM -> immutable AOSP artifact in actor SQLite @@ -42,15 +42,17 @@ warm direct request app-defined actor request -> Rivet gateway -> authenticated Dynamic Apps callback - -> existing bounded Node worker - -> real RivetKit WASM registry + -> cached AgentOS VM and guest process + -> real RivetKit WASM serverless registry + -> agentOS native VM HTTP stream ``` -The app-defined actor worker remains in this revision. It does not use -`isolated-vm`, and its streaming response protocol is required for RivetKit -connections and events. Replacing that worker with one-shot agentOS -`evaluate()` would regress streaming semantics. The worker must, however, -forward stdout and stderr through the new log hook. +App-defined actors use agentOS as the mandatory hostile-code boundary. The app +listens on the supplied `PORT`, and Dynamic Apps forwards the real response head +and chunks with agentOS's native VM HTTP stream. The init packet is not mocked: +Engine uses its encoded runner ID and protocol version. Actor traffic travels +over RivetKit's outbound WebSocket; the SSE stream carries init, keepalive, and +connection lifetime only. ## Non-goals @@ -284,11 +286,11 @@ The direct release must be a Node-targeted ESM bundle, not a browser IIFE. - Continue rejecting native `.node` addons. - Delete the Dynamic Apps RivetKit stub entirely. - For an app importing RivetKit, build the direct bundle with the real RivetKit - runtime. The app mounts `registry.handler()` in its exported fetch router and - does not call `registry.start()` or `serve()`; actor startup remains - host-managed. -- Keep the separate platform-linked actor bundle used by the existing bounded - actor worker. + runtime. The app mounts `registry.handler()` in its exported fetch router. In + serverless mode it calls `serve()` on `PORT` and awaits the listening + callback; Dynamic Apps starts the actor entrypoint as a normal Node program. +- Build the separate actor entrypoint with RivetKit and its WASM asset bundled + for execution inside AgentOS. - Continue validating both bundles before activating a release. The direct dispatcher should be an exported async function in @@ -312,10 +314,11 @@ Include `appId`, release, request ID, and stream. ### App-defined actors -Create actor workers with `stdout: true` and `stderr: true`, consume both Node -streams, and use the same bounded line decoder. Add `appId` and release to -`ActorRuntimeRequest` so output can be attributed. Emit with -`source: "actor"`. Keep worker transport errors as runtime error events. +Consume the AgentOS guest process stdout and stderr with the same bounded line +decoder. There is no Dynamic Apps actor RPC framing: HTTP requests and response +bodies use agentOS's native VM HTTP stream. Add `appId` and release to +`ActorRuntimeRequest` so output can be attributed. Emit application output with +`source: "actor"` and transport failures as runtime errors. ### Build and host runtime @@ -372,7 +375,7 @@ The file must contain no import from `isolated-vm`, no custom `Request` or Implement the public event types, the process-global handler setter, frozen event construction, message/metadata bounds, handler-error isolation, and the -incremental UTF-8 line decoder shared by direct executions and actor workers. +incremental UTF-8 line decoder shared by direct executions and actor guests. #### `packages/dynamic-apps/src/index.ts` @@ -381,12 +384,15 @@ alongside the unchanged `appsRouter` and `deployApp` exports. #### `packages/dynamic-apps/src/actor-runtime.ts` -- retain the bounded Node worker and real RivetKit WASM runtime; +- mount the verified release in AgentOS and start the bundled RivetKit WASM + serverless entrypoint as a guest process; - add `appId` and release attribution to `ActorRuntimeRequest`; -- opt into worker stdout/stderr streams and forward them through the shared - line decoder and log emitter; -- emit bounded worker startup, exit, timeout, and transport errors; and -- dispose stream listeners with each worker entry. +- wait for the guest entrypoint to report that its HTTP listening callback + completed, then forward every callback route through agentOS's native VM HTTP + stream; preserve the real `/start` SSE stream, cancellation, and backpressure; +- treat the retained-runtime count as an idle-cache target rather than callback + admission, with no Dynamic Apps concurrency queue; and +- dispose the AgentOS VM and mounted artifact with each runtime entry. #### `packages/dynamic-apps/src/actors.ts` @@ -432,8 +438,7 @@ native artifacts disappear from the lockfile. - add an explicit `directAgentOs` build mode using `platform: "node"`, ESM, and the existing bounded filesystem plugin; - bundle real RivetKit plus its WASM asset for actor-enabled direct bundles; -- continue keeping RivetKit external only for the platform-linked actor bundle; - and +- bundle RivetKit WASM in both direct and actor AgentOS bundles; and - retain native-addon rejection and output limits. #### `packages/dynamic-apps-builder/test/builder.test.ts` @@ -485,8 +490,8 @@ network permission boundary. Keep native addons unsupported. Add a brief public page titled **Collecting logs**: -1. Explain that application `console.log`/stdout, `console.error`/stderr, actor - worker output, and host runtime summaries become structured events. +1. Explain that application `console.log`/stdout, `console.error`/stderr, + sandboxed actor output, and host runtime summaries become structured events. 2. Show `setDynamicAppsLogHandler((event) => process.stdout.write(JSON.stringify(event) + "\\n"))` as the recommended Cloud Run/Rivet Compute aggregation path. diff --git a/specs/dynamic-apps-core.md b/specs/dynamic-apps-core.md index 5d239ab0f..256afacb9 100644 --- a/specs/dynamic-apps-core.md +++ b/specs/dynamic-apps-core.md @@ -141,7 +141,7 @@ export type DeployAppInput = }); export interface ReleaseArtifact { - format: "agentos-apps-direct-v2"; + format: "dynamic-apps-direct-v2"; entrypoint: "direct-v2/main.mjs"; hash: string; bytes: Uint8Array; @@ -528,7 +528,7 @@ Implement `createDynamicApps` with no module-level mutable state: 8. `diagnostics()` delegates to the executor and adds no credentials or artifact contents. 9. `dispose()` is idempotent and delegates to the executor. After disposal, - deploy and request operations fail with `agentos_apps_executor_disposed`. + deploy and request operations fail with `dynamic_apps_executor_disposed`. The factory keeps an instance-local disposed flag and set of in-flight deploy promises. Check the flag before source preparation and again after build but @@ -673,9 +673,9 @@ export function createAppsRouter( ``` Move the existing handler body unchanged except for calling the injected -executor. Remove `PRIVATE_REGISTRY_SENTINEL`, `handlePrivateAppsRegistry`, -`requestOverride`, `setRouterRequestOverride`, and the custom `router.fetch` -override. Those belong only to the Rivet wrapper. +executor. Remove private-registry dispatch, `handlePrivateAppsRegistry`, +`requestOverride`, `setRouterRequestOverride`, and any custom `router.fetch` +override. Registry routing belongs only to the Rivet wrapper. Unit tests inject an `AppRequestExecutor`; do not add a new global test seam. @@ -874,7 +874,7 @@ Do not cache artifacts in `release-store.ts`; core owns caching. ### Actor persistence protocol -Keep `agentOSAppsApp` and all current tables. Extend `AppState` with optional +Keep `dynamicAppsApp` and all current tables. Extend `AppState` with optional fields so old serialized state needs no migration: ```ts @@ -931,7 +931,7 @@ after a newer `beginReleasePublish` has superseded it. Under the existing per-actor `serialized` lock: 1. Require `sequence === latestPublishSequence`. Otherwise throw - `agentos_apps_publish_superseded` and keep the current active release. + `dynamic_apps_publish_superseded` and keep the current active release. 2. Load the building/ready row and verify expected chunk count and total bytes. 3. Stream/read all stored chunks in order and compute SHA-256; compare it to the expected hash. Do not mark ready on mismatch. @@ -976,7 +976,7 @@ The top-level adapter `deployApp` behaves as follows: - With no injected `options.client`, delegate to the default core instance so the build occurs before the release-store upload. - With `options.client`, retain the current `prepareSource` plus - `client.agentOSAppsApp.get/getOrCreate(...).deploy(preparedInput)` path exactly. + `client.dynamicAppsApp.get/getOrCreate(...).deploy(preparedInput)` path exactly. This preserves existing injected-client behavior while making the ordinary path use the new core architecture. Add a code comment explaining why the two @@ -1007,11 +1007,16 @@ The core router does not know about the private Rivet registry. In adapter `router.ts`: 1. Obtain `defaultDynamicApps.appsRouter`. -2. Preserve the existing custom `fetch` behavior for the private sentinel - header and `/api/rivet` paths. +2. Register an ordinary `/api/rivet/*` Hono route that forwards to the private + registry handler only when the raw request path is rooted at `/api/rivet/`. + This keeps mounting the router at `/apps` from intercepting an app named + `api` whose own path begins with `/rivet/`. 3. Delegate all ordinary app traffic to the core router. 4. Export the wrapped value as `appsRouter` with the current exact Hono type. +Do not add a bare `/api/rivet` route, a sentinel header, or a custom `fetch` +override. + Do not restore `setRouterRequestOverride`; adapter tests should instantiate a core factory with fake hooks or exercise the wrapped router directly. diff --git a/tests/e2e/dynamic-apps/package.json b/tests/e2e/dynamic-apps/package.json index 6eb28d8e3..eac6ce406 100644 --- a/tests/e2e/dynamic-apps/package.json +++ b/tests/e2e/dynamic-apps/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "test:e2e": "node --import tsx src/run.ts", - "test:e2e:build": "node --import tsx src/run.ts --build-only", - "test:e2e:fast": "node --import tsx src/run.ts --fast", + "test:e2e": "npx tsx src/run.ts", + "test:e2e:build": "npx tsx src/run.ts --build-only", + "test:e2e:fast": "npx tsx src/run.ts --fast", "check-types": "tsc --noEmit" }, "dependencies": { @@ -15,7 +15,7 @@ "rivetkit": "2.3.11" }, "devDependencies": { - "@rivet-dev/agentos-toolchain": "0.2.15", + "@rivet-dev/agentos-toolchain": "0.2.16-rc.3", "@rivetkit/engine-cli": "2.3.11", "@types/node": "^22.19.15", "get-port": "^7.1.0", diff --git a/tests/e2e/dynamic-apps/src/run.ts b/tests/e2e/dynamic-apps/src/run.ts index 1505371b3..cb1449a4f 100644 --- a/tests/e2e/dynamic-apps/src/run.ts +++ b/tests/e2e/dynamic-apps/src/run.ts @@ -8,10 +8,14 @@ import getPort from "get-port"; const fast = process.argv.includes("--fast"); const buildOnly = process.argv.includes("--build-only"); const sanity = process.argv.includes("--sanity"); -const root = await mkdtemp(join(tmpdir(), "agentos-apps-e2e-")); +const root = await mkdtemp(join(tmpdir(), "dynamic-apps-e2e-")); const databasePath = join(root, "db"); await mkdir(databasePath, { recursive: true }); -const guardPort = await getPort(); +const configuredGuardPort = Number(process.env.DYNAMIC_APPS_E2E_ENGINE_PORT); +const guardPort = + Number.isInteger(configuredGuardPort) && configuredGuardPort > 0 + ? configuredGuardPort + : await getPort(); const peerPort = await getPort({ exclude: [guardPort] }); const metricsPort = await getPort({ exclude: [guardPort, peerPort] }); const endpoint = `http://127.0.0.1:${guardPort}`; @@ -48,15 +52,15 @@ const engine = spawn(getEnginePath(), ["--config", configPath, "start"], { try { await waitUntilHealthy(endpoint, engine); process.env.RIVET_ENDPOINT = endpoint; - process.env.AGENTOS_APPS_E2E_FAST = fast ? "1" : "0"; - process.env.AGENTOS_APPS_E2E_BUILD_ONLY = buildOnly ? "1" : "0"; + process.env.DYNAMIC_APPS_E2E_FAST = fast ? "1" : "0"; + process.env.DYNAMIC_APPS_E2E_BUILD_ONLY = buildOnly ? "1" : "0"; if (fast) { - process.env.AGENTOS_APPS_E2E_ARTIFACT_CACHE = join( + process.env.DYNAMIC_APPS_E2E_ARTIFACT_CACHE = join( tmpdir(), - "agentos-apps-artifact-cache-v16", + "dynamic-apps-artifact-cache-v1", ); } else { - delete process.env.AGENTOS_APPS_E2E_ARTIFACT_CACHE; + delete process.env.DYNAMIC_APPS_E2E_ARTIFACT_CACHE; } delete process.env.RIVET_ENGINE; delete process.env.RIVET_RUN_ENGINE; diff --git a/tests/e2e/dynamic-apps/src/sanity.ts b/tests/e2e/dynamic-apps/src/sanity.ts index fcba20eb8..1e3ef8172 100644 --- a/tests/e2e/dynamic-apps/src/sanity.ts +++ b/tests/e2e/dynamic-apps/src/sanity.ts @@ -1,7 +1,17 @@ -import { appsRouter, deployApp } from "@rivet-dev/dynamic-apps"; +import { + appsRouter, + deployApp, + setDynamicAppsLogHandler, +} from "@rivet-dev/dynamic-apps"; import { createClient } from "rivetkit/client"; -const appId = `dynamic-apps-sanity-${Date.now()}`; +setDynamicAppsLogHandler((event) => { + console.error( + `[dynamic-apps:${event.source}:${event.stream ?? event.level}] ${event.message}`, + ); +}); + +const appId = "dynamic-apps-sanity"; const deployment = await deployApp({ appId, files: { @@ -11,10 +21,15 @@ const deployment = await deployApp({ private: true, type: "module", main: "index.js", - dependencies: { hono: "4.13.3", rivetkit: "2.3.11" }, + dependencies: { + "@hono/node-server": "2.1.1", + hono: "4.13.3", + rivetkit: "2.3.11", + }, }), "index.js": ` import { Hono } from "hono"; +import { serve } from "@hono/node-server"; import { actor, setup } from "rivetkit"; import { db } from "rivetkit/db"; const counter = actor({ @@ -33,6 +48,15 @@ const registry = setup({ use: { counter } }); const app = new Hono(); app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); app.all("*", () => Response.json({ ok: true, path: "direct" })); +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: Number(process.env.PORT), hostname: "0.0.0.0" }, + resolve, + ); + server.once("error", reject); + }); +} export default app; `, }, @@ -63,6 +87,8 @@ console.log( event: "dynamic_apps_sanity_passed", deployment, direct: true, + health: true, + metadata: true, sqlite: [first, second], }, null, diff --git a/tests/e2e/dynamic-apps/src/verify.ts b/tests/e2e/dynamic-apps/src/verify.ts index a38a229cc..8ceb0b62e 100644 --- a/tests/e2e/dynamic-apps/src/verify.ts +++ b/tests/e2e/dynamic-apps/src/verify.ts @@ -149,12 +149,14 @@ async function deployActorFixture() { type: "module", main: "index.js", dependencies: { + "@hono/node-server": "2.1.1", hono: "4.13.3", rivetkit: "2.3.11", }, }), "index.js": ` import { Hono } from "hono"; +import { serve } from "@hono/node-server"; import { actor, event, setup } from "rivetkit"; const counter = actor({ @@ -176,6 +178,15 @@ const registry = setup({ use: { counter } }); const app = new Hono(); app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); app.all("*", () => Response.json({ ok: true, workload: "actor-and-direct-http" })); +if (process.env.RIVETKIT_RUNTIME_MODE === "serverless") { + await new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: Number(process.env.PORT), hostname: "0.0.0.0" }, + resolve, + ); + server.once("error", reject); + }); +} export default app; `, },