experimental-a2 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +373 -0
- package/README.md +1 -1
- package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
- package/dist/actor-Bjce9_5U.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-DgOBltJ_.js → ai-BpOGm-zB.js} +621 -216
- package/dist/ai-BpOGm-zB.js.map +1 -0
- package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
- package/dist/ai-DrUaMyRt.d.ts.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +109 -43
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +526 -283
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
- package/dist/client-BReKqwZC.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
- package/dist/client-h_DKFAjj.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +7 -3
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BBXgHWFc.d.ts +86 -0
- package/dist/react-BBXgHWFc.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-CqA59Q6U.d.ts} +46 -30
- package/dist/reducer-CqA59Q6U.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
- package/dist/store-Djdv0bnZ.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +205 -66
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
- package/dist/store-polling-B5FcwJfo.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +450 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-BRITti_o.js +2493 -0
- package/dist/store-redis-notify-BRITti_o.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +197 -33
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
- package/dist/wire-rSUglYo8.js.map +1 -0
- package/docs/01-quickstart.mdx +50 -84
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -82
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -137
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +112 -156
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +53 -7
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +53 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +218 -0
- package/examples/playground/app/a0/app-view.tsx +63 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
- package/examples/playground/app/a0/apps.test.ts +128 -0
- package/examples/playground/app/a0/apps.ts +102 -0
- package/examples/playground/app/a0/chat.tsx +410 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +73 -0
- package/examples/playground/app/a0/model.ts +95 -0
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +75 -0
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +213 -0
- package/examples/playground/app/a0/session.ts +59 -0
- package/examples/playground/app/a0/shell.tsx +158 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +101 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +19 -15
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +189 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +12 -12
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +112 -0
- package/examples/playground/app/chat/server.ts +113 -117
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +13 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +36 -0
- package/package.json +29 -3
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2517 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +6 -3
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +92 -72
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +262 -64
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +23 -1965
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +339 -155
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +52 -0
- package/src/libfx.d.ts +34 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +93 -11
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +1133 -255
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +252 -75
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +541 -114
- package/src/store-redis-core.ts +1450 -493
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +262 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +319 -45
- package/src/store.ts +79 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +14 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrEf6p_W.d.ts.map +0 -1
- package/dist/ai-DgOBltJ_.js.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -142
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -185
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -384
- package/docs/guides/04-local-first.mdx +0 -88
- package/docs/guides/05-production.mdx +0 -521
- package/docs/guides/06-ai-agents.mdx +0 -1318
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -266
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -1896
- package/docs/reference/02-errors.mdx +0 -149
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { AppWindow, LoaderCircle } from 'lucide-react'
|
|
5
|
+
import { appPort } from './model'
|
|
6
|
+
import { shownPreview } from './preview'
|
|
7
|
+
import { useSession } from './session'
|
|
8
|
+
|
|
9
|
+
// Checks come quickly until the app answers, then only as often as keeping
|
|
10
|
+
// its sandbox up needs, since each one runs a command in it.
|
|
11
|
+
const CHECK_MS = 3000
|
|
12
|
+
const KEEP_UP_MS = 2 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
/** Has the server make sure the app is up; says whether it answers. */
|
|
15
|
+
export async function checkApp({
|
|
16
|
+
appId,
|
|
17
|
+
newApp = false,
|
|
18
|
+
signal,
|
|
19
|
+
}: {
|
|
20
|
+
appId: string
|
|
21
|
+
/** An app nobody has prompted yet, whose sandbox goes if nobody does. */
|
|
22
|
+
newApp?: boolean
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
}): Promise<boolean> {
|
|
25
|
+
const response = await fetch(`/a0/apps/${appId}/preview`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ newApp }),
|
|
29
|
+
...(signal ? { signal } : {}),
|
|
30
|
+
})
|
|
31
|
+
return ((await response.json()) as { up?: boolean }).up === true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Liveness = {
|
|
35
|
+
up: boolean | null
|
|
36
|
+
/** How many times it came back after being down. */
|
|
37
|
+
returns: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether the app answers, asked while the page is visible, which keeps its
|
|
42
|
+
* sandbox up. One ask at a time.
|
|
43
|
+
*/
|
|
44
|
+
function useAppUp({
|
|
45
|
+
appId,
|
|
46
|
+
watching,
|
|
47
|
+
}: {
|
|
48
|
+
appId: string
|
|
49
|
+
watching: boolean
|
|
50
|
+
}): Liveness {
|
|
51
|
+
const [liveness, setLiveness] = useState<Liveness>({
|
|
52
|
+
up: null,
|
|
53
|
+
returns: 0,
|
|
54
|
+
})
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!watching) return
|
|
57
|
+
const controller = new AbortController()
|
|
58
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
59
|
+
const check = async (): Promise<void> => {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
let answering = false
|
|
62
|
+
try {
|
|
63
|
+
answering = await checkApp({ appId, signal: controller.signal })
|
|
64
|
+
} catch {}
|
|
65
|
+
if (controller.signal.aborted) return
|
|
66
|
+
setLiveness(({ up, returns }) => ({
|
|
67
|
+
up: answering,
|
|
68
|
+
returns: answering && up === false ? returns + 1 : returns,
|
|
69
|
+
}))
|
|
70
|
+
if (document.visibilityState === 'visible')
|
|
71
|
+
timer = setTimeout(check, answering ? KEEP_UP_MS : CHECK_MS)
|
|
72
|
+
}
|
|
73
|
+
const resume = (): void => {
|
|
74
|
+
if (document.visibilityState === 'visible') void check()
|
|
75
|
+
}
|
|
76
|
+
void check()
|
|
77
|
+
document.addEventListener('visibilitychange', resume)
|
|
78
|
+
return () => {
|
|
79
|
+
controller.abort()
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
document.removeEventListener('visibilitychange', resume)
|
|
82
|
+
}
|
|
83
|
+
}, [appId, watching])
|
|
84
|
+
return liveness
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The app the agent last showed the user, as its sandbox serves it. */
|
|
88
|
+
export function PreviewPane({ appId }: { appId: string }): ReactNode {
|
|
89
|
+
const { state } = useSession()
|
|
90
|
+
const shown = useMemo(() => shownPreview(state.messages), [state.messages])
|
|
91
|
+
// An app with a conversation stays up while it's on screen.
|
|
92
|
+
const { up, returns } = useAppUp({
|
|
93
|
+
appId,
|
|
94
|
+
watching: state.messages.length > 0,
|
|
95
|
+
})
|
|
96
|
+
const live = shown && up ? shown.url : null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<section aria-label="Preview" className="a0-preview">
|
|
100
|
+
{live ? (
|
|
101
|
+
<iframe
|
|
102
|
+
// Showing the app again, or its server coming back, loads it again.
|
|
103
|
+
key={`${live}:${shown?.callId}:${returns}`}
|
|
104
|
+
title="App"
|
|
105
|
+
src={live}
|
|
106
|
+
allow="clipboard-write"
|
|
107
|
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<Placeholder shown={shown !== null} checking={up === null} />
|
|
111
|
+
)}
|
|
112
|
+
</section>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Placeholder({
|
|
117
|
+
shown,
|
|
118
|
+
checking,
|
|
119
|
+
}: {
|
|
120
|
+
shown: boolean
|
|
121
|
+
checking: boolean
|
|
122
|
+
}): ReactNode {
|
|
123
|
+
return (
|
|
124
|
+
<div className="a0-placeholder">
|
|
125
|
+
{shown && checking ? (
|
|
126
|
+
<p>
|
|
127
|
+
<LoaderCircle size={14} className="a0-spin" aria-hidden />
|
|
128
|
+
Connecting to port {appPort}
|
|
129
|
+
</p>
|
|
130
|
+
) : (
|
|
131
|
+
<div>
|
|
132
|
+
<AppWindow size={24} strokeWidth={1.5} aria-hidden />
|
|
133
|
+
<p className="a0-placeholder-title">
|
|
134
|
+
{shown ? `Nothing is running on port ${appPort}` : 'No app yet'}
|
|
135
|
+
</p>
|
|
136
|
+
<p>
|
|
137
|
+
{shown
|
|
138
|
+
? 'Ask the agent to start it again.'
|
|
139
|
+
: 'It shows up here when the agent is done.'}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
|
+
import type { AppMessage } from './model'
|
|
3
|
+
import { shownPreview } from './preview'
|
|
4
|
+
|
|
5
|
+
type Part = AppMessage['parts'][number]
|
|
6
|
+
|
|
7
|
+
const message = (role: AppMessage['role'], ...parts: Part[]): AppMessage => ({
|
|
8
|
+
id: crypto.randomUUID(),
|
|
9
|
+
role,
|
|
10
|
+
parts,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const shown = (callId: string, output: unknown): Part =>
|
|
14
|
+
({
|
|
15
|
+
type: 'tool-show_preview',
|
|
16
|
+
toolCallId: callId,
|
|
17
|
+
state: 'output-available',
|
|
18
|
+
input: { path: '/' },
|
|
19
|
+
output,
|
|
20
|
+
}) as Part
|
|
21
|
+
|
|
22
|
+
const url = 'https://sb-3000.vercel.run/'
|
|
23
|
+
|
|
24
|
+
it('shows nothing until the agent shows the app', () => {
|
|
25
|
+
expect(shownPreview([])).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('follows the latest preview the agent showed', () => {
|
|
29
|
+
const messages = [
|
|
30
|
+
message('assistant', shown('p1', { url })),
|
|
31
|
+
message('assistant', shown('p2', { url: `${url}about` })),
|
|
32
|
+
message(
|
|
33
|
+
'assistant',
|
|
34
|
+
shown('p3', { error: 'Nothing is serving port 3000.' }),
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
expect(shownPreview(messages)).toEqual({ url: `${url}about`, callId: 'p2' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores previews a user message carries', () => {
|
|
41
|
+
expect(shownPreview([message('user', shown('p1', { url }))])).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('frames only https sandbox hosts, and survives malformed output', () => {
|
|
45
|
+
for (const output of [
|
|
46
|
+
null,
|
|
47
|
+
url,
|
|
48
|
+
{ url: 'https://example.com/' },
|
|
49
|
+
{ url: 'http://sb-3000.vercel.run/' },
|
|
50
|
+
{ url: 'javascript:alert(1)' },
|
|
51
|
+
])
|
|
52
|
+
expect(shownPreview([message('assistant', shown('p1', output))])).toBeNull()
|
|
53
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { AppMessage } from './model'
|
|
3
|
+
|
|
4
|
+
// Only the agent's own show_preview results, which point at a sandbox's
|
|
5
|
+
// public host. Anything else in the log never reaches the iframe.
|
|
6
|
+
const previewSchema = z.object({
|
|
7
|
+
url: z.url({ protocol: /^https$/, hostname: /\.vercel\.run$/ }),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** The app the agent last showed the user, so a reload shows it again. */
|
|
11
|
+
export function shownPreview(
|
|
12
|
+
messages: readonly AppMessage[],
|
|
13
|
+
): { url: string; callId: string } | null {
|
|
14
|
+
let shown: { url: string; callId: string } | null = null
|
|
15
|
+
for (const message of messages) {
|
|
16
|
+
if (message.role !== 'assistant') continue
|
|
17
|
+
for (const part of message.parts) {
|
|
18
|
+
if (part.type !== 'tool-show_preview') continue
|
|
19
|
+
if (!('state' in part) || part.state !== 'output-available') continue
|
|
20
|
+
const parsed = previewSchema.safeParse(part.output)
|
|
21
|
+
if (parsed.success)
|
|
22
|
+
shown = { url: parsed.data.url, callId: part.toolCallId }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return shown
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { contract } from 'experimental-a2'
|
|
3
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
4
|
+
import { createServer } from 'experimental-a2/server'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { store } from '@/lib/store'
|
|
7
|
+
import { isAppId } from './apps'
|
|
8
|
+
import { deleteAppSandbox } from './sandbox'
|
|
9
|
+
import { agentServers } from './server'
|
|
10
|
+
|
|
11
|
+
// How long a new app's sandbox waits for a first prompt.
|
|
12
|
+
const IDLE_GRACE_MS = 10 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An app's sandbox lifecycle, in a log of its own so the app's log stays
|
|
16
|
+
* its conversation. A new app's sandbox opens on sight, before anyone
|
|
17
|
+
* prompts it, so each one schedules a check that deletes it if the app is
|
|
18
|
+
* still empty when the grace period ends.
|
|
19
|
+
*/
|
|
20
|
+
const sandboxLog = contract({
|
|
21
|
+
name: 'a0-sandboxes',
|
|
22
|
+
events: {
|
|
23
|
+
'sandbox.idle.checked': z.object({ appId: z.uuid() }),
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const sandboxLogId = (appId: string): string => `${appId}:sandbox`
|
|
28
|
+
|
|
29
|
+
export const sandboxScheduler =
|
|
30
|
+
process.env.VERCEL || process.env.A2_QUEUES
|
|
31
|
+
? vercelQueues({ topic: 'a2-a0-sandboxes' })
|
|
32
|
+
: undefined
|
|
33
|
+
|
|
34
|
+
export const sandboxServer = createServer({
|
|
35
|
+
...(store ? { store } : {}),
|
|
36
|
+
...(sandboxScheduler ? { scheduler: sandboxScheduler } : {}),
|
|
37
|
+
contract: sandboxLog,
|
|
38
|
+
handlers: {
|
|
39
|
+
'sandbox.idle.checked': async ({ event }) => {
|
|
40
|
+
const { appId } = event.payload
|
|
41
|
+
// A prompt since the sandbox opened makes it a real app's, whichever
|
|
42
|
+
// harness's agent got it.
|
|
43
|
+
const reads = await Promise.all(
|
|
44
|
+
Object.values(agentServers).map((server) =>
|
|
45
|
+
server.session(appId).state(),
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
if (reads.every(({ state }) => state.messages.length === 0))
|
|
49
|
+
await deleteAppSandbox({ appId })
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Schedules the check for a new app's sandbox. It's keyed to the sandbox's
|
|
56
|
+
* creation, so repeating it converges on one check, and a sandbox made
|
|
57
|
+
* again later gets its own. Without a scheduler, as under next dev, the
|
|
58
|
+
* sandbox just waits for its session to time out.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkWhenIdle({
|
|
61
|
+
appId,
|
|
62
|
+
createdAt,
|
|
63
|
+
}: {
|
|
64
|
+
appId: string
|
|
65
|
+
createdAt: Date
|
|
66
|
+
}): Promise<void> {
|
|
67
|
+
if (!sandboxScheduler || !isAppId(appId)) return
|
|
68
|
+
await sandboxServer
|
|
69
|
+
.session(sandboxLogId(appId))
|
|
70
|
+
.schedule(
|
|
71
|
+
`idle-check-${createdAt.getTime()}`,
|
|
72
|
+
{ at: new Date(createdAt.getTime() + IDLE_GRACE_MS) },
|
|
73
|
+
{ type: 'sandbox.idle.checked', payload: { appId } },
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import type { Sandbox } from '@vercel/sandbox'
|
|
3
|
+
import type { Experimental_SandboxSession } from 'ai'
|
|
4
|
+
import { appDir } from './sandbox'
|
|
5
|
+
|
|
6
|
+
// A job the command backgrounds, like `next dev &`, keeps whatever output it
|
|
7
|
+
// started with, and a read of a pipe would wait on it forever. Output goes to
|
|
8
|
+
// a file instead; background jobs live on.
|
|
9
|
+
const CAPTURE = `out=$(mktemp); bash -lc "$0" > "$out" 2>&1 < /dev/null; code=$?; cat "$out"; rm -f "$out"; exit $code`
|
|
10
|
+
|
|
11
|
+
type Run = Parameters<Experimental_SandboxSession['run']>[0]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The app's Vercel Sandbox as an AI SDK sandbox session, for harnesses whose
|
|
15
|
+
* own tools need one. Commands run in the app's directory.
|
|
16
|
+
*/
|
|
17
|
+
export function sandboxSession(sandbox: Sandbox): Experimental_SandboxSession {
|
|
18
|
+
const params = ({ command, workingDirectory, env, abortSignal }: Run) => ({
|
|
19
|
+
cmd: 'bash',
|
|
20
|
+
args: ['-c', CAPTURE, command],
|
|
21
|
+
cwd: workingDirectory ?? appDir,
|
|
22
|
+
...(env ? { env } : {}),
|
|
23
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
24
|
+
})
|
|
25
|
+
const readBinaryFile = async ({
|
|
26
|
+
path,
|
|
27
|
+
abortSignal,
|
|
28
|
+
}: {
|
|
29
|
+
path: string
|
|
30
|
+
abortSignal?: AbortSignal
|
|
31
|
+
}): Promise<Uint8Array | null> => {
|
|
32
|
+
const buffer = await sandbox.readFileToBuffer(
|
|
33
|
+
{ path, cwd: appDir },
|
|
34
|
+
abortSignal ? { signal: abortSignal } : {},
|
|
35
|
+
)
|
|
36
|
+
return buffer && new Uint8Array(buffer)
|
|
37
|
+
}
|
|
38
|
+
// The sandbox extracts written files at its root, creating their directories.
|
|
39
|
+
const write = (path: string, content: Uint8Array, signal?: AbortSignal) =>
|
|
40
|
+
sandbox.writeFiles(
|
|
41
|
+
[{ path: path.startsWith('/') ? path : `${appDir}/${path}`, content }],
|
|
42
|
+
signal ? { signal } : {},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
description: `A Vercel Sandbox. The app is in ${appDir}.`,
|
|
47
|
+
// Streams, so a reader that stops early stops the download.
|
|
48
|
+
readFile: async ({ path, abortSignal }) => {
|
|
49
|
+
const stream = await sandbox.readFile(
|
|
50
|
+
{ path, cwd: appDir },
|
|
51
|
+
abortSignal ? { signal: abortSignal } : {},
|
|
52
|
+
)
|
|
53
|
+
if (stream === null) return null
|
|
54
|
+
const chunks = stream[Symbol.asyncIterator]()
|
|
55
|
+
return new ReadableStream<Uint8Array>({
|
|
56
|
+
pull: async (controller) => {
|
|
57
|
+
const { done, value } = await chunks.next()
|
|
58
|
+
if (done) controller.close()
|
|
59
|
+
else
|
|
60
|
+
controller.enqueue(
|
|
61
|
+
typeof value === 'string'
|
|
62
|
+
? new TextEncoder().encode(value)
|
|
63
|
+
: value,
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
cancel: async () => {
|
|
67
|
+
await chunks.return?.()
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
readBinaryFile,
|
|
72
|
+
readTextFile: async ({ encoding, startLine, endLine, ...options }) => {
|
|
73
|
+
const bytes = await readBinaryFile(options)
|
|
74
|
+
if (bytes === null) return null
|
|
75
|
+
const text = new TextDecoder(encoding).decode(bytes)
|
|
76
|
+
if (startLine === undefined && endLine === undefined) return text
|
|
77
|
+
return text
|
|
78
|
+
.split('\n')
|
|
79
|
+
.slice((startLine ?? 1) - 1, endLine)
|
|
80
|
+
.join('\n')
|
|
81
|
+
},
|
|
82
|
+
writeFile: async ({ path, content, abortSignal }) =>
|
|
83
|
+
write(
|
|
84
|
+
path,
|
|
85
|
+
new Uint8Array(await new Response(content).arrayBuffer()),
|
|
86
|
+
abortSignal,
|
|
87
|
+
),
|
|
88
|
+
writeBinaryFile: ({ path, content, abortSignal }) =>
|
|
89
|
+
write(path, content, abortSignal),
|
|
90
|
+
writeTextFile: ({ path, content, abortSignal }) =>
|
|
91
|
+
write(path, new TextEncoder().encode(content), abortSignal),
|
|
92
|
+
run: async (options) => {
|
|
93
|
+
const finished = await sandbox.runCommand(params(options))
|
|
94
|
+
return {
|
|
95
|
+
exitCode: finished.exitCode,
|
|
96
|
+
stdout: await finished.stdout(),
|
|
97
|
+
stderr: await finished.stderr(),
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
spawn: async (options) => {
|
|
101
|
+
const { abortSignal } = options
|
|
102
|
+
const command = await sandbox.runCommand({
|
|
103
|
+
...params(options),
|
|
104
|
+
detached: true,
|
|
105
|
+
})
|
|
106
|
+
// For a detached command the signal only cancels its start; aborting
|
|
107
|
+
// kills it, as the AI SDK's sandbox contract says.
|
|
108
|
+
const kill = () => void command.kill().catch(() => {})
|
|
109
|
+
if (abortSignal?.aborted) kill()
|
|
110
|
+
abortSignal?.addEventListener('abort', kill, { once: true })
|
|
111
|
+
const encoder = new TextEncoder()
|
|
112
|
+
let stdout!: ReadableStreamDefaultController<Uint8Array>
|
|
113
|
+
let stderr!: ReadableStreamDefaultController<Uint8Array>
|
|
114
|
+
const streams = {
|
|
115
|
+
stdout: new ReadableStream<Uint8Array>({
|
|
116
|
+
start: (c) => void (stdout = c),
|
|
117
|
+
}),
|
|
118
|
+
stderr: new ReadableStream<Uint8Array>({
|
|
119
|
+
start: (c) => void (stderr = c),
|
|
120
|
+
}),
|
|
121
|
+
}
|
|
122
|
+
// The command's log ends when it exits, and closes both streams.
|
|
123
|
+
void (async () => {
|
|
124
|
+
try {
|
|
125
|
+
for await (const line of command.logs())
|
|
126
|
+
(line.stream === 'stdout' ? stdout : stderr).enqueue(
|
|
127
|
+
encoder.encode(line.data),
|
|
128
|
+
)
|
|
129
|
+
stdout.close()
|
|
130
|
+
stderr.close()
|
|
131
|
+
} catch (error) {
|
|
132
|
+
stdout.error(error)
|
|
133
|
+
stderr.error(error)
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
return {
|
|
137
|
+
...streams,
|
|
138
|
+
wait: async () => {
|
|
139
|
+
try {
|
|
140
|
+
const finished = await command.wait(
|
|
141
|
+
abortSignal ? { signal: abortSignal } : {},
|
|
142
|
+
)
|
|
143
|
+
return { exitCode: finished.exitCode }
|
|
144
|
+
} finally {
|
|
145
|
+
abortSignal?.removeEventListener('abort', kill)
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
kill: () => command.kill(),
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { APIError, Sandbox } from '@vercel/sandbox'
|
|
3
|
+
import { appPort } from './model'
|
|
4
|
+
|
|
5
|
+
// Each app has one persistent Vercel Sandbox, started from a template that
|
|
6
|
+
// already holds a Next.js app with its dependencies (scripts/a0-template.ts).
|
|
7
|
+
|
|
8
|
+
// The default image's home, owned by the user commands run as.
|
|
9
|
+
export const workspace = '/vercel'
|
|
10
|
+
export const appDir = `${workspace}/app`
|
|
11
|
+
// A sandbox's session lasts this long from its start unless kept alive.
|
|
12
|
+
const SESSION_TIMEOUT_MS = 15 * 60 * 1000
|
|
13
|
+
// Each tool call keeps the sandbox up at least this much longer.
|
|
14
|
+
const KEEP_ALIVE_MS = 10 * 60 * 1000
|
|
15
|
+
// Abandoned apps lose their files after this long.
|
|
16
|
+
const SNAPSHOT_EXPIRATION_MS = 14 * 24 * 60 * 60 * 1000
|
|
17
|
+
const OUTPUT_LIMIT = 32 * 1024
|
|
18
|
+
|
|
19
|
+
// Local runs and deployments share the project's sandboxes, so each name
|
|
20
|
+
// says where it came from.
|
|
21
|
+
const scope = process.env.VERCEL_ENV ?? 'local'
|
|
22
|
+
const sandboxName = (appId: string): string => `a2-a0-${scope}-${appId}`
|
|
23
|
+
|
|
24
|
+
const status = (error: unknown): number | undefined =>
|
|
25
|
+
error instanceof APIError ? error.response.status : undefined
|
|
26
|
+
const notFound = (error: unknown): boolean => status(error) === 404
|
|
27
|
+
|
|
28
|
+
// Starts next dev unless one is already running or booting: it holds a lock
|
|
29
|
+
// for as long as it runs, so another start exits at once, and a server that
|
|
30
|
+
// dies frees it. A server started without the lock, by older code or by hand,
|
|
31
|
+
// still owns the port, which a bare TCP connect checks without asking it for a
|
|
32
|
+
// page. A command only finishes once every process in its group exits, so
|
|
33
|
+
// next dev gets its own session (setsid -f), and appends to its log.
|
|
34
|
+
export const START_DEV = `cd ${appDir} && { (: < /dev/tcp/127.0.0.1/${appPort}) 2>/dev/null || setsid -f flock -n /tmp/next.lock npm run dev -- --port ${appPort} >> /tmp/next.log 2>&1 < /dev/null; }`
|
|
35
|
+
|
|
36
|
+
// Starts next dev, then asks it for the home page in the background, so the
|
|
37
|
+
// page is compiled by the time anyone looks. Neither is waited for.
|
|
38
|
+
const WARM_UP = `${START_DEV}; setsid -f timeout 60 bash -c 'until curl -s -o /dev/null localhost:${appPort}; do sleep 0.5; done' > /dev/null 2>&1 < /dev/null`
|
|
39
|
+
|
|
40
|
+
const startDev = async (sandbox: Sandbox): Promise<void> => {
|
|
41
|
+
await sandbox.runCommand({ cmd: 'bash', args: ['-c', WARM_UP] })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function templateSnapshot(): string {
|
|
45
|
+
const snapshotId = process.env.A0_TEMPLATE_SNAPSHOT_ID
|
|
46
|
+
if (!snapshotId)
|
|
47
|
+
throw new Error(
|
|
48
|
+
'A0_TEMPLATE_SNAPSHOT_ID is not set. Build the template with `pnpm --filter playground a0:template` and set the id it prints.',
|
|
49
|
+
)
|
|
50
|
+
return snapshotId
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function openApp(appId: string): Promise<Sandbox> {
|
|
54
|
+
const name = sandboxName(appId)
|
|
55
|
+
const existing = () => Sandbox.get({ name, resume: true, onResume: startDev })
|
|
56
|
+
try {
|
|
57
|
+
return await existing()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (!notFound(error)) throw error
|
|
60
|
+
}
|
|
61
|
+
let sandbox: Sandbox
|
|
62
|
+
try {
|
|
63
|
+
sandbox = await Sandbox.create({
|
|
64
|
+
name,
|
|
65
|
+
source: { type: 'snapshot', snapshotId: templateSnapshot() },
|
|
66
|
+
ports: [appPort],
|
|
67
|
+
timeout: SESSION_TIMEOUT_MS,
|
|
68
|
+
persistent: true,
|
|
69
|
+
snapshotExpiration: SNAPSHOT_EXPIRATION_MS,
|
|
70
|
+
keepLastSnapshots: { count: 1 },
|
|
71
|
+
})
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Another instance created it first. The API holds a racing create until
|
|
74
|
+
// the first one finishes, then refuses it: 409, or 400 "already exists".
|
|
75
|
+
if (status(error) !== 409 && status(error) !== 400) throw error
|
|
76
|
+
return existing().catch((missing: unknown) => {
|
|
77
|
+
throw notFound(missing) ? error : missing
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
await startDev(sandbox)
|
|
81
|
+
return sandbox
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parallel tool calls on a new app share one create within this instance.
|
|
85
|
+
const opening = new Map<string, Promise<Sandbox>>()
|
|
86
|
+
|
|
87
|
+
/** The app's sandbox, created or resumed, and kept up while it's used. */
|
|
88
|
+
export async function appSandbox({
|
|
89
|
+
appId,
|
|
90
|
+
}: {
|
|
91
|
+
appId: string
|
|
92
|
+
}): Promise<Sandbox> {
|
|
93
|
+
const pending =
|
|
94
|
+
opening.get(appId) ?? openApp(appId).finally(() => opening.delete(appId))
|
|
95
|
+
opening.set(appId, pending)
|
|
96
|
+
const sandbox = await pending
|
|
97
|
+
// Extends only by the shortfall, so repeated calls never stack up time.
|
|
98
|
+
const shortfall =
|
|
99
|
+
Date.now() + KEEP_ALIVE_MS - (sandbox.expiresAt?.getTime() ?? 0)
|
|
100
|
+
// Throws past the plan's session limit; the sandbox still works until then.
|
|
101
|
+
if (shortfall > 0) await sandbox.extendTimeout(shortfall).catch(() => {})
|
|
102
|
+
return sandbox
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A server that's booting refuses connections for a moment, and a page
|
|
106
|
+
// that's still compiling answers late; the probe waits out both.
|
|
107
|
+
const ANSWER_WAIT_SECONDS = 30
|
|
108
|
+
const PROBE = `curl -s -o /dev/null -w '%{http_code}' --retry ${ANSWER_WAIT_SECONDS} --retry-delay 1 --retry-connrefused --retry-max-time ${ANSWER_WAIT_SECONDS} --max-time ${ANSWER_WAIT_SECONDS} http://localhost:${appPort}/`
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Makes sure the app is up: opens its sandbox, creating or resuming it and
|
|
112
|
+
* keeping it running, starts next dev if it isn't running, and says whether
|
|
113
|
+
* the app answers. Safe to repeat; it only does what's missing.
|
|
114
|
+
*/
|
|
115
|
+
export async function ensureApp({
|
|
116
|
+
appId,
|
|
117
|
+
}: {
|
|
118
|
+
appId: string
|
|
119
|
+
}): Promise<{ up: boolean; createdAt: Date }> {
|
|
120
|
+
const sandbox = await appSandbox({ appId })
|
|
121
|
+
const probe = await sandbox.runCommand({
|
|
122
|
+
cmd: 'bash',
|
|
123
|
+
args: ['-c', `${START_DEV}; ${PROBE}`],
|
|
124
|
+
})
|
|
125
|
+
return {
|
|
126
|
+
up: Number(await probe.stdout()) > 0,
|
|
127
|
+
createdAt: sandbox.createdAt,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The public URL of a path on the app. It doesn't ask the dev server; the
|
|
133
|
+
* preview pane waits for the page itself.
|
|
134
|
+
*/
|
|
135
|
+
export const previewUrl = ({
|
|
136
|
+
sandbox,
|
|
137
|
+
path,
|
|
138
|
+
}: {
|
|
139
|
+
sandbox: Sandbox
|
|
140
|
+
path: string
|
|
141
|
+
}): { url: string } => ({ url: new URL(path, sandbox.domain(appPort)).href })
|
|
142
|
+
|
|
143
|
+
export async function deleteAppSandbox({
|
|
144
|
+
appId,
|
|
145
|
+
}: {
|
|
146
|
+
appId: string
|
|
147
|
+
}): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
const sandbox = await Sandbox.get({
|
|
150
|
+
name: sandboxName(appId),
|
|
151
|
+
resume: false,
|
|
152
|
+
})
|
|
153
|
+
await sandbox.delete()
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!notFound(error)) throw error
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Runs the command with its output in a file rather than the sandbox's pipe:
|
|
160
|
+
// a job it backgrounds, like `a && b && server &`, keeps whatever output it
|
|
161
|
+
// started with, and on the pipe the call would wait for it forever.
|
|
162
|
+
// coreutils' timeout ends it; background jobs live on. Only the first
|
|
163
|
+
// OUTPUT_LIMIT bytes leave the sandbox.
|
|
164
|
+
const RUN = `out=$(mktemp); timeout "$1" bash -lc "$0" > "$out" 2>&1 < /dev/null; code=$?; size=$(wc -c < "$out"); head -c ${OUTPUT_LIMIT} "$out"; if [ "$size" -gt ${OUTPUT_LIMIT} ]; then printf '\\n… %d more bytes' $((size - ${OUTPUT_LIMIT})); fi; rm -f "$out"; exit $code`
|
|
165
|
+
|
|
166
|
+
/** Runs a shell command in the app's workspace, with output capped. */
|
|
167
|
+
export async function bash({
|
|
168
|
+
sandbox,
|
|
169
|
+
command,
|
|
170
|
+
timeoutSeconds,
|
|
171
|
+
signal,
|
|
172
|
+
}: {
|
|
173
|
+
sandbox: Sandbox
|
|
174
|
+
command: string
|
|
175
|
+
timeoutSeconds: number
|
|
176
|
+
signal?: AbortSignal
|
|
177
|
+
}): Promise<{ exitCode: number; timedOut?: string; output: string }> {
|
|
178
|
+
const result = await sandbox.runCommand({
|
|
179
|
+
cmd: 'bash',
|
|
180
|
+
args: ['-c', RUN, command, `${timeoutSeconds}s`],
|
|
181
|
+
cwd: workspace,
|
|
182
|
+
...(signal ? { signal } : {}),
|
|
183
|
+
})
|
|
184
|
+
return {
|
|
185
|
+
exitCode: result.exitCode,
|
|
186
|
+
...(result.exitCode === 124 && {
|
|
187
|
+
timedOut: `Stopped after ${timeoutSeconds}s. Background long-running servers.`,
|
|
188
|
+
}),
|
|
189
|
+
output: await result.stdout(),
|
|
190
|
+
}
|
|
191
|
+
}
|