From 06f1c14bcfdf87e89cbe6a772716376f559e2169 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 31 Aug 2026 00:28:07 -0700 Subject: [PATCH] feat: run dynamic apps with agentOS inline execution --- Dockerfile | 2 +- benchmarks/dynamic-apps/RESULTS.md | 27 +- benchmarks/dynamic-apps/package.json | 4 +- benchmarks/dynamic-apps/src/cloud-stress.ts | 30 +- benchmarks/dynamic-apps/src/edge.ts | 54 +- benchmarks/dynamic-apps/src/fixture.ts | 9 +- benchmarks/dynamic-apps/src/load.ts | 3 - benchmarks/dynamic-apps/src/runtime-stress.ts | 101 +- benchmarks/dynamic-apps/src/suite.ts | 71 +- docs/content/docs/deploy.mdx | 6 +- docs/content/docs/index.mdx | 24 +- docs/content/docs/logging.mdx | 37 + docs/content/docs/quickstart.mdx | 8 +- docs/content/docs/state-and-data.mdx | 4 +- docs/sidebar.json | 4 + examples/apps-ai-builder/package.json | 2 +- examples/apps-hello-world/package.json | 2 +- .../dynamic-apps-builder/cli/apps-builder.mjs | 79 +- packages/dynamic-apps-builder/package.json | 4 +- .../dynamic-apps-builder/test/builder.test.ts | 207 +++- packages/dynamic-apps/API_CONTRACT.md | 59 +- packages/dynamic-apps/README.md | 89 +- packages/dynamic-apps/package.json | 8 +- packages/dynamic-apps/src/actor-runtime.ts | 88 +- packages/dynamic-apps/src/actors.ts | 67 +- packages/dynamic-apps/src/executor.ts | 1014 ++++++++--------- packages/dynamic-apps/src/index.ts | 7 + packages/dynamic-apps/src/logging.ts | 144 +++ packages/dynamic-apps/src/memory.ts | 4 +- packages/dynamic-apps/src/router.ts | 21 +- packages/dynamic-apps/src/runtime.ts | 34 +- .../tests/agentos-inline-spike.test.ts | 123 ++ packages/dynamic-apps/tests/direct.test.ts | 244 +++- packages/dynamic-apps/tests/logging.test.ts | 90 ++ pnpm-lock.yaml | 415 ++----- pnpm-workspace.yaml | 8 +- scripts/check-boundaries.mjs | 26 +- scripts/test-packed.mjs | 35 +- specs/direct-isolate-runtime.md | 423 +------ tests/e2e/dynamic-apps/package.json | 4 +- tests/e2e/dynamic-apps/src/verify.ts | 2 +- 41 files changed, 1923 insertions(+), 1660 deletions(-) create mode 100644 docs/content/docs/logging.mdx create mode 100644 packages/dynamic-apps/src/logging.ts create mode 100644 packages/dynamic-apps/tests/agentos-inline-spike.test.ts create mode 100644 packages/dynamic-apps/tests/logging.test.ts diff --git a/Dockerfile b/Dockerfile index fa83f21e2..7d26a63b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN pnpm build EXPOSE 3000 -CMD ["node", "--no-node-snapshot", "--import", "tsx", "benchmarks/dynamic-apps/src/server.ts", "--host", "0.0.0.0"] +CMD ["node", "--import", "tsx", "benchmarks/dynamic-apps/src/server.ts", "--host", "0.0.0.0"] diff --git a/benchmarks/dynamic-apps/RESULTS.md b/benchmarks/dynamic-apps/RESULTS.md index 0f9d1b6dd..61c5b2001 100644 --- a/benchmarks/dynamic-apps/RESULTS.md +++ b/benchmarks/dynamic-apps/RESULTS.md @@ -1,4 +1,19 @@ -# Direct-isolate Dynamic Apps benchmark +# agentOS inline Dynamic Apps benchmark + +Status: local correctness implemented; deployed performance qualification is +pending. + +The active architecture keeps the durable per-app Rivet actor for deployment, +then caches one agentOS VM per immutable release and executes the exported ESM +dispatcher through headless JavaScript evaluation. `pooled` mode resets and +reinitializes retained contexts; `ephemeral` mode requests a fresh context from +the cached VM. + +No agentOS inline latency numbers are published yet. In particular, the old +guest-server and native-isolate results below must not be reused as inline +evaluation latency or as evidence that the new performance gates pass. + +## Historical comparison: direct-isolate runtime Qualified 2026-08-26 (US/Pacific). The rewritten architecture keeps the durable per-app Rivet actor for releases and invalidation, but removes scaler @@ -26,7 +41,7 @@ direct isolate pool 2, actor-worker cache 4, and actor-worker heap limit 96 MiB. Every Cloud mutation targeted only `dynamic-apps-ben-562e-production-sqac`. -## Runtime hardening qualification (2026-08-30) +## Historical runtime hardening qualification (2026-08-30) The local stress suite now exercises multi-app cache churn, large payload bursts, release invalidation, cold-cache fan-out, queue overflow, oversized @@ -75,7 +90,7 @@ from the same release. Its warm actor action cases were 100% successful at 23.42 ms sequential p50 and 61.27 ms concurrency-16 p50; those numbers include the local Engine path, unlike the lower-level worker timing above. -## Decision +## Historical decision **Use the direct local-isolate path for ordinary request/response. Keep Rivet actors for durable state and app-defined actor semantics.** @@ -92,7 +107,7 @@ Public Rivet Run ingress from this client is roughly 190โ€“220 ms at low load, s outer latency hides the architectural difference. Server timing headers are the relevant comparison. -## Final Rivet Compute direct results +## Historical Rivet Compute direct results The workload is a zero-dependency JSON `fetch()` handler. Initialization and warm-up requests are excluded from the steady samples. @@ -141,7 +156,7 @@ The small pool deliberately trades burst-tail CPU for bounded idle memory. Even with 21.23% overflow creation at concurrency 32, the stated server target (p50 <= 25 ms, p95 <= 50 ms) passed and memory returned to its steady bound. -## App-defined actor results +## Historical app-defined actor results The actor fixture uses ordinary RivetKit state, actions, an event subscription, and a direct HTTP handler in the same deployment. Correctness passed locally @@ -164,7 +179,7 @@ about 5.5x the complete fresh-isolate server p50. Public ingress adds another roughly 225 ms at the median. This confirms that the earlier actor-heavy design was benchmarking network topology rather than a useful V8 warm-cache advantage. -## Local direct baseline +## Historical local direct baseline The same direct executor on a local Engine completed a 10,000-request pool-2, concurrency-2 run at 100% success: diff --git a/benchmarks/dynamic-apps/package.json b/benchmarks/dynamic-apps/package.json index 3439dad3c..79718ca16 100644 --- a/benchmarks/dynamic-apps/package.json +++ b/benchmarks/dynamic-apps/package.json @@ -20,10 +20,10 @@ "@rivet-dev/agentos-toolchain": "0.2.15", "@rivet-dev/dynamic-apps": "workspace:*", "hono": "^4.12.9", - "rivetkit": "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9" + "rivetkit": "2.3.11" }, "devDependencies": { - "@rivetkit/engine-cli": "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9", + "@rivetkit/engine-cli": "2.3.11", "@types/node": "^22.19.15", "get-port": "^7.1.0", "tsx": "^4.20.6", diff --git a/benchmarks/dynamic-apps/src/cloud-stress.ts b/benchmarks/dynamic-apps/src/cloud-stress.ts index 1aacf8521..f4ee8576b 100644 --- a/benchmarks/dynamic-apps/src/cloud-stress.ts +++ b/benchmarks/dynamic-apps/src/cloud-stress.ts @@ -9,7 +9,7 @@ import { type StressMode = "ramp" | "soak" | "both"; interface StressCase { - name: "warm" | "snapshot" | "actor"; + name: "pooled" | "ephemeral" | "actor"; path: string; concurrency: number; echoRequestId: boolean; @@ -103,10 +103,10 @@ export async function runCloudStress( try { if (config.mode === "ramp" || config.mode === "both") { result.ramps.push( - await runRamp(config, "warm", (concurrency) => [ + await runRamp(config, "pooled", (concurrency) => [ { - name: "warm", - path: "/bench/warm", + name: "pooled", + path: "/bench/pooled", concurrency, echoRequestId: true, }, @@ -271,20 +271,20 @@ async function runStage( } function mixedCases(concurrency: number): StressCase[] { - const warm = Math.max(1, Math.floor(concurrency * 0.8)); - const snapshot = Math.max(1, Math.floor(concurrency * 0.1)); - const actor = Math.max(1, concurrency - warm - snapshot); + const pooled = Math.max(1, Math.floor(concurrency * 0.8)); + const ephemeral = Math.max(1, Math.floor(concurrency * 0.1)); + const actor = Math.max(1, concurrency - pooled - ephemeral); return [ { - name: "warm", - path: "/bench/warm", - concurrency: warm, + name: "pooled", + path: "/bench/pooled", + concurrency: pooled, echoRequestId: true, }, { - name: "snapshot", - path: "/bench/snapshot", - concurrency: snapshot, + name: "ephemeral", + path: "/bench/ephemeral", + concurrency: ephemeral, echoRequestId: true, }, { @@ -357,7 +357,7 @@ async function setup(baseUrl: string): Promise { ); for (let index = 0; index < 10; index += 1) { const response = await fetch( - `${baseUrl}/bench/warm?requestId=warmup-${index}`, + `${baseUrl}/bench/pooled?requestId=warmup-${index}`, { signal: AbortSignal.timeout(60_000), }, @@ -372,7 +372,7 @@ async function setup(baseUrl: string): Promise { body.requestId !== `warmup-${index}` ) { throw new Error( - `direct warmup ${index} failed with HTTP ${response.status}`, + `direct pooled warmup ${index} failed with HTTP ${response.status}`, ); } } diff --git a/benchmarks/dynamic-apps/src/edge.ts b/benchmarks/dynamic-apps/src/edge.ts index 3e2dd8edd..0db83f9d6 100644 --- a/benchmarks/dynamic-apps/src/edge.ts +++ b/benchmarks/dynamic-apps/src/edge.ts @@ -66,20 +66,15 @@ export function createBenchmarkApplication(): Hono { ...process.env, DYNAMIC_APPS_TIMING_HEADERS: "1", }); - const warm = new DynamicAppsExecutor({ + const pooled = new DynamicAppsExecutor({ ...baseConfig, - isolateMode: "prewarm", - isolatePoolSize: integerEnv("BENCH_WARM_POOL_SIZE", 8, 1, 128), + executionMode: "pooled", + contextPoolSize: integerEnv("BENCH_CONTEXT_POOL_SIZE", 8, 1, 128), }); - const snapshot = new DynamicAppsExecutor({ + const ephemeral = new DynamicAppsExecutor({ ...baseConfig, - isolateMode: "snapshot", - isolatePoolSize: 0, - }); - const fresh = new DynamicAppsExecutor({ - ...baseConfig, - isolateMode: "fresh", - isolatePoolSize: 0, + executionMode: "ephemeral", + contextPoolSize: 0, }); const client = createClient() as unknown as StateClient & BenchmarkDeploymentClient; @@ -126,7 +121,7 @@ export function createBenchmarkApplication(): Hono { const direct = ( executor: DynamicAppsExecutor, prefix: string, - architecture: "warm" | "snapshot" | "fresh", + architecture: "pooled" | "ephemeral", request: Request, ) => { const url = new URL(request.url); @@ -138,21 +133,17 @@ export function createBenchmarkApplication(): Hono { return response; }); }; - app.all("/bench/warm", (c) => direct(warm, "/bench/warm", "warm", c.req.raw)); - app.all("/bench/warm/*", (c) => - direct(warm, "/bench/warm", "warm", c.req.raw), - ); - app.all("/bench/fresh", (c) => - direct(fresh, "/bench/fresh", "fresh", c.req.raw), + app.all("/bench/pooled", (c) => + direct(pooled, "/bench/pooled", "pooled", c.req.raw), ); - app.all("/bench/fresh/*", (c) => - direct(fresh, "/bench/fresh", "fresh", c.req.raw), + app.all("/bench/pooled/*", (c) => + direct(pooled, "/bench/pooled", "pooled", c.req.raw), ); - app.all("/bench/snapshot", (c) => - direct(snapshot, "/bench/snapshot", "snapshot", c.req.raw), + app.all("/bench/ephemeral", (c) => + direct(ephemeral, "/bench/ephemeral", "ephemeral", c.req.raw), ); - app.all("/bench/snapshot/*", (c) => - direct(snapshot, "/bench/snapshot", "snapshot", c.req.raw), + app.all("/bench/ephemeral/*", (c) => + direct(ephemeral, "/bench/ephemeral", "ephemeral", c.req.raw), ); app.all("/bench/actor/resolve", async () => { @@ -229,7 +220,7 @@ export function createBenchmarkApplication(): Hono { const second = await counter.add(3); const current = await counter.inspect(); await counter.dispose(); - const directResponse = await warm.request( + const directResponse = await pooled.request( ACTOR_BENCHMARK_APP_ID, new Request("http://dynamic-app.test/"), ); @@ -280,16 +271,14 @@ export function createBenchmarkApplication(): Hono { memory: process.memoryUsage(), }, cpuParallelism: availableParallelism(), - warm: warm.diagnostics(), - snapshot: snapshot.diagnostics(), - fresh: fresh.diagnostics(), + pooled: pooled.diagnostics(), + ephemeral: ephemeral.diagnostics(), paths: [ "/bench/noop", "/bench/actor/resolve", "/bench/actor/action", - "/bench/warm", - "/bench/snapshot", - "/bench/fresh", + "/bench/pooled", + "/bench/ephemeral", "POST /bench/actor-app/setup", "POST /bench/actor-app/verify", "/bench/actor-app/action", @@ -386,8 +375,7 @@ export function actorApplicationClientConfig( poolName: deployment.pool, ...(deployment.token || endpointToken || process.env.RIVET_TOKEN ? { - token: - deployment.token ?? endpointToken ?? process.env.RIVET_TOKEN, + token: deployment.token ?? endpointToken ?? process.env.RIVET_TOKEN, } : {}), }; diff --git a/benchmarks/dynamic-apps/src/fixture.ts b/benchmarks/dynamic-apps/src/fixture.ts index 423b641f9..cf209ac8b 100644 --- a/benchmarks/dynamic-apps/src/fixture.ts +++ b/benchmarks/dynamic-apps/src/fixture.ts @@ -23,13 +23,20 @@ export async function deployBenchmarkFixture( main: "index.js", }), "index.js": ` +import { basename } from "node:path"; + export default { fetch(request) { const url = new URL(request.url); + if (url.searchParams.get("logs") === "1") { + console.log("benchmark stdout"); + console.error("benchmark stderr"); + } return Response.json({ ok: true, workload: "basic-request-response", requestId: url.searchParams.get("requestId"), + node: { platform: process.platform, file: basename(import.meta.filename) }, }); }, }; @@ -61,7 +68,7 @@ export async function deployActorBenchmarkFixture( type: "module", main: "index.js", dependencies: { - rivetkit: "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9", + rivetkit: "2.3.11", }, }), "index.js": ` diff --git a/benchmarks/dynamic-apps/src/load.ts b/benchmarks/dynamic-apps/src/load.ts index f2376deb4..d2a0c9eab 100644 --- a/benchmarks/dynamic-apps/src/load.ts +++ b/benchmarks/dynamic-apps/src/load.ts @@ -215,9 +215,6 @@ export async function runLoadTest( } for (const [phase, header] of Object.entries({ bundleLoad: "x-agentos-app-bundle-load-ms", - isolateStart: "x-agentos-app-isolate-start-ms", - processReady: "x-agentos-app-process-ready-ms", - dispatch: "x-agentos-app-dispatch-ms", appReleaseLookup: "x-agentos-app-release-lookup-ms", appRequestBody: "x-agentos-app-request-body-ms", appScalerAcquire: "x-agentos-app-scaler-acquire-ms", diff --git a/benchmarks/dynamic-apps/src/runtime-stress.ts b/benchmarks/dynamic-apps/src/runtime-stress.ts index 00df7aa4f..500722abf 100644 --- a/benchmarks/dynamic-apps/src/runtime-stress.ts +++ b/benchmarks/dynamic-apps/src/runtime-stress.ts @@ -12,6 +12,7 @@ import { DynamicAppsExecutor, readExecutorConfig, } from "../../../packages/dynamic-apps/src/executor.js"; +import { setDynamicAppsLogHandler } from "../../../packages/dynamic-apps/src/logging.js"; import { DIRECT_ENTRYPOINT, DIRECT_RUNTIME_FORMAT, @@ -56,6 +57,7 @@ const STRESS_CASES = [ "actorHandlerStall", "actorShutdown", "directShutdown", + "logFlood", "actorMemory", ] as const; @@ -203,9 +205,9 @@ async function main(): Promise { const directStallArtifact = await createArtifact( "direct-stall", "direct", - `globalThis.__dynamicAppDispatch = async function() { + `export async function dispatch() { return new Promise(() => {}); -};`, +}`, ); const actorHandlerStallArtifact = await createActorArtifact( "actor-handler-stall", @@ -300,6 +302,9 @@ async function main(): Promise { await runStressCase(result, selectedCases, "directShutdown", () => directShutdownStress(artifacts[0] as Artifact, concurrency), ); + await runStressCase(result, selectedCases, "logFlood", () => + logFloodStress(artifacts[0] as Artifact), + ); await runStressCase(result, selectedCases, "actorMemory", () => actorMemoryStress(actorMemoryArtifact, concurrency, actorMemoryBytes), ); @@ -406,10 +411,10 @@ async function multiAppStress( const diagnostics = executor.diagnostics() as Record; assert.equal(diagnostics.activeEvaluations, 0); assert.equal(diagnostics.queuedEvaluations, 0); - assert.equal(diagnostics.inUseIsolates, 0); + assert.equal(diagnostics.inUseContexts, 0); assert.equal(diagnostics.contextResetFailures, 0); - assert(diagnostics.cleanIsolates <= poolMaxTotal); - assert(diagnostics.pooledIsolates <= poolMaxTotal); + assert(diagnostics.cleanContexts <= poolMaxTotal); + assert(diagnostics.pooledContexts <= poolMaxTotal); return { elapsedMs: round(performance.now() - startedAt), requestsPerSecond: round( @@ -464,7 +469,7 @@ async function payloadBurstStress( const diagnostics = executor.diagnostics() as Record; assert.equal(diagnostics.activeEvaluations, 0); assert.equal(diagnostics.queuedEvaluations, 0); - assert.equal(diagnostics.inUseIsolates, 0); + assert.equal(diagnostics.inUseContexts, 0); assert.equal(diagnostics.contextResetFailures, 0); return { elapsedMs: round(performance.now() - startedAt), @@ -868,6 +873,60 @@ async function directStallStress( } } +async function logFloodStress(artifact: Artifact): Promise { + const plane = new FakeStatePlane(); + plane.set("log-flood", artifact); + const executor = new DynamicAppsExecutor( + executorConfig({ + appEntries: 1, + concurrency: 1, + poolMaxTotal: 1, + poolSize: 1, + }), + plane.client as never, + ); + let delivered = 0; + try { + setDynamicAppsLogHandler(() => { + delivered += 1; + }); + const flood = await executor.request( + "log-flood", + new Request("http://stress.test/logs?logLines=1000"), + ); + assert.equal(flood.status, 200); + assert(delivered >= 2_000); + + setDynamicAppsLogHandler(() => { + throw new Error("intentional stress log handler failure"); + }); + const throwing = await executor.request( + "log-flood", + new Request("http://stress.test/logs?logLines=1"), + ); + assert.equal(throwing.status, 200); + + setDynamicAppsLogHandler(() => { + const deadline = performance.now() + 1; + while (performance.now() < deadline) {} + }); + const slowStartedAt = performance.now(); + const slow = await executor.request( + "log-flood", + new Request("http://stress.test/logs?logLines=2"), + ); + assert.equal(slow.status, 200); + return { + delivered, + slowHandlerElapsedMs: round(performance.now() - slowStartedAt), + diagnostics: executor.diagnostics(), + }; + } finally { + setDynamicAppsLogHandler(undefined); + await executor.dispose(); + } +} + async function actorAdmissionStress( artifact: Artifact, concurrency: number, @@ -1073,7 +1132,7 @@ async function directShutdownStress( const settled = await Promise.all(outcomes); assert(settled.every((value) => value === "rejected")); assert.equal(executor.diagnostics().runtimes, 0); - assert.equal(executor.diagnostics().pooledIsolates, 0); + assert.equal(executor.diagnostics().pooledContexts, 0); return { requests, stateCalls: plane.calls, @@ -1139,10 +1198,10 @@ function executorConfig(input: { poolSize?: number; }) { return readExecutorConfig({ - DYNAMIC_APPS_ISOLATE_MODE: "prewarm", - DYNAMIC_APPS_ISOLATE_POOL_SIZE: String(input.poolSize ?? 2), - DYNAMIC_APPS_ISOLATE_POOL_MAX_TOTAL: String(input.poolMaxTotal), - DYNAMIC_APPS_ISOLATE_HEAP_LIMIT_MB: "64", + DYNAMIC_APPS_EXECUTION_MODE: "pooled", + DYNAMIC_APPS_CONTEXT_POOL_SIZE: String(input.poolSize ?? 2), + DYNAMIC_APPS_CONTEXT_POOL_MAX_TOTAL: String(input.poolMaxTotal), + DYNAMIC_APPS_CONTEXT_HEAP_LIMIT_MB: "64", DYNAMIC_APPS_RUNTIME_CACHE_MAX_ENTRIES: String(input.appEntries), DYNAMIC_APPS_RUNTIME_CACHE_MAX_BYTES: String(512 * 1024 * 1024), DYNAMIC_APPS_MEMORY_HIGH_WATER_PERCENT: "95", @@ -1175,28 +1234,32 @@ function resolution(appId: string, state: AppState) { async function createDirectArtifact(marker: string): Promise { const source = ` let counter = 0; -globalThis.__dynamicAppDispatch = async function(inputJson) { - const input = JSON.parse(inputJson); +export async function dispatch(input) { counter += 1; const url = new URL(input.url); + const logLines = Math.max(0, Math.min(10000, Number(url.searchParams.get("logLines") || 0))); + for (let index = 0; index < logLines; index += 1) { + console.log("stdout:" + index); + console.error("stderr:" + index); + } const responseBytes = Math.max(0, Math.min(4194304, Number(url.searchParams.get("responseBytes") || 0))); const requestBody = input.bodyBase64 - ? globalThis.__dynamicAppsBase64Decode(input.bodyBase64) + ? Buffer.from(input.bodyBase64, "base64") : new Uint8Array(); const body = responseBytes > 0 ? new Uint8Array(responseBytes).fill(120) - : new TextEncoder().encode(JSON.stringify({ + : Buffer.from(JSON.stringify({ marker: ${JSON.stringify(marker)}, counter, requestBytes: requestBody.byteLength, })); - return JSON.stringify({ + return { status: 200, statusText: "OK", headers: [["content-type", responseBytes > 0 ? "application/octet-stream" : "application/json"]], - bodyBase64: globalThis.__dynamicAppsBase64Encode(body), - }); -}; + bodyBase64: Buffer.from(body).toString("base64"), + }; +} `; return createArtifact(marker, "direct", source); } diff --git a/benchmarks/dynamic-apps/src/suite.ts b/benchmarks/dynamic-apps/src/suite.ts index fb3e8c440..345ec5e63 100644 --- a/benchmarks/dynamic-apps/src/suite.ts +++ b/benchmarks/dynamic-apps/src/suite.ts @@ -53,7 +53,7 @@ export async function runBenchmarkSuite( const before = await readDiagnostics(normalized); const warmup: Record = {}; const initialization: Record = {}; - for (const architecture of ["warm", "snapshot", "fresh"] as const) { + for (const architecture of ["pooled", "ephemeral"] as const) { if ( definitions.some((definition) => definition.path.startsWith(`/bench/${architecture}`), @@ -126,16 +126,15 @@ function suiteDefinitions(profile: string): CaseDefinition[] { 1, 100_000, ); - const warmRequests = integerEnv("BENCH_WARM_REQUESTS", 80, 1, 100_000); - const warmConcurrentRequests = integerEnv( - "BENCH_WARM_CONCURRENT_REQUESTS", + const pooledRequests = integerEnv("BENCH_POOLED_REQUESTS", 80, 1, 100_000); + const pooledConcurrentRequests = integerEnv( + "BENCH_POOLED_CONCURRENT_REQUESTS", 32, 1, 100_000, ); - const freshRequests = integerEnv("BENCH_FRESH_REQUESTS", 80, 1, 100_000); - const snapshotRequests = integerEnv( - "BENCH_SNAPSHOT_REQUESTS", + const ephemeralRequests = integerEnv( + "BENCH_EPHEMERAL_REQUESTS", 80, 1, 100_000, @@ -153,30 +152,24 @@ function suiteDefinitions(profile: string): CaseDefinition[] { requests: 4, }, { - name: "warmSequential", - path: "/bench/warm", + name: "pooledSequential", + path: "/bench/pooled", concurrency: 1, requests: 4, }, { - name: "snapshotSequential", - path: "/bench/snapshot", + name: "ephemeralSequential", + path: "/bench/ephemeral", concurrency: 1, requests: 4, }, - { - name: "freshSequential", - path: "/bench/fresh", - concurrency: 1, - requests: 2, - }, ]; } if (profile === "stability") { return [ { - name: "warmStability", - path: "/bench/warm", + name: "pooledStability", + path: "/bench/pooled", concurrency: integerEnv("BENCH_STABILITY_CONCURRENCY", 8, 1, 1_000), requests: integerEnv("BENCH_STABILITY_REQUESTS", 10_000, 1, 10_000_000), timeoutMs: 60_000, @@ -200,45 +193,33 @@ function suiteDefinitions(profile: string): CaseDefinition[] { requests: 800, }, { - name: "warmSequential", - path: "/bench/warm", + name: "pooledSequential", + path: "/bench/pooled", concurrency: 1, - requests: warmRequests, + requests: pooledRequests, }, { - name: "warmConcurrent", - path: "/bench/warm", + name: "pooledConcurrent", + path: "/bench/pooled", concurrency: 8, - requests: warmConcurrentRequests, + requests: pooledConcurrentRequests, timeoutMs: 10_000, }, { - name: "snapshotSequential", - path: "/bench/snapshot", - concurrency: 1, - requests: snapshotRequests, - }, - { - name: "snapshotConcurrent", - path: "/bench/snapshot", - concurrency: 8, - requests: Math.min(snapshotRequests, 64), - }, - { - name: "freshSequential", - path: "/bench/fresh", + name: "ephemeralSequential", + path: "/bench/ephemeral", concurrency: 1, - requests: freshRequests, + requests: ephemeralRequests, }, { - name: "freshConcurrent", - path: "/bench/fresh", + name: "ephemeralConcurrent", + path: "/bench/ephemeral", concurrency: 8, - requests: Math.min(freshRequests, 32), + requests: Math.min(ephemeralRequests, 64), }, { - name: "warmConcurrent32", - path: "/bench/warm", + name: "pooledConcurrent32", + path: "/bench/pooled", concurrency: 32, requests: 128, timeoutMs: 60_000, diff --git a/docs/content/docs/deploy.mdx b/docs/content/docs/deploy.mdx index 35517f365..6da9de963 100644 --- a/docs/content/docs/deploy.mdx +++ b/docs/content/docs/deploy.mdx @@ -36,8 +36,10 @@ await deployApp({ ``` The direct entrypoint must default-export a function or an object with -`fetch(request)`. Static-only directories, Node builtins, and native addons are -not supported in this release candidate. +`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. ## Build repair and rollback diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 5f66d460d..788042c5c 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,12 +1,12 @@ --- title: "Dynamic Apps" -description: "Build user-generated HTTP apps and serve them from bounded process-local V8 isolates." +description: "Build user-generated HTTP apps and serve them through bounded agentOS VMs." skill: true --- Dynamic Apps builds user-generated HTTP applications in a sandboxed deployment VM, stores immutable releases in a per-app Rivet actor, and serves ordinary HTTP -from V8 isolates in your own server process. +through agentOS in your own server process. Dynamic Apps is in preview and its API is subject to change. @@ -22,22 +22,22 @@ Deployment and request serving are deliberately separate: ```text deployApp -> per-app state actor -> AgentOS build VM -> immutable AOSP release -first HTTP request -> state actor -> verified artifact -> snapshot/isolate cache -cache-hit HTTP request -> local V8 isolate -> response (zero actor calls) +first HTTP request -> state actor -> verified artifact -> cached agentOS VM +cache-hit HTTP request -> headless JavaScript evaluation -> response (zero actor calls) ``` `appsRouter` executes each request in a clean JavaScript context. The default -mode keeps a small bounded pool of native isolates and restores a clean context -from a cached V8 snapshot after every request. Snapshot-only and fully fresh -isolate modes are also available. +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. 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 in -the direct-isolate path. +connections, while the same application's ordinary HTTP handler still runs +through headless agentOS evaluation. -`isolated-vm` is a V8 isolation primitive, not a complete hostile multi-tenant -sandbox. Run one trust domain per container and rely on container isolation for -mutually untrusted tenants. +agentOS provides the filesystem, process, environment, and network permission +boundary for direct requests. App-defined actor workers share the host process, +so run one trust domain per container for mutually untrusted tenants. diff --git a/docs/content/docs/logging.mdx b/docs/content/docs/logging.mdx new file mode 100644 index 000000000..e7185785a --- /dev/null +++ b/docs/content/docs/logging.mdx @@ -0,0 +1,37 @@ +--- +title: "Collecting logs" +description: "Forward Dynamic Apps output and runtime events to your logging provider." +--- + +Dynamic Apps turns application `console.log` and stdout, application +`console.error` and stderr, actor output, build progress, and enabled request +summaries into structured events. + +For Cloud Run or Rivet Compute, write each event as one JSON line so the +platform can collect it: + +```ts +import { setDynamicAppsLogHandler } from "@rivet-dev/dynamic-apps"; + +setDynamicAppsLogHandler((event) => + process.stdout.write(`${JSON.stringify(event)}\n`), +); +``` + +You can also enqueue events into a synchronous or buffered logger: + +```ts +setDynamicAppsLogHandler((event) => { + logger.log(event.level, event.message, event); +}); +``` + +Each event includes a version, timestamp, level, source, and message. When +available, it also includes the app, release, request, actor, and output stream, +plus bounded metadata. Messages are limited to 64 KiB and carry +`metadata.truncated: true` when shortened. + +Delivery is best effort and happens synchronously. Do not make blocking network +requests in the callback; enqueue into your logging SDK instead. Request and +response bodies, authorization headers, environment variables, callback +secrets, endpoint credentials, and build source are excluded. diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx index c8c83d6bb..2e113f419 100644 --- a/docs/content/docs/quickstart.mdx +++ b/docs/content/docs/quickstart.mdx @@ -1,6 +1,6 @@ --- title: "Quickstart" -description: "Start the host server, deploy a generated app, and serve it from a local V8 isolate." +description: "Start the host server, deploy a generated app, and serve it through agentOS." skill: true --- @@ -26,14 +26,14 @@ npm pkg set type=module Mount the private Rivet callback separately from application traffic. The callback keeps the deployment state actor available; ordinary app requests are -served by `appsRouter` from local isolates. +served by `appsRouter` through a cached agentOS VM. -Run the server with Node snapshots disabled, as required by `isolated-vm`: +Run the server normally: ```sh -node --no-node-snapshot --import tsx src/server.ts +node --import tsx src/server.ts ``` diff --git a/docs/content/docs/state-and-data.mdx b/docs/content/docs/state-and-data.mdx index 0b7b6ebdd..589e1bc8d 100644 --- a/docs/content/docs/state-and-data.mdx +++ b/docs/content/docs/state-and-data.mdx @@ -1,6 +1,6 @@ --- title: "State & Actors" -description: "Add durable RivetKit actors to an application while keeping ordinary HTTP on the direct-isolate path." +description: "Add durable RivetKit actors while ordinary HTTP runs through agentOS." --- An application can combine ordinary HTTP with app-defined RivetKit actors. @@ -46,4 +46,4 @@ 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 process-local direct-isolate path. +use the cached agentOS direct-request path. diff --git a/docs/sidebar.json b/docs/sidebar.json index e1dcb81b5..c88bc7051 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -38,6 +38,10 @@ { "title": "Realtime Events", "href": "/dynamic-apps/docs/realtime" + }, + { + "title": "Collecting logs", + "href": "/dynamic-apps/docs/logging" } ] }, diff --git a/examples/apps-ai-builder/package.json b/examples/apps-ai-builder/package.json index 3d35ce660..cc05f174d 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 --no-node-snapshot --import tsx src/server.ts", + "start": "node --import tsx src/server.ts", "check-types": "tsc --noEmit" }, "dependencies": { diff --git a/examples/apps-hello-world/package.json b/examples/apps-hello-world/package.json index 9c5ea687b..70e05afa7 100644 --- a/examples/apps-hello-world/package.json +++ b/examples/apps-hello-world/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "start": "node --no-node-snapshot --import tsx src/server.ts", + "start": "node --import tsx src/server.ts", "deploy": "node --import tsx src/deploy.ts", "check-types": "tsc --noEmit" }, diff --git a/packages/dynamic-apps-builder/cli/apps-builder.mjs b/packages/dynamic-apps-builder/cli/apps-builder.mjs index e36d155a7..bc56d19fc 100755 --- a/packages/dynamic-apps-builder/cli/apps-builder.mjs +++ b/packages/dynamic-apps-builder/cli/apps-builder.mjs @@ -38,9 +38,8 @@ const entrypoint = resolve(workspace, config.entrypoint); const maxOutputBytes = positiveInteger(config.maxOutputBytes, "maxOutputBytes"); const maxOutputFiles = positiveInteger(config.maxOutputFiles, "maxOutputFiles"); const maxFileBytes = positiveInteger(config.maxFileBytes, "maxFileBytes"); -const directIsolate = config.directIsolate === true; -const stubRivetKit = directIsolate && config.stubRivetKit === true; -const platformRivetKit = !directIsolate && config.platformRivetKit === true; +const directAgentOs = config.directAgentOs === true; +const platformRivetKit = !directAgentOs && config.platformRivetKit === true; await rm(release, { recursive: true, force: true }); await mkdir(join(release, "modules"), { recursive: true }); @@ -51,7 +50,7 @@ const define = { const require = createRequire(pathToFileURL(entrypoint)); const builderRequire = createRequire(import.meta.url); if (config.usesRivetKit && !platformRivetKit) { - const wasmSource = require.resolve( + const wasmSource = resolvePlatformModule( "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm", ); const wasmBytes = await readFile(wasmSource); @@ -79,16 +78,12 @@ const build = await esbuild.build({ entryPoints: [entrypoint], outfile: join(release, "main.mjs"), bundle: true, - format: directIsolate ? "iife" : "esm", - platform: directIsolate ? "browser" : "node", - target: directIsolate ? "es2022" : "node22", - ...(directIsolate - ? {} - : { - banner: { - js: 'import { createRequire as __agentOSCreateRequire } from "node:module"; const require = __agentOSCreateRequire(import.meta.url);', - }, - }), + format: "esm", + platform: "node", + target: "node22", + banner: { + js: 'import { createRequire as __agentOSCreateRequire } from "node:module"; const require = __agentOSCreateRequire(import.meta.url);', + }, treeShaking: true, minify: true, sourcemap: "external", @@ -277,10 +272,6 @@ function nodeFileSystemPlugin() { return { name: "agentos-node-filesystem", setup(build) { - build.onResolve({ filter: /^rivetkit(?:\/.*)?$/ }, (args) => { - if (!stubRivetKit) return; - return { path: args.path, namespace: "dynamic-apps-rivetkit-stub" }; - }); build.onResolve({ filter: /^rivetkit(?:\/.*)?$/ }, (args) => { if (!platformRivetKit) return; return { path: args.path, external: true }; @@ -292,24 +283,8 @@ function nodeFileSystemPlugin() { return { path: args.path, external: true }; }, ); - build.onLoad( - { filter: /.*/, namespace: "dynamic-apps-rivetkit-stub" }, - (args) => ({ - contents: rivetKitStub(args.path), - loader: "js", - }), - ); build.onResolve({ filter: /.*/ }, async (args) => { if (builtins.has(args.path)) { - if (directIsolate) { - return { - errors: [ - { - text: `Node builtin ${JSON.stringify(args.path)} is unsupported in the direct isolate runtime`, - }, - ], - }; - } return { path: args.path, external: true }; } const importer = @@ -328,6 +303,16 @@ function nodeFileSystemPlugin() { const resolver = createRequire(pathToFileURL(importer)); return { path: resolver.resolve(args.path) }; } catch (error) { + if ( + config.usesRivetKit && + (args.path === "rivetkit" || + args.path.startsWith("rivetkit/") || + args.path.startsWith("@rivetkit/")) + ) { + try { + return { path: builderRequire.resolve(args.path) }; + } catch {} + } if (OPTIONAL_RUNTIME_MODULES.has(args.path)) { return { path: args.path, external: true }; } @@ -394,28 +379,12 @@ function nodeFileSystemPlugin() { }; } -function rivetKitStub(path) { - if (path === "rivetkit/db" || path === "rivetkit/db/drizzle") { - return "export const db = (config) => config;"; - } - if (path === "rivetkit/workflow") { - return "export const workflow = (definition) => definition; export class Loop {};"; - } - if (path === "rivetkit/client") { - return "export const createClient = () => { throw new Error('RivetKit clients are unavailable in the direct request isolate'); };"; +function resolvePlatformModule(specifier) { + try { + return require.resolve(specifier); + } catch { + return builderRequire.resolve(specifier); } - return ` -export const actor = (definition) => definition; -export const event = (definition = {}) => definition; -export const queue = (definition = {}) => definition; -export const setup = (config) => ({ config, start() {}, startAndWait: async () => {}, handler: async () => new Response("RivetKit actor callbacks use the actor runtime", { status: 503 }) }); -export const defineRunHandler = (handler) => handler; -export const db = (config) => config; -export class UserError extends Error {} -export class RivetError extends Error {} -export class ActorError extends RivetError {} -export class Registry {} -`; } async function resolveEsmImport(specifier, importer) { diff --git a/packages/dynamic-apps-builder/package.json b/packages/dynamic-apps-builder/package.json index 060fca5fc..a3d3fec1a 100644 --- a/packages/dynamic-apps-builder/package.json +++ b/packages/dynamic-apps-builder/package.json @@ -34,9 +34,11 @@ "test": "vitest run test/ --passWithNoTests" }, "dependencies": { - "esbuild-wasm": "0.27.4" + "esbuild-wasm": "0.27.4", + "rivetkit": "2.3.11" }, "devDependencies": { + "@rivet-dev/agentos-core": "0.2.15", "@rivet-dev/agentos-toolchain": "0.2.15", "@types/node": "^22.19.15", "typescript": "^5.7.3", diff --git a/packages/dynamic-apps-builder/test/builder.test.ts b/packages/dynamic-apps-builder/test/builder.test.ts index ea072e1b5..419b3be05 100644 --- a/packages/dynamic-apps-builder/test/builder.test.ts +++ b/packages/dynamic-apps-builder/test/builder.test.ts @@ -5,7 +5,8 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { createContext, runInContext } from "node:vm"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; import { describe, expect, test } from "vitest"; import { actorRunnerSource, @@ -17,7 +18,7 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const builder = join(packageRoot, "cli", "apps-builder.mjs"); describe("apps-builder", () => { - test("emits an executable direct-isolate IIFE and rejects Node builtins", async () => { + test("emits an executable Node ESM dispatcher with Node builtins", async () => { const root = await mkdtemp(join(tmpdir(), "agentos-apps-direct-builder-")); const workspace = join(root, "workspace"); const release = join(root, "release"); @@ -33,11 +34,12 @@ describe("apps-builder", () => { await writeFile( join(workspace, "app.ts"), [ + 'import { basename } from "node:path";', "let count = 0;", "export default {", " fetch(request: Request) {", " count += 1;", - " return new Response(request.method + ':' + new URL(request.url).pathname + ':' + count);", + " return new Response(basename(new URL(request.url).pathname) + ':' + request.method + ':' + count);", " },", "};", ].join("\n"), @@ -50,7 +52,7 @@ describe("apps-builder", () => { version: "direct-test", sourceFiles: ["app.ts"], usesRivetKit: false, - directIsolate: true, + directAgentOs: true, maxOutputBytes: 1024 * 1024, maxOutputFiles: 16, maxFileBytes: 1024 * 1024, @@ -59,49 +61,115 @@ describe("apps-builder", () => { await execFileAsync(process.execPath, [builder, configPath]); const source = await readFile(join(release, "main.mjs"), "utf8"); - expect(source).not.toMatch(/^\s*(?:import|export)\s/m); - const sandbox = { - Headers, - Request, - Response, - URL, - Uint8Array, - performance, - __dynamicAppsBase64Decode: (value: string) => - new Uint8Array(Buffer.from(value, "base64")), - __dynamicAppsBase64Encode: (value: Uint8Array) => - Buffer.from(value).toString("base64"), - __dynamicAppDispatch: undefined as - | ((input: string) => Promise) - | undefined, - }; - const context = createContext(sandbox); - runInContext(source, context); - if (!sandbox.__dynamicAppDispatch) - throw new Error("direct bundle did not install its dispatcher"); - const output = JSON.parse( - await sandbox.__dynamicAppDispatch( - JSON.stringify({ - url: "https://example.test/nested", - method: "POST", - headers: [], - }), + expect(source).toMatch(/\bexport\s*\{/); + const output = await dispatchInAgentOs(release, { + url: "https://example.test/nested", + method: "POST", + headers: [], + }); + expect(Buffer.from(output.bodyBase64, "base64").toString()).toBe( + "nested:POST:1", + ); + expect(source).not.toContain("__dynamicAppsBase64Decode"); + }); + + test("bundles real RivetKit and invokes it inside agentOS", async () => { + const root = await mkdtemp(join(tmpdir(), "agentos-apps-rivetkit-")); + const workspace = join(root, "workspace"); + const release = join(root, "release"); + await mkdir(workspace, { recursive: true }); + await writeFile( + join(workspace, "runner.mjs"), + directRunnerSource({ + entrypoint: "app.ts", + release: "rivetkit-direct-test", + maxResponseBytes: 1024 * 1024, + usesRivetKit: true, + }), + ); + await writeFile( + join(workspace, "app.ts"), + [ + 'import { actor, setup } from "rivetkit";', + "const counter = actor({ state: { count: 0 } });", + "export const registry = setup({ use: { counter } });", + "registry.start();", + "export default {", + " fetch() {", + ' return new Response(typeof registry.handler + ":" + typeof counter);', + " },", + "};", + ].join("\n"), + ); + const configPath = join(root, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + workspace, + release, + entrypoint: "runner.mjs", + version: "rivetkit-direct-test", + sourceFiles: ["app.ts"], + usesRivetKit: true, + directAgentOs: true, + maxOutputBytes: 32 * 1024 * 1024, + maxOutputFiles: 64, + maxFileBytes: 16 * 1024 * 1024, + }), + ); + + await execFileAsync(process.execPath, [builder, configPath]); + const paths = await listFiles(release); + expect( + paths.some( + (path) => + path.startsWith("modules/rivetkit-") && path.endsWith(".wasm"), ), + ).toBe(true); + const source = await readFile(join(release, "main.mjs"), "utf8"); + expect(source).not.toContain( + "RivetKit actor callbacks use the actor runtime", ); + const output = await dispatchInAgentOs(release, { + url: "https://example.test/", + method: "GET", + headers: [], + }); expect(Buffer.from(output.bodyBase64, "base64").toString()).toBe( - "POST:/nested:1", + "function:object", ); + }, 30_000); + test("rejects native Node addons in direct agentOS bundles", async () => { + const root = await mkdtemp(join(tmpdir(), "agentos-apps-native-addon-")); + const workspace = join(root, "workspace"); + const release = join(root, "release"); + await mkdir(workspace, { recursive: true }); + await writeFile(join(workspace, "addon.node"), new Uint8Array([1, 2, 3])); await writeFile( - join(workspace, "app.ts"), - 'import { readFile } from "node:fs/promises"; export default { fetch: () => new Response(String(readFile)) };', + join(workspace, "entry.mjs"), + 'import addon from "./addon.node"; export default addon;', + ); + const configPath = join(root, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + workspace, + release, + entrypoint: "entry.mjs", + version: "native-test", + sourceFiles: ["entry.mjs", "addon.node"], + usesRivetKit: false, + directAgentOs: true, + maxOutputBytes: 1024 * 1024, + maxOutputFiles: 16, + maxFileBytes: 1024 * 1024, + }), ); await expect( execFileAsync(process.execPath, [builder, configPath]), ).rejects.toMatchObject({ - stderr: expect.stringContaining( - 'Node builtin "node:fs/promises" is unsupported', - ), + stderr: expect.stringContaining("native Node addon is unsupported"), }); }); @@ -278,3 +346,68 @@ async function listFiles(root: string): Promise { await walk(root); return paths.sort(); } + +async function dispatchInAgentOs( + release: string, + request: { + url: string; + method: string; + headers: Array<[string, string]>; + bodyBase64?: string; + }, +): Promise<{ + status: number; + statusText: string; + headers: Array<[string, string]>; + bodyBase64: string; +}> { + const archive = `${release}.tar`; + const artifact = `${release}.aospkg`; + await execFileAsync("tar", ["-cf", archive, "-C", release, "."]); + await writeFile( + artifact, + packAospkgFromTarBytes(await readFile(archive)).bytes, + ); + const vm = await AgentOs.create({ + defaultSoftware: false, + mounts: [ + { + path: "/app", + readOnly: true, + plugin: { + id: "agentos_packages", + config: { + kind: "tar", + tarPath: artifact, + root: "/", + readOnly: true, + }, + }, + }, + ], + permissions: { + fs: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + network: "allow", + }, + }); + try { + const result = await vm.javascript.evaluate<{ + status: number; + statusText: string; + headers: Array<[string, string]>; + bodyBase64: string; + }>('await (await import("/app/main.mjs")).dispatch(inputs.request)', { + inputs: { request }, + timeoutMs: 10_000, + }); + if (result.outcome !== "succeeded" || result.value === undefined) { + throw new Error(`agentOS dispatcher failed: ${JSON.stringify(result)}`); + } + return result.value; + } finally { + await vm.dispose(); + } +} diff --git a/packages/dynamic-apps/API_CONTRACT.md b/packages/dynamic-apps/API_CONTRACT.md index 8bfd3c9c4..de5cd6df1 100644 --- a/packages/dynamic-apps/API_CONTRACT.md +++ b/packages/dynamic-apps/API_CONTRACT.md @@ -2,7 +2,7 @@ Status: normative rewrite contract Baseline: `@rivet-dev/dynamic-apps@0.2.15`, JJ `xuymorrq`, commit `baca1719` -Scope: only `appsRouter` and `deployApp` +Scope: `appsRouter`, `deployApp`, and structured log delivery This file is the source of truth that must be written and verified against the old implementation before its internals are deleted. It deliberately does not @@ -23,10 +23,10 @@ deleted. ## Exact root module -The rewritten package root exports exactly two runtime values: +The rewritten package root exports exactly three runtime values: ```ts -export { appsRouter, deployApp }; +export { appsRouter, deployApp, setDynamicAppsLogHandler }; ``` The `./advanced` export is removed. Named exports including `setup`, @@ -114,6 +114,35 @@ export declare function deployApp( ): Promise; export declare const appsRouter: Hono; + +export type DynamicAppsLogLevel = "debug" | "info" | "warn" | "error"; +export type DynamicAppsLogSource = + | "application" + | "actor" + | "build" + | "runtime"; + +export interface DynamicAppsLogEvent { + version: 1; + timestamp: number; + level: DynamicAppsLogLevel; + source: DynamicAppsLogSource; + message: string; + appId?: string; + release?: string; + requestId?: string; + actorId?: string; + stream?: "stdout" | "stderr"; + metadata?: Readonly>; +} + +export type DynamicAppsLogHandler = ( + event: Readonly, +) => void; + +export declare function setDynamicAppsLogHandler( + handler: DynamicAppsLogHandler | undefined, +): void; ``` There are no overloads. The optional structural `client` argument is part of @@ -159,9 +188,9 @@ export. - Install, build, and packaging have a 15-minute timeout and capture at most 2 MiB of diagnostic output. The build filesystem is limited to 2 GiB. - The final AOSP package is limited to 64 MiB, 4,096 files, and 32 MiB per - file. Its direct entrypoint is a self-contained browser-targeted IIFE stored - as `direct/main.mjs`; it rejects Node builtins and is validated before - activation. + file. Its direct entrypoint is a self-contained Node-targeted ESM dispatcher + 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 @@ -248,7 +277,7 @@ The resolved value contains exactly these enumerable keys: buffered body. - Invalid app IDs fail before actor lookup or cache work. - The serialized absolute request URL is limited to 16 KiB of UTF-8. This is an - intentional first-preview limit so every accepted request fits the isolated + intentional first-preview limit so every accepted request fits the bounded transport envelope. - Request bodies are limited to 1 MiB even without `Content-Length`. - Response bodies are limited to 4 MiB. Version one intentionally buffers the @@ -256,7 +285,7 @@ The resolved value contains exactly these enumerable keys: - The first preview adds an explicit envelope bound: at most 256 request header pairs and 64 KiB total UTF-8 header names/values; responses have the same header limit plus a 1 KiB UTF-8 status-text limit. This is an intentional new - limit required by isolated serialization. + limit required by envelope serialization. - `x-agentos-app-region` selects a configured release region for compatibility and is stripped before application execution. It does not change the physical placement of the process-local executor. @@ -344,8 +373,8 @@ is split into explicit endpoint, namespace, and publishable-token fields before the worker 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 separately isolated and -never enters the actor worker. +hostile-code security boundary. Direct HTTP remains inside agentOS and never +enters the actor worker. ## Deliberate runtime narrowing @@ -386,9 +415,9 @@ 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 isolate -boundary copies JSON text and enforces the logical body and header maxima before -and after base64 expansion. +`agentos_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`, @@ -422,12 +451,12 @@ The old implementation must first pass: rollback. Record an explicit reviewed removal snapshot for all old root and subpath -exports. The old package is not expected to pass the new two-export assertion. +exports. The old package is not expected to pass the new three-export assertion. After the rewrite, the package must additionally pass: 1. a packed-package assertion that the runtime export key set is exactly - `appsRouter,deployApp`; + `appsRouter,deployApp,setDynamicAppsLogHandler`; 2. a normalized generated `dist/index.d.ts` snapshot matching this file; and 3. explicit new tests for every intentional difference in โ€œDeliberate runtime narrowing.โ€ diff --git a/packages/dynamic-apps/README.md b/packages/dynamic-apps/README.md index 556bfc01c..32ff54c72 100644 --- a/packages/dynamic-apps/README.md +++ b/packages/dynamic-apps/README.md @@ -1,11 +1,13 @@ # `@rivet-dev/dynamic-apps` -Dynamic Apps exposes two runtime values: +Dynamic Apps exposes three runtime values: - `deployApp(input, options?)` builds, persists, and activates an immutable app release through the per-app Rivet state actor. - `appsRouter` is a Hono router that serves the active release at - `/:appId/*` from process-local V8 isolates. + `/:appId/*` through cached agentOS VMs. +- `setDynamicAppsLogHandler(handler)` receives structured application, actor, + build, and runtime logs. Deployment still uses the existing resource-bounded agentOS build VM, builder package, AOSP artifact chunks, activation ordering, rollback behavior, and @@ -25,10 +27,10 @@ export default { }; ``` -The first preview buffers request and response bodies, rejects Node builtins, -and supports the host-provided Fetch subset documented by the tests. It does -not support WebSockets, streaming ordinary HTTP bodies, or Node APIs inside the -direct app isolate. +The first preview buffers request and response bodies. Direct apps run in a +Node 22 agentOS sandbox, so supported Node builtins and the standard Node Web +APIs are available. Native addons, WebSockets, and streaming ordinary HTTP +bodies are not supported. ## App-defined actors @@ -80,7 +82,7 @@ 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 WebAssembly runtime. State, actions, events, connections, and streaming actor responses are handled by RivetKit; -ordinary HTTP for the same app still uses the direct isolate path. +ordinary HTTP for the same app still uses the agentOS evaluation path. 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 @@ -112,10 +114,10 @@ server.route("/apps", appsRouter); serve({ fetch: server.fetch, port: 3000 }); ``` -Run Node 22 or newer with `--no-node-snapshot`, as required by `isolated-vm`: +Run Node 22 or newer normally: ```sh -node --no-node-snapshot dist/server.js +node dist/server.js ``` Deploying an app keeps the existing call shape: @@ -131,25 +133,23 @@ const deployment = await deployApp({ ## Execution modes -`DYNAMIC_APPS_ISOLATE_MODE` selects the request isolation strategy: +`DYNAMIC_APPS_EXECUTION_MODE` selects the request execution strategy: | Mode | Cached object | Cache-hit request | | --- | --- | --- | -| `prewarm` (default) | artifact, V8 heap snapshot, and up to N reusable clean isolates | lease an isolate, run once, destroy its context, restore a clean context from the snapshot, return the isolate to the pool | -| `snapshot` | artifact and V8 heap snapshot | create one isolate from the snapshot, run once, destroy it | -| `fresh` | verified artifact source | create an empty isolate, compile/evaluate the bundle, run once, destroy it | +| `pooled` (default) | verified artifact, one agentOS VM, and up to N retained contexts | lease a context, evaluate once, reset and reinitialize it, then return it to the pool | +| `ephemeral` | verified artifact and one agentOS VM | evaluate in a fresh context managed by agentOS | -The prewarm pool is a cache, not a capacity limit. A burst beyond the pool uses -snapshot-restored overflow isolates under the global execution limit; only the -configured number remain idle afterward. Setting the pool size to zero selects -`snapshot` mode. +The context pool is a cache, not a capacity limit. A burst beyond the pool uses +ephemeral evaluations under the global execution limit; only the configured +number of retained contexts remain idle afterward. | Variable | Default | | --- | ---: | -| `DYNAMIC_APPS_ISOLATE_POOL_SIZE` | `2` | -| `DYNAMIC_APPS_ISOLATE_POOL_MAX_TOTAL` | `8` | -| `DYNAMIC_APPS_ISOLATE_IDLE_TTL_MS` | `30000` | -| `DYNAMIC_APPS_ISOLATE_HEAP_LIMIT_MB` | `64` | +| `DYNAMIC_APPS_CONTEXT_POOL_SIZE` | `2` | +| `DYNAMIC_APPS_CONTEXT_POOL_MAX_TOTAL` | `8` | +| `DYNAMIC_APPS_CONTEXT_IDLE_TTL_MS` | `30000` | +| `DYNAMIC_APPS_CONTEXT_HEAP_LIMIT_MB` | `64` | | `DYNAMIC_APPS_RUNTIME_CACHE_MAX_ENTRIES` | `16` | | `DYNAMIC_APPS_RUNTIME_CACHE_MAX_BYTES` | `268435456` | | `DYNAMIC_APPS_RUNTIME_CACHE_IDLE_TTL_MS` | `900000` | @@ -168,7 +168,7 @@ configured number remain idle afterward. Setting the pool size to zero selects | `DYNAMIC_APPS_ACTOR_REQUEST_QUEUE_WAIT_MS` | `5000` | | `DYNAMIC_APPS_ACTOR_REQUEST_TIMEOUT_MS` | `30000` | -Inside a finite cgroup, execution concurrency, both isolate-pool limits, and +Inside a finite cgroup, execution concurrency, both context-pool limits, and the actor-worker limit 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 @@ -188,22 +188,35 @@ with `get()` when the supplied RivetKit client supports it, then falls back to only `getOrCreate()` retain the original behavior. `DYNAMIC_APPS_TIMING_HEADERS=1` adds benchmark-only phase headers. -`DYNAMIC_APPS_LOG_REQUESTS=1` writes structured request timing records without -request/response bodies or credentials. +`DYNAMIC_APPS_LOG_REQUESTS=1` emits structured request timing records through +the configured log handler without request/response bodies or credentials. + +## Collecting logs + +Register one synchronous handler during host startup: + +```ts +import { setDynamicAppsLogHandler } from "@rivet-dev/dynamic-apps"; + +setDynamicAppsLogHandler((event) => { + process.stdout.write(`${JSON.stringify(event)}\n`); +}); +``` + +The handler receives application stdout/stderr, actor worker output, build +phases, and runtime events. Enqueue into a logging SDK rather than performing a +blocking network request in the callback. ## Memory and trust boundary -Each cached isolate has its own configured V8 heap limit. The pool, runtime -entry count, artifact bytes, idle TTLs, execution concurrency, queue, and cgroup -high-water eviction are independently bounded. Direct and actor admission is -performed before request-body buffering. A used JavaScript context is never -reused: it is released before a fresh snapshot-backed context is created on the -cached native isolate. - -This preview executes isolates in the serving process. `isolated-vm` is a V8 -isolation primitive, not a complete hostile multi-tenant sandbox, and snapshot -creation can terminate the process if top-level application code exhausts -native resources. App actor code runs in a worker-thread V8 isolate but shares -the same containing process. Run one trust domain per container, keep Node/V8 -patched, and rely on Compute restart isolation; do not treat this preview as a -boundary for mutually hostile tenants. +Each immutable release owns one bounded agentOS VM with a read-only mounted +artifact. Context count, runtime entries, artifact bytes, idle TTLs, execution +concurrency, queues, and cgroup high-water eviction are independently bounded. +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. diff --git a/packages/dynamic-apps/package.json b/packages/dynamic-apps/package.json index 3a7f19ecd..c2cfa257c 100644 --- a/packages/dynamic-apps/package.json +++ b/packages/dynamic-apps/package.json @@ -1,7 +1,7 @@ { "name": "@rivet-dev/dynamic-apps", "version": "0.12.0-rc.1", - "description": "Build and run isolated user-generated HTTP applications with durable Rivet state.", + "description": "Build and run user-generated HTTP applications in agentOS with durable Rivet state.", "license": "Apache-2.0", "repository": { "type": "git", @@ -26,20 +26,18 @@ "node": ">=22.0.0" }, "scripts": { - "build": "tsup src/index.ts --format esm --dts --sourcemap --clean --external rivetkit --external @rivet-dev/agentos --external @rivet-dev/agentos-core --external @rivet-dev/agentos-toolchain --external @rivet-dev/dynamic-apps-builder --external @agentos-software/sh --external @agentos-software/tar", + "build": "tsup src/index.ts --format esm --dts --sourcemap --clean --external rivetkit --external @rivet-dev/agentos-core --external @rivet-dev/agentos-toolchain --external @rivet-dev/dynamic-apps-builder --external @agentos-software/sh --external @agentos-software/tar", "check-types": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@agentos-software/sh": "0.2.15", "@agentos-software/tar": "0.3.5", - "@rivet-dev/agentos": "0.2.15", "@rivet-dev/agentos-core": "0.2.15", "@rivet-dev/agentos-toolchain": "0.2.15", "@rivet-dev/dynamic-apps-builder": "workspace:0.12.0-rc.1", "hono": "^4.7.0", - "isolated-vm": "^6.2.0", - "rivetkit": "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9" + "rivetkit": "2.3.11" }, "devDependencies": { "@types/node": "^22.19.15", diff --git a/packages/dynamic-apps/src/actor-runtime.ts b/packages/dynamic-apps/src/actor-runtime.ts index 1689b7343..901b9e83f 100644 --- a/packages/dynamic-apps/src/actor-runtime.ts +++ b/packages/dynamic-apps/src/actor-runtime.ts @@ -13,6 +13,7 @@ import { dirname, join, posix } from "node:path"; import { pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; import { DynamicAppsError } from "./errors.js"; +import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; import { capConcurrencyForMemory, readCgroupMemory } from "./memory.js"; import { ACTOR_BUNDLE_PATH } from "./runtime.js"; @@ -35,6 +36,8 @@ interface ActorRuntimeConfig { export interface ActorRuntimeRequest { key: string; + appId?: string; + release?: string; loadArtifact: () => Promise; endpoint: string; namespace: string; @@ -55,6 +58,8 @@ interface PendingRequest { interface RuntimeEntry { key: string; + appId: string; + release: string; directory: string; worker: Worker; ready: Promise; @@ -63,6 +68,7 @@ interface RuntimeEntry { lastUsedAt: number; disposed: boolean; nextRequestId: number; + logDisposers: Array<() => void>; } type WorkerMessage = @@ -109,7 +115,7 @@ export class DynamicActorRuntime { maxEntries: cgroupMemory ? capConcurrencyForMemory({ requested: requestedMaxEntries, - heapLimitMb, + contextAndVmLimitMb: heapLimitMb, memoryHighWaterPercent, currentBytes: cgroupMemory.currentBytes, maxBytes: cgroupMemory.maxBytes, @@ -215,6 +221,13 @@ export class DynamicActorRuntime { 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( @@ -236,6 +249,13 @@ export class DynamicActorRuntime { }); if (input.request.signal.aborted) cancel(); } 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); } @@ -407,10 +427,14 @@ export class DynamicActorRuntime { Math.min(32, Math.floor(this.config.heapLimitMb / 4)), ), }, + stdout: true, + stderr: true, }, ); const entry: RuntimeEntry = { key: input.key, + appId: input.appId ?? "unknown", + release: input.release ?? "unknown", directory, worker, ready, @@ -419,7 +443,28 @@ export class DynamicActorRuntime { 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; @@ -427,6 +472,13 @@ export class DynamicActorRuntime { const error = new Error( `Dynamic App actor worker startup exceeded ${this.config.startTimeoutMs}ms`, ); + 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); @@ -443,6 +495,13 @@ export class DynamicActorRuntime { 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)); @@ -451,6 +510,13 @@ export class DynamicActorRuntime { 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); this.#failEntry(entry, error); @@ -458,6 +524,14 @@ export class DynamicActorRuntime { 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}`, ); @@ -596,10 +670,20 @@ export class DynamicActorRuntime { pending.controller?.error(new Error("Dynamic App actor worker disposed")); this.#settle(entry, id); } - await Promise.allSettled([ + for (const dispose of entry.logDisposers.splice(0)) dispose(); + const results = await Promise.allSettled([ entry.worker.terminate(), 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", + appId: entry.appId, + release: entry.release, + }); + } } } diff --git a/packages/dynamic-apps/src/actors.ts b/packages/dynamic-apps/src/actors.ts index a59a585ea..843a4b7da 100644 --- a/packages/dynamic-apps/src/actors.ts +++ b/packages/dynamic-apps/src/actors.ts @@ -23,6 +23,7 @@ import { resolveDefaultRivetConnection, } from "./control-plane.js"; import { DynamicAppsError } from "./errors.js"; +import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; import { APP_CALLBACK_SECRET_HEADER, actorRunnerSource, @@ -699,6 +700,28 @@ function boundedOutput(value: string, maximum: number): string { return `${bytes.subarray(0, maximum).toString("utf8")}\n[truncated at ${maximum} bytes]`; } +function emitBuildOutput( + appId: string, + release: string, + result: Pick, +): void { + for (const stream of ["stdout", "stderr"] as const) { + const decoder = new DynamicAppsLogLineDecoder((message, truncated) => + emitDynamicAppsLog({ + level: stream === "stdout" ? "info" : "error", + source: "build", + message, + appId, + release, + stream, + ...(truncated ? { metadata: { truncated: true } } : {}), + }), + ); + decoder.write(Buffer.from(result[stream])); + decoder.end(); + } +} + function throwCommandFailure( kind: "install" | "build" | "pack", command: string, @@ -749,13 +772,23 @@ async function buildRelease( } const build = await config.createBuildVm(); const startedAt = performance.now(); - const phase = (name: string) => + const phase = (name: string) => { + const elapsedMs = performance.now() - startedAt; c.log.info({ msg: "Dynamic Apps build phase completed", release, phase: name, - elapsedMs: performance.now() - startedAt, + elapsedMs, }); + emitDynamicAppsLog({ + level: "info", + source: "build", + message: "Dynamic Apps build phase completed", + appId: input.appId, + release, + metadata: { phase: name, elapsedMs }, + }); + }; let buildError: unknown; try { const files = Object.entries(input.files).map(([path, content]) => ({ @@ -772,6 +805,7 @@ async function buildRelease( entrypoint: plan.entrypoint, release, maxResponseBytes: config.maxResponseBytes, + usesRivetKit: plan.usesRivetKit, }), ), }); @@ -808,6 +842,7 @@ async function buildRelease( timeout: config.buildTimeoutMs, captureStdio: true, }); + emitBuildOutput(input.appId, release, install); if (install.exitCode !== 0) { throwCommandFailure( "install", @@ -823,6 +858,7 @@ async function buildRelease( timeout: config.buildTimeoutMs, captureStdio: true, }); + emitBuildOutput(input.appId, release, result); if (result.exitCode !== 0) { throwCommandFailure( "build", @@ -848,6 +884,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, prune); if (prune.exitCode !== 0) { throwCommandFailure( "install", @@ -868,6 +905,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, nativeAddonCheck); if (nativeAddonCheck.exitCode === 42) { fail( "agentos_apps_native_addon_unsupported", @@ -898,9 +936,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: DEFAULT_MAX_BUILD_ARTIFACT_FILES, maxFileBytes: DEFAULT_MAX_BUILD_ARTIFACT_FILE_BYTES, @@ -923,6 +960,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, directBundle); if (directBundle.exitCode !== 0) { throwCommandFailure( "build", @@ -943,7 +981,6 @@ async function buildRelease( entrypoint: "actor-runner.mjs", sourceFiles: Object.keys(input.files), usesRivetKit: true, - directIsolate: false, platformRivetKit: true, maxOutputBytes: config.maxBuildArtifactBytes, maxOutputFiles: DEFAULT_MAX_BUILD_ARTIFACT_FILES, @@ -966,6 +1003,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, actorBundle); if (actorBundle.exitCode !== 0) { throwCommandFailure( "build", @@ -980,7 +1018,7 @@ async function buildRelease( "node", [ "-e", - `import("/release/${DIRECT_BUNDLE_PATH}").then(()=>{if(globalThis.__dynamicAppMetadata?.format!==${JSON.stringify(DIRECT_RUNTIME_FORMAT)}||typeof globalThis.__dynamicAppDispatch!=="function") throw new TypeError("invalid direct app handler")}).catch((error)=>{console.error(error);process.exitCode=1})`, + `import("/release/${DIRECT_BUNDLE_PATH}").then((module)=>{if(module.dynamicAppMetadata?.format!==${JSON.stringify(DIRECT_RUNTIME_FORMAT)}||typeof module.dispatch!=="function") throw new TypeError("invalid direct app handler")}).catch((error)=>{console.error(error);process.exitCode=1})`, ], { cwd: "/release", @@ -988,6 +1026,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, validation); if (validation.exitCode !== 0) { fail( "agentos_apps_invalid_handler", @@ -1028,6 +1067,7 @@ async function buildRelease( captureStdio: true, }, ); + emitBuildOutput(input.appId, release, pack); if (pack.exitCode !== 0) { throwCommandFailure("pack", "tar", pack, config.maxBuildOutputBytes); } @@ -1063,6 +1103,13 @@ async function buildRelease( throw error; } finally { await build.dispose().catch((disposeError) => { + emitDynamicAppsLog({ + level: "error", + source: "build", + message: "failed to dispose Dynamic Apps build VM", + appId: input.appId, + release, + }); if (!buildError) throw disposeError; c.log.error({ msg: "failed to dispose Dynamic Apps build VM after build failure", @@ -1183,6 +1230,8 @@ export function createAppsActors( try { return await getDefaultActorRuntime().request({ key: `${release.release}:${release.artifactHash}`, + appId: c.key[0] ?? "unknown", + release: release.release, loadArtifact: () => readStoredArtifact(c.db, release), endpoint: actorPublicEndpoint(release, state), namespace: release.namespace, @@ -1477,9 +1526,7 @@ export function createAppsActors( endpoint: runtime.endpoint, namespace: runtime.namespace, pool: runtime.pool, - ...(runtime.publicToken - ? { token: runtime.publicToken } - : {}), + ...(runtime.publicToken ? { token: runtime.publicToken } : {}), regions, appActorId: c.actorId, usesRivetKit: release.usesRivetKit, diff --git a/packages/dynamic-apps/src/executor.ts b/packages/dynamic-apps/src/executor.ts index 18d7bd4d3..ccf0b57a9 100644 --- a/packages/dynamic-apps/src/executor.ts +++ b/packages/dynamic-apps/src/executor.ts @@ -1,9 +1,12 @@ import { createHash, randomUUID } from "node:crypto"; -import { availableParallelism } from "node:os"; -import ivm from "isolated-vm"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { availableParallelism, tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentOs } from "@rivet-dev/agentos-core"; import { createClient } from "rivetkit/client"; import type { AppRouteResolution } from "./actors.js"; import { DynamicAppsError } from "./errors.js"; +import { DynamicAppsLogLineDecoder, emitDynamicAppsLog } from "./logging.js"; import { capConcurrencyForMemory, readCgroupMemory } from "./memory.js"; import { ensurePrivateAppsRegistry } from "./registry.js"; import { DIRECT_BUNDLE_PATH, DIRECT_RUNTIME_FORMAT } from "./runtime.js"; @@ -15,6 +18,7 @@ const MAX_HEADER_BYTES = 64 * 1024; const MAX_RESPONSE_STATUS_TEXT_BYTES = 1024; const MAX_REQUEST_BODY_BYTES = 1024 * 1024; const MAX_RESPONSE_BODY_BYTES = 4 * 1024 * 1024; +const AGENTOS_VM_OVERHEAD_MB = 64; const HOP_BY_HOP_HEADERS = [ "connection", @@ -27,14 +31,14 @@ const HOP_BY_HOP_HEADERS = [ "upgrade", ] as const; -export type IsolateMode = "fresh" | "snapshot" | "prewarm"; +export type ExecutionMode = "ephemeral" | "pooled"; export interface ExecutorConfig { - isolateMode: IsolateMode; - isolatePoolSize: number; - isolatePoolMaxTotal: number; - isolateIdleTtlMs: number; - isolateHeapLimitMb: number; + executionMode: ExecutionMode; + contextPoolSize: number; + contextPoolMaxTotal: number; + contextIdleTtlMs: number; + contextHeapLimitMb: number; runtimeCacheMaxEntries: number; runtimeCacheMaxBytes: number; runtimeCacheIdleTtlMs: number; @@ -100,29 +104,32 @@ interface ResponseEnvelope { } interface RequestTrace { + appId: string; + requestId: string; startedAt: number; phases: Map; cacheOutcome: string; - isolateMode: IsolateMode; + executionMode: ExecutionMode; release?: string; } -interface IsolateSlot { - isolate: ivm.Isolate; +interface ContextSlot { + id: string; pooled: boolean; - context?: ivm.Context; - dispatch?: ivm.Reference<(input: string) => Promise>; lastUsedAt: number; } interface PreparedRuntime { key: string; + appId: string; release: string; artifactHash: string; artifactBytes: number; - source: string; - snapshot?: ivm.ExternalCopy; - cleanSlots: IsolateSlot[]; + artifact: Uint8Array; + directory: string; + artifactPath: string; + vm: AgentOs; + cleanContexts: ContextSlot[]; inUse: number; refilling: number; refs: number; @@ -130,14 +137,17 @@ interface PreparedRuntime { disposing: boolean; lastUsedAt: number; backgroundTasks: Set>; - isolateCreates: number; - isolateDisposes: number; + activeControllers: Set; + activeEvaluations: Set>; + disposePromise?: Promise; + vmCreates: number; + vmDisposes: number; contextCreates: number; contextDisposes: number; contextResetFailures: number; - prewarmOverflowCreates: number; + contextOverflowEvaluations: number; lastContextResetError?: string; - dispatches: number; + evaluations: number; } interface AppMapping { @@ -158,20 +168,32 @@ interface AppCacheEntry { refs: number; } +interface EvaluationResult { + outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; + value?: T; + error?: { message?: string }; +} + export function readExecutorConfig( env: NodeJS.ProcessEnv = process.env, ): ExecutorConfig { - const mode = env.DYNAMIC_APPS_ISOLATE_MODE ?? "prewarm"; - if (mode !== "fresh" && mode !== "snapshot" && mode !== "prewarm") { + const executionMode = env.DYNAMIC_APPS_EXECUTION_MODE ?? "pooled"; + if (executionMode !== "ephemeral" && executionMode !== "pooled") { throw new DynamicAppsError( "agentos_apps_invalid_config", - "DYNAMIC_APPS_ISOLATE_MODE must be fresh, snapshot, or prewarm", + "DYNAMIC_APPS_EXECUTION_MODE must be ephemeral or pooled", ); } - const poolSize = integerEnv(env, "DYNAMIC_APPS_ISOLATE_POOL_SIZE", 2, 0, 128); - const isolateHeapLimitMb = integerEnv( + const requestedPoolSize = integerEnv( env, - "DYNAMIC_APPS_ISOLATE_HEAP_LIMIT_MB", + "DYNAMIC_APPS_CONTEXT_POOL_SIZE", + 2, + 0, + 128, + ); + const contextHeapLimitMb = integerEnv( + env, + "DYNAMIC_APPS_CONTEXT_HEAP_LIMIT_MB", 64, 8, 2_048, @@ -192,42 +214,39 @@ export function readExecutorConfig( ); const requestedPoolMaxTotal = integerEnv( env, - "DYNAMIC_APPS_ISOLATE_POOL_MAX_TOTAL", + "DYNAMIC_APPS_CONTEXT_POOL_MAX_TOTAL", 8, 0, 1_024, ); const cgroupMemory = readCgroupMemory(); - const memoryIsolateCap = cgroupMemory + const memoryContextCap = cgroupMemory ? capConcurrencyForMemory({ requested: 1_024, - heapLimitMb: isolateHeapLimitMb, + contextAndVmLimitMb: contextHeapLimitMb + AGENTOS_VM_OVERHEAD_MB, memoryHighWaterPercent, currentBytes: cgroupMemory.currentBytes, maxBytes: cgroupMemory.maxBytes, }) : undefined; - const executionConcurrency = Math.min( - requestedExecutionConcurrency, - memoryIsolateCap ?? requestedExecutionConcurrency, - ); - const isolatePoolSize = Math.min(poolSize, memoryIsolateCap ?? poolSize); return { - isolateMode: - mode === "prewarm" && isolatePoolSize === 0 ? "snapshot" : mode, - isolatePoolSize, - isolatePoolMaxTotal: Math.min( + executionMode, + contextPoolSize: Math.min( + requestedPoolSize, + memoryContextCap ?? requestedPoolSize, + ), + contextPoolMaxTotal: Math.min( requestedPoolMaxTotal, - memoryIsolateCap ?? requestedPoolMaxTotal, + memoryContextCap ?? requestedPoolMaxTotal, ), - isolateIdleTtlMs: integerEnv( + contextIdleTtlMs: integerEnv( env, - "DYNAMIC_APPS_ISOLATE_IDLE_TTL_MS", + "DYNAMIC_APPS_CONTEXT_IDLE_TTL_MS", 30_000, 1_000, 60 * 60_000, ), - isolateHeapLimitMb, + contextHeapLimitMb, runtimeCacheMaxEntries: integerEnv( env, "DYNAMIC_APPS_RUNTIME_CACHE_MAX_ENTRIES", @@ -250,7 +269,10 @@ export function readExecutorConfig( 24 * 60 * 60_000, ), memoryHighWaterPercent, - executionConcurrency, + executionConcurrency: Math.min( + requestedExecutionConcurrency, + memoryContextCap ?? requestedExecutionConcurrency, + ), executionQueueSize: integerEnv( env, "DYNAMIC_APPS_EXECUTION_QUEUE_SIZE", @@ -286,7 +308,7 @@ export class DynamicAppsExecutor { readonly #runtimes = new Map(); readonly #runtimePromises = new Map>(); readonly #cleanupTimer: ReturnType; - #pooledIsolates = 0; + #pooledContexts = 0; #poolReservations = 0; #disposed = false; #disposePromise?: Promise; @@ -305,12 +327,16 @@ export class DynamicAppsExecutor { ); this.#cleanupTimer = setInterval( () => void this.#pruneCaches(true), - Math.min(config.isolateIdleTtlMs, config.runtimeCacheIdleTtlMs, 30_000), + Math.min(config.contextIdleTtlMs, config.runtimeCacheIdleTtlMs, 30_000), ); this.#cleanupTimer.unref?.(); } - async request(appId: string, request: Request): Promise { + async request( + appId: string, + request: Request, + requestId: string = randomUUID(), + ): Promise { if (this.#disposed) { throw new DynamicAppsError( "agentos_apps_executor_disposed", @@ -318,10 +344,12 @@ export class DynamicAppsExecutor { ); } const trace: RequestTrace = { + appId, + requestId, startedAt: performance.now(), phases: new Map(), cacheOutcome: "app-hit", - isolateMode: this.config.isolateMode, + executionMode: this.config.executionMode, }; let admitted = false; try { @@ -361,6 +389,7 @@ export class DynamicAppsExecutor { mapping.runtime, envelope, trace, + request.signal, ); this.#finishTrace(response.headers, trace); return response; @@ -389,28 +418,21 @@ export class DynamicAppsExecutor { ), activeEvaluations: this.#semaphore.active, queuedEvaluations: this.#semaphore.queued, - cleanIsolates: runtimes.reduce( - (sum, item) => sum + item.cleanSlots.length, + cleanContexts: runtimes.reduce( + (sum, item) => sum + item.cleanContexts.length, 0, ), - pooledIsolates: this.#pooledIsolates, + pooledContexts: this.#pooledContexts, poolReservations: this.#poolReservations, - isolatePoolMaxTotal: this.config.isolatePoolMaxTotal, - inUseIsolates: runtimes.reduce((sum, item) => sum + item.inUse, 0), - refillingIsolates: runtimes.reduce( + contextPoolMaxTotal: this.config.contextPoolMaxTotal, + inUseContexts: runtimes.reduce((sum, item) => sum + item.inUse, 0), + refillingContexts: runtimes.reduce( (sum, item) => sum + item.refilling, 0, ), rssBytes: process.memoryUsage().rss, - isolatedVmExternalBytes: ivm.ExternalCopy.totalExternalSize, - isolateCreates: runtimes.reduce( - (sum, item) => sum + item.isolateCreates, - 0, - ), - isolateDisposes: runtimes.reduce( - (sum, item) => sum + item.isolateDisposes, - 0, - ), + vmCreates: runtimes.reduce((sum, item) => sum + item.vmCreates, 0), + vmDisposes: runtimes.reduce((sum, item) => sum + item.vmDisposes, 0), contextCreates: runtimes.reduce( (sum, item) => sum + item.contextCreates, 0, @@ -423,14 +445,14 @@ export class DynamicAppsExecutor { (sum, item) => sum + item.contextResetFailures, 0, ), - prewarmOverflowCreates: runtimes.reduce( - (sum, item) => sum + item.prewarmOverflowCreates, + contextOverflowEvaluations: runtimes.reduce( + (sum, item) => sum + item.contextOverflowEvaluations, 0, ), lastContextResetError: runtimes .filter((item) => item.lastContextResetError !== undefined) .at(-1)?.lastContextResetError, - dispatches: runtimes.reduce((sum, item) => sum + item.dispatches, 0), + evaluations: runtimes.reduce((sum, item) => sum + item.evaluations, 0), }; } @@ -477,18 +499,13 @@ export class DynamicAppsExecutor { if (!validReleaseEvent(event) || event.revision <= entry.highestRevision) return; entry.highestRevision = event.revision; - entry.epoch += 1; - entry.mapping = undefined; + this.#invalidateMapping(entry); void this.#resolveAndPrepare(entry).catch(() => {}); }); - connection.onClose(() => { - entry.epoch += 1; - entry.mapping = undefined; - }); + connection.onClose(() => this.#invalidateMapping(entry)); connection.onOpen(() => { if (entry.mapping || entry.highestRevision > 0) { - entry.epoch += 1; - entry.mapping = undefined; + this.#invalidateMapping(entry); void this.#resolveAndPrepare(entry).catch(() => {}); } }); @@ -496,6 +513,21 @@ export class DynamicAppsExecutor { return { entry, hit: false }; } + #invalidateMapping(entry: AppCacheEntry): void { + entry.epoch += 1; + const runtime = entry.mapping?.runtime; + entry.mapping = undefined; + if (runtime) this.#invalidateRuntime(runtime); + } + + #invalidateRuntime(runtime: PreparedRuntime): void { + runtime.stale = true; + if (this.#runtimes.get(runtime.key) === runtime) { + this.#runtimes.delete(runtime.key); + } + void this.#maybeDisposeRuntime(runtime); + } + async #resolveAndPrepare( entry: AppCacheEntry, trace?: RequestTrace, @@ -519,6 +551,7 @@ export class DynamicAppsExecutor { resolution.revision, ); const runtime = await this.#prepareRuntime( + entry.appId, entry.handle, resolution, trace, @@ -526,8 +559,10 @@ export class DynamicAppsExecutor { if ( entry.epoch !== epoch || resolution.revision < entry.highestRevision - ) + ) { + this.#invalidateRuntime(runtime); continue; + } const mapping = { resolution, runtime }; entry.mapping = mapping; return mapping; @@ -542,6 +577,7 @@ export class DynamicAppsExecutor { } async #prepareRuntime( + appId: string, handle: AppHandle, resolution: AppRouteResolution, trace?: RequestTrace, @@ -552,7 +588,7 @@ export class DynamicAppsExecutor { "Dynamic Apps executor is shutting down", ); } - const key = `${resolution.artifactHash}:${DIRECT_RUNTIME_FORMAT}`; + const key = `${appId}:${resolution.release}:${resolution.artifactHash}:${DIRECT_RUNTIME_FORMAT}`; const existing = this.#runtimes.get(key); if (existing && !existing.stale) { existing.lastUsedAt = Date.now(); @@ -560,7 +596,7 @@ export class DynamicAppsExecutor { } const pending = this.#runtimePromises.get(key); if (pending) return pending; - const promise = this.#createRuntime(key, handle, resolution, trace); + const promise = this.#createRuntime(key, appId, handle, resolution, trace); this.#runtimePromises.set(key, promise); try { return await promise; @@ -573,6 +609,7 @@ export class DynamicAppsExecutor { async #createRuntime( key: string, + appId: string, handle: AppHandle, resolution: AppRouteResolution, trace?: RequestTrace, @@ -624,55 +661,74 @@ export class DynamicAppsExecutor { return new Uint8Array(Buffer.concat(chunks, bytes)); }, ); - const source = await measureOptional(trace, "artifact-parse", async () => - extractAospkgTextFile(artifact, DIRECT_BUNDLE_PATH), - ); - const runtime: PreparedRuntime = { - key, - release: resolution.release, - artifactHash: resolution.artifactHash, - artifactBytes: resolution.artifactBytes, - source, - cleanSlots: [], - inUse: 0, - refilling: 0, - refs: 0, - stale: false, - disposing: false, - lastUsedAt: Date.now(), - backgroundTasks: new Set(), - isolateCreates: 0, - isolateDisposes: 0, - contextCreates: 0, - contextDisposes: 0, - contextResetFailures: 0, - prewarmOverflowCreates: 0, - dispatches: 0, - }; - if (this.config.isolateMode !== "fresh") { - runtime.snapshot = await measureOptional( - trace, - "snapshot-create", - async () => - ivm.Isolate.createSnapshot([ + const directory = await mkdtemp(join(tmpdir(), "dynamic-app-runtime-")); + const artifactPath = join(directory, "release.aospkg"); + let vm: AgentOs | undefined; + try { + await chmod(directory, 0o700); + await writeFile(artifactPath, artifact, { mode: 0o600 }); + vm = await measureOptional(trace, "vm-prepare", () => + AgentOs.create({ + sidecar: { kind: "shared", pool: "dynamic-apps-direct" }, + defaultSoftware: false, + mounts: [ { - code: ISOLATE_BOOTSTRAP_SOURCE, - filename: "dynamic-apps:bootstrap", + path: "/app", + readOnly: true, + plugin: { + id: "agentos_packages", + config: { + kind: "tar", + tarPath: artifactPath, + root: "/", + readOnly: true, + }, + }, }, - { code: source, filename: "dynamic-apps:application" }, - ]), + ], + permissions: { + fs: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + network: "allow", + }, + limits: { + jsRuntime: { v8HeapLimitMb: this.config.contextHeapLimitMb }, + }, + }), ); - } - this.#runtimes.set(key, runtime); - try { - if (this.#disposed) { - throw new DynamicAppsError( - "agentos_apps_executor_disposed", - "Dynamic Apps executor is shutting down", - ); - } - if (this.config.isolateMode === "prewarm") { - await measureOptional(trace, "isolate-prewarm", () => + const runtime: PreparedRuntime = { + key, + appId, + release: resolution.release, + artifactHash: resolution.artifactHash, + artifactBytes: resolution.artifactBytes, + artifact, + directory, + artifactPath, + vm, + cleanContexts: [], + inUse: 0, + refilling: 0, + refs: 0, + stale: false, + disposing: false, + lastUsedAt: Date.now(), + backgroundTasks: new Set(), + activeControllers: new Set(), + activeEvaluations: new Set(), + vmCreates: 1, + vmDisposes: 0, + contextCreates: 0, + contextDisposes: 0, + contextResetFailures: 0, + contextOverflowEvaluations: 0, + evaluations: 0, + }; + this.#runtimes.set(key, runtime); + if (this.config.executionMode === "pooled") { + await measureOptional(trace, "context-prewarm", () => this.#fillPool(runtime), ); } @@ -682,11 +738,25 @@ export class DynamicAppsExecutor { "Dynamic Apps executor is shutting down", ); } + emitDynamicAppsLog({ + level: "debug", + source: "runtime", + message: "Dynamic Apps release runtime prepared", + appId, + release: resolution.release, + }); return runtime; } catch (error) { - runtime.stale = true; + if (vm) await vm.dispose().catch(() => {}); + await rm(directory, { recursive: true, force: true }).catch(() => {}); this.#runtimes.delete(key); - await this.#disposeRuntime(runtime); + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps release runtime preparation failed", + appId, + release: resolution.release, + }); throw error; } } @@ -695,28 +765,32 @@ export class DynamicAppsExecutor { runtime: PreparedRuntime, envelope: RequestEnvelope, trace: RequestTrace, + requestSignal: AbortSignal, ): Promise { - let slot: IsolateSlot | undefined; - let prewarmActive = false; + let slot: ContextSlot | undefined; + let pooledActive = false; let completedSuccessfully = false; try { - if (this.config.isolateMode === "prewarm") { - slot = await measure(trace, "isolate-lease", () => - this.#acquireSlot(runtime), + if (this.config.executionMode === "pooled") { + slot = await measure(trace, "context-lease", () => + this.#acquireContext(runtime), ); - prewarmActive = true; + pooledActive = true; + if (!slot) runtime.contextOverflowEvaluations += 1; } - if (!slot) { - if (prewarmActive) runtime.prewarmOverflowCreates += 1; - slot = await measure(trace, "isolate-create", () => - this.#createSlot(runtime), - ); - } - const executionSlot = slot; - const output = await measure(trace, "evaluation", () => - this.#dispatch(executionSlot, envelope), + const expression = slot + ? "await globalThis.__dynamicAppsDispatch(inputs.request)" + : `await (await import("/app/${DIRECT_BUNDLE_PATH}")).dispatch(inputs.request)`; + const result = await measure(trace, "evaluation", () => + this.#evaluate(runtime, expression, { + ...(slot ? { contextId: slot.id } : {}), + inputs: { request: envelope as never }, + trace, + signal: requestSignal, + }), ); - runtime.dispatches += 1; + const output = evaluationValue(result, this.config.executionTimeoutMs); + runtime.evaluations += 1; if (output.timing) { for (const [name, value] of Object.entries(output.timing)) { if (Number.isFinite(value)) @@ -727,178 +801,198 @@ export class DynamicAppsExecutor { completedSuccessfully = true; return response; } finally { - if (slot) { - const startedAt = performance.now(); - if (prewarmActive && completedSuccessfully) { - this.#releaseSlotContext(runtime, slot); - } else { - this.#disposeSlot(runtime, slot); - } - trace.phases.set( - prewarmActive ? "context-destroy" : "isolate-destroy", - performance.now() - startedAt, - ); - } - if (prewarmActive) { - const shouldCache = - slot !== undefined && - completedSuccessfully && - !runtime.stale && - !this.#disposed && - runtime.cleanSlots.length + runtime.inUse - 1 + runtime.refilling < - this.config.isolatePoolSize; - if (shouldCache && slot) { + if (pooledActive) { + if (slot) { const startedAt = performance.now(); - try { - await this.#initializeSlot(runtime, slot); - this.#offerSlot(runtime, slot); - } catch (error) { - runtime.contextResetFailures += 1; - runtime.lastContextResetError = - error instanceof Error ? error.message : String(error); - this.#disposeSlot(runtime, slot); + if (completedSuccessfully && !runtime.stale && !this.#disposed) { + try { + await runtime.vm.contexts.reset(slot.id); + await this.#initializeContext(runtime, slot, trace); + this.#offerContext(runtime, slot); + } catch (error) { + runtime.contextResetFailures += 1; + runtime.lastContextResetError = errorMessage(error); + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps context reset failed", + appId: runtime.appId, + release: runtime.release, + requestId: trace.requestId, + }); + await this.#deleteContext(runtime, slot); + } + } else { + await this.#deleteContext(runtime, slot); } trace.phases.set("context-reset", performance.now() - startedAt); - } else if (slot && completedSuccessfully) { - this.#disposeSlot(runtime, slot); } - runtime.inUse -= 1; + runtime.inUse = Math.max(0, runtime.inUse - 1); this.#ensurePool(runtime); } } } - async #dispatch( - slot: IsolateSlot, - envelope: RequestEnvelope, - ): Promise { + async #evaluate( + runtime: PreparedRuntime, + expression: string, + options: { + contextId?: string; + inputs?: Record; + trace?: RequestTrace; + signal?: AbortSignal; + }, + ): Promise> { + const controller = new AbortController(); + runtime.activeControllers.add(controller); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + const stdout = this.#executionLogDecoder(runtime, options.trace, "stdout"); + const stderr = this.#executionLogDecoder(runtime, options.trace, "stderr"); + const evaluation = runtime.vm.javascript.evaluate(expression, { + ...(options.contextId ? { contextId: options.contextId } : {}), + ...(options.inputs ? { inputs: options.inputs } : {}), + format: "module", + timeoutMs: this.config.executionTimeoutMs, + signal, + onStdout: (chunk) => stdout.write(chunk), + onStderr: (chunk) => stderr.write(chunk), + }) as Promise>; + runtime.activeEvaluations.add(evaluation); try { - if (!slot.dispatch) - throw new Error("application isolate has no active dispatcher"); - const output = await withDeadline( - slot.dispatch.apply(undefined, [JSON.stringify(envelope)], { - arguments: { copy: true }, - result: { copy: true, promise: true }, - timeout: this.config.executionTimeoutMs, - }), - this.config.executionTimeoutMs, - ); - if (typeof output !== "string") - throw new Error("dispatcher returned non-text"); - return JSON.parse(output) as ResponseEnvelope; + return await evaluation; } catch (error) { - if (error instanceof Error && /timed out/i.test(error.message)) { + if (error instanceof Error && error.name === "AbortError") { throw new DynamicAppsError( - "agentos_apps_execution_timeout", - `application execution exceeded ${this.config.executionTimeoutMs}ms`, + "agentos_apps_execution_cancelled", + "application execution was cancelled", ); } - throw new ApplicationHandlerError( - error instanceof Error ? error.message : String(error), - ); + throw error; + } finally { + stdout.end(); + stderr.end(); + runtime.activeEvaluations.delete(evaluation); + runtime.activeControllers.delete(controller); } } - async #createSlot(runtime: PreparedRuntime): Promise { - const isolate = new ivm.Isolate({ - memoryLimit: this.config.isolateHeapLimitMb, - ...(runtime.snapshot ? { snapshot: runtime.snapshot } : {}), + #executionLogDecoder( + runtime: PreparedRuntime, + trace: RequestTrace | undefined, + stream: "stdout" | "stderr", + ): DynamicAppsLogLineDecoder { + return new DynamicAppsLogLineDecoder((message, truncated) => { + const startedAt = performance.now(); + emitDynamicAppsLog({ + level: stream === "stdout" ? "info" : "error", + source: "application", + message, + appId: runtime.appId, + release: runtime.release, + ...(trace ? { requestId: trace.requestId } : {}), + stream, + ...(truncated ? { metadata: { truncated: true } } : {}), + }); + if (trace) { + trace.phases.set( + "log-dispatch", + (trace.phases.get("log-dispatch") ?? 0) + + (performance.now() - startedAt), + ); + } }); - const slot: IsolateSlot = { - isolate, - pooled: false, - lastUsedAt: Date.now(), - }; - try { - await this.#initializeSlot(runtime, slot); - runtime.isolateCreates += 1; - return slot; - } catch (error) { - isolate.dispose(); - throw error; - } } - async #initializeSlot( + async #initializeContext( runtime: PreparedRuntime, - slot: IsolateSlot, + slot: ContextSlot, + trace?: RequestTrace, ): Promise { - const context = await slot.isolate.createContext(); + const result = await this.#evaluate( + runtime, + `await import("/app/${DIRECT_BUNDLE_PATH}").then((module) => { globalThis.__dynamicAppsDispatch = module.dispatch; return typeof module.dispatch === "function"; })`, + { contextId: slot.id, trace }, + ); + if (evaluationValue(result, this.config.executionTimeoutMs) !== true) { + throw new Error("application bundle did not export a dispatcher"); + } + } + + async #createContext(runtime: PreparedRuntime): Promise { + const slot: ContextSlot = { + id: randomUUID(), + pooled: false, + lastUsedAt: Date.now(), + }; + await runtime.vm.createContext(slot.id); try { - if (!runtime.snapshot) { - const script = await slot.isolate.compileScript( - `${ISOLATE_BOOTSTRAP_SOURCE}\n${runtime.source}`, - { filename: "dynamic-apps:application" }, - ); - await script.run(context, { timeout: this.config.executionTimeoutMs }); - } - const dispatch = await context.global.get("__dynamicAppDispatch", { - reference: true, - }); - if (!(dispatch instanceof ivm.Reference)) { - throw new Error("application bundle did not install a dispatcher"); - } - slot.context = context; - slot.dispatch = dispatch as NonNullable; - slot.lastUsedAt = Date.now(); + await this.#initializeContext(runtime, slot); runtime.contextCreates += 1; + return slot; } catch (error) { - context.release(); + await runtime.vm.contexts.delete(slot.id).catch(() => {}); throw error; } } async #fillPool(runtime: PreparedRuntime): Promise { if (await this.#memoryPressure()) return; - const reserved = this.#reservePoolSlots(this.config.isolatePoolSize); + const reserved = this.#reservePoolContexts(this.config.contextPoolSize); const outcomes = await Promise.allSettled( - Array.from({ length: reserved }, () => this.#createSlot(runtime)), + Array.from({ length: reserved }, () => this.#createContext(runtime)), ); const failed = outcomes.find( (outcome): outcome is PromiseRejectedResult => outcome.status === "rejected", ); - if (failed) { - for (const outcome of outcomes) { - this.#poolReservations -= 1; - if (outcome.status === "fulfilled") { - this.#disposeSlot(runtime, outcome.value); - } - } - throw failed.reason; - } for (const outcome of outcomes) { - this.#poolReservations -= 1; - if (outcome.status === "fulfilled") - this.#offerSlot(runtime, outcome.value); + this.#poolReservations = Math.max(0, this.#poolReservations - 1); + if (outcome.status === "fulfilled") { + if (failed) await this.#deleteContext(runtime, outcome.value); + else this.#offerContext(runtime, outcome.value); + } } + if (failed) throw failed.reason; } #ensurePool(runtime: PreparedRuntime): void { if ( - this.config.isolateMode !== "prewarm" || + this.config.executionMode !== "pooled" || runtime.stale || this.#disposed ) return; const missingForRuntime = - this.config.isolatePoolSize - - (runtime.cleanSlots.length + runtime.inUse + runtime.refilling); - const missing = this.#reservePoolSlots(missingForRuntime); + this.config.contextPoolSize - + (runtime.cleanContexts.length + runtime.inUse + runtime.refilling); + const missing = this.#reservePoolContexts(missingForRuntime); for (let index = 0; index < missing; index += 1) { runtime.refilling += 1; let reserved = true; const task = this.#memoryPressure() .then(async (pressure) => { if (pressure || runtime.stale) return; - const slot = await this.#createSlot(runtime); - this.#poolReservations -= 1; + const slot = await this.#createContext(runtime); + this.#poolReservations = Math.max(0, this.#poolReservations - 1); reserved = false; - this.#offerSlot(runtime, slot); + this.#offerContext(runtime, slot); + }) + .catch((error) => { + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps context replenishment failed", + appId: runtime.appId, + release: runtime.release, + metadata: { error: errorMessage(error) }, + }); }) - .catch(() => {}) .finally(() => { - if (reserved) this.#poolReservations -= 1; + if (reserved) { + this.#poolReservations = Math.max(0, this.#poolReservations - 1); + } runtime.refilling -= 1; void this.#maybeDisposeRuntime(runtime); }); @@ -906,69 +1000,64 @@ export class DynamicAppsExecutor { } } - async #acquireSlot( + async #acquireContext( runtime: PreparedRuntime, - ): Promise { - const slot = runtime.cleanSlots.shift(); - // Account for the lease before this async method resolves. Otherwise a - // concurrent refill can observe the slot missing from both cleanSlots and - // inUse and overfill the bounded pool. + ): Promise { + const slot = runtime.cleanContexts.shift(); runtime.inUse += 1; - if (slot) { - slot.lastUsedAt = Date.now(); - return slot; - } - return undefined; + if (slot) slot.lastUsedAt = Date.now(); + return slot; } - #offerSlot(runtime: PreparedRuntime, slot: IsolateSlot): void { + #offerContext(runtime: PreparedRuntime, slot: ContextSlot): void { if (runtime.stale || this.#disposed) { - this.#disposeSlot(runtime, slot); + void this.#deleteContext(runtime, slot); return; } if (!slot.pooled) { if ( - this.#pooledIsolates + this.#poolReservations >= - this.config.isolatePoolMaxTotal + this.#pooledContexts + this.#poolReservations >= + this.config.contextPoolMaxTotal ) { - this.#disposeSlot(runtime, slot); + void this.#deleteContext(runtime, slot); return; } slot.pooled = true; - this.#pooledIsolates += 1; + this.#pooledContexts += 1; } - runtime.cleanSlots.push(slot); + slot.lastUsedAt = Date.now(); + runtime.cleanContexts.push(slot); } - #disposeSlot(runtime: PreparedRuntime, slot: IsolateSlot): void { + async #deleteContext( + runtime: PreparedRuntime, + slot: ContextSlot, + ): Promise { if (slot.pooled) { slot.pooled = false; - this.#pooledIsolates = Math.max(0, this.#pooledIsolates - 1); + this.#pooledContexts = Math.max(0, this.#pooledContexts - 1); } - this.#releaseSlotContext(runtime, slot); try { - slot.isolate.dispose(); - } catch {} - runtime.isolateDisposes += 1; - } - - #releaseSlotContext(runtime: PreparedRuntime, slot: IsolateSlot): void { - try { - slot.dispatch?.release(); - } catch {} - try { - slot.context?.release(); - } catch {} - if (slot.dispatch || slot.context) runtime.contextDisposes += 1; - slot.dispatch = undefined; - slot.context = undefined; + await runtime.vm.contexts.delete(slot.id); + } catch (error) { + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps context disposal failed", + appId: runtime.appId, + release: runtime.release, + metadata: { error: errorMessage(error) }, + }); + } finally { + runtime.contextDisposes += 1; + } } - #reservePoolSlots(requested: number): number { + #reservePoolContexts(requested: number): number { const available = Math.max( 0, - this.config.isolatePoolMaxTotal - - this.#pooledIsolates - + this.config.contextPoolMaxTotal - + this.#pooledContexts - this.#poolReservations, ); const reserved = Math.max(0, Math.min(requested, available)); @@ -980,15 +1069,15 @@ export class DynamicAppsExecutor { if (this.#disposed) return; const now = Date.now(); for (const runtime of this.#runtimes.values()) { - const keep: IsolateSlot[] = []; - for (const slot of runtime.cleanSlots) { - if (now - slot.lastUsedAt >= this.config.isolateIdleTtlMs) { - this.#disposeSlot(runtime, slot); + const keep: ContextSlot[] = []; + for (const slot of runtime.cleanContexts) { + if (now - slot.lastUsedAt >= this.config.contextIdleTtlMs) { + await this.#deleteContext(runtime, slot); } else { keep.push(slot); } } - runtime.cleanSlots = keep; + runtime.cleanContexts = keep; } for (const entry of [...this.#apps.values()].sort( (a, b) => a.lastUsedAt - b.lastUsedAt, @@ -1019,13 +1108,11 @@ export class DynamicAppsExecutor { runtime.refs === 0 && (now - runtime.lastUsedAt >= this.config.runtimeCacheIdleTtlMs || over) ) { - runtime.stale = true; - this.#runtimes.delete(runtime.key); + this.#invalidateRuntime(runtime); for (const app of this.#apps.values()) { if (app.mapping?.runtime === runtime) app.mapping = undefined; } bytes -= runtime.artifactBytes; - void this.#maybeDisposeRuntime(runtime); } } } @@ -1043,12 +1130,45 @@ export class DynamicAppsExecutor { } async #disposeRuntime(runtime: PreparedRuntime): Promise { - if (runtime.disposing) return; + if (runtime.disposePromise !== undefined) return runtime.disposePromise; runtime.disposing = true; - await Promise.allSettled([...runtime.backgroundTasks]); - for (const slot of runtime.cleanSlots.splice(0)) - this.#disposeSlot(runtime, slot); - runtime.snapshot?.release(); + runtime.stale = true; + runtime.disposePromise = (async () => { + for (const controller of runtime.activeControllers) controller.abort(); + await Promise.allSettled([...runtime.activeEvaluations]); + await Promise.allSettled([...runtime.backgroundTasks]); + await Promise.allSettled( + runtime.cleanContexts + .splice(0) + .map((slot) => this.#deleteContext(runtime, slot)), + ); + try { + await runtime.vm.dispose(); + runtime.vmDisposes += 1; + } catch (error) { + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps VM disposal failed", + appId: runtime.appId, + release: runtime.release, + metadata: { error: errorMessage(error) }, + }); + } + await rm(runtime.directory, { recursive: true, force: true }).catch( + (error) => + emitDynamicAppsLog({ + level: "error", + source: "runtime", + message: "Dynamic Apps artifact cleanup failed", + appId: runtime.appId, + release: runtime.release, + metadata: { error: errorMessage(error) }, + }), + ); + runtime.artifact = new Uint8Array(); + })(); + return runtime.disposePromise; } #track(runtime: PreparedRuntime, task: Promise): void { @@ -1067,7 +1187,7 @@ export class DynamicAppsExecutor { } #finishTrace(headers: Headers, trace: RequestTrace): void { - const total = performance.now() - trace.startedAt; + const totalMs = performance.now() - trace.startedAt; headers.set("x-agentos-app-release", trace.release ?? "unknown"); headers.set( "x-agentos-app-cold-start", @@ -1075,30 +1195,53 @@ export class DynamicAppsExecutor { ); if (this.config.timingHeaders) { headers.set("x-agentos-app-cache", trace.cacheOutcome); - headers.set("x-agentos-app-isolate-mode", trace.isolateMode); + headers.set("x-agentos-app-execution-mode", trace.executionMode); for (const [name, value] of trace.phases) { headers.set(`x-agentos-bench-${name}-ms`, value.toFixed(2)); } - headers.set("x-agentos-bench-server-total-ms", total.toFixed(2)); + headers.set("x-agentos-bench-server-total-ms", totalMs.toFixed(2)); } if (this.config.logRequests) { - console.log( - JSON.stringify({ - event: "dynamic_apps_request", - requestId: randomUUID(), - release: trace.release, + emitDynamicAppsLog({ + level: "info", + source: "runtime", + message: "Dynamic Apps request completed", + appId: trace.appId, + release: trace.release, + requestId: trace.requestId, + metadata: { cache: trace.cacheOutcome, - isolateMode: trace.isolateMode, - totalMs: total, - phases: Object.fromEntries(trace.phases), - }), - ); + executionMode: trace.executionMode, + totalMs, + }, + }); } } } export class ApplicationHandlerError extends Error {} +function evaluationValue(result: EvaluationResult, timeoutMs: number): T { + if (result.outcome === "succeeded" && result.value !== undefined) { + return result.value; + } + if (result.outcome === "timed_out") { + throw new DynamicAppsError( + "agentos_apps_execution_timeout", + `application execution exceeded ${timeoutMs}ms`, + ); + } + if (result.outcome === "cancelled") { + throw new DynamicAppsError( + "agentos_apps_execution_cancelled", + "application execution was cancelled", + ); + } + throw new ApplicationHandlerError( + result.error?.message ?? "application evaluation failed", + ); +} + function validReleaseEvent(event: unknown): event is ReleaseActivatedEvent { if (!event || typeof event !== "object") return false; const value = event as Partial; @@ -1132,49 +1275,6 @@ function validateManifest( } } -function extractAospkgTextFile(bytes: Uint8Array, target: string): string { - const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if ( - buffer.byteLength < 16 || - buffer[0] !== 137 || - buffer.subarray(1, 4).toString("ascii") !== "AOS" - ) { - throw new DynamicAppsError( - "agentos_apps_artifact_format_invalid", - "application artifact is not an AOSP package", - ); - } - let offset = 16 + buffer.readUInt32LE(8) + buffer.readUInt32LE(12); - while (offset + 512 <= buffer.byteLength) { - const header = buffer.subarray(offset, offset + 512); - if (header.every((value) => value === 0)) break; - const name = tarString(header.subarray(0, 100)); - const prefix = tarString(header.subarray(345, 500)); - const path = `${prefix ? `${prefix}/` : ""}${name}`.replace(/^\.\//, ""); - const sizeText = tarString(header.subarray(124, 136)).trim(); - const size = Number.parseInt(sizeText || "0", 8); - if (!Number.isSafeInteger(size) || size < 0) break; - const dataOffset = offset + 512; - const next = dataOffset + Math.ceil(size / 512) * 512; - if (next > buffer.byteLength) break; - if (path === target || path === `/${target}`) { - return new TextDecoder("utf-8", { fatal: true }).decode( - buffer.subarray(dataOffset, dataOffset + size), - ); - } - offset = next; - } - throw new DynamicAppsError( - "agentos_apps_artifact_entry_missing", - `application artifact is missing ${target}`, - ); -} - -function tarString(bytes: Uint8Array): string { - const end = bytes.indexOf(0); - return Buffer.from(end < 0 ? bytes : bytes.subarray(0, end)).toString("utf8"); -} - async function serializeRequest(request: Request): Promise { if (Buffer.byteLength(request.url) > MAX_URL_BYTES) { throw new DynamicAppsError( @@ -1196,8 +1296,9 @@ async function serializeRequest(request: Request): Promise { .split(",") .map((value) => value.trim().toLowerCase()) .filter(Boolean); - for (const name of [...HOP_BY_HOP_HEADERS, ...connectionTokens]) + for (const name of [...HOP_BY_HOP_HEADERS, ...connectionTokens]) { headers.delete(name); + } for (const name of [ "x-rivet-token", "x-agentos-app-region", @@ -1338,23 +1439,6 @@ async function measure( } } -async function withDeadline(operation: Promise, timeoutMs: number) { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - operation, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error("isolate promise timed out")), - timeoutMs, - ); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} - function measureOptional( trace: RequestTrace | undefined, name: string, @@ -1384,17 +1468,21 @@ function integerEnv( return value; } -/** @internal Computes the safe active-isolate cap for a finite cgroup. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** @internal Computes the safe active-context cap for a finite cgroup. */ export function capExecutionConcurrencyForMemory(input: { requested: number; - isolateHeapLimitMb: number; + contextHeapLimitMb: number; memoryHighWaterPercent: number; currentBytes: number; maxBytes: number; }): number { return capConcurrencyForMemory({ requested: input.requested, - heapLimitMb: input.isolateHeapLimitMb, + contextAndVmLimitMb: input.contextHeapLimitMb + AGENTOS_VM_OVERHEAD_MB, memoryHighWaterPercent: input.memoryHighWaterPercent, currentBytes: input.currentBytes, maxBytes: input.maxBytes, @@ -1424,22 +1512,6 @@ class Semaphore { } async acquire(): Promise { - await this.#acquire(); - } - - release(): void { - if (this.#active <= 0) return; - this.#active -= 1; - this.#wake(); - } - - dispose(): void { - this.#disposed = true; - for (const item of this.#queue.splice(0)) - item.reject(new Error("disposed")); - } - - async #acquire(): Promise { if (this.#disposed) throw new Error("semaphore disposed"); if (this.#active < this.capacity) { this.#active += 1; @@ -1482,140 +1554,20 @@ class Semaphore { }); } - #wake(): void { - if (this.#active >= this.capacity) return; - this.#queue.shift()?.resolve(); + release(): void { + if (this.#active <= 0) return; + this.#active -= 1; + if (this.#active < this.capacity) this.#queue.shift()?.resolve(); + } + + dispose(): void { + this.#disposed = true; + for (const item of this.#queue.splice(0)) { + item.reject(new Error("disposed")); + } } } -const ISOLATE_BOOTSTRAP_SOURCE = String.raw` -(() => { - const utf8Encode = (text) => { - const escaped = unescape(encodeURIComponent(String(text))); - const bytes = new Uint8Array(escaped.length); - for (let i = 0; i < escaped.length; i++) bytes[i] = escaped.charCodeAt(i); - return bytes; - }; - const utf8Decode = (bytes) => { - let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - return decodeURIComponent(escape(binary)); - }; - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - globalThis.__dynamicAppsBase64Encode = (bytes) => { - const chunks = []; - let out = ""; - for (let i = 0; i < bytes.length; i += 3) { - const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0); - out += alphabet[(n >> 18) & 63] + alphabet[(n >> 12) & 63] + - (i + 1 < bytes.length ? alphabet[(n >> 6) & 63] : "=") + - (i + 2 < bytes.length ? alphabet[n & 63] : "="); - if (out.length >= 16384) { chunks.push(out); out = ""; } - } - chunks.push(out); - return chunks.join(""); - }; - globalThis.__dynamicAppsBase64Decode = (text) => { - const clean = String(text).replace(/=+$/, ""); - const out = new Uint8Array(Math.floor(clean.length * 3 / 4)); - let offset = 0; - let bits = 0, count = 0; - for (const char of clean) { - const value = alphabet.indexOf(char); - if (value < 0) continue; - bits = (bits << 6) | value; - count += 6; - if (count >= 8) { - count -= 8; - out[offset++] = (bits >> count) & 255; - bits &= count === 0 ? 0 : (1 << count) - 1; - } - } - return offset === out.length ? out : out.slice(0, offset); - }; - class TextEncoder { encode(value = "") { return utf8Encode(value); } } - class TextDecoder { decode(value = new Uint8Array()) { return utf8Decode(new Uint8Array(value)); } } - class Headers { - constructor(init) { - this._items = []; - if (init instanceof Headers) init = init._items; - if (Array.isArray(init)) for (const pair of init) this.append(pair[0], pair[1]); - else if (init) for (const key of Object.keys(init)) this.append(key, init[key]); - } - append(name, value) { this._items.push([String(name).toLowerCase(), String(value)]); } - set(name, value) { this.delete(name); this.append(name, value); } - get(name) { const values = this._items.filter(x => x[0] === String(name).toLowerCase()).map(x => x[1]); return values.length ? values.join(", ") : null; } - has(name) { return this._items.some(x => x[0] === String(name).toLowerCase()); } - delete(name) { name = String(name).toLowerCase(); this._items = this._items.filter(x => x[0] !== name); } - forEach(fn, self) { for (const [name, value] of this.entries()) fn.call(self, value, name, this); } - *entries() { const seen = new Set(); for (const [name] of this._items) if (!seen.has(name)) { seen.add(name); yield [name, this.get(name)]; } } - *keys() { for (const [name] of this.entries()) yield name; } - *values() { for (const [, value] of this.entries()) yield value; } - [Symbol.iterator]() { return this.entries(); } - getSetCookie() { return this._items.filter(x => x[0] === "set-cookie").map(x => x[1]); } - } - const bodyBytes = (body) => body == null ? new Uint8Array() : body instanceof Uint8Array ? body.slice() : body instanceof ArrayBuffer ? new Uint8Array(body.slice(0)) : utf8Encode(body); - class Body { - constructor(body) { this._body = bodyBytes(body); this.bodyUsed = false; } - async arrayBuffer() { this.bodyUsed = true; return this._body.slice().buffer; } - async text() { this.bodyUsed = true; return utf8Decode(this._body); } - async json() { return JSON.parse(await this.text()); } - } - class Request extends Body { - constructor(input, init = {}) { - const prior = input instanceof Request ? input : null; - super(init.body ?? prior?._body); - this.url = prior ? prior.url : String(input); - this.method = String(init.method ?? prior?.method ?? "GET").toUpperCase(); - this.headers = new Headers(init.headers ?? prior?.headers); - } - clone() { return new Request(this); } - } - const statusText = { 200: "OK", 201: "Created", 204: "No Content", 301: "Moved Permanently", 302: "Found", 304: "Not Modified", 400: "Bad Request", 404: "Not Found", 500: "Internal Server Error" }; - class Response extends Body { - constructor(body = null, init = {}) { - super(body); - this.status = Number(init.status ?? 200); - this.statusText = String(init.statusText ?? statusText[this.status] ?? ""); - this.headers = new Headers(init.headers); - this.ok = this.status >= 200 && this.status < 300; - } - clone() { return new Response(this._body, { status: this.status, statusText: this.statusText, headers: this.headers }); } - static json(value, init = {}) { const headers = new Headers(init.headers); if (!headers.has("content-type")) headers.set("content-type", "application/json"); return new Response(JSON.stringify(value), { ...init, headers }); } - static redirect(url, status = 302) { return new Response(null, { status, headers: { location: String(url) } }); } - } - class URLSearchParams { - constructor(input = "") { this._items = []; for (const item of String(input).replace(/^\?/, "").split("&")) { if (!item) continue; const [key, ...rest] = item.split("="); this.append(decodeURIComponent(key.replace(/\+/g, " ")), decodeURIComponent(rest.join("=").replace(/\+/g, " "))); } } - append(key, value) { this._items.push([String(key), String(value)]); } - get(key) { const item = this._items.find(x => x[0] === String(key)); return item ? item[1] : null; } - getAll(key) { return this._items.filter(x => x[0] === String(key)).map(x => x[1]); } - has(key) { return this._items.some(x => x[0] === String(key)); } - set(key, value) { this.delete(key); this.append(key, value); } - delete(key) { key = String(key); this._items = this._items.filter(x => x[0] !== key); } - entries() { return this._items[Symbol.iterator](); } - [Symbol.iterator]() { return this.entries(); } - toString() { return this._items.map(x => encodeURIComponent(x[0]).replace(/%20/g, "+") + "=" + encodeURIComponent(x[1]).replace(/%20/g, "+")).join("&"); } - } - class URL { - constructor(input, base) { - input = String(input); - if (base && !/^[a-z][a-z0-9+.-]*:/i.test(input)) input = String(base).replace(/\/[^/]*$/, "/") + input; - const match = /^([a-z][a-z0-9+.-]*:)(?:\/\/([^/?#]*))?([^?#]*)(\?[^#]*)?(#.*)?$/i.exec(input); - if (!match) throw new TypeError("Invalid URL"); - this.protocol = match[1]; this.host = match[2] ?? ""; this.hostname = this.host.split(":")[0]; - this.pathname = match[3] || "/"; this.search = match[4] ?? ""; this.hash = match[5] ?? ""; - this.origin = this.host ? this.protocol + "//" + this.host : "null"; - this.searchParams = new URLSearchParams(this.search); - } - toString() { const query = this.searchParams.toString(); return (this.host ? this.protocol + "//" + this.host : this.protocol) + this.pathname + (query ? "?" + query : "") + this.hash; } - get href() { return this.toString(); } - } - globalThis.TextEncoder = TextEncoder; globalThis.TextDecoder = TextDecoder; - globalThis.Headers = Headers; globalThis.Request = Request; globalThis.Response = Response; - globalThis.URL = URL; globalThis.URLSearchParams = URLSearchParams; - globalThis.performance = { now: () => Date.now() }; -})();`; - let defaultExecutor: DynamicAppsExecutor | undefined; export function getDefaultExecutor(): DynamicAppsExecutor { diff --git a/packages/dynamic-apps/src/index.ts b/packages/dynamic-apps/src/index.ts index 085244667..70efb4ac3 100644 --- a/packages/dynamic-apps/src/index.ts +++ b/packages/dynamic-apps/src/index.ts @@ -1,2 +1,9 @@ export { deployApp } from "./deploy.js"; +export { + type DynamicAppsLogEvent, + type DynamicAppsLogHandler, + type DynamicAppsLogLevel, + type DynamicAppsLogSource, + setDynamicAppsLogHandler, +} from "./logging.js"; export { appsRouter } from "./router.js"; diff --git a/packages/dynamic-apps/src/logging.ts b/packages/dynamic-apps/src/logging.ts new file mode 100644 index 000000000..51bfa067f --- /dev/null +++ b/packages/dynamic-apps/src/logging.ts @@ -0,0 +1,144 @@ +export type DynamicAppsLogLevel = "debug" | "info" | "warn" | "error"; + +export type DynamicAppsLogSource = + | "application" + | "actor" + | "build" + | "runtime"; + +export interface DynamicAppsLogEvent { + version: 1; + timestamp: number; + level: DynamicAppsLogLevel; + source: DynamicAppsLogSource; + message: string; + appId?: string; + release?: string; + requestId?: string; + actorId?: string; + stream?: "stdout" | "stderr"; + metadata?: Readonly>; +} + +export type DynamicAppsLogHandler = ( + event: Readonly, +) => void; + +type DynamicAppsLogInput = Omit; + +export const MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES = 64 * 1024; +const HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS = 60_000; + +let logHandler: DynamicAppsLogHandler | undefined; +let lastHandlerErrorDiagnosticAt = 0; + +export function setDynamicAppsLogHandler( + handler: DynamicAppsLogHandler | undefined, +): void { + logHandler = handler; +} + +/** @internal */ +export function emitDynamicAppsLog(input: DynamicAppsLogInput): void { + const handler = logHandler; + if (!handler) return; + const truncated = truncateUtf8(input.message); + const metadata = input.metadata + ? Object.freeze({ + ...input.metadata, + ...(truncated.truncated ? { truncated: true } : {}), + }) + : truncated.truncated + ? Object.freeze({ truncated: true }) + : undefined; + const event = Object.freeze({ + ...input, + version: 1 as const, + timestamp: Date.now(), + message: truncated.value, + ...(metadata ? { metadata } : {}), + }); + try { + handler(event); + } catch (error) { + const now = Date.now(); + if ( + now - lastHandlerErrorDiagnosticAt >= + HANDLER_ERROR_DIAGNOSTIC_INTERVAL_MS + ) { + lastHandlerErrorDiagnosticAt = now; + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[dynamic-apps] log handler failed: ${truncateUtf8(message).value}\n`, + ); + } + } +} + +/** Incrementally reconstructs bounded UTF-8 lines from a byte stream. */ +export class DynamicAppsLogLineDecoder { + readonly #decoder = new TextDecoder(); + readonly #emit: (message: string, truncated: boolean) => void; + #buffer = ""; + #bufferBytes = 0; + #truncated = false; + #ended = false; + + constructor(emit: (message: string, truncated: boolean) => void) { + this.#emit = emit; + } + + write(chunk: Uint8Array): void { + if (this.#ended) return; + this.#consume(this.#decoder.decode(chunk, { stream: true })); + } + + end(): void { + if (this.#ended) return; + this.#ended = true; + this.#consume(this.#decoder.decode()); + if (this.#buffer || this.#truncated) this.#flushLine(); + } + + #consume(text: string): void { + for (const character of text) { + if (character === "\n") { + this.#flushLine(); + continue; + } + if (this.#truncated) continue; + const bytes = Buffer.byteLength(character); + if (this.#bufferBytes + bytes > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { + this.#truncated = true; + continue; + } + this.#buffer += character; + this.#bufferBytes += bytes; + } + } + + #flushLine(): void { + const message = this.#buffer.endsWith("\r") + ? this.#buffer.slice(0, -1) + : this.#buffer; + this.#emit(message, this.#truncated); + this.#buffer = ""; + this.#bufferBytes = 0; + this.#truncated = false; + } +} + +function truncateUtf8(value: string): { value: string; truncated: boolean } { + if (Buffer.byteLength(value) <= MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) { + return { value, truncated: false }; + } + let output = ""; + let bytes = 0; + for (const character of value) { + const size = Buffer.byteLength(character); + if (bytes + size > MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES) break; + output += character; + bytes += size; + } + return { value: output, truncated: true }; +} diff --git a/packages/dynamic-apps/src/memory.ts b/packages/dynamic-apps/src/memory.ts index ef59ef53e..92a8b065c 100644 --- a/packages/dynamic-apps/src/memory.ts +++ b/packages/dynamic-apps/src/memory.ts @@ -10,7 +10,7 @@ export interface CgroupMemory { export function capConcurrencyForMemory(input: { requested: number; - heapLimitMb: number; + contextAndVmLimitMb: number; memoryHighWaterPercent: number; currentBytes: number; maxBytes: number; @@ -20,7 +20,7 @@ export function capConcurrencyForMemory(input: { MEMORY_ADMISSION_RESERVE_BYTES; const availableBytes = Math.max(0, targetBytes - input.currentBytes); const perRequestBytes = - input.heapLimitMb * 1024 * 1024 + MEMORY_ADMISSION_PAYLOAD_BYTES; + input.contextAndVmLimitMb * 1024 * 1024 + MEMORY_ADMISSION_PAYLOAD_BYTES; return Math.max( 1, Math.min(input.requested, Math.floor(availableBytes / perRequestBytes)), diff --git a/packages/dynamic-apps/src/router.ts b/packages/dynamic-apps/src/router.ts index 3fadd6cce..60644caa4 100644 --- a/packages/dynamic-apps/src/router.ts +++ b/packages/dynamic-apps/src/router.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Hono } from "hono"; import type { BlankEnv, BlankSchema } from "hono/types"; import { DynamicAppsError } from "./errors.js"; @@ -9,9 +10,16 @@ const PRIVATE_REGISTRY_SENTINEL = "x-agentos-app-registry-dispatch"; const MAX_URL_BYTES = 16 * 1024; const MAX_METHOD_BYTES = 256; let requestOverride: - | ((appId: string, request: Request) => Promise) + | ((appId: string, request: Request, requestId: string) => Promise) | undefined; +function requestId(request: Request): string { + const provided = request.headers.get("x-request-id"); + return provided && /^[\x21-\x7e]{1,128}$/.test(provided) + ? provided + : randomUUID(); +} + function errorCode(error: unknown): string | undefined { if (error instanceof DynamicAppsError) return error.code; if (typeof error !== "object" || error === null || !("code" in error)) { @@ -136,9 +144,10 @@ const handler = async (context: { } url.pathname = suffix.startsWith("/") ? suffix : `/${suffix}`; const forwarded = new Request(url, original); + const id = requestId(original); return await (requestOverride - ? requestOverride(appId, forwarded) - : getDefaultExecutor().request(appId, forwarded)); + ? requestOverride(appId, forwarded, id) + : getDefaultExecutor().request(appId, forwarded, id)); } catch (error) { return exceptionResponse(error); } @@ -166,7 +175,11 @@ export const appsRouter: Hono = router; /** @internal Test and benchmark seam; not exported from the package root. */ export function setRouterRequestOverride( - override?: (appId: string, request: Request) => Promise, + override?: ( + appId: string, + request: Request, + requestId: string, + ) => Promise, ): void { requestOverride = override; } diff --git a/packages/dynamic-apps/src/runtime.ts b/packages/dynamic-apps/src/runtime.ts index 2c3addeb9..f294fed02 100644 --- a/packages/dynamic-apps/src/runtime.ts +++ b/packages/dynamic-apps/src/runtime.ts @@ -78,9 +78,25 @@ export function directRunnerSource(input: { entrypoint: string; release: string; maxResponseBytes: number; + usesRivetKit?: boolean; }): string { const entrypoint = `./${normalizeAppPath(input.entrypoint)}`; - return `import exported from ${JSON.stringify(entrypoint)}; + const importApplication = input.usesRivetKit + ? `const dynamicAppsModuleImportStartedAt = performance.now(); +import { Registry } from "rivetkit"; +const originalStart = Registry.prototype.start; +Registry.prototype.start = function dynamicAppsManagedStart() {}; +let application; +try { + application = await import(${JSON.stringify(entrypoint)}); +} finally { + Registry.prototype.start = originalStart; +}` + : `const dynamicAppsModuleImportStartedAt = performance.now(); +const application = await import(${JSON.stringify(entrypoint)});`; + return `${importApplication} +const dynamicAppsModuleImportMs = performance.now() - dynamicAppsModuleImportStartedAt; +const exported = application.default; const appFetch = typeof exported === "function" ? exported : typeof exported?.fetch === "function" @@ -92,16 +108,15 @@ if (!appFetch) { ); } -globalThis.__dynamicAppMetadata = Object.freeze({ +export const dynamicAppMetadata = Object.freeze({ format: ${JSON.stringify(DIRECT_RUNTIME_FORMAT)}, release: ${JSON.stringify(input.release)}, }); -globalThis.__dynamicAppDispatch = async function(inputJson) { - const input = JSON.parse(inputJson); +export async function dispatch(input) { const startedAt = performance.now(); const body = input.bodyBase64 - ? globalThis.__dynamicAppsBase64Decode(input.bodyBase64) + ? Buffer.from(input.bodyBase64, "base64") : undefined; const request = new Request(input.url, { method: input.method, @@ -130,19 +145,20 @@ globalThis.__dynamicAppDispatch = async function(inputJson) { headers.push(["set-cookie", cookie]); } const serializedAt = performance.now(); - return JSON.stringify({ + return { status: response.status, statusText: response.statusText, headers, - bodyBase64: globalThis.__dynamicAppsBase64Encode(responseBody), + bodyBase64: Buffer.from(responseBody).toString("base64"), timing: { + moduleImportMs: dynamicAppsModuleImportMs, requestBuildMs: requestBuiltAt - startedAt, handlerMs: handlerAt - requestBuiltAt, responseSerializeMs: serializedAt - handlerAt, dispatcherMs: serializedAt - startedAt, }, - }); -}; + }; +} `; } diff --git a/packages/dynamic-apps/tests/agentos-inline-spike.test.ts b/packages/dynamic-apps/tests/agentos-inline-spike.test.ts new file mode 100644 index 000000000..8d7012368 --- /dev/null +++ b/packages/dynamic-apps/tests/agentos-inline-spike.test.ts @@ -0,0 +1,123 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { AgentOs } from "@rivet-dev/agentos-core"; +import { packAospkgFromTarBytes } from "@rivet-dev/agentos-toolchain"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); + +test("agentOS supports the inline Dynamic Apps execution contract", async () => { + const directory = await mkdtemp( + join(tmpdir(), "dynamic-apps-agentos-spike-"), + ); + const archive = join(directory, "app.tar"); + const artifact = join(directory, "app.aospkg"); + let vm: AgentOs | undefined; + try { + await mkdir(join(directory, "direct")); + await writeFile( + join(directory, "direct", "main.mjs"), + `let count = 0; +export async function dispatch(request) { + count += 1; + console.log("stdout:" + request.path); + console.error("stderr:" + request.path); + return { status: 200, path: request.path, count }; +} +`, + ); + await writeFile( + join(directory, "agentos-package.json"), + JSON.stringify({ name: "dynamic-apps-agentos-spike", version: "1.0.0" }), + ); + await execFileAsync( + "tar", + ["-cf", archive, "direct", "agentos-package.json"], + { cwd: directory }, + ); + await writeFile( + artifact, + packAospkgFromTarBytes(await readFile(archive)).bytes, + ); + + vm = await AgentOs.create({ + defaultSoftware: false, + mounts: [ + { + path: "/app", + readOnly: true, + plugin: { + id: "agentos_packages", + config: { + kind: "tar", + tarPath: artifact, + root: "/", + readOnly: true, + }, + }, + }, + ], + permissions: { + fs: "allow", + childProcess: "allow", + process: "allow", + env: "allow", + network: "allow", + }, + }); + + const stdout: Uint8Array[] = []; + const stderr: Uint8Array[] = []; + const activeVm = vm; + const evaluate = (contextId?: string) => + activeVm.javascript.evaluate<{ + status: number; + path: string; + count: number; + }>( + `await (await import("/app/direct/main.mjs")).dispatch(inputs.request)`, + { + ...(contextId ? { contextId } : {}), + inputs: { request: { path: "/spike" } }, + onStdout: (chunk) => stdout.push(chunk), + onStderr: (chunk) => stderr.push(chunk), + timeoutMs: 5_000, + }, + ); + + await vm.createContext("retained"); + expect(await evaluate("retained")).toMatchObject({ + outcome: "succeeded", + value: { status: 200, path: "/spike", count: 1 }, + }); + expect(Buffer.concat(stdout).toString()).toContain("stdout:/spike"); + expect(Buffer.concat(stderr).toString()).toContain("stderr:/spike"); + + await vm.contexts.reset("retained"); + expect(await evaluate("retained")).toMatchObject({ + outcome: "succeeded", + value: { count: 1 }, + }); + await vm.contexts.delete("retained"); + expect(await vm.contexts.list()).toEqual([]); + + expect( + await vm.javascript.evaluate("await new Promise(() => {})", { + timeoutMs: 25, + }), + ).toMatchObject({ outcome: "timed_out" }); + const controller = new AbortController(); + const aborted = vm.javascript.evaluate("await new Promise(() => {})", { + signal: controller.signal, + timeoutMs: 5_000, + }); + controller.abort(); + await expect(aborted).rejects.toMatchObject({ name: "AbortError" }); + } finally { + await vm?.dispose(); + await rm(directory, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/dynamic-apps/tests/direct.test.ts b/packages/dynamic-apps/tests/direct.test.ts index e44815fa8..c3f0f64e9 100644 --- a/packages/dynamic-apps/tests/direct.test.ts +++ b/packages/dynamic-apps/tests/direct.test.ts @@ -21,6 +21,10 @@ import { type ExecutorConfig, readExecutorConfig, } from "../src/executor.js"; +import { + type DynamicAppsLogEvent, + setDynamicAppsLogHandler, +} from "../src/logging.js"; import { appsRouter, setRouterRequestOverride } from "../src/router.js"; import { canonicalDeploymentHash, @@ -33,7 +37,10 @@ import { prepareSource } from "../src/source.js"; const execFileAsync = promisify(execFile); -afterEach(() => setRouterRequestOverride()); +afterEach(() => { + setRouterRequestOverride(); + setDynamicAppsLogHandler(undefined); +}); describe("retained public surface", () => { test("rewrites a prefix-mounted application request", async () => { @@ -302,40 +309,38 @@ describe("source and runtime contract", () => { release: "release-1", maxResponseBytes: 1024, }); - expect(source).toContain("__dynamicAppDispatch"); + expect(source).toContain("export async function dispatch"); expect(source).not.toContain("listen("); expect(source).not.toContain("createServer"); }); - test("supports fresh, snapshot, and prewarm config", () => { + test("supports ephemeral and pooled execution config", () => { expect( - readExecutorConfig({ DYNAMIC_APPS_ISOLATE_MODE: "fresh" }).isolateMode, - ).toBe("fresh"); + readExecutorConfig({ DYNAMIC_APPS_EXECUTION_MODE: "ephemeral" }) + .executionMode, + ).toBe("ephemeral"); expect( readExecutorConfig({ - DYNAMIC_APPS_ISOLATE_MODE: "prewarm", - DYNAMIC_APPS_ISOLATE_POOL_SIZE: "0", - }).isolateMode, - ).toBe("snapshot"); - expect( - readExecutorConfig({ DYNAMIC_APPS_ISOLATE_MODE: "snapshot" }).isolateMode, - ).toBe("snapshot"); + DYNAMIC_APPS_EXECUTION_MODE: "pooled", + DYNAMIC_APPS_CONTEXT_POOL_SIZE: "0", + }).executionMode, + ).toBe("pooled"); }); - test("caps isolate admission below a finite cgroup high-water mark", () => { + test("caps agentOS context admission below a finite cgroup high-water mark", () => { expect( capExecutionConcurrencyForMemory({ requested: 32, - isolateHeapLimitMb: 64, + contextHeapLimitMb: 64, memoryHighWaterPercent: 70, currentBytes: 128 * 1024 * 1024, maxBytes: 512 * 1024 * 1024, }), - ).toBe(2); + ).toBe(1); }); }); -describe("direct V8 execution", () => { +describe("direct agentOS execution", () => { test("does not publish a runtime that finishes preparing during shutdown", async () => { const artifact = await makeArtifact("shutdown-prepare"); let releaseChunk = () => {}; @@ -353,7 +358,7 @@ describe("direct V8 execution", () => { }, }); const executor = new DynamicAppsExecutor( - executorConfig("prewarm"), + executorConfig("pooled"), fake.client, ); const outcome = executor @@ -370,7 +375,7 @@ describe("direct V8 execution", () => { expect(await outcome).toBe("rejected"); expect(executor.diagnostics()).toMatchObject({ runtimes: 0, - pooledIsolates: 0, + pooledContexts: 0, poolReservations: 0, }); } finally { @@ -383,12 +388,12 @@ describe("direct V8 execution", () => { test("times out an asynchronous handler that never settles", async () => { const artifact = await makeArtifact( "async-stall", - `globalThis.__dynamicAppDispatch = async function() { - return new Promise(() => {}); -};`, + `export async function dispatch() { + return new Promise(() => {}); + }`, ); const fake = fakeStateClient(artifact); - const config = { ...executorConfig("prewarm"), executionTimeoutMs: 50 }; + const config = { ...executorConfig("pooled"), executionTimeoutMs: 50 }; const executor = new DynamicAppsExecutor(config, fake.client); try { const outcome = await Promise.race([ @@ -400,7 +405,7 @@ describe("direct V8 execution", () => { : Promise.reject(error), ), new Promise<"hung">((resolve) => - setTimeout(() => resolve("hung"), 250), + setTimeout(() => resolve("hung"), 1_000), ), ]); expect(outcome).toBe("timeout"); @@ -410,11 +415,11 @@ describe("direct V8 execution", () => { } }, 5_000); - test("round-trips binary request bodies through the isolate envelope", async () => { + test("round-trips binary request bodies through the agentOS envelope", async () => { const artifact = await makeArtifact("binary"); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( - executorConfig("prewarm"), + executorConfig("pooled"), fake.client, ); const input = Uint8Array.from( @@ -439,10 +444,9 @@ describe("direct V8 execution", () => { }); test.each([ - "fresh", - "snapshot", - "prewarm", - ] as const)("isolates every request in %s mode", async (mode) => { + "ephemeral", + "pooled", + ] as const)("starts every request clean in %s mode", async (mode) => { const artifact = await makeArtifact("one"); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor(executorConfig(mode), fake.client); @@ -481,11 +485,11 @@ describe("direct V8 execution", () => { } }, 120_000); - test("reuses bounded prewarmed isolates while resetting context state", async () => { + test("reuses bounded retained contexts while resetting module state", async () => { const artifact = await makeArtifact("reuse"); const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( - executorConfig("prewarm"), + executorConfig("pooled"), fake.client, ); try { @@ -498,12 +502,11 @@ describe("direct V8 execution", () => { await waitForCleanPool(executor, 2); } expect(executor.diagnostics()).toMatchObject({ - cleanIsolates: 2, - isolateCreates: 2, - isolateDisposes: 0, - contextCreates: 22, - contextDisposes: 20, - dispatches: 20, + cleanContexts: 2, + vmCreates: 1, + contextCreates: 2, + contextDisposes: 0, + evaluations: 20, }); } finally { await executor.dispose(); @@ -516,7 +519,7 @@ describe("direct V8 execution", () => { const fake = fakeStateClient(artifact); const executor = new DynamicAppsExecutor( { - ...executorConfig("prewarm"), + ...executorConfig("pooled"), executionConcurrency: 1, executionQueueSize: 0, }, @@ -570,7 +573,7 @@ describe("direct V8 execution", () => { } }); - test("bounds the total prewarm cache across applications", async () => { + test("bounds the total retained context cache across applications", async () => { const artifacts = await Promise.all( Array.from({ length: 6 }, (_, index) => makeArtifact(`multi-${index}`)), ); @@ -581,9 +584,9 @@ describe("direct V8 execution", () => { ); const executor = new DynamicAppsExecutor( { - ...executorConfig("prewarm"), + ...executorConfig("pooled"), runtimeCacheMaxEntries: artifacts.length, - isolatePoolMaxTotal: 4, + contextPoolMaxTotal: 4, } as ExecutorConfig, fake.client, ); @@ -597,13 +600,91 @@ describe("direct V8 execution", () => { } expect(executor.diagnostics()).toMatchObject({ runtimes: artifacts.length, - cleanIsolates: 4, + cleanContexts: 4, }); } finally { await executor.dispose(); await Promise.all(artifacts.map((artifact) => artifact.dispose())); } }); + + test("runs supported Node builtins inside the sandbox", async () => { + const artifact = await makeArtifact( + "node-api", + `import { basename } from "node:path"; +export async function dispatch(input) { + return { status: 200, statusText: "OK", headers: [], bodyBase64: Buffer.from(basename(new URL(input.url).pathname)).toString("base64") }; +}`, + ); + const fake = fakeStateClient(artifact); + const executor = new DynamicAppsExecutor( + executorConfig("ephemeral"), + fake.client, + ); + try { + const response = await executor.request( + "demo", + new Request("http://example.test/path/file.txt"), + ); + expect(await response.text()).toBe("file.txt"); + } finally { + await executor.dispose(); + await artifact.dispose(); + } + }); + + test("attributes application output and request summaries", async () => { + const events: Readonly[] = []; + setDynamicAppsLogHandler((event) => events.push(event)); + const artifact = await makeArtifact( + "logging", + `export async function dispatch() { + console.log("application stdout"); + console.error("application stderr"); + return { status: 200, statusText: "OK", headers: [], bodyBase64: "" }; +}`, + ); + const fake = fakeStateClient(artifact); + const executor = new DynamicAppsExecutor( + { ...executorConfig("ephemeral"), logRequests: true }, + fake.client, + ); + try { + const response = await executor.request( + "demo", + new Request("http://example.test/log", { + headers: { authorization: "Bearer never-log-this" }, + }), + "request-test", + ); + expect(response.status).toBe(200); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "application", + stream: "stdout", + message: "application stdout", + appId: "demo", + requestId: "request-test", + }), + expect.objectContaining({ + source: "application", + stream: "stderr", + message: "application stderr", + }), + expect.objectContaining({ + source: "runtime", + message: "Dynamic Apps request completed", + requestId: "request-test", + }), + ]), + ); + expect(JSON.stringify(events)).not.toContain("never-log-this"); + } finally { + await executor.dispose(); + await artifact.dispose(); + } + }); }); describe("actor callback resource limits", () => { @@ -994,6 +1075,52 @@ export const registry = { await artifact.dispose(); } }, 5_000); + + test("attributes actor worker stdout and stderr", async () => { + const events: Readonly[] = []; + setDynamicAppsLogHandler((event) => events.push(event)); + const artifact = await makeActorArtifact(` +console.log("actor stdout"); +console.error("actor stderr"); +export const registry = { handler: () => new Response("ok") }; +`); + const runtime = new DynamicActorRuntime(); + try { + const response = await runtime.request({ + key: "actor-logging", + appId: "demo", + release: "release-logging", + loadArtifact: async () => artifact.bytes, + endpoint: "http://example.test", + namespace: "test", + pool: "default", + request: new Request("http://example.test/start", { method: "POST" }), + }); + expect(await response.text()).toBe("ok"); + await waitFor( + () => events.filter((event) => event.source === "actor").length >= 2, + ); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "actor", + stream: "stdout", + message: "actor stdout", + appId: "demo", + release: "release-logging", + }), + expect.objectContaining({ + source: "actor", + stream: "stderr", + message: "actor stderr", + }), + ]), + ); + } finally { + await runtime.dispose(); + await artifact.dispose(); + } + }); }); async function waitForCleanPool( @@ -1001,10 +1128,10 @@ async function waitForCleanPool( expected: number, ): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { - if (executor.diagnostics().cleanIsolates === expected) return; + if (executor.diagnostics().cleanContexts === expected) return; await new Promise((resolve) => setTimeout(resolve, 1)); } - throw new Error("prewarm pool did not refill"); + throw new Error("retained context pool did not refill"); } async function waitFor(predicate: () => boolean): Promise { @@ -1026,15 +1153,13 @@ function streamingRequest( } as RequestInit & { duplex: "half" }); } -function executorConfig( - mode: "fresh" | "snapshot" | "prewarm", -): ExecutorConfig { +function executorConfig(mode: "ephemeral" | "pooled"): ExecutorConfig { return { - isolateMode: mode, - isolatePoolSize: 2, - isolatePoolMaxTotal: 8, - isolateIdleTtlMs: 30_000, - isolateHeapLimitMb: 128, + executionMode: mode, + contextPoolSize: 2, + contextPoolMaxTotal: 8, + contextIdleTtlMs: 30_000, + contextHeapLimitMb: 128, runtimeCacheMaxEntries: 4, runtimeCacheMaxBytes: 64 * 1024 * 1024, runtimeCacheIdleTtlMs: 60_000, @@ -1064,9 +1189,8 @@ async function makeArtifact( const moduleSource = customSource ?? `let counter = 0; -globalThis.__dynamicAppDispatch = async function(inputJson) { - const input = JSON.parse(inputJson); - const requestBody = globalThis.__dynamicAppsBase64Decode(input.bodyBase64 || ""); +export async function dispatch(input) { + const requestBody = Buffer.from(input.bodyBase64 || "", "base64"); counter += 1; const request = new Request(input.url, { method: input.method, headers: input.headers }); const body = JSON.stringify({ @@ -1075,16 +1199,16 @@ globalThis.__dynamicAppDispatch = async function(inputJson) { path: new URL(request.url).pathname + new URL(request.url).search, authorization: request.headers.get("authorization"), privateToken: request.headers.get("x-rivet-token"), - requestBodyBase64: globalThis.__dynamicAppsBase64Encode(requestBody), + requestBodyBase64: Buffer.from(requestBody).toString("base64"), }); - return JSON.stringify({ + return { status: 200, statusText: "OK", headers: [["content-type", "application/json"], ["set-cookie", "a=1"], ["set-cookie", "b=2"]], - bodyBase64: globalThis.__dynamicAppsBase64Encode(new TextEncoder().encode(body)), + bodyBase64: Buffer.from(body).toString("base64"), timing: { moduleImportMs: 0, handlerMs: 0 }, - }); -};`; + }; +}`; await mkdir(join(directory, "direct")); await writeFile(join(directory, "direct", "main.mjs"), moduleSource); await writeFile( diff --git a/packages/dynamic-apps/tests/logging.test.ts b/packages/dynamic-apps/tests/logging.test.ts new file mode 100644 index 000000000..af4830f08 --- /dev/null +++ b/packages/dynamic-apps/tests/logging.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + DynamicAppsLogLineDecoder, + emitDynamicAppsLog, + MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES, + setDynamicAppsLogHandler, +} from "../src/logging.js"; + +afterEach(() => { + setDynamicAppsLogHandler(undefined); + vi.restoreAllMocks(); +}); + +describe("structured Dynamic Apps logging", () => { + test("replaces and removes the process-global handler", () => { + const first: string[] = []; + const second: string[] = []; + setDynamicAppsLogHandler((event) => first.push(event.message)); + emit("first"); + setDynamicAppsLogHandler((event) => second.push(event.message)); + emit("second"); + setDynamicAppsLogHandler(undefined); + emit("disabled"); + expect(first).toEqual(["first"]); + expect(second).toEqual(["second"]); + }); + + test("freezes events and metadata before synchronous delivery", () => { + let received: + | Parameters< + NonNullable[0]> + >[0] + | undefined; + setDynamicAppsLogHandler((event) => { + received = event; + }); + emitDynamicAppsLog({ + level: "info", + source: "runtime", + message: "complete", + metadata: { durationMs: 1 }, + }); + expect(received).toMatchObject({ version: 1, message: "complete" }); + expect(Object.isFrozen(received)).toBe(true); + expect(Object.isFrozen(received?.metadata)).toBe(true); + }); + + test("isolates throwing handlers and truncates messages at 64 KiB", () => { + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + setDynamicAppsLogHandler(() => { + throw new Error("logger offline"); + }); + expect(() => emit("safe response")).not.toThrow(); + expect(stderr).toHaveBeenCalled(); + + let event: + | { message: string; metadata?: Readonly> } + | undefined; + setDynamicAppsLogHandler((value) => { + event = value; + }); + emit("๐Ÿ™‚".repeat(MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES)); + expect(Buffer.byteLength(event?.message ?? "")).toBeLessThanOrEqual( + MAX_DYNAMIC_APPS_LOG_MESSAGE_BYTES, + ); + expect(event?.metadata?.truncated).toBe(true); + }); + + test("reconstructs split UTF-8 lines and flushes a final fragment", () => { + const lines: Array<[string, boolean]> = []; + const decoder = new DynamicAppsLogLineDecoder((message, truncated) => + lines.push([message, truncated]), + ); + const bytes = Buffer.from("first ๐Ÿ™‚\nlast"); + for (const byte of bytes) decoder.write(Uint8Array.of(byte)); + decoder.end(); + expect(lines).toEqual([ + ["first ๐Ÿ™‚", false], + ["last", false], + ]); + }); +}); + +function emit(message: string): void { + emitDynamicAppsLog({ + level: "info", + source: "runtime", + message, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 847bda86f..5d4b56ee6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,10 @@ settings: overrides: '@rivet-dev/agentos-core': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-wasm': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/engine-cli': 2.3.11 + '@rivetkit/react': 2.3.11 + '@rivetkit/rivetkit-wasm': 2.3.11 + rivetkit: 2.3.11 importers: @@ -44,12 +46,12 @@ importers: specifier: ^4.12.9 version: 4.13.3 rivetkit: - specifier: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - version: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.3) devDependencies: '@rivetkit/engine-cli': - specifier: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - version: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + specifier: 2.3.11 + version: 2.3.11 '@types/node': specifier: ^22.19.15 version: 22.20.1 @@ -121,9 +123,6 @@ importers: '@agentos-software/tar': specifier: 0.3.5 version: 0.3.5 - '@rivet-dev/agentos': - specifier: 0.2.15 - version: 0.2.15(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(ws@8.21.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) @@ -136,12 +135,9 @@ importers: hono: specifier: ^4.7.0 version: 4.13.3 - isolated-vm: - specifier: ^6.2.0 - version: 6.2.0 rivetkit: - specifier: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - version: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.3) devDependencies: '@types/node': specifier: ^22.19.15 @@ -161,7 +157,13 @@ importers: esbuild-wasm: specifier: 0.27.4 version: 0.27.4 + rivetkit: + specifier: 2.3.11 + version: 2.3.11(better-sqlite3@12.11.1)(ws@8.21.3) devDependencies: + '@rivet-dev/agentos-core': + specifier: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c + version: 0.0.0-fix-core-spawn-keep-stdin-open.242e11c(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3) '@rivet-dev/agentos-toolchain': specifier: 0.2.15 version: 0.2.15 @@ -184,15 +186,15 @@ importers: specifier: workspace:* version: link:../../../packages/dynamic-apps-builder rivetkit: - specifier: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - version: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) + specifier: 2.3.11 + 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 '@rivetkit/engine-cli': - specifier: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - version: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + specifier: 2.3.11 + version: 2.3.11 '@types/node': specifier: ^22.19.15 version: 22.20.1 @@ -223,9 +225,6 @@ packages: '@agentos-software/common@0.0.0-fix-core-spawn-keep-stdin-open.242e11c': resolution: {integrity: sha512-hTMKv/gtvxlcMuLIdCi1mWVNZcIDdnbltKnNH2EEYyAEcOk7OnAuY6eEclGrLrRfojEbWr4M8JBTzd3tRLtPoA==} - '@agentos-software/common@0.2.15': - resolution: {integrity: sha512-33roACt3EfAp+C6mciKfthVU7I21XeulM+/yLJRo9tSuXfbMLF18EnkodKhvYAJWjzlLGhbmsxfuSuBF7afrhw==} - '@agentos-software/coreutils@0.3.4': resolution: {integrity: sha512-tGd0gQjUjHnm+5KgOBwidwUtlkKnl9biQuD7X2sZl15VOhYqUJpnyLF33h/nF3r9WtOTclSiLj/dc2xCxmAXnw==} @@ -1300,118 +1299,109 @@ packages: resolution: {integrity: sha512-DcjNMIvXijTNGolTVee+9+rf/G//4P7QK+IssTET8cmYqByDBl2/XDSsocRhnu1giFIxSJTFw5AwxtxO7hiAtw==} hasBin: true - '@rivet-dev/agentos@0.2.15': - resolution: {integrity: sha512-NLJjFNVD0uP/EzGfGOcDaZ+GMx595P9sps/A2aLn+xb/KPUqpc+mxmZi8VYhCj2cr20QBa6jhfA2Ohf+0r72Mg==} - engines: {node: '>=22.0.0'} - peerDependencies: - react: ^18 || ^19 - react-dom: ^18 || ^19 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - '@rivetkit/bare-ts@0.6.2': resolution: {integrity: sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg==} engines: {node: ^14.18.0 || >=16.0.0} - '@rivetkit/engine-cli-darwin-arm64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-OB450mPesx27N9Zcs7R6h2+SnPqsMZGvEcLlO8LpK91nwJCmwp3rx2nNnfG6E7w9djyMd2gGNVCbC+JyvyoShg==} + '@rivetkit/engine-cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-cooLx87XVvBhkZISX5gw5CezO5RLrEWEwZK7xNZjJJ/OQqi6CsZLlNFkANiWcPDHXAAlx2nEQs3+bakgvMle/A==} engines: {node: '>= 20.0.0'} cpu: [arm64] os: [darwin] - '@rivetkit/engine-cli-darwin-x64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-ZVAiGvUAOrvQYO/IHCGqZV8iv6zDjTsxxdWejy2aNsRHq9Oo9BwMruxJAUDPiqp55hV2pqfJ2EjGGnQSReuO+g==} + '@rivetkit/engine-cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-mUoPkJa2NMbUDS9XnbXEzH4z45R2HK1j/mgGa5XTIIvLYWz/OsXpS3NcWnAn2AUE9Mi19HOhDHLYEjD7AJlSkw==} engines: {node: '>= 20.0.0'} cpu: [x64] os: [darwin] - '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-AtcDyANVy/vMUxxnENVyuMf8b4e2S4NjFxnf5wfF6aSI6kjr+AuadxpKM4GJWA+P7euRwxIRbsuPgh9QVWGRIg==} + '@rivetkit/engine-cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-ARnFeoSf0MbNQaB8XUI1OymwUdFKktoW3piN44bazZxJaKvsgJx7aja0KuwI12mNQOLVU0FYtY3awKGXvVLW0A==} engines: {node: '>= 20.0.0'} cpu: [arm64] os: [linux] - '@rivetkit/engine-cli-linux-x64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-1tBFYJXcGZ4na0ZyOBPc2hT6yBPP+5HLhKH3UUF7VI3vxiLnWV/NDtlvzrzlMm4g6LCUMG4vaqg1J/ttxRQrPA==} + '@rivetkit/engine-cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-sVP5vzJ4tiyfkt/4yT+OQnpNeGZ4I51geXgr4u9Al5eh0Oi3lJaui4Kj5sApoOejrhhJu9D/AkYilyTGRBvEMQ==} engines: {node: '>= 20.0.0'} cpu: [x64] os: [linux] - '@rivetkit/engine-cli@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-9dk8nMhjgzSh6P0iLLi9A5sowKXRAeH62z+jCOfxpuZyyG2+nReufU/tuCfEHbEaI1SeZIFV5vdj4x/hYjX5YQ==} + '@rivetkit/engine-cli-win32-x64@2.3.11': + resolution: {integrity: sha512-S7v3kHgiEcBsya5/BLNTmcbFBSkO6JSBs8dvp1K7xiGVLjQtlZeot8pxsBPpGftvxIuq7I/nAMxQxO2LdQUriA==} engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [win32] - '@rivetkit/engine-envoy-protocol@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-kNQHhSKIKx8E8wXPoRBTSP/9fPuA0EG4VQZPF4c3xLNQ4tmIVAGqsFy8oIFwSup+5XQ9/DJhEPrkpMwZOwhvfw==} + '@rivetkit/engine-cli@2.3.11': + resolution: {integrity: sha512-nT4pFT12gIqPuyBR/LD/R8mlze7AxMh/NpUldOXEs3wWqSU+WDE2UwMjv/DV6VVupGC4oZG2IlUn082j3/YHWA==} + engines: {node: '>= 20.0.0'} - '@rivetkit/framework-base@2.3.9': - resolution: {integrity: sha512-ZSxrclYcpmdGsLMiVE2dfWNfUB6diSx+t8k4EQgL4cN02ThzJD3BM5mhU+zQVCCwfNw42eRZVIoxydYDLl/yHw==} + '@rivetkit/engine-envoy-protocol@2.3.11': + resolution: {integrity: sha512-PDDCqj9Y5OOpIVJiDwPLXCCawvKfzvUCxzUOxor+K7ubv9U5kriYABxHUdxga4nhbFnhQct/RSZmy4jnAhieyA==} '@rivetkit/on-change@6.0.1': resolution: {integrity: sha512-QBN/KRBXLJdCgN4gBTL3XAc/zKm58atSnieXWMOyFSPmo6F1/yIVV/LTRdvAktfCttrGx7W6c32i/lwqCHWnsQ==} engines: {node: '>=20'} - '@rivetkit/react@2.3.9': - resolution: {integrity: sha512-j9t82h/yIqqSt17coQZeRu3F9Q5w2FgWPDUC9fCyQUJp9PTjO7ea494PR0lSWvuEiwMRqE5JpgbHdYUQ1woE9w==} - peerDependencies: - react: ^18 || ^19 - react-dom: ^18 || ^19 - - '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-mMVWV4MsTuNpoYc8MwcikpJikv/7nQjolheWGo2+Xh5evQiajdbeUB+eukfRWUIYyKZ1DjVIVAelCWz4T4QOUg==} + '@rivetkit/rivetkit-napi-darwin-arm64@2.3.11': + resolution: {integrity: sha512-gcUNpWoxKnSWFI978yePOmR6Ne6MoF99toRqqzUjEFmYhF7Y2em87YqqpRq1hVycsaWQxg/B0JTTVZo4pDEyzQ==} engines: {node: '>= 20.0.0'} cpu: [arm64] os: [darwin] - '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-btTN0LCIeAQwJpPYbRaoIKj4ASfYyRx/t9juJKg4pg47TzJphxjjqbmgrlUKtGk+hTCg96HNGDgswBO+H63p3g==} + '@rivetkit/rivetkit-napi-darwin-x64@2.3.11': + resolution: {integrity: sha512-eGYqvIcPAODZA3KZgErHzv0ByeJRinrTu895qIN8tXdmivVehExoxXBSrmFrLsK58cYy5yigon1txzE1NcudSA==} engines: {node: '>= 20.0.0'} cpu: [x64] os: [darwin] - '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-1f1ral9ZGIse2gbiyFwvduGmBxHfL2Iwh50mHGx0fbjhdQ3DOHCK+S7RN2VzuviJT+k2L6ZhLLJcsssng8dZkg==} + '@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.11': + resolution: {integrity: sha512-2xu+f9w/DolzMPf3k3wjH+h4/YAxeapb9tJP7U/zONgsq8kiCBx9p0o2weijutpBoQs7thfIyYx0WcIl3UQEVQ==} engines: {node: '>= 20.0.0'} cpu: [arm64] os: [linux] - '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-yhYZzWqx5egBGgjChWuHjcNVXLjUJ90RLtZOE/dBNHVV/YhKbor7j2n5KCpyKoFSzCyWndLRuuVq0ictpoy/nw==} + '@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-W3vcXDc26zIEHRkpl+B3FNoZ16wraf08c7PGF5wlctH8Pb0bGdLPi7zr6jvJ7nalEVP5twCJO6D3z3YbWYgtqA==} engines: {node: '>= 20.0.0'} cpu: [arm64] os: [linux] - '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-ahfHq033jS3Mkp1JVnfYwiHqSHLsOEkHy0AeuJNYPfjegehI0y5iYuYm6FKvVSbomp3Pw1Gadok5UTbiDT0zWA==} + '@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.11': + resolution: {integrity: sha512-QyOANKzvEr6IzrgQGdJyP2fbdCxrm+pH/qfnf3I08JVMmUV38DCkC+1/vu+Twtfa3kpb3Dqa/Q5KpVsH5Ur/GQ==} engines: {node: '>= 20.0.0'} cpu: [x64] os: [linux] - '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-Q9vYUl9Xz/6cyzY0rrovy2V/gBTmJObjCFT2x5Iw+XQq5RDaXuxRrJsI2rs/n2x2GUGtvQtO/nZMnn4B85lApQ==} + '@rivetkit/rivetkit-napi-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-0k6TUxhLYGHni1etmqdIVz9b7hnI4kN+i8DVhy2ViROrc9PTpi8YHCdrHp9HWvyo6U6PN91lj8Bx9K3tpngUmg==} engines: {node: '>= 20.0.0'} cpu: [x64] os: [linux] - '@rivetkit/rivetkit-napi@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-BMoZz5vkXbF5C2jJ9sRuc0OmoXeyzk+/UyPw++k/afpQm/uMoU/PDOG2uDp15f0YAfEvA9OM3zPQkb3RJO+CAg==} + '@rivetkit/rivetkit-napi-win32-x64-msvc@2.3.11': + resolution: {integrity: sha512-1/GC4DCvf8DtsmCVK1LInMSrKCg5U7xjDAqRiPDOn0unxLQ5TYlubINPJ+XmkmavVi9ep+30KgElupBsBXY/uQ==} + engines: {node: '>= 20.0.0'} + cpu: [x64] + os: [win32] + + '@rivetkit/rivetkit-napi@2.3.11': + resolution: {integrity: sha512-rgnD1V7oCU5iVOHsDmgdGEPB7CgfRU38ctbaaeAOfddK+M/UVD4lcfpQ7SdUB+VQ9hFVyYLUgbaQWQLWSYJR8g==} engines: {node: '>= 20.0.0'} - '@rivetkit/rivetkit-wasm@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-09OrZk1nzacGM4Dm5eIQP0k6KAJ9iGI+t5NY5pTpaWl1kYtJxZ8xsolrrgAwPmEm9DcUoLI565Rs/lrP8D+/bQ==} + '@rivetkit/rivetkit-wasm@2.3.11': + resolution: {integrity: sha512-Wtk9KPkS0vC0Od640dNDxkhDd2yzLaw9bdMc52+Ej8OGK/zFUV7HoSL3IU+Qyxj6HPT4HiBhi+xfpIAnR6T4iA==} - '@rivetkit/traces@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-RJYJQBfkl2I9qZGA8I9mTRkuCoVoPF8TQ1sKi4XaJhDZxLh30bxcsAzMbcy6Xn7ni+PAfLRxwSkY+AO8r7LYsA==} + '@rivetkit/traces@2.3.11': + resolution: {integrity: sha512-44lTLApHUqAv+kjDj6MkbC/Wq7mj1CJfRncXOc93ycLxkftFafEldX5A6P0hu0jQ/KHXZXNwKR7D+fNusAZYaQ==} engines: {node: '>=18.0.0'} - '@rivetkit/virtual-websocket@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-HO4YRllAYg8ephUf5W77mXR/arB0yT6iY9tGDSZRK4JGQS3127c5qLPo4fND7vmMvtC1cX1R1MMMpRw30sKEgA==} + '@rivetkit/virtual-websocket@2.3.11': + resolution: {integrity: sha512-3NtJvpnyadQKS2lzFE1SwWzSjfir3RHXcB/V3m+WVlZ1lfvOsmGoLIa8n6R05261T3sZcza1k08y0UWiG9kSQw==} - '@rivetkit/workflow-engine@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': - resolution: {integrity: sha512-szdhepDiO8E7cMXDgp3Ie7IydYvaaoH6/PWSuwuCrlKau/Ig9KVS6j0Uzhvh5iJ4OiMyoo1+9MkHRL8oucU1bw==} + '@rivetkit/workflow-engine@2.3.11': + resolution: {integrity: sha512-SLzk1tQzry0Ds052eK9+6IMtq/RaSBc30AbdUWOUnQz56hojCSgkZMUTJA8ATIaqwDwqqhsY37AkWTR1JP8Pfg==} engines: {node: '>=18.0.0'} '@rollup/rollup-android-arm-eabi@4.62.4': @@ -1601,15 +1591,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/react-store@0.7.7': - resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@tanstack/store@0.7.7': - resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -3018,15 +2999,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} - peerDependencies: - react: ^19.2.8 - - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} - engines: {node: '>=0.10.0'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -3071,8 +3043,8 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} - rivetkit@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9: - resolution: {integrity: sha512-fs8c21BiLh8srF7nk0W9P3/dyKth3BAcF9sN51oCZQF1Xqg2+X5XYW/AGpcsagrNwuVuuQqwMvGRWLu1qrMUBA==} + rivetkit@2.3.11: + resolution: {integrity: sha512-19JDIQoff7Es3t2GlQPv5vA7TvnzCanuAkJbO+ODr46UeOZ4Y0hHqiTZWXghdFU+urxqf7YkvcYUHBLcWtKo8g==} engines: {node: '>=22.0.0'} peerDependencies: drizzle-kit: ^0.31.2 @@ -3112,9 +3084,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - secure-exec@0.2.1: resolution: {integrity: sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ==} @@ -3407,11 +3376,6 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3614,17 +3578,6 @@ snapshots: '@agentos-software/sed': 0.3.4 '@agentos-software/tar': 0.3.5 - '@agentos-software/common@0.2.15': - dependencies: - '@agentos-software/coreutils': 0.3.4 - '@agentos-software/diffutils': 0.3.4 - '@agentos-software/findutils': 0.3.4 - '@agentos-software/gawk': 0.3.4 - '@agentos-software/grep': 0.3.4 - '@agentos-software/gzip': 0.3.4 - '@agentos-software/sed': 0.3.4 - '@agentos-software/tar': 0.3.5 - '@agentos-software/coreutils@0.3.4': {} '@agentos-software/diffutils@0.3.4': {} @@ -4605,209 +4558,83 @@ snapshots: dependencies: '@rivetkit/bare-ts': 0.6.2 - '@rivet-dev/agentos@0.2.15(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(ws@8.21.3)': - dependencies: - '@agentclientprotocol/sdk': 0.16.1(zod@4.4.3) - '@agentos-software/common': 0.2.15 - '@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) - '@rivetkit/react': 2.3.9(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(ws@8.21.3) - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) - zod: 4.4.3 - optionalDependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - transitivePeerDependencies: - - '@aws-sdk/client-rds-data' - - '@cfworker/json-schema' - - '@cloudflare/workers-types' - - '@electric-sql/pglite' - - '@libsql/client' - - '@libsql/client-wasm' - - '@modelcontextprotocol/sdk' - - '@neondatabase/serverless' - - '@op-engineering/op-sqlite' - - '@opentelemetry/api' - - '@planetscale/database' - - '@prisma/client' - - '@tidbcloud/serverless' - - '@types/better-sqlite3' - - '@types/pg' - - '@types/sql.js' - - '@upstash/redis' - - '@vercel/postgres' - - '@xata.io/client' - - better-sqlite3 - - bufferutil - - bun-types - - drizzle-kit - - encoding - - eventsource - - expo-sqlite - - gel - - knex - - kysely - - mysql2 - - pg - - postgres - - prisma - - pyodide - - sql.js - - sqlite3 - - supports-color - - utf-8-validate - - ws - '@rivetkit/bare-ts@0.6.2': {} - '@rivetkit/engine-cli-darwin-arm64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-cli-darwin-arm64@2.3.11': + optional: true + + '@rivetkit/engine-cli-darwin-x64@2.3.11': optional: true - '@rivetkit/engine-cli-darwin-x64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-cli-linux-arm64-musl@2.3.11': optional: true - '@rivetkit/engine-cli-linux-arm64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-cli-linux-x64-musl@2.3.11': optional: true - '@rivetkit/engine-cli-linux-x64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-cli-win32-x64@2.3.11': optional: true - '@rivetkit/engine-cli@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-cli@2.3.11': optionalDependencies: - '@rivetkit/engine-cli-darwin-arm64': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/engine-cli-darwin-x64': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/engine-cli-linux-arm64-musl': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/engine-cli-linux-x64-musl': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/engine-cli-darwin-arm64': 2.3.11 + '@rivetkit/engine-cli-darwin-x64': 2.3.11 + '@rivetkit/engine-cli-linux-arm64-musl': 2.3.11 + '@rivetkit/engine-cli-linux-x64-musl': 2.3.11 + '@rivetkit/engine-cli-win32-x64': 2.3.11 - '@rivetkit/engine-envoy-protocol@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/engine-envoy-protocol@2.3.11': dependencies: '@rivetkit/bare-ts': 0.6.2 - '@rivetkit/framework-base@2.3.9(better-sqlite3@12.11.1)(ws@8.21.3)': - dependencies: - '@tanstack/store': 0.7.7 - fast-deep-equal: 3.1.3 - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) - transitivePeerDependencies: - - '@aws-sdk/client-rds-data' - - '@cloudflare/workers-types' - - '@electric-sql/pglite' - - '@libsql/client' - - '@libsql/client-wasm' - - '@neondatabase/serverless' - - '@op-engineering/op-sqlite' - - '@opentelemetry/api' - - '@planetscale/database' - - '@prisma/client' - - '@tidbcloud/serverless' - - '@types/better-sqlite3' - - '@types/pg' - - '@types/sql.js' - - '@upstash/redis' - - '@vercel/postgres' - - '@xata.io/client' - - better-sqlite3 - - bun-types - - drizzle-kit - - eventsource - - expo-sqlite - - gel - - knex - - kysely - - mysql2 - - pg - - postgres - - prisma - - pyodide - - sql.js - - sqlite3 - - ws - '@rivetkit/on-change@6.0.1': {} - '@rivetkit/react@2.3.9(better-sqlite3@12.11.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(ws@8.21.3)': - dependencies: - '@rivetkit/framework-base': 2.3.9(better-sqlite3@12.11.1)(ws@8.21.3) - '@tanstack/react-store': 0.7.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3) - transitivePeerDependencies: - - '@aws-sdk/client-rds-data' - - '@cloudflare/workers-types' - - '@electric-sql/pglite' - - '@libsql/client' - - '@libsql/client-wasm' - - '@neondatabase/serverless' - - '@op-engineering/op-sqlite' - - '@opentelemetry/api' - - '@planetscale/database' - - '@prisma/client' - - '@tidbcloud/serverless' - - '@types/better-sqlite3' - - '@types/pg' - - '@types/sql.js' - - '@upstash/redis' - - '@vercel/postgres' - - '@xata.io/client' - - better-sqlite3 - - bun-types - - drizzle-kit - - eventsource - - expo-sqlite - - gel - - knex - - kysely - - mysql2 - - pg - - postgres - - prisma - - pyodide - - sql.js - - sqlite3 - - ws + '@rivetkit/rivetkit-napi-darwin-arm64@2.3.11': + optional: true - '@rivetkit/rivetkit-napi-darwin-arm64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-darwin-x64@2.3.11': optional: true - '@rivetkit/rivetkit-napi-darwin-x64@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-linux-arm64-gnu@2.3.11': optional: true - '@rivetkit/rivetkit-napi-linux-arm64-gnu@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-linux-arm64-musl@2.3.11': optional: true - '@rivetkit/rivetkit-napi-linux-arm64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-linux-x64-gnu@2.3.11': optional: true - '@rivetkit/rivetkit-napi-linux-x64-gnu@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-linux-x64-musl@2.3.11': optional: true - '@rivetkit/rivetkit-napi-linux-x64-musl@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi-win32-x64-msvc@2.3.11': optional: true - '@rivetkit/rivetkit-napi@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/rivetkit-napi@2.3.11': dependencies: '@napi-rs/cli': 2.18.4 - '@rivetkit/engine-envoy-protocol': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/engine-envoy-protocol': 2.3.11 optionalDependencies: - '@rivetkit/rivetkit-napi-darwin-arm64': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-napi-darwin-x64': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-napi-linux-arm64-gnu': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-napi-linux-arm64-musl': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-napi-linux-x64-gnu': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-napi-linux-x64-musl': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/rivetkit-napi-darwin-arm64': 2.3.11 + '@rivetkit/rivetkit-napi-darwin-x64': 2.3.11 + '@rivetkit/rivetkit-napi-linux-arm64-gnu': 2.3.11 + '@rivetkit/rivetkit-napi-linux-arm64-musl': 2.3.11 + '@rivetkit/rivetkit-napi-linux-x64-gnu': 2.3.11 + '@rivetkit/rivetkit-napi-linux-x64-musl': 2.3.11 + '@rivetkit/rivetkit-napi-win32-x64-msvc': 2.3.11 - '@rivetkit/rivetkit-wasm@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': {} + '@rivetkit/rivetkit-wasm@2.3.11': {} - '@rivetkit/traces@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/traces@2.3.11': dependencies: '@rivetkit/bare-ts': 0.6.2 cbor-x: 1.6.5 fdb-tuple: 1.0.0 vbare: 0.0.4 - '@rivetkit/virtual-websocket@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': {} + '@rivetkit/virtual-websocket@2.3.11': {} - '@rivetkit/workflow-engine@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9': + '@rivetkit/workflow-engine@2.3.11': dependencies: '@rivetkit/bare-ts': 0.6.2 cbor-x: 1.6.5 @@ -4965,15 +4792,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tanstack/react-store@0.7.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@tanstack/store': 0.7.7 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) - - '@tanstack/store@0.7.7': {} - '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -6501,13 +6319,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dom@19.2.8(react@19.2.8): - dependencies: - react: 19.2.8 - scheduler: 0.27.0 - - react@19.2.8: {} - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -6550,19 +6361,19 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - rivetkit@0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9(better-sqlite3@12.11.1)(ws@8.21.3): + rivetkit@2.3.11(better-sqlite3@12.11.1)(ws@8.21.3): dependencies: '@hono/zod-openapi': 1.6.0(hono@4.13.3)(zod@4.4.3) '@rivet-dev/agent-os-core': 0.1.1 '@rivetkit/bare-ts': 0.6.2 - '@rivetkit/engine-cli': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/engine-envoy-protocol': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/engine-cli': 2.3.11 + '@rivetkit/engine-envoy-protocol': 2.3.11 '@rivetkit/on-change': 6.0.1 - '@rivetkit/rivetkit-napi': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-wasm': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/traces': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/virtual-websocket': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/workflow-engine': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/rivetkit-napi': 2.3.11 + '@rivetkit/rivetkit-wasm': 2.3.11 + '@rivetkit/traces': 2.3.11 + '@rivetkit/virtual-websocket': 2.3.11 + '@rivetkit/workflow-engine': 2.3.11 cbor-x: 1.6.5 drizzle-orm: 0.44.7(better-sqlite3@12.11.1) hono: 4.13.3 @@ -6662,8 +6473,6 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.27.0: {} - secure-exec@0.2.1: dependencies: '@secure-exec/core': 0.2.1 @@ -6992,10 +6801,6 @@ snapshots: punycode: 1.4.1 qs: 6.15.3 - use-sync-external-store@1.6.0(react@19.2.8): - dependencies: - react: 19.2.8 - util-deprecate@1.0.2: {} util@0.12.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6d5779eed..4ca301ace 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,9 +12,11 @@ onlyBuiltDependencies: overrides: '@rivet-dev/agentos-core': 0.0.0-fix-core-spawn-keep-stdin-open.242e11c - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 - '@rivetkit/rivetkit-wasm': 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + '@rivetkit/engine-cli': 2.3.11 + '@rivetkit/react': 2.3.11 + '@rivetkit/rivetkit-wasm': 2.3.11 + rivetkit: 2.3.11 catalogs: rivetkit: - rivetkit: 0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9 + rivetkit: 2.3.11 diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 490d8b3b4..d4d810172 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -28,12 +28,20 @@ const mainPackage = JSON.parse( await read("packages/dynamic-apps/package.json"), ); assert( - mainPackage.dependencies?.["@rivet-dev/agentos"] === "0.2.15", - "agentOS must remain a pinned implementation dependency", + !mainPackage.dependencies?.["@rivet-dev/agentos"], + "Dynamic Apps must use agentOS core rather than the actor package", ); assert( - !mainPackage.peerDependencies?.["@rivet-dev/agentos"], - "agentOS must not be a peer dependency", + mainPackage.dependencies?.["@rivet-dev/agentos-core"] === "0.2.15", + "agentOS core must remain a pinned implementation dependency", +); +assert( + !mainPackage.dependencies?.["isolated-vm"], + "isolated-vm must not be a direct runtime dependency", +); +assert( + !mainPackage.peerDependencies?.["@rivet-dev/agentos-core"], + "agentOS core must not be a peer dependency", ); assert( mainPackage.dependencies?.["@rivet-dev/dynamic-apps-builder"] === @@ -75,9 +83,17 @@ const index = await read("packages/dynamic-apps/src/index.ts"); assert( index.includes("export { appsRouter }") && index.includes("export { deployApp }") && + index.includes("setDynamicAppsLogHandler,") && !index.includes("setupApps") && !index.includes("createAppsRouter"), - "package root must retain only appsRouter and deployApp", + "package root must expose only the retained values and log handler", +); + +const logging = await read("packages/dynamic-apps/src/logging.ts"); +assert( + logging.includes("export function setDynamicAppsLogHandler") && + logging.includes("export interface DynamicAppsLogEvent"), + "structured logging public surface is missing", ); const builderManifest = JSON.parse( diff --git a/scripts/test-packed.mjs b/scripts/test-packed.mjs index ca26b6aac..fc012b5e1 100644 --- a/scripts/test-packed.mjs +++ b/scripts/test-packed.mjs @@ -75,9 +75,14 @@ await access(builder.default.packagePath); const main = await import(pathToFileURL(join(mainRoot, "dist/index.js"))); const exports = Object.keys(main).sort(); -if (JSON.stringify(exports) !== JSON.stringify(["appsRouter", "deployApp"])) { +if ( + JSON.stringify(exports) !== + JSON.stringify(["appsRouter", "deployApp", "setDynamicAppsLogHandler"]) +) { throw new Error(`packed main package has unexpected exports: ${exports}`); } +main.setDynamicAppsLogHandler(() => {}); +main.setDynamicAppsLogHandler(undefined); for (const packageRoot of [builderRoot, mainRoot]) { const manifest = JSON.parse( @@ -89,6 +94,14 @@ for (const packageRoot of [builderRoot, mainRoot]) { `${manifest.name} contains an unpublished dependency specifier`, ); } + if ( + manifest.name === "@rivet-dev/dynamic-apps" && + manifest.dependencies?.["isolated-vm"] + ) { + throw new Error( + "packed Dynamic Apps still depends directly on isolated-vm", + ); + } } const declaration = await readFile(join(mainRoot, "dist/index.d.ts"), "utf8"); @@ -97,13 +110,19 @@ if (/from ["']@rivet-dev\/agentos/.test(declaration)) { "packed public declarations expose agentOS implementation types", ); } +if ( + !declaration.includes("setDynamicAppsLogHandler") || + !declaration.includes("interface DynamicAppsLogEvent") +) { + throw new Error("packed declarations omit the structured log API"); +} const workspace = join(fixture, "builder-smoke"); const release = join(fixture, "builder-release"); await mkdir(workspace, { recursive: true }); await writeFile( join(workspace, "entry.ts"), - 'globalThis.__dynamicAppDispatch = async () => JSON.stringify({ status: 200, statusText: "OK", headers: [], bodyBase64: "" });\n', + 'export async function dispatch() { return { status: 200, statusText: "OK", headers: [], bodyBase64: "" }; }\n', ); await writeFile( join(workspace, "package.json"), @@ -119,7 +138,7 @@ await writeFile( version: "packed-smoke", sourceFiles: ["entry.ts"], usesRivetKit: false, - directIsolate: true, + directAgentOs: true, maxOutputBytes: 1024 * 1024, maxOutputFiles: 16, maxFileBytes: 512 * 1024, @@ -130,12 +149,9 @@ await execFileAsync(process.execPath, [ configPath, ]); await access(join(release, "main.mjs")); -if ( - /^\s*(?:import|export)\s/m.test( - await readFile(join(release, "main.mjs"), "utf8"), - ) -) { - throw new Error("packed direct builder emitted a module instead of an IIFE"); +const directModule = await import(pathToFileURL(join(release, "main.mjs"))); +if (typeof directModule.dispatch !== "function") { + throw new Error("packed direct builder did not emit an ESM dispatcher"); } const actorWorkspace = join(fixture, "actor-builder-smoke"); @@ -159,7 +175,6 @@ await writeFile( version: "packed-actor-smoke", sourceFiles: ["entry.ts"], usesRivetKit: true, - directIsolate: false, platformRivetKit: true, maxOutputBytes: 1024 * 1024, maxOutputFiles: 16, diff --git a/specs/direct-isolate-runtime.md b/specs/direct-isolate-runtime.md index 52da3718e..bc5235892 100644 --- a/specs/direct-isolate-runtime.md +++ b/specs/direct-isolate-runtime.md @@ -1,422 +1,9 @@ # Direct-isolate Dynamic Apps rewrite -Status: direct request and deployed-app actor runtimes implemented and qualified locally and on Rivet Compute -Public API baseline: `packages/dynamic-apps/API_CONTRACT.md` -Baseline implementation: JJ `xuymorrq`, commit `baca1719` +Status: superseded -## Executive decision +The direct-isolate architecture is no longer active. Dynamic Apps now executes +ordinary HTTP requests through agentOS's inline JavaScript API. -Keep the existing deployment implementation and durable per-app actor. Delete -the scaler/replica serving graph and serve requests in the edge process with -`isolated-vm`. - -```text -deployApp - -> agentOSAppsApp[appId] - -> existing sandboxed agentOS build VM and apps-builder - -> existing AOSP package, release rows, chunks, activation, rollback - -> releaseActivated(revision, release, artifactHash) - -first request in one edge process - -> appsRouter - -> connect to state actor and resolve active release - -> read and verify artifact chunks - -> extract direct IIFE bundle and optionally create a V8 heap snapshot - -> execute request in a bounded isolate - -cache-hit request - -> appsRouter - -> process-local app/runtime cache (zero actor calls) - -> execute according to fresh, snapshot, or prewarm mode - -deployment invalidation - -> releaseActivated event - -> atomically invalidate app mapping - -> resolve and prepare the newly active immutable release - -> retire the old runtime after in-flight references drain -``` - -The deployment actor remains the source of durable state and cache invalidation. -It is not on the cache-hit request path. - -## Scope - -The package root exposes exactly two runtime values: - -```ts -export { appsRouter }; -export { deployApp }; -``` - -The exact input, output, routing, retry, error, and limit behavior is locked in -`packages/dynamic-apps/API_CONTRACT.md`. `setup`, `setupApps`, -`createAppsRouter`, `./advanced`, actor definitions, error classes, inspector -APIs, and all other old exports are intentionally removed. JJ history is the -archive; no compatibility implementation remains. - -Ordinary application HTTP remains a buffered direct-isolate request/response -path. A deployment that depends on `rivetkit` may additionally export a -registry for app-defined actors. WebSockets, streaming ordinary app bodies, -static-only packages, Node builtins in application code, and dirty -JavaScript-context reuse remain out of scope. - -## Retained deployment implementation - -The following implementation remains in place: - -- `deployApp` directory/generated-file inputs, normalization, namespace - behavior, injected-client structural call, retries, and five-field result; -- existing actors are resolved with `get()` when available before the retained - `getOrCreate()` fallback, avoiding cross-datacenter creation races during - idempotent deploys; -- private actor name `agentOSAppsApp` keyed by `[appId]`; -- sandboxed agentOS build VM, install/build timeout and resource limits; -- `@rivet-dev/dynamic-apps-builder` and AOSP packaging; -- release metadata, artifact chunks, active-release pointer, revision, and - bounded release retention; -- build/import validation before activation; -- failed candidate rollback semantics; and -- monotonic `releaseActivated` events. - -There is no schema migration for the rewrite. Existing release/artifact tables -remain. Scaler/replica tables and code are not read or recreated. A release hash -uses a new direct-runtime domain so old HTTP-runner artifacts cannot collide -with direct artifacts. - -The builder always emits a self-contained browser-targeted direct IIFE, rejects -application Node builtins, and persists it under `direct/` in the same AOSP -package pipeline. When the app declares a `rivetkit` dependency, it also emits -a platform-linked actor registry bundle under `actor/`. The host supplies its -pinned RivetKit runtime instead of duplicating RivetKit in every app artifact. -Deployment validates the direct bundle before activation and the actor bundle -when its runner starts. An incomplete or invalid artifact never replaces the -prior active release. - -## Application and HTTP contract - -The app entrypoint must default-export an object with `fetch(request)` (a -default fetch function remains accepted for actor-app compatibility): - -```ts -export default { - async fetch(request: Request): Promise { - return new Response("ok"); - }, -}; -``` - -The builder installs one internal `globalThis.__dynamicAppDispatch` function. -The host sends a JSON envelope containing URL, method, ordered headers, and an -optional base64 body. The dispatcher reconstructs a fresh request, invokes the -handler, buffers and bounds the response, and returns status, status text, -ordered headers, base64 body, and guest timings. - -Private credentials and hop-by-hop headers are stripped before entering the -isolate. The limits are 16 KiB URL, 256-byte method, 256 header pairs/64 KiB, -1 MiB request body, 4 MiB response body, and 1 KiB status text. GET/HEAD and -204/205/304 response-body semantics are enforced by the host. - -The first preview provides a deliberately small Fetch-compatible runtime: -`Headers`, `Request`, `Response`, `URL`, `URLSearchParams`, UTF-8 codecs, -base64 helpers, and monotonic `performance.now`. It does not expose host -objects, filesystem, process, environment, network fetch, or Node modules. - -## App-defined RivetKit actors - -Actor support retains the pre-rewrite user contract without adding a package -root export. An actor-enabled app declares `rivetkit`, exports `registry`, and -may keep calling `registry.start()`; the platform suppresses that call while -loading the artifact: - -```ts -import { actor, event, setup } from "rivetkit"; - -const room = actor({ - state: { count: 0 }, - events: { changed: event() }, - actions: { - increment(c) { - c.state.count += 1; - c.broadcast("changed", c.state.count); - return c.state.count; - }, - }, -}); - -export const registry = setup({ use: { room } }); -registry.start(); -export default { fetch: () => new Response("ok") }; -``` - -`deployApp` continues returning `namespace` and `pool`; an ordinary RivetKit -client uses those fields to create and call the deployed actors. Deployment -provisions the per-app namespace and configures its stable serverless runner -pool only after both bundles validate. Runner-configuration failure rolls back -the active release. - -Engine metadata/start callbacks enter the existing `agentOSAppsApp` request -hook through an authenticated callback URL. That actor validates the callback -secret and release, then dispatches the streaming request to a process-local -actor runtime. The runtime extracts only verified `actor/` files and starts one -bounded Node worker thread per active app release. The worker uses the host's -pinned RivetKit WebAssembly core, preserves the `/start` response stream, -backpressure, and cancellation, and uses the deployment's namespace/pool. -Ordinary app HTTP never enters this worker and remains eligible for direct -snapshot/prewarm caching. - -Actor workers are singleflight, reference counted, bounded by entry count, -heap limit, idle TTL, and cgroup pressure. A release event prevents new actor -callbacks from entering a stale worker; existing actor streams drain or are -terminated at their runner lifecycle boundary. Worker failure poisons that -runtime and fails its open streams instead of reusing it. Defaults are: - -| Environment variable | Default | -| --- | ---: | -| `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_IDLE_TTL_MS` | `30000` | -| `DYNAMIC_APPS_ACTOR_START_PAYLOAD_MAX_BYTES` | `1048576` | - -`DYNAMIC_APPS_CONTROL_TOKEN` optionally supplies a separate credential for -Dynamic Apps control-plane requests independently of `RIVET_ENDPOINT`. The -control token remains host-only, is never copied into an app worker, and is not -used for app actor requests. - -The worker receives only the public app-namespace Rivet connection. It does not -receive the host deployment/control credential. This remains one application -trust domain per container, matching the direct-runtime preview boundary. -An authenticated server must provide `RIVET_PUBLIC_ENDPOINT`; activation fails -closed when only a credential-bearing secret endpoint is available. - -## Private registry integration - -Removing the public setup API makes the package own one private RivetKit -registry containing only `agentOSAppsApp`. - -- Local/envoy mode memoizes `privateRegistry.startAndWait()` before the first - default client operation. -- Serverless mode dispatches Compute callbacks through - `privateRegistry.handler(request)`. -- The host mounts application routes and the private callback separately: - -```ts -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.route("/apps", appsRouter); -``` - -The Dockerfile, Compute CLI, port, namespace, and public app URL do not change. -This callback mount is the only host integration change required by deleting -the public actor registry. - -## Executor and caching - -The process keeps two bounded maps: - -```text -appId -> actor connection, authoritative revision/release, runtime reference -artifactHash + direct ABI -> verified source, optional snapshot, isolate pool -``` - -Preparation is singleflight. It connects/subscribes before resolving, validates -the actor manifest, downloads ordered chunks, verifies byte count and SHA-256, -extracts `main.mjs`, and creates a V8 snapshot for snapshot/prewarm modes. A -cache-hit request performs no actor operation. - -The subscription protocol is monotonic. An event above the observed revision -increments the entry epoch and immediately removes the verified mapping. A -resolve may install only if its captured epoch remains current and its revision -is at least the event high-water mark. Disconnect/reconnect invalidates and -rereads state, so missed non-replayed events cannot leave a permanent stale -mapping. In-flight requests may finish on their immutable old release; newly -admitted requests cannot use it after invalidation. - -### Isolation modes - -| Mode | Behavior | -| --- | --- | -| `fresh` | Cache verified source. For every request create an empty isolate, compile/evaluate bootstrap and bundle, run once, and destroy the isolate. | -| `snapshot` | Cache one app-specific V8 heap snapshot. For every request create an isolate from it, create a context, run once, and destroy the isolate. | -| `prewarm` | Cache the artifact, snapshot, and up to N native isolates. Lease one isolate, run its clean context once, synchronously release the dirty context, create a new context from the snapshot, and return the native isolate to the pool. | - -No JavaScript context is reused. Module/global state therefore starts from the -snapshot for every request. Reusing the native isolate preserves V8 allocation -and routing machinery without preserving application objects. A timed-out, -invalid, or failed request poisons and destroys its native isolate instead of -returning it to the pool. - -The prewarm pool is a cache, not a capacity limit. When concurrency exceeds the -pool, overflow requests create snapshot-backed isolates under the global -execution semaphore. At drain, only N clean native isolates remain. Setting N -to zero changes prewarm to snapshot mode. - -Defaults: - -| Environment variable | Default | -| --- | ---: | -| `DYNAMIC_APPS_ISOLATE_MODE` | `prewarm` | -| `DYNAMIC_APPS_ISOLATE_POOL_SIZE` | `2` | -| `DYNAMIC_APPS_ISOLATE_POOL_MAX_TOTAL` | `8` | -| `DYNAMIC_APPS_ISOLATE_IDLE_TTL_MS` | `30000` | -| `DYNAMIC_APPS_ISOLATE_HEAP_LIMIT_MB` | `64` | -| `DYNAMIC_APPS_RUNTIME_CACHE_MAX_ENTRIES` | `16` | -| `DYNAMIC_APPS_RUNTIME_CACHE_MAX_BYTES` | `268435456` | -| `DYNAMIC_APPS_RUNTIME_CACHE_IDLE_TTL_MS` | `900000` | -| `DYNAMIC_APPS_MEMORY_HIGH_WATER_PERCENT` | `70` | -| `DYNAMIC_APPS_EXECUTION_CONCURRENCY` | available CPU count | -| `DYNAMIC_APPS_EXECUTION_QUEUE_SIZE` | `64` | -| `DYNAMIC_APPS_EXECUTION_QUEUE_WAIT_MS` | `5000` | -| `DYNAMIC_APPS_EXECUTION_TIMEOUT_MS` | `30000` | - -All numeric configuration is range checked. Cache admission evicts zero-ref LRU -entries for entry/byte/TTL/cgroup pressure. A periodic high-water check retires -cached runtimes. Execution concurrency and queue length are bounded; overflow -fails with a typed 503 rather than admitting unbounded isolates. - -Node must run with `--no-node-snapshot`, as required by `isolated-vm` on modern -Node versions. The production Docker command includes it. - -## Trust boundary - -Build/install remains in the existing sandboxed VM. Serving intentionally moves -the direct bundle into an `isolated-vm` isolate inside the edge process. -`isolated-vm` must not receive host References or objects. - -This preview is not a complete mutually-hostile multi-tenant sandbox. V8 bugs -can compromise or crash the process, and `Isolate.createSnapshot` evaluates -top-level app code without the normal isolate memory limit; excessive native -allocation can terminate the container. Actor code runs in a worker-thread V8 -isolate but shares the containing process and is not a hostile-code boundary. -Run one trust domain per container, keep Node/V8 patched, rely on Compute -container restart isolation, and do not expose this preview as a boundary -between hostile tenants. Moving direct snapshot creation/execution and actor -runners into sacrificial processes is a follow-up hardening option. - -## Observability - -With `DYNAMIC_APPS_TIMING_HEADERS=1`, the benchmark records: - -- registry ready, actor connect/resolve; -- artifact manifest/download/parse; -- snapshot creation and initial pool fill; -- execution queue, isolate lease/create, context destroy/reset, isolate destroy; -- guest request build, handler, response serialization, and dispatcher; -- evaluation and complete server duration; and -- app/runtime cache outcome and isolate mode. - -Diagnostics expose app/runtime/artifact counts, clean/in-use/refilling isolates, -active/queued evaluations, RSS, external snapshot bytes, isolate and context -create/dispose counts, reset failures, overflow creates, and dispatch count. -Structured request logs are opt-in and exclude bodies and credentials. - -## Correctness qualification - -Required automated checks: - -- exact packed root exports and declaration surface; -- router mount/redirect/path/query/method/body/header/error behavior; -- injected-client `get([appId]).deploy(input)` preference, actor-not-found-only - `getOrCreate([appId]).deploy(input)` fallback, legacy getOrCreate-only client, - and five-field result; -- deterministic source/release identity and path/size limits; -- direct IIFE output, Node-builtin rejection, handler validation; -- fresh/snapshot/prewarm global counter isolation; -- bounded native-isolate reuse with context create/dispose parity; -- first resolve/download singleflight and zero actor calls after cache hit; -- deployment activation, event invalidation, failed-build rollback, and rapid - concurrent request isolation against a real local Rivet Engine; -- actor-app dual-bundle validation, runner configuration rollback, callback - authentication, metadata/start streaming, worker cancellation/cleanup, and - an end-to-end state/action/event/client test against a real local Engine; -- unit tests, E2E, type checks, build, lint, boundary checks, packed tarball - install, examples, and Docker serverless health. - -The E2E must deploy release A, serve it, deploy B, observe B without polling, -reject an invalid candidate while B remains active, and run at least 64 -concurrent requests that all observe request-local state. A separate actor app -must create a keyed actor through `deployment.namespace`/`deployment.pool`, -persist state across actions, deliver an event subscription, and continue -serving direct HTTP on the same release. - -## Performance qualification - -Benchmark the same zero-dependency <=10 KiB handler through side-by-side routes: - -1. edge no-op; -2. low-level actor key resolve; -3. low-level actor action; -4. first request in `fresh`, `snapshot`, and `prewarm` executors, including - actor, download, snapshot, and pool phases; -5. steady sequential fresh/snapshot/prewarm; -6. below-saturation concurrency 2, 8, and 32; -7. configured-pool concurrency with exact native-isolate count; -8. at least 10,000 requests for stability; and -9. deployment invalidation while requests are active. - -Report outer latency and server duration independently. Never add independent -phase percentiles. Artifact download is measured but not optimized in this -work. - -Gates for the trivial fixture: - -| Case | Gate | -| --- | --- | -| Prewarm cache-hit server, sequential | p50 <= 10 ms, p95 <= 25 ms | -| Snapshot cache-hit server, sequential | p50 <= 10 ms, p95 <= 25 ms | -| Fresh cache-hit server, sequential | p50 <= 25 ms, p95 <= 50 ms | -| Cache-hit actor calls | exactly zero | -| Below-saturation | 100% correct responses, bounded isolates/queue | -| Pool reuse | native isolate creates remain at configured pool size when concurrency <= pool | -| 10,000 stability | 100% success; active/queue/in-use return to zero; no reset failures | -| Invalidation | no newly admitted request uses the invalidated mapping | - -The local and Rivet Compute measurements after implementation are recorded in -`benchmarks/dynamic-apps/RESULTS.md`. The final Cloud qualification uses one -image and immutable fixture release for each compared execution mode. - -## Compute qualification - -Every Cloud mutation is restricted to: - -```text -dynamic-apps-ben-562e-production-sqac -``` - -Every deploy command must explicitly pass: - -```sh ---namespace dynamic-apps-ben-562e-production-sqac -``` - -Never deploy to or modify default `production`. Build and run the Docker image -locally with `RIVETKIT_RUNTIME_MODE=serverless`, verify `/health` and -`/api/rivet/health`, then deploy. In Cloud: - -1. verify public health and callback health; -2. deploy the benchmark fixture without namespace creation; -3. run initialization and steady fresh/snapshot/prewarm suites; -4. run the 10,000-request stability profile and collect diagnostics; -5. redeploy the fixture and verify invalidation; -6. restart/replace Compute and verify durable empty-cache recovery; and -7. preserve image/package versions and raw phase output in the report. - -## Documentation and prerelease - -Update the root/package guides, retained examples, API contract, spec, benchmark -report, boundary check, and packed-package test. Remove every example and asset -whose purpose depends on the deleted actor/scaler/replica APIs. Keep -https://rivet.dev/llms.txt in `AGENTS.md` as the RivetKit reference. - -Choose matching prerelease versions for `@rivet-dev/dynamic-apps-builder` and -`@rivet-dev/dynamic-apps`. Pack both, install them into a clean registry-like -consumer, verify exact manifests/exports/native installation, then publish the -builder first and Dynamic Apps second under the requested prerelease dist-tag. -Never move `latest` or publish a stable release. Reinstall the exact published -versions and repeat the smoke test before declaring the goal complete. +See [`agentos-inline-runtime-and-logging.md`](./agentos-inline-runtime-and-logging.md) +for the current implementation specification. diff --git a/tests/e2e/dynamic-apps/package.json b/tests/e2e/dynamic-apps/package.json index 350614fb6..6eb28d8e3 100644 --- a/tests/e2e/dynamic-apps/package.json +++ b/tests/e2e/dynamic-apps/package.json @@ -12,11 +12,11 @@ "dependencies": { "@rivet-dev/dynamic-apps": "workspace:*", "@rivet-dev/dynamic-apps-builder": "workspace:*", - "rivetkit": "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9" + "rivetkit": "2.3.11" }, "devDependencies": { "@rivet-dev/agentos-toolchain": "0.2.15", - "@rivetkit/engine-cli": "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9", + "@rivetkit/engine-cli": "2.3.11", "@types/node": "^22.19.15", "get-port": "^7.1.0", "tsx": "^4.20.6", diff --git a/tests/e2e/dynamic-apps/src/verify.ts b/tests/e2e/dynamic-apps/src/verify.ts index 7cc4700f1..3a3928beb 100644 --- a/tests/e2e/dynamic-apps/src/verify.ts +++ b/tests/e2e/dynamic-apps/src/verify.ts @@ -149,7 +149,7 @@ async function deployActorFixture() { type: "module", main: "index.js", dependencies: { - rivetkit: "0.0.0-fix-rivetkit-wasm-serve-config.e2b11f9", + rivetkit: "2.3.11", }, }), "index.js": `