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.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +111 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +11 -2
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- 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'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'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,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,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,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'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
|
+
})
|