experimental-a2 0.8.0 → 0.9.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.
Files changed (182) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +29 -0
  4. package/dist/actor-client.d.ts +46 -0
  5. package/dist/actor-client.d.ts.map +1 -0
  6. package/dist/actor-client.js +54 -0
  7. package/dist/actor-client.js.map +1 -0
  8. package/dist/actor-react.d.ts +54 -0
  9. package/dist/actor-react.d.ts.map +1 -0
  10. package/dist/actor-react.js +79 -0
  11. package/dist/actor-react.js.map +1 -0
  12. package/dist/actor-shared-BACubf4x.d.ts +136 -0
  13. package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
  14. package/dist/actor-shared-DI7J5upy.js +127 -0
  15. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  16. package/dist/actor.browser.d.ts +1 -0
  17. package/dist/actor.browser.js +13 -0
  18. package/dist/actor.browser.js.map +1 -0
  19. package/dist/actor.d.ts +176 -0
  20. package/dist/actor.d.ts.map +1 -0
  21. package/dist/actor.js +437 -0
  22. package/dist/actor.js.map +1 -0
  23. package/dist/ai-server.d.ts +2 -2
  24. package/dist/ai-server.js +2 -2
  25. package/dist/ai.d.ts +2 -2
  26. package/dist/client.d.ts +1 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +4 -4
  29. package/dist/client.js.map +1 -1
  30. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  31. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  32. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  33. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/react.d.ts +1 -1
  38. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  39. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  40. package/dist/scheduler-qstash.d.ts +2 -2
  41. package/dist/scheduler-qstash.js +2 -2
  42. package/dist/scheduler-vercel.d.ts +2 -2
  43. package/dist/scheduler-vercel.js +1 -1
  44. package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
  45. package/dist/server-CBET-jSz.js.map +1 -0
  46. package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
  47. package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
  48. package/dist/server.d.ts +3 -3
  49. package/dist/server.js +1 -1
  50. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  51. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-memory.js +2 -2
  54. package/dist/store-postgres.d.ts +1 -1
  55. package/dist/store-postgres.js +2 -2
  56. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  57. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  58. package/dist/store-redis-http.d.ts +1 -1
  59. package/dist/store-redis-http.js +2 -2
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +2 -2
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/dist/store-sqlite.js +2 -2
  64. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  65. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  66. package/docs/actors/01-introduction.mdx +189 -0
  67. package/docs/actors/02-concurrency.mdx +154 -0
  68. package/docs/actors/03-timers.mdx +120 -0
  69. package/docs/actors/04-routes.mdx +352 -0
  70. package/docs/actors/meta.ts +1 -0
  71. package/docs/concepts/meta.ts +1 -0
  72. package/docs/guides/07-examples.mdx +56 -0
  73. package/docs/guides/meta.ts +1 -0
  74. package/docs/index.mdx +16 -0
  75. package/docs/reference/02-errors.mdx +33 -0
  76. package/docs/reference/meta.ts +1 -0
  77. package/examples/README.md +15 -0
  78. package/examples/playground/AGENTS.md +11 -0
  79. package/examples/playground/DEPLOY.md +106 -0
  80. package/examples/playground/README.md +19 -0
  81. package/examples/playground/activity-feed.test.ts +10 -0
  82. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  83. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  84. package/examples/playground/app/agent/events/route.ts +4 -0
  85. package/examples/playground/app/agent/model.ts +3 -0
  86. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  87. package/examples/playground/app/agent/page.tsx +25 -0
  88. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  89. package/examples/playground/app/agent/server.ts +153 -0
  90. package/examples/playground/app/agent/session.ts +13 -0
  91. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  92. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  93. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  94. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  95. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  96. package/examples/playground/app/canvas/events/route.ts +13 -0
  97. package/examples/playground/app/canvas/model.ts +94 -0
  98. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  99. package/examples/playground/app/canvas/page.tsx +20 -0
  100. package/examples/playground/app/canvas/server.ts +9 -0
  101. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  102. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  103. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  104. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  105. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  106. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  107. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  108. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  109. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  110. package/examples/playground/app/chat/events/route.ts +7 -0
  111. package/examples/playground/app/chat/model.test.ts +155 -0
  112. package/examples/playground/app/chat/model.ts +310 -0
  113. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  114. package/examples/playground/app/chat/page.tsx +25 -0
  115. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  116. package/examples/playground/app/chat/server.ts +184 -0
  117. package/examples/playground/app/components/activity-feed.tsx +54 -0
  118. package/examples/playground/app/components/connection-pill.tsx +29 -0
  119. package/examples/playground/app/counter/counter-client.tsx +72 -0
  120. package/examples/playground/app/counter/events/route.ts +4 -0
  121. package/examples/playground/app/counter/model.test.ts +36 -0
  122. package/examples/playground/app/counter/model.ts +31 -0
  123. package/examples/playground/app/counter/page.tsx +24 -0
  124. package/examples/playground/app/counter/server.ts +9 -0
  125. package/examples/playground/app/counter/session.ts +13 -0
  126. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  127. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  128. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  129. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  130. package/examples/playground/app/documents/events/route.ts +4 -0
  131. package/examples/playground/app/documents/model.ts +55 -0
  132. package/examples/playground/app/documents/open-document.tsx +40 -0
  133. package/examples/playground/app/documents/page.tsx +22 -0
  134. package/examples/playground/app/documents/server.ts +9 -0
  135. package/examples/playground/app/globals.css +2078 -0
  136. package/examples/playground/app/layout.tsx +44 -0
  137. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  138. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  139. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  140. package/examples/playground/app/orders/create/route.ts +30 -0
  141. package/examples/playground/app/orders/events/route.ts +4 -0
  142. package/examples/playground/app/orders/model.ts +79 -0
  143. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  144. package/examples/playground/app/orders/page.tsx +22 -0
  145. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  146. package/examples/playground/app/orders/server.ts +50 -0
  147. package/examples/playground/app/page.tsx +111 -0
  148. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  149. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  150. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  151. package/examples/playground/app/recovery/events/route.ts +3 -0
  152. package/examples/playground/app/recovery/model.ts +55 -0
  153. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  154. package/examples/playground/app/recovery/page.tsx +22 -0
  155. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  156. package/examples/playground/app/recovery/server.ts +53 -0
  157. package/examples/playground/app/recovery/start/route.ts +41 -0
  158. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  159. package/examples/playground/app/vault/page.tsx +12 -0
  160. package/examples/playground/app/vault/server.ts +9 -0
  161. package/examples/playground/app/vault/vault-client.tsx +124 -0
  162. package/examples/playground/app/vault/vault.test.ts +147 -0
  163. package/examples/playground/app/vault/vault.ts +119 -0
  164. package/examples/playground/css.d.ts +4 -0
  165. package/examples/playground/lib/store.ts +15 -0
  166. package/examples/playground/next-env.d.ts +5 -0
  167. package/examples/playground/next.config.ts +10 -0
  168. package/examples/playground/package.json +46 -0
  169. package/examples/playground/tsconfig.json +37 -0
  170. package/examples/playground/vercel.json +40 -0
  171. package/package.json +11 -2
  172. package/src/actor-client.ts +132 -0
  173. package/src/actor-react.ts +143 -0
  174. package/src/actor-shared.ts +356 -0
  175. package/src/actor.browser.ts +12 -0
  176. package/src/actor.ts +914 -0
  177. package/src/client.ts +9 -1
  178. package/src/errors.ts +15 -0
  179. package/src/index.ts +1 -1
  180. package/src/server.ts +13 -3
  181. package/dist/contract-jIfaR085.d.ts.map +0 -1
  182. package/dist/server-B2XNevQA.js.map +0 -1
@@ -0,0 +1,144 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useState } from 'react'
4
+ import { ActivityFeed } from '@/app/components/activity-feed'
5
+ import { ConnectionPill } from '@/app/components/connection-pill'
6
+ import { useSession } from './session'
7
+
8
+ const FUNCTION_LIMIT_SECONDS = 20
9
+ const STEP_SECONDS = 8
10
+ const STEP_COUNT = 4
11
+
12
+ export function RecoveryClient({
13
+ sessionId,
14
+ onVercel,
15
+ }: {
16
+ sessionId: string
17
+ onVercel: boolean
18
+ }): ReactNode {
19
+ const { state, events, index, connection } = useSession()
20
+ const [error, setError] = useState<string | null>(null)
21
+ const [starting, setStarting] = useState(false)
22
+
23
+ const run = async (): Promise<void> => {
24
+ setError(null)
25
+ setStarting(true)
26
+ try {
27
+ const response = await fetch('/recovery/start', {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify({ sessionId }),
31
+ })
32
+ if (!response.ok) {
33
+ throw new Error(`recovery trigger returned ${response.status}`)
34
+ }
35
+ } catch (err) {
36
+ setError(err instanceof Error ? err.message : String(err))
37
+ } finally {
38
+ setStarting(false)
39
+ }
40
+ }
41
+
42
+ const running = state.phase === 'running'
43
+ const completed = state.phase === 'completed'
44
+
45
+ return (
46
+ <article>
47
+ <header className="session-header">
48
+ <h1>Cross-invocation handler chain</h1>
49
+ <ConnectionPill connection={connection} />
50
+ <span
51
+ className={`badge${completed ? ' done' : running ? ' waiting' : ''}`}
52
+ >
53
+ {completed
54
+ ? 'completed'
55
+ : running
56
+ ? `${state.completedSteps}/${state.stepCount ?? STEP_COUNT} steps`
57
+ : 'ready'}
58
+ </span>
59
+ </header>
60
+
61
+ <p className="lede">
62
+ Four handlers fit individually, but their 32-second chain does not fit
63
+ in one 20-second function. Every invocation has the same limit.
64
+ </p>
65
+
66
+ <dl className="facts">
67
+ <dt>Function limit</dt>
68
+ <dd>{FUNCTION_LIMIT_SECONDS} seconds</dd>
69
+ <dt>Each handler</dt>
70
+ <dd>{STEP_SECONDS} seconds</dd>
71
+ <dt>Whole chain</dt>
72
+ <dd>
73
+ {STEP_COUNT} × {STEP_SECONDS} = {STEP_COUNT * STEP_SECONDS} seconds
74
+ </dd>
75
+ <dt>Queue consumer limit</dt>
76
+ <dd>{FUNCTION_LIMIT_SECONDS} seconds</dd>
77
+ </dl>
78
+
79
+ <button
80
+ disabled={state.phase !== 'idle' || starting || !onVercel}
81
+ onClick={() => void run()}
82
+ >
83
+ {starting ? 'Committing…' : 'Start four-step chain'}
84
+ </button>
85
+ {!onVercel ? (
86
+ <p className="hint">
87
+ Deploy this playground to Vercel to supply a real invocation deadline
88
+ and queue redelivery. <code>next dev</code> has neither boundary.
89
+ </p>
90
+ ) : null}
91
+
92
+ {running ? (
93
+ <section className="timeout-callout">
94
+ <h2>The chain is crossing function invocations</h2>
95
+ <p>
96
+ Each step waits eight seconds before appending its completion fact.
97
+ A2 does not predict whether the next handler fits. The first
98
+ invocation completes two handlers, starts the third, and is stopped
99
+ by Vercel at 20 seconds. That event stays pending. Its claim
100
+ watchdog acquires the expired claim and retries the same event in a
101
+ fresh function.
102
+ </p>
103
+ <p className="hint">
104
+ Keep this page open. Claim heartbeats keep scheduling watchdogs
105
+ while a handler is alive. The known function deadline caps the last
106
+ claim window, so the scheduler arrives just after Vercel stops the
107
+ worker. Reloading can also trigger A2&apos;s separate
108
+ interaction-healing path.
109
+ </p>
110
+ </section>
111
+ ) : null}
112
+
113
+ {completed ? (
114
+ <p className="proof">
115
+ All {state.completedSteps} bounded handlers finished. The scheduler
116
+ added no event of its own; it only continued draining the pending
117
+ application events.
118
+ </p>
119
+ ) : null}
120
+ {error ? <p className="error">{error}</p> : null}
121
+
122
+ <ActivityFeed
123
+ events={events}
124
+ index={index}
125
+ status={
126
+ running ? (
127
+ state.completedSteps < STEP_COUNT ? (
128
+ <>
129
+ Waiting for step {state.completedSteps + 1} of {STEP_COUNT}. The
130
+ event log stays quiet while its {STEP_SECONDS}-second handler
131
+ runs; the next row is the handler&apos;s real completion fact.
132
+ </>
133
+ ) : (
134
+ <>
135
+ All four handlers finished. Waiting for the final{' '}
136
+ <code>pipeline.completed</code> fact from the active drain.
137
+ </>
138
+ )
139
+ ) : undefined
140
+ }
141
+ />
142
+ </article>
143
+ )
144
+ }
@@ -0,0 +1,7 @@
1
+ 'use client'
2
+ import { createReact } from 'experimental-a2/react'
3
+ import { recoveryReducer } from '../model'
4
+
5
+ export const { SessionProvider, useSession } = createReact({
6
+ reducer: recoveryReducer,
7
+ })
@@ -0,0 +1,3 @@
1
+ import { recoveryServer } from '../server'
2
+
3
+ export const GET = recoveryServer.fetch
@@ -0,0 +1,55 @@
1
+ import * as a2 from 'experimental-a2'
2
+ import { z } from 'zod'
3
+
4
+ export const recovery = a2.contract({
5
+ name: 'recovery',
6
+ events: {
7
+ 'pipeline.started': z.object({
8
+ stepCount: z.literal(4),
9
+ stepDurationSeconds: z.literal(8),
10
+ }),
11
+ 'step.completed': z.object({
12
+ step: z.number().int().min(1).max(4),
13
+ durationSeconds: z.literal(8),
14
+ }),
15
+ 'pipeline.completed': z.object({ stepCount: z.literal(4) }),
16
+ },
17
+ })
18
+
19
+ const recoveryState = z.object({
20
+ phase: z.enum(['idle', 'running', 'completed']),
21
+ stepCount: z.number().int().positive().nullable(),
22
+ stepDurationSeconds: z.number().int().positive().nullable(),
23
+ completedSteps: z.number().int().nonnegative(),
24
+ })
25
+
26
+ export type RecoveryState = z.output<typeof recoveryState>
27
+
28
+ export const recoveryReducer = recovery
29
+ .reducer({
30
+ name: 'recovery',
31
+ initialState: {
32
+ phase: 'idle',
33
+ stepCount: null,
34
+ stepDurationSeconds: null,
35
+ completedSteps: 0,
36
+ },
37
+ stateSchema: recoveryState,
38
+ })
39
+ .fold((state, event) => {
40
+ switch (event.type) {
41
+ case 'pipeline.started':
42
+ return {
43
+ ...state,
44
+ phase: 'running',
45
+ stepCount: event.payload.stepCount,
46
+ stepDurationSeconds: event.payload.stepDurationSeconds,
47
+ }
48
+ case 'step.completed':
49
+ return { ...state, completedSteps: event.payload.step }
50
+ case 'pipeline.completed':
51
+ return { ...state, phase: 'completed' }
52
+ default:
53
+ return state
54
+ }
55
+ })
@@ -0,0 +1,20 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+
5
+ const newSessionId = (): string =>
6
+ `recovery-${Math.random().toString(36).slice(2, 8)}`
7
+
8
+ export function NewRecoverySession(): ReactNode {
9
+ const router = useRouter()
10
+
11
+ return (
12
+ <button
13
+ onClick={() =>
14
+ router.push(`/recovery/${encodeURIComponent(newSessionId())}`)
15
+ }
16
+ >
17
+ Start a recovery run
18
+ </button>
19
+ )
20
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react'
2
+ import { NewRecoverySession } from './new-recovery-session'
3
+
4
+ export default function RecoveryPage(): ReactNode {
5
+ return (
6
+ <article>
7
+ <h1>Recovery lab</h1>
8
+ <p className="lede">
9
+ Run four 8-second handlers in a chain. Each fits inside a 20-second
10
+ Vercel function, but all 32 seconds cannot. Watch Vercel stop one
11
+ mid-handler, then let A2 retry that pending event in a fresh invocation
12
+ with the same limit.
13
+ </p>
14
+ <NewRecoverySession />
15
+ <p className="hint">
16
+ Each run gets a fresh session. This is enabled only on Vercel, where the
17
+ platform can supply the deadline and deliver the armed queue message.
18
+ The event log contains pipeline facts only.
19
+ </p>
20
+ </article>
21
+ )
22
+ }
@@ -0,0 +1,7 @@
1
+ import { recoveryScheduler, recoveryServer } from '../server'
2
+
3
+ export const maxDuration = 20
4
+
5
+ export const POST =
6
+ recoveryScheduler?.handler(recoveryServer) ??
7
+ (() => new Response('recovery scheduler is not configured', { status: 503 }))
@@ -0,0 +1,53 @@
1
+ import 'server-only'
2
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
3
+ import { createServer } from 'experimental-a2/server'
4
+ import { store } from '@/lib/store'
5
+ import { recovery } from './model'
6
+
7
+ const stepCount = 4 as const
8
+ const stepDurationSeconds = 8 as const
9
+
10
+ const wait = (ms: number): Promise<void> =>
11
+ new Promise((resolve) => setTimeout(resolve, ms))
12
+
13
+ export const recoveryScheduler =
14
+ process.env.VERCEL || process.env.A2_QUEUES
15
+ ? vercelQueues({ topic: 'a2-recovery' })
16
+ : undefined
17
+
18
+ export const recoveryServer = createServer({
19
+ ...(store ? { store } : {}),
20
+ ...(recoveryScheduler ? { scheduler: recoveryScheduler } : {}),
21
+ contract: recovery,
22
+ handlers: {
23
+ 'pipeline.started': {
24
+ lane: 'pipeline',
25
+ handler: async () => {
26
+ await wait(stepDurationSeconds * 1_000)
27
+ return {
28
+ type: 'step.completed',
29
+ payload: { step: 1, durationSeconds: stepDurationSeconds },
30
+ }
31
+ },
32
+ },
33
+ 'step.completed': {
34
+ lane: 'pipeline',
35
+ handler: async ({ event }) => {
36
+ if (event.payload.step === stepCount) {
37
+ return {
38
+ type: 'pipeline.completed',
39
+ payload: { stepCount },
40
+ }
41
+ }
42
+ await wait(stepDurationSeconds * 1_000)
43
+ return {
44
+ type: 'step.completed',
45
+ payload: {
46
+ step: event.payload.step + 1,
47
+ durationSeconds: stepDurationSeconds,
48
+ },
49
+ }
50
+ },
51
+ },
52
+ },
53
+ })
@@ -0,0 +1,41 @@
1
+ import { A2Error } from 'experimental-a2'
2
+ import { recoveryServer } from '../server'
3
+
4
+ const errorResponse = (error: unknown): Response => {
5
+ if (error instanceof A2Error) {
6
+ return Response.json(
7
+ { error: { code: error.code, message: error.message } },
8
+ { status: error.code === 'STORE_UNAVAILABLE' ? 503 : 400 },
9
+ )
10
+ }
11
+ return Response.json(
12
+ { error: { message: 'internal error' } },
13
+ { status: 500 },
14
+ )
15
+ }
16
+
17
+ export const maxDuration = 20
18
+
19
+ export async function POST(request: Request): Promise<Response> {
20
+ try {
21
+ const body: unknown = await request.json()
22
+ const sessionId =
23
+ typeof body === 'object' && body !== null && 'sessionId' in body
24
+ ? Reflect.get(body, 'sessionId')
25
+ : undefined
26
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
27
+ throw new A2Error(
28
+ 'INVALID_PAYLOAD',
29
+ 'sessionId must be a non-empty string',
30
+ )
31
+ }
32
+ const result = await recoveryServer.session(sessionId).append({
33
+ id: `${sessionId}:pipeline`,
34
+ type: 'pipeline.started',
35
+ payload: { stepCount: 4, stepDurationSeconds: 8 },
36
+ })
37
+ return Response.json(result)
38
+ } catch (error) {
39
+ return errorResponse(error)
40
+ }
41
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The vault's ingress — the actor mirror of `server.fetch`: your route
3
+ * authenticates and picks the instance, `handle.fetch` serves the rest
4
+ * (GET → the state-plane SSE, POST → the call lane).
5
+ */
6
+ import { vault } from '../server'
7
+
8
+ async function respond(
9
+ request: Request,
10
+ context: { params: Promise<{ vaultId: string }> },
11
+ ): Promise<Response> {
12
+ const { vaultId } = await context.params
13
+ // here's where you'd authenticate; per-operation policy goes in
14
+ // fetch's { authorize } option
15
+ return vault.actor(vaultId).fetch(request)
16
+ }
17
+
18
+ export const GET = respond
19
+ export const POST = respond
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react'
2
+ import { vault } from './server'
3
+ import { VaultClient } from './vault-client'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ const VAULT_ID = 'shared'
8
+
9
+ export default async function VaultPage(): Promise<ReactNode> {
10
+ const initial = await vault.actor(VAULT_ID).state()
11
+ return <VaultClient vaultId={VAULT_ID} initial={initial} />
12
+ }
@@ -0,0 +1,9 @@
1
+ import 'server-only'
2
+ import { store } from '@/lib/store'
3
+ import { createVault } from './vault'
4
+
5
+ export type { VaultState } from './vault'
6
+
7
+ export const vault: ReturnType<typeof createVault> = createVault(
8
+ store ? { store } : {},
9
+ )
@@ -0,0 +1,124 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useEffect, useState } from 'react'
4
+ import { ActorRefusedError } from 'experimental-a2/actor/client'
5
+ import { useActor } from 'experimental-a2/actor/react'
6
+ import type { ActorSnapshot } from 'experimental-a2/actor/react'
7
+ import type { vault, VaultState } from '@/app/vault/server'
8
+ import { ActivityFeed } from '@/app/components/activity-feed'
9
+ import { ConnectionPill } from '@/app/components/connection-pill'
10
+
11
+ const ACTIVITY_LIMIT = 50
12
+
13
+ export function VaultClient({
14
+ vaultId,
15
+ initial,
16
+ }: {
17
+ vaultId: string
18
+ initial: ActorSnapshot<VaultState>
19
+ }): ReactNode {
20
+ const [participant] = useState(
21
+ () => `guest-${Math.random().toString(36).slice(2, 8)}`,
22
+ )
23
+ const { state, call, events, index, connection, presence, setPresence } =
24
+ useActor<typeof vault>({
25
+ api: `/vault/${vaultId}`,
26
+ id: vaultId,
27
+ initial,
28
+ participant,
29
+ })
30
+ const [answer, setAnswer] = useState<string | null>(null)
31
+
32
+ useEffect(() => {
33
+ setPresence({ viewing: true })
34
+ }, [setPresence])
35
+ const here = Object.keys(presence).length
36
+
37
+ const run = async (invoke: () => Promise<unknown>): Promise<void> => {
38
+ try {
39
+ await invoke()
40
+ setAnswer(null)
41
+ } catch (err) {
42
+ setAnswer(
43
+ err instanceof ActorRefusedError
44
+ ? err.message
45
+ : err instanceof Error
46
+ ? err.message
47
+ : String(err),
48
+ )
49
+ }
50
+ }
51
+
52
+ // deployed state may predate the `pending` field
53
+ const pending = Object.entries(state.pending ?? {})
54
+ const visibleEvents = events.slice(-ACTIVITY_LIMIT)
55
+
56
+ return (
57
+ <article>
58
+ <header className="session-header">
59
+ <h1>Vault</h1>
60
+ <ConnectionPill connection={connection} />
61
+ <span className="badge live">shared</span>
62
+ {here > 1 ? <span className="badge">{here} viewing</span> : null}
63
+ </header>
64
+ <p className="lede">
65
+ One durable actor per vault id — serial brain, concurrent muscle. A
66
+ withdrawal is a saga: the action <em>reserves</em> instantly (balance
67
+ drops, a pending transfer appears), a <em>task</em> runs the slow
68
+ 2-second transfer off the lane, and its result comes back as a message
69
+ that <em>settles</em> or <em>refunds</em>. The lane is never blocked:
70
+ keep depositing while transfers are in flight.
71
+ </p>
72
+
73
+ <section className="counter-panel" aria-label="Vault controls">
74
+ <output className="counter-value" aria-live="polite">
75
+ ${state.balance}
76
+ </output>
77
+ <div className="counter-actions">
78
+ <button
79
+ type="button"
80
+ onClick={() => void run(() => call.deposit({ amount: 25 }))}
81
+ >
82
+ Deposit $25
83
+ </button>
84
+ <button
85
+ type="button"
86
+ className="secondary"
87
+ onClick={() => void run(() => call.withdraw({ amount: 60 }))}
88
+ >
89
+ Withdraw $60
90
+ </button>
91
+ <button
92
+ type="button"
93
+ className="secondary"
94
+ onClick={() => void run(() => call.withdraw({ amount: 19 }))}
95
+ >
96
+ Withdraw $19 (bounces)
97
+ </button>
98
+ </div>
99
+ <p className="hint" aria-live="polite">
100
+ {pending.length > 0
101
+ ? `${pending.length} transfer${pending.length === 1 ? '' : 's'} in flight: ${pending
102
+ .map(([, amount]) => `$${amount}`)
103
+ .join(', ')}`
104
+ : `${state.deposits} deposits, ${state.withdrawals} settled withdrawals`}
105
+ </p>
106
+ </section>
107
+
108
+ {answer ? <p className="error">refused: {answer}</p> : null}
109
+ <p className="hint">
110
+ Watch the state plane below narrate the saga: <code>withdraw</code> (the
111
+ reserve commits with the transfer trigger, atomically), then two seconds
112
+ later <code>settle</code> — or <code>refund</code> for amounts ending in
113
+ 9, the demo bank&apos;s bounce policy. Withdrawing more than the balance
114
+ is refused instantly by the guard; the reserve means in-flight transfers
115
+ can never oversell the balance, from any number of tabs.
116
+ </p>
117
+
118
+ <ActivityFeed events={visibleEvents} index={index} />
119
+ {events.length > ACTIVITY_LIMIT ? (
120
+ <p className="hint">Showing the latest {ACTIVITY_LIMIT} events.</p>
121
+ ) : null}
122
+ </article>
123
+ )
124
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The vault saga, tested against a real (memory) store — the whole
3
+ * durable pipeline runs: appends, lanes, claims, atomic commits,
4
+ * concurrent tasks. Nothing is mocked; the store and the fake bank's
5
+ * latency are injected through the factory, and every test gets an
6
+ * isolated vault.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { ActorRefusedError } from 'experimental-a2/actor/client'
10
+ import { memory } from 'experimental-a2/store-memory'
11
+ import { createVault } from './vault'
12
+
13
+ const makeVault = (transferMs = 25) =>
14
+ createVault({ store: memory(), transferMs })
15
+
16
+ describe('vault saga', () => {
17
+ it('reserve → transfer → settle: the happy path', async () => {
18
+ const vault = makeVault().actor('acct')
19
+ await vault.call.deposit({ amount: 100 })
20
+
21
+ // the withdraw answers at reserve time: balance already down,
22
+ // the transfer visible as pending
23
+ const reserved = await vault.call.withdraw({ amount: 60 })
24
+ expect(reserved.state.balance).toBe(40)
25
+ expect(Object.values(reserved.state.pending)).toEqual([60])
26
+ expect(reserved.state.withdrawals).toBe(0)
27
+
28
+ // the settle arrives as a message once the transfer completes
29
+ await expect
30
+ .poll(async () => (await vault.state()).state, { timeout: 5_000 })
31
+ .toEqual({ balance: 40, deposits: 1, withdrawals: 1, pending: {} })
32
+ })
33
+
34
+ it('reserve → transfer bounces → refund: the compensation path', async () => {
35
+ const vault = makeVault().actor('acct')
36
+ await vault.call.deposit({ amount: 100 })
37
+
38
+ const reserved = await vault.call.withdraw({ amount: 19 }) // ends in 9: bounces
39
+ expect(reserved.state.balance).toBe(81)
40
+
41
+ await expect
42
+ .poll(async () => (await vault.state()).state, { timeout: 5_000 })
43
+ .toEqual({ balance: 100, deposits: 1, withdrawals: 0, pending: {} })
44
+ })
45
+
46
+ it('the reserve prevents overselling while a transfer is in flight', async () => {
47
+ // a slow bank, so the second withdraw races the in-flight transfer
48
+ const vault = makeVault(500).actor('acct')
49
+ await vault.call.deposit({ amount: 100 })
50
+
51
+ await vault.call.withdraw({ amount: 60 })
52
+ // the transfer has not settled — but the money is already reserved
53
+ await expect(vault.call.withdraw({ amount: 60 })).rejects.toThrow(
54
+ 'insufficient funds: the balance is 40',
55
+ )
56
+ })
57
+
58
+ it('the lane stays free while transfers are in flight', async () => {
59
+ const vault = makeVault(500).actor('acct')
60
+ await vault.call.deposit({ amount: 100 })
61
+ await vault.call.withdraw({ amount: 60 })
62
+
63
+ // the deposit answers immediately, while pending proves the
64
+ // transfer is still running — the task holds no lane
65
+ const during = await vault.call.deposit({ amount: 5 })
66
+ expect(during.state.balance).toBe(45)
67
+ expect(Object.keys(during.state.pending)).toHaveLength(1)
68
+ })
69
+
70
+ it('interleavings reorder but never corrupt: the mixed scenario', async () => {
71
+ const vault = makeVault().actor('acct')
72
+ await vault.call.deposit({ amount: 100 })
73
+ await vault.call.withdraw({ amount: 19 }) // will bounce → refund
74
+ await vault.call.deposit({ amount: 25 })
75
+ await vault.call.withdraw({ amount: 60 }) // will settle
76
+
77
+ // whatever order the two transfers resolve in, the final state is
78
+ // the same — every decision ran serialized against fresh state
79
+ await expect
80
+ .poll(async () => (await vault.state()).state, { timeout: 5_000 })
81
+ .toEqual({
82
+ balance: 65, // 100 − 19 + 25 − 60 + 19 (refund)
83
+ deposits: 2,
84
+ withdrawals: 1,
85
+ pending: {},
86
+ })
87
+ })
88
+
89
+ it('finalizers are idempotent: a manual refund wins, the late settle no-ops', async () => {
90
+ const vault = makeVault(300).actor('acct')
91
+ await vault.call.deposit({ amount: 100 })
92
+
93
+ const reserved = await vault.call.withdraw({ amount: 60 })
94
+ const ref = Object.keys(reserved.state.pending)[0]!
95
+
96
+ // support intervenes before the bank answers
97
+ const refunded = await vault.call.refund({ ref })
98
+ expect(refunded.state.balance).toBe(100)
99
+ expect(refunded.state.pending).toEqual({})
100
+
101
+ // the transfer later settles into a void: pending[ref] is gone, so
102
+ // the settle no-ops — no double-credit, no phantom withdrawal
103
+ await new Promise((resolve) => setTimeout(resolve, 600))
104
+ const { state } = await vault.state()
105
+ expect(state).toEqual({
106
+ balance: 100,
107
+ deposits: 1,
108
+ withdrawals: 0,
109
+ pending: {},
110
+ })
111
+ })
112
+
113
+ it('state predating the pending field is defaulted in code (deploy drift)', async () => {
114
+ const vault = makeVault().actor('legacy')
115
+ // seed a commit written by an older deployment: no `pending` field
116
+ await vault.session.append({
117
+ type: 'a2.actor.state',
118
+ payload: {
119
+ state: { balance: 100, deposits: 1, withdrawals: 0 },
120
+ event: 'legacy',
121
+ message: 'legacy-1',
122
+ },
123
+ })
124
+ // new code reads the old shape and defaults the new field
125
+ const reserved = await vault.call.withdraw({ amount: 60 })
126
+ expect(reserved.state.balance).toBe(40)
127
+ expect(Object.keys(reserved.state.pending)).toHaveLength(1)
128
+ await expect
129
+ .poll(async () => (await vault.state()).state.pending, {
130
+ timeout: 5_000,
131
+ })
132
+ .toEqual({})
133
+ })
134
+
135
+ it('guards refuse garbage with an answer, not a retry', async () => {
136
+ const vault = makeVault().actor('acct')
137
+ await expect(vault.call.withdraw({ amount: -5 })).rejects.toThrowError(
138
+ ActorRefusedError,
139
+ )
140
+ await expect(vault.call.withdraw({ amount: -5 })).rejects.toThrow(
141
+ 'amount must be between 1 and 1,000,000',
142
+ )
143
+ // the refusals are history, not corruption
144
+ const { state } = await vault.state()
145
+ expect(state.balance).toBe(0)
146
+ })
147
+ })