experimental-a2 0.16.0 → 0.17.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 +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.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-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.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-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 +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.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-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.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-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.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-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.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 +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.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 +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.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-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.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 +166 -32
- 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-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -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/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -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 +69 -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 +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -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 +98 -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 +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -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 +11 -11
- 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 +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- 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/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- 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 +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- 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 +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- 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-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- 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 +45 -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 +92 -10
- 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 +957 -237
- 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 +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -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-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.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/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
|
@@ -0,0 +1,69 @@
|
|
|
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 { agentServer } 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.
|
|
42
|
+
const { state } = await agentServer.session(appId).state()
|
|
43
|
+
if (state.messages.length === 0) await deleteAppSandbox({ appId })
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Schedules the check for a new app's sandbox. It's keyed to the sandbox's
|
|
50
|
+
* creation, so repeating it converges on one check, and a sandbox made
|
|
51
|
+
* again later gets its own. Without a scheduler, as under next dev, the
|
|
52
|
+
* sandbox just waits for its session to time out.
|
|
53
|
+
*/
|
|
54
|
+
export async function checkWhenIdle({
|
|
55
|
+
appId,
|
|
56
|
+
createdAt,
|
|
57
|
+
}: {
|
|
58
|
+
appId: string
|
|
59
|
+
createdAt: Date
|
|
60
|
+
}): Promise<void> {
|
|
61
|
+
if (!sandboxScheduler || !isAppId(appId)) return
|
|
62
|
+
await sandboxServer
|
|
63
|
+
.session(sandboxLogId(appId))
|
|
64
|
+
.schedule(
|
|
65
|
+
`idle-check-${createdAt.getTime()}`,
|
|
66
|
+
{ at: new Date(createdAt.getTime() + IDLE_GRACE_MS) },
|
|
67
|
+
{ type: 'sandbox.idle.checked', payload: { appId } },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { waitUntil } from '@vercel/functions'
|
|
3
|
+
import { tool } from 'ai'
|
|
4
|
+
import { handlerContext } from 'experimental-a2/ai'
|
|
5
|
+
import { agent } from 'experimental-a2/ai/server'
|
|
6
|
+
import { createServer } from 'experimental-a2/server'
|
|
7
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { store } from '@/lib/store'
|
|
10
|
+
import { appList, isAppId } from './apps'
|
|
11
|
+
import { crashOnRequest } from './crash-on-request'
|
|
12
|
+
import { crashLog, crashLogId, isCrashLogId } from './crashes'
|
|
13
|
+
import { appAgent, appPort } from './model'
|
|
14
|
+
import {
|
|
15
|
+
appDir,
|
|
16
|
+
appSandbox,
|
|
17
|
+
bash,
|
|
18
|
+
previewUrl,
|
|
19
|
+
START_DEV,
|
|
20
|
+
workspace,
|
|
21
|
+
} from './sandbox'
|
|
22
|
+
|
|
23
|
+
const INSTRUCTIONS = `You are a coding agent that builds a Next.js app in your own Linux sandbox, through the bash tool. The user watches the app in a live preview next to this chat.
|
|
24
|
+
- ${appDir} already holds a blank Next.js app (App Router, TypeScript, Tailwind) with its dependencies installed, and next dev serves it on port ${appPort} with hot reload. It may still be starting when you begin: don't wait for it, poll it, or start another one. Build what the user asks for there; don't create another app.
|
|
25
|
+
- Until you change them, app/page.tsx renders an empty <main />, app/layout.tsx only sets the title, and app/globals.css only imports Tailwind. Write them directly; there's no need to look first.
|
|
26
|
+
- Assume your edits work. Don't curl the dev server, sleep, or read the page back to check, and don't run builds, type checks or lint unless the user asks.
|
|
27
|
+
- When the change is ready, call show_preview once, as your last step, so the user sees the result; until then they see no app. After that first time, saved files show up in their preview on their own, so call it again only to show a different path.
|
|
28
|
+
- If the preview stops answering, read /tmp/next.log, then start next dev with \`${START_DEV}\`; it does nothing while one is already running.
|
|
29
|
+
- next.config allows the preview's *.vercel.run host (allowedDevOrigins); keep that when you edit it.
|
|
30
|
+
- Background anything long-running; a bash call stops after its timeout.
|
|
31
|
+
- Keep replies short and in plain text, without Markdown: what you changed.`
|
|
32
|
+
|
|
33
|
+
/** A tool's result, with how long it took; the chat shows it. */
|
|
34
|
+
async function timed<T extends object>(
|
|
35
|
+
run: () => Promise<T>,
|
|
36
|
+
): Promise<T & { durationMs: number }> {
|
|
37
|
+
const started = performance.now()
|
|
38
|
+
const result = await run()
|
|
39
|
+
return { ...result, durationMs: Math.round(performance.now() - started) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tools = {
|
|
43
|
+
show_preview: tool({
|
|
44
|
+
description: `Show the user the app, as the dev server on port ${appPort} serves it, next to this chat. It doesn't check or start the server. Returns the public URL.`,
|
|
45
|
+
inputSchema: z.object({
|
|
46
|
+
path: z.string().startsWith('/').default('/'),
|
|
47
|
+
}),
|
|
48
|
+
execute: ({ path }) =>
|
|
49
|
+
timed(async () =>
|
|
50
|
+
previewUrl({
|
|
51
|
+
sandbox: await appSandbox({
|
|
52
|
+
appId: handlerContext(appAgent).session.id,
|
|
53
|
+
}),
|
|
54
|
+
path,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
}),
|
|
58
|
+
bash: tool({
|
|
59
|
+
description: `Run a shell command in ${workspace}. Files persist between commands. Background long-running servers.`,
|
|
60
|
+
inputSchema: z.object({
|
|
61
|
+
command: z.string().min(1),
|
|
62
|
+
// Within one function invocation; see maxDuration on the routes.
|
|
63
|
+
timeoutSeconds: z.number().int().min(1).max(240).default(120),
|
|
64
|
+
}),
|
|
65
|
+
execute: ({ command, timeoutSeconds }) => {
|
|
66
|
+
const context = handlerContext(appAgent)
|
|
67
|
+
return timed(async () =>
|
|
68
|
+
bash({
|
|
69
|
+
sandbox: await appSandbox({ appId: context.session.id }),
|
|
70
|
+
command,
|
|
71
|
+
timeoutSeconds,
|
|
72
|
+
signal: context.signal,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wakes agent work in a fresh invocation when the one that started it ends,
|
|
81
|
+
* and recovers from crashed ones.
|
|
82
|
+
*/
|
|
83
|
+
export const appScheduler =
|
|
84
|
+
process.env.VERCEL || process.env.A2_QUEUES
|
|
85
|
+
? vercelQueues({ topic: 'a2-a0' })
|
|
86
|
+
: undefined
|
|
87
|
+
|
|
88
|
+
// Each step runs with the app's model choice. The browser pushes it, and the
|
|
89
|
+
// contract's schema checks it on the way in.
|
|
90
|
+
export const agentServer = agent({
|
|
91
|
+
...(store ? { store } : {}),
|
|
92
|
+
...(appScheduler ? { scheduler: appScheduler } : {}),
|
|
93
|
+
contract: appAgent,
|
|
94
|
+
model: ({ event, state }) => {
|
|
95
|
+
// Every step starts here, in the process that runs it, which is what
|
|
96
|
+
// the dev tools' crash has to end.
|
|
97
|
+
if (crashesAllowed) {
|
|
98
|
+
const appId = event.sessionId
|
|
99
|
+
waitUntil(
|
|
100
|
+
crashOnRequest({
|
|
101
|
+
appId,
|
|
102
|
+
app: agentServer.session(appId),
|
|
103
|
+
log: crashServer.session(crashLogId(appId)),
|
|
104
|
+
}).catch(() => {}),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
const { model, reasoning, fast } = state.modelChoice
|
|
108
|
+
return { model, reasoning, speed: fast ? 'fast' : 'standard' }
|
|
109
|
+
},
|
|
110
|
+
generation: () => ({
|
|
111
|
+
// Claude leaves its thinking's text out unless asked for a summary.
|
|
112
|
+
providerOptions: {
|
|
113
|
+
anthropic: { thinking: { type: 'adaptive', display: 'summarized' } },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
instructions: INSTRUCTIONS,
|
|
117
|
+
tools,
|
|
118
|
+
maxSteps: 80,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** The app's event stream and pushes; only an app id names a session. */
|
|
122
|
+
export function appEvents(request: Request): Promise<Response> {
|
|
123
|
+
return agentServer.fetch(request, {
|
|
124
|
+
// The agent refuses the AI protocol's server-authored events itself.
|
|
125
|
+
authorize: (operation) => isAppId(operation.sessionId),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The apps sidebar's lists, which only the browser writes. */
|
|
130
|
+
export const listServer = createServer({
|
|
131
|
+
...(store ? { store } : {}),
|
|
132
|
+
contract: appList,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** The dev tools' crash logs, one per app. They have no handlers. */
|
|
136
|
+
export const crashServer = createServer({
|
|
137
|
+
...(store ? { store } : {}),
|
|
138
|
+
contract: crashLog,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A crash ends the function instance, and anything else it's serving, so
|
|
143
|
+
* only previews allow it, behind Deployment Protection, unless A0_CRASHES
|
|
144
|
+
* is on. Under next dev it would take the dev server down.
|
|
145
|
+
*/
|
|
146
|
+
export const crashesAllowed =
|
|
147
|
+
process.env.VERCEL_ENV === 'preview' || process.env.A0_CRASHES === 'on'
|
|
148
|
+
|
|
149
|
+
/** The browser can only ask for a crash; the process it ends records it. */
|
|
150
|
+
export function crashEvents(request: Request): Promise<Response> {
|
|
151
|
+
return crashServer.fetch(request, {
|
|
152
|
+
authorize: (operation) =>
|
|
153
|
+
isCrashLogId(operation.sessionId) &&
|
|
154
|
+
(crashesAllowed || operation.type !== 'push') &&
|
|
155
|
+
(operation.type !== 'push' ||
|
|
156
|
+
operation.events.every((event) => event.type === 'crash.requested')),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { createClient } from 'experimental-a2/client'
|
|
3
|
+
import { createReact } from 'experimental-a2/react'
|
|
4
|
+
import { appList } from './apps'
|
|
5
|
+
import { crashLog } from './crashes'
|
|
6
|
+
import { appAgent } from './model'
|
|
7
|
+
|
|
8
|
+
/** The dev tools' switch, `?limit=5s`, for the app at this URL, while they're open. */
|
|
9
|
+
export function shortFunctions(appId: string): boolean {
|
|
10
|
+
const params = new URLSearchParams(location.search)
|
|
11
|
+
return (
|
|
12
|
+
location.pathname === `/a0/${appId}` &&
|
|
13
|
+
params.has('devtools') &&
|
|
14
|
+
params.get('limit') === '5s'
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Follows an app's conversation log over the events route. Pushes go to a
|
|
20
|
+
* 5-second function instead while the dev tools' switch is on.
|
|
21
|
+
*/
|
|
22
|
+
export const appClient = createClient({
|
|
23
|
+
contract: appAgent,
|
|
24
|
+
api: {
|
|
25
|
+
type: 'http',
|
|
26
|
+
stream: '/a0/events',
|
|
27
|
+
push: (appId) =>
|
|
28
|
+
shortFunctions(appId) ? '/a0/short/events' : '/a0/events',
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const { SessionProvider, useSession } = createReact({
|
|
33
|
+
client: appClient,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Follows this browser's app list. */
|
|
37
|
+
export const listClient = createClient({ contract: appList, api: '/a0/list' })
|
|
38
|
+
|
|
39
|
+
export const { useSession: useList } = createReact({ client: listClient })
|
|
40
|
+
|
|
41
|
+
/** Follows an app's crash log, for the dev tools. */
|
|
42
|
+
export const crashClient = createClient({
|
|
43
|
+
contract: crashLog,
|
|
44
|
+
api: '/a0/crashes',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const { useSession: useCrashLog } = createReact({ client: crashClient })
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from 'react'
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
5
|
+
import { SiteNav } from '../components/site-header'
|
|
6
|
+
import { type App, byLatestPrompt } from './apps'
|
|
7
|
+
import { AppList } from './app-list'
|
|
8
|
+
import { ColumnEdge, Columns } from './columns'
|
|
9
|
+
import { listCookie, readCookie, type Widths, writeCookie } from './cookies'
|
|
10
|
+
import { toggleDevtools } from './flags'
|
|
11
|
+
import { useList } from './session'
|
|
12
|
+
|
|
13
|
+
type Apps = {
|
|
14
|
+
listed: (id: string) => App | null
|
|
15
|
+
/** Records a prompt to an app, listing it under `name` the first time. */
|
|
16
|
+
prompted: (app: { id: string; name: string }) => void
|
|
17
|
+
/** Makes a new app on screen the one "+" opens until it's prompted. */
|
|
18
|
+
opened: (appId: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AppsContext = createContext<Apps | null>(null)
|
|
22
|
+
|
|
23
|
+
export function useApps(): Apps {
|
|
24
|
+
const apps = useContext(AppsContext)
|
|
25
|
+
if (!apps) throw new Error('useApps needs the A0 layout.')
|
|
26
|
+
return apps
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ListSeed = NonNullable<Parameters<typeof useList>[0]>['initial']
|
|
30
|
+
|
|
31
|
+
const idFromPath = (pathname: string): string | null =>
|
|
32
|
+
pathname.split('/')[2] || null
|
|
33
|
+
|
|
34
|
+
/** A0's frame: this browser's apps, beside whichever app is open. */
|
|
35
|
+
export function A0Shell({
|
|
36
|
+
list,
|
|
37
|
+
widths,
|
|
38
|
+
renderedAt,
|
|
39
|
+
newAppId,
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
list: { id: string; initial: ListSeed }
|
|
43
|
+
widths: Widths
|
|
44
|
+
renderedAt: number
|
|
45
|
+
/** A new app is an id with no log yet. */
|
|
46
|
+
newAppId: string
|
|
47
|
+
children: ReactNode
|
|
48
|
+
}): ReactNode {
|
|
49
|
+
const router = useRouter()
|
|
50
|
+
const current = idFromPath(usePathname())
|
|
51
|
+
const devtools = useSearchParams().has('devtools')
|
|
52
|
+
const { state: apps, push } = useList({
|
|
53
|
+
sessionId: list.id,
|
|
54
|
+
initial: list.initial,
|
|
55
|
+
})
|
|
56
|
+
const [listError, setListError] = useState<string | null>(null)
|
|
57
|
+
// The new app "+" opens: the one last on screen, so its sandbox is likely
|
|
58
|
+
// warm, until it's prompted.
|
|
59
|
+
const [ready, setReady] = useState(newAppId)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const toggle = (event: KeyboardEvent): void => {
|
|
63
|
+
if (event.key.toLowerCase() !== 'j' || !(event.metaKey || event.ctrlKey))
|
|
64
|
+
return
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
toggleDevtools()
|
|
67
|
+
}
|
|
68
|
+
document.addEventListener('keydown', toggle)
|
|
69
|
+
return () => document.removeEventListener('keydown', toggle)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const write = (...event: Parameters<typeof push>): void => {
|
|
73
|
+
// The list is this browser's from its first write.
|
|
74
|
+
if (!readCookie(listCookie))
|
|
75
|
+
writeCookie({ name: listCookie, value: list.id })
|
|
76
|
+
setListError(null)
|
|
77
|
+
// A failed write has already been rolled back out of the list.
|
|
78
|
+
push(...event).catch((cause: unknown) =>
|
|
79
|
+
setListError(
|
|
80
|
+
`Couldn't save that: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const listed = (id: string): App | null =>
|
|
85
|
+
apps.find((app) => app.id === id) ?? null
|
|
86
|
+
const context: Apps = {
|
|
87
|
+
listed,
|
|
88
|
+
prompted: ({ id, name }) => {
|
|
89
|
+
if (listed(id)) {
|
|
90
|
+
write({ type: 'app.prompted', payload: { appId: id } })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
write({ type: 'app.created', payload: { appId: id, name } })
|
|
94
|
+
if (id === ready) setReady(crypto.randomUUID())
|
|
95
|
+
},
|
|
96
|
+
opened: setReady,
|
|
97
|
+
}
|
|
98
|
+
const remove = async (target: App): Promise<void> => {
|
|
99
|
+
if (
|
|
100
|
+
!confirm(
|
|
101
|
+
`Delete ${target.name}? This deletes its sandbox, with everything the agent built in it.`,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
const response = await fetch(`/a0/apps/${target.id}`, { method: 'DELETE' })
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
alert(`Could not delete ${target.name}.`)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
write({ type: 'app.deleted', payload: { appId: target.id } })
|
|
111
|
+
if (target.id === current) router.replace(`/a0/${ready}${location.search}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<AppsContext value={context}>
|
|
116
|
+
<Columns saved={widths} panel={devtools}>
|
|
117
|
+
<aside aria-label="A0" className="a0-apps">
|
|
118
|
+
<div className="site-header a0-head">
|
|
119
|
+
<SiteNav />
|
|
120
|
+
</div>
|
|
121
|
+
<AppList
|
|
122
|
+
apps={byLatestPrompt(apps)}
|
|
123
|
+
currentId={current}
|
|
124
|
+
renderedAt={renderedAt}
|
|
125
|
+
newAppId={ready}
|
|
126
|
+
onRename={({ id, name }) =>
|
|
127
|
+
write({ type: 'app.renamed', payload: { appId: id, name } })
|
|
128
|
+
}
|
|
129
|
+
onDelete={(target) => void remove(target)}
|
|
130
|
+
/>
|
|
131
|
+
{listError && (
|
|
132
|
+
<p role="alert" className="a0-error a0-list-error">
|
|
133
|
+
{listError}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className="a0-devtools-toggle"
|
|
139
|
+
aria-pressed={devtools}
|
|
140
|
+
onClick={toggleDevtools}
|
|
141
|
+
>
|
|
142
|
+
Dev tools <kbd>⌘J</kbd>
|
|
143
|
+
</button>
|
|
144
|
+
<ColumnEdge column="apps" />
|
|
145
|
+
</aside>
|
|
146
|
+
{children}
|
|
147
|
+
</Columns>
|
|
148
|
+
</AppsContext>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { appEvents } from '../../server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pushes from the dev tools' 5-second switch. Agent work they start stops
|
|
5
|
+
* with the function, and the scheduler resumes it in a full-length one.
|
|
6
|
+
*/
|
|
7
|
+
export { appEvents as POST }
|
|
8
|
+
|
|
9
|
+
export const maxDuration = 5
|