experimental-a2 0.2.0 → 0.4.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 +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -11,6 +11,7 @@ description: "The whole surface, small enough to read in one sitting: contract,
|
|
|
11
11
|
a2.contract(options: {
|
|
12
12
|
name: string
|
|
13
13
|
events: Record<string, StandardSchemaV1>
|
|
14
|
+
presence?: Record<string, StandardSchemaV1>
|
|
14
15
|
}): Contract
|
|
15
16
|
```
|
|
16
17
|
|
|
@@ -20,6 +21,11 @@ messages. The result is a plain, importable, **isomorphic** value: the server
|
|
|
20
21
|
implements it, reducers derive from it, the browser types its pushes off it.
|
|
21
22
|
See [Contracts and sessions](/concepts/contracts).
|
|
22
23
|
|
|
24
|
+
`presence` is the optional second vocabulary: each key a field of one
|
|
25
|
+
participant's ephemeral state, each value its schema. Presence fields are
|
|
26
|
+
replicated last-writer-wins and never stored in the log; reducers cannot
|
|
27
|
+
see them. See [Presence](/guides/presence).
|
|
28
|
+
|
|
23
29
|
Schemas are [Standard Schema](https://standardschema.dev): Zod, Valibot,
|
|
24
30
|
ArkType, anything that implements it. Validators must be synchronous
|
|
25
31
|
(async ones are rejected here, at definition time), and the validated
|
|
@@ -63,10 +69,11 @@ Everything A2 throws. One class, discriminated by `code`. See
|
|
|
63
69
|
```ts
|
|
64
70
|
createServer(options: {
|
|
65
71
|
contract: Contract // the vocabulary this server implements
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
store?: A2Store // default: sqlite in dev, memory in tests, required in prod
|
|
73
|
+
scheduler?: A2Scheduler
|
|
68
74
|
telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
|
|
69
75
|
validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
|
|
76
|
+
presence?: { ttlMs?: number } // presence expiry policy; default 60s
|
|
70
77
|
handlers?: {
|
|
71
78
|
[type]:
|
|
72
79
|
| Handler
|
|
@@ -99,28 +106,42 @@ For example, `lane: ({ event }) => event.payload.warehouseId` serializes work
|
|
|
99
106
|
per warehouse while different warehouses continue concurrently.
|
|
100
107
|
|
|
101
108
|
Handlers can return one event or an array. A2 marks the triggering event
|
|
102
|
-
processed and appends the returned batch in one atomic
|
|
109
|
+
processed and appends the returned batch in one atomic store operation. Returned
|
|
103
110
|
events do not exist when the handler throws. `ctx.session.append(name, ...events)`
|
|
104
111
|
is different: it commits immediately, so its events may run while the current
|
|
105
112
|
handler is still active unless a lane orders them.
|
|
113
|
+
`ctx.session.schedule(name, timing, ...events)` waits for provider acceptance;
|
|
114
|
+
its events append later and are not part of the parent completion transaction.
|
|
106
115
|
|
|
107
116
|
Handlers are optional per event type. An event type without one settles in
|
|
108
117
|
the append transaction with no dispatch attempt. If the session has no older
|
|
109
|
-
pending handler work, A2 starts no drain or
|
|
118
|
+
pending handler work, A2 starts no drain or scheduler arm. A server with no
|
|
110
119
|
handlers is a durable event log with no reaction infrastructure. See
|
|
111
120
|
[Events without handlers](/concepts/handlers#events-without-handlers).
|
|
112
121
|
|
|
113
122
|
Server-only by construction: `experimental-a2/server` is the only entry point that
|
|
114
|
-
can reach a
|
|
123
|
+
can reach a store backend, and its exports map resolves to a loud error
|
|
115
124
|
under the browser condition.
|
|
116
125
|
|
|
117
|
-
`validatePush(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
`validatePush(context)` runs only for input that came from `parsePushBody()`,
|
|
127
|
+
once per plane. The events plane invokes it with `{ sessionId, events }` before
|
|
128
|
+
contract schema validation and before the store append, so throwing rejects the
|
|
129
|
+
complete push without writing anything. The presence plane invokes it with
|
|
130
|
+
`{ sessionId, events: [], presence }`, the whole pushed patch with its
|
|
131
|
+
participant, before field validation and the broadcast, so authorizing the
|
|
132
|
+
participant id (and applying any size or cardinality policy) happens at the
|
|
133
|
+
same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
|
|
134
|
+
attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
|
|
135
|
+
`setPresence` bypass it. `parsePushBody()` creates
|
|
121
136
|
the runtime provenance brand after reading the envelope; a caller-supplied
|
|
122
137
|
field with the same name is ignored, and the brand is not stored in the log.
|
|
123
138
|
|
|
139
|
+
`presence.ttlMs` sets how long a presence value survives without a
|
|
140
|
+
refreshing set (default 60 seconds, as a positive integer of
|
|
141
|
+
milliseconds). Expiry counts on the storage's own clock, never on the
|
|
142
|
+
sender's stamp. The option is valid only on contracts that declare
|
|
143
|
+
`presence`; anywhere else construction throws a `TypeError`.
|
|
144
|
+
|
|
124
145
|
`abortOn` names the events that fire `ctx.signal` while a handler runs.
|
|
125
146
|
an array matches by type; an object takes per-type predicates for
|
|
126
147
|
targeted cancellation:
|
|
@@ -144,8 +165,8 @@ The context every handler receives:
|
|
|
144
165
|
| ------------- | --------------------------------------------------------- |
|
|
145
166
|
| `ctx.event` | `Event`: the triggering event |
|
|
146
167
|
| `ctx.attempt` | durable 1-based dispatch claim ordinal |
|
|
147
|
-
| `ctx.session` | this session's `id`, `append`, `history`, `state`, `stream` |
|
|
148
|
-
| `ctx.signal` | `AbortSignal`:
|
|
168
|
+
| `ctx.session` | this session's `id`, `append`, `schedule`, `history`, `state`, `stream`, `setPresence` |
|
|
169
|
+
| `ctx.signal` | `AbortSignal`: fires on lease lapse (`CLAIM_EXPIRED`), proven supersession (`SUPERSEDED_ATTEMPT`), and matching `abortOn` events |
|
|
149
170
|
|
|
150
171
|
`ctx.attempt` starts at `1` and increments on every durable claim. It may skip
|
|
151
172
|
when a process dies before handler entry.
|
|
@@ -172,6 +193,11 @@ event `id` wins over the generated id, which lets different triggering events
|
|
|
172
193
|
converge on one fact. A root `server.session(id).append(...events)` takes no
|
|
173
194
|
name.
|
|
174
195
|
|
|
196
|
+
`ctx.session.schedule(name, timing, ...events)` includes the triggering event
|
|
197
|
+
in its stable task identity. A relative delay is anchored to that event's
|
|
198
|
+
durable `createdAt`, so a handler retry keeps the same delivery time. The call
|
|
199
|
+
returns `Promise<void>` after the configured scheduler accepts the task.
|
|
200
|
+
|
|
175
201
|
A state read and following append are not atomic. Concurrent appends and
|
|
176
202
|
retries may move the frontier between them. A generic join should use a
|
|
177
203
|
monotone readiness predicate and a stable explicit output event `id`, so every
|
|
@@ -193,14 +219,18 @@ const session = server.session('order-42')
|
|
|
193
219
|
|
|
194
220
|
session.id
|
|
195
221
|
session.append(...events)
|
|
196
|
-
session.
|
|
222
|
+
session.schedule('expire-order', { delay: '5d' }, {
|
|
223
|
+
type: 'expired',
|
|
224
|
+
payload: {},
|
|
225
|
+
})
|
|
226
|
+
session.history({ gte, lte })
|
|
197
227
|
session.state(reducer)
|
|
198
|
-
session.stream({
|
|
228
|
+
session.stream({ startAfter })
|
|
199
229
|
```
|
|
200
230
|
|
|
201
231
|
`session.id` is the id passed to `server.session(id)`. Root `append` takes only
|
|
202
232
|
events. The handler-local form at `ctx.session.append` adds its required name
|
|
203
|
-
before the events.
|
|
233
|
+
before the events. Both root and handler sessions expose `schedule`.
|
|
204
234
|
|
|
205
235
|
### `server.drain(sessionId)`
|
|
206
236
|
|
|
@@ -211,15 +241,15 @@ server.drain(sessionId: string): Promise<{ settled: boolean }>
|
|
|
211
241
|
Claims every currently eligible event and runs their handlers concurrently.
|
|
212
242
|
`settled` means no actionable or live-claimed work remains. A dead-lettered
|
|
213
243
|
event can leave later work in its lane blocked while other lanes continue.
|
|
214
|
-
You'll rarely call this yourself; it is the primitive
|
|
244
|
+
You'll rarely call this yourself; it is the primitive scheduler callbacks use.
|
|
215
245
|
The public result stays this simple boolean.
|
|
216
246
|
|
|
217
|
-
### `
|
|
247
|
+
### `A2Store`
|
|
218
248
|
|
|
219
249
|
Custom adapters implement these atomic drain methods:
|
|
220
250
|
|
|
221
251
|
```ts
|
|
222
|
-
// Custom server
|
|
252
|
+
// Custom server store adapter:
|
|
223
253
|
type EventCause = {
|
|
224
254
|
index: number
|
|
225
255
|
attempt: number
|
|
@@ -255,12 +285,12 @@ type StoredEvent = Event & {
|
|
|
255
285
|
failedAt: Date | null
|
|
256
286
|
}
|
|
257
287
|
|
|
258
|
-
type
|
|
288
|
+
type StoreAppendResult = {
|
|
259
289
|
events: StoredEvent[]
|
|
260
290
|
hasPending: boolean
|
|
261
291
|
}
|
|
262
292
|
|
|
263
|
-
type
|
|
293
|
+
type StoreClaimAvailableResult =
|
|
264
294
|
| { outcome: 'claimed'; events: StoredEvent[] }
|
|
265
295
|
| { outcome: 'busy'; retryAt: Date }
|
|
266
296
|
| { outcome: 'settled' }
|
|
@@ -269,11 +299,11 @@ type CompleteAttemptResult =
|
|
|
269
299
|
| { outcome: 'completed'; events: StoredEvent[] }
|
|
270
300
|
| { outcome: 'superseded' }
|
|
271
301
|
|
|
272
|
-
interface
|
|
302
|
+
interface A2Store {
|
|
273
303
|
append(
|
|
274
304
|
sessionId: string,
|
|
275
305
|
events: AppendEvent[],
|
|
276
|
-
): Promise<
|
|
306
|
+
): Promise<StoreAppendResult>
|
|
277
307
|
|
|
278
308
|
claimAvailable(options: {
|
|
279
309
|
sessionId: string
|
|
@@ -281,15 +311,15 @@ interface A2Log {
|
|
|
281
311
|
ttlMs: number
|
|
282
312
|
expiresAtMs?: number
|
|
283
313
|
excludeIndexes?: readonly number[]
|
|
284
|
-
}): Promise<
|
|
314
|
+
}): Promise<StoreClaimAvailableResult>
|
|
285
315
|
|
|
286
316
|
renewClaims(options: {
|
|
287
317
|
sessionId: string
|
|
288
318
|
holder: string
|
|
289
|
-
|
|
319
|
+
claims: readonly { index: number; attempt: number }[]
|
|
290
320
|
ttlMs: number
|
|
291
321
|
expiresAtMs?: number
|
|
292
|
-
}): Promise<number[]>
|
|
322
|
+
}): Promise<{ renewed: number[]; superseded: number[] }>
|
|
293
323
|
|
|
294
324
|
completeAttempt(options: {
|
|
295
325
|
sessionId: string
|
|
@@ -313,9 +343,9 @@ interface A2Log {
|
|
|
313
343
|
|
|
314
344
|
| Method | Atomic effect |
|
|
315
345
|
| --- | --- |
|
|
316
|
-
| `append` | Write a consecutive batch. Events carrying core's internal `settled` flag receive `processedAt` in the same transaction, with no dispatch attempt. |
|
|
346
|
+
| `append` | Write a consecutive batch. Events carrying core's internal `settled` flag receive `processedAt` in the same transaction, with no dispatch attempt. An event carrying `cause` is fenced by attempt currency: it commits only while `cause.attempt` is still the parent's latest attempt and the parent is not dead-lettered, throwing `SUPERSEDED_ATTEMPT` otherwise (the check serializes against a concurrent claim; the idempotent-replay path runs first). |
|
|
317
347
|
| `claimAvailable` | Claim every eligible event. All unlaned events are independent; only the lowest-index unfinished event in each lane is eligible. A claim records its holder, expiry, timestamps, and next `attemptCount`. |
|
|
318
|
-
| `renewClaims` | Extend the listed live claims still owned by the holder.
|
|
348
|
+
| `renewClaims` | Extend the listed live claims still owned by the holder. `renewed` lists the claims that remain owned; `superseded` lists the claims whose event's `attemptCount` passed the listed attempt. An expired claim no successor took appears in neither: it may still complete. Renewal never revives an expired claim. |
|
|
319
349
|
| `completeAttempt` | Fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch in the same transaction. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
|
|
320
350
|
| `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. A repeated failure acknowledgment is idempotent; stale attempts return `superseded`. |
|
|
321
351
|
|
|
@@ -328,11 +358,62 @@ means a top-level event.
|
|
|
328
358
|
`lane` is the session-scoped serialized group resolved before append.
|
|
329
359
|
`returnedEventIds` makes a lost completion
|
|
330
360
|
acknowledgment recoverable without accepting a partial child batch. Lifecycle
|
|
331
|
-
timestamps are adapter clock values for their atomic
|
|
361
|
+
timestamps are adapter clock values for their atomic store operations, not exact
|
|
332
362
|
database commit times.
|
|
333
363
|
Built-in adapters persist these fields inside their existing atomic operations,
|
|
334
364
|
with no extra backend round trip.
|
|
335
365
|
|
|
366
|
+
### `A2Scheduler`
|
|
367
|
+
|
|
368
|
+
Custom scheduler adapters implement two methods:
|
|
369
|
+
|
|
370
|
+
| Method | Effect |
|
|
371
|
+
| --- | --- |
|
|
372
|
+
| `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
|
|
373
|
+
| `handler(...servers)` | return the route that receives drain and append tasks |
|
|
374
|
+
|
|
375
|
+
`handler` is the low-level adapter-author seam. Application routes call
|
|
376
|
+
`schedulerHandler(...servers)` from `experimental-a2/http`, which derives the
|
|
377
|
+
adapter from the servers and verifies their wiring before it delegates here.
|
|
378
|
+
|
|
379
|
+
`SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask`. These task types,
|
|
380
|
+
plus `ScheduledEvent`, are exported from `experimental-a2/server`.
|
|
381
|
+
|
|
382
|
+
| Task | Fields |
|
|
383
|
+
| --- | --- |
|
|
384
|
+
| `SchedulerDrainTask` | `version: 1`, `kind: 'drain'`, `contract`, `sessionId`, required epoch-ms `dueAt` |
|
|
385
|
+
| `SchedulerAppendTask` | `version: 1`, `kind: 'append'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, readonly `events` |
|
|
386
|
+
| `ScheduledEvent` | fixed `id`, `type`, and JSON `payload` |
|
|
387
|
+
|
|
388
|
+
Drain tasks wake pending work and carry no application payload. Append tasks
|
|
389
|
+
carry the events for `session.schedule`.
|
|
390
|
+
|
|
391
|
+
Custom adapters deliver an authenticated, normalized append task through the
|
|
392
|
+
server helper:
|
|
393
|
+
|
|
394
|
+
```ts custom-scheduler.ts
|
|
395
|
+
import {
|
|
396
|
+
deliverSchedulerAppend,
|
|
397
|
+
type DrainableServer,
|
|
398
|
+
type SchedulerAppendTask,
|
|
399
|
+
} from 'experimental-a2/server'
|
|
400
|
+
|
|
401
|
+
export async function deliver(
|
|
402
|
+
server: DrainableServer,
|
|
403
|
+
task: SchedulerAppendTask,
|
|
404
|
+
) {
|
|
405
|
+
await deliverSchedulerAppend(server, task)
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Import `deliverSchedulerAppend` from `experimental-a2/server`. It verifies the
|
|
410
|
+
task and server contract match, revalidates the carried event inputs, and uses
|
|
411
|
+
the ordinary top-level append path. The target server must have a scheduler.
|
|
412
|
+
When the append leaves pending handler work, the helper resolves only after a
|
|
413
|
+
recovery watchdog is durable; a send failure keeps the provider delivery
|
|
414
|
+
retryable. Authenticate the transport and validate the task envelope before
|
|
415
|
+
calling it.
|
|
416
|
+
|
|
336
417
|
## Session
|
|
337
418
|
|
|
338
419
|
### `session.append(...events)`
|
|
@@ -354,7 +435,7 @@ identical batch returns the original rows. (Events parsed by
|
|
|
354
435
|
After the write, A2 dispatches only event types with registered handlers.
|
|
355
436
|
Other event types are already settled by the append itself. An unhandled
|
|
356
437
|
append still starts session healing when older handled work is pending. The
|
|
357
|
-
|
|
438
|
+
store reports that session-wide pending state as part of the atomic append, so
|
|
358
439
|
this decision needs no follow-up read.
|
|
359
440
|
|
|
360
441
|
### `session.append.dispatch(...events)`
|
|
@@ -366,13 +447,13 @@ session.append.dispatch(
|
|
|
366
447
|
```
|
|
367
448
|
|
|
368
449
|
Commits the same atomic batch, but sends pending work directly to configured
|
|
369
|
-
|
|
370
|
-
immediate
|
|
371
|
-
|
|
450
|
+
scheduler instead of starting an inline drain. The call awaits acceptance of an
|
|
451
|
+
immediate scheduler message. It throws before writing if the server has no
|
|
452
|
+
scheduler adapter.
|
|
372
453
|
|
|
373
454
|
If the queue send fails, the events are already durable. Give them explicit
|
|
374
455
|
IDs and retry the same dispatch safely. A batch that leaves no pending handler
|
|
375
|
-
work does not send a
|
|
456
|
+
work does not send a scheduler message. Dispatch chooses how this append wakes
|
|
376
457
|
the session; it does not reserve events for one worker. A drain that is already
|
|
377
458
|
active may still claim newly eligible work first.
|
|
378
459
|
|
|
@@ -381,13 +462,57 @@ active may still claim newly eligible work first.
|
|
|
381
462
|
does not expose `dispatch`. Its immediate children and atomically returned
|
|
382
463
|
children become eligible in that drain.
|
|
383
464
|
|
|
465
|
+
### `session.schedule(name, timing, ...events)`
|
|
466
|
+
|
|
467
|
+
`schedule` returns `Promise<void>` and is available on both root and handler
|
|
468
|
+
sessions. `name` must be non-empty. At least one event is required.
|
|
469
|
+
`ScheduleDelay`, `ScheduleTiming`, and `SessionSchedule` are exported from
|
|
470
|
+
`experimental-a2/server`.
|
|
471
|
+
|
|
472
|
+
| Timing | Meaning |
|
|
473
|
+
| --- | --- |
|
|
474
|
+
| `{ delay: '30s' }` | relative delay with unit `ms`, `s`, `m`, `h`, or `d` |
|
|
475
|
+
| `{ at: Date }` | absolute delivery time |
|
|
476
|
+
|
|
477
|
+
The template type checks the unit suffix. At runtime, the delay number must be
|
|
478
|
+
an unsigned base-10 decimal without leading zeros, finite, and greater than
|
|
479
|
+
zero. `0s`, signs, exponent notation, malformed durations, invalid dates, and
|
|
480
|
+
objects containing both timing fields are rejected. A root relative delay
|
|
481
|
+
starts from that call's wall clock. A handler relative delay starts from the
|
|
482
|
+
triggering event's durable `createdAt`, so retries preserve its delivery time.
|
|
483
|
+
Use `{ at }` when a retried root call needs one target time. The requested
|
|
484
|
+
target is the earliest useful delivery time. Adapters encode provider timing
|
|
485
|
+
in whole-second slots. An already-due target is sent immediately.
|
|
486
|
+
|
|
487
|
+
The stable task id derives from the contract, session, and name. Handler tasks
|
|
488
|
+
also include the triggering event id. Omitted event ids derive from that task
|
|
489
|
+
id and event position; explicit ids win. Reuse a name only for an identical
|
|
490
|
+
timing and event batch. Provider deduplication is an optimization. Stable event
|
|
491
|
+
ids make later duplicate deliveries converge in the store.
|
|
492
|
+
|
|
493
|
+
A2 snapshots payloads into plain JSON trees, then validates the event schemas
|
|
494
|
+
before sending. Encode dates as ISO strings and custom classes as plain data;
|
|
495
|
+
values such as `undefined`, bigint, functions, symbols, cycles, accessors, and
|
|
496
|
+
non-plain prototypes are rejected. The promise resolves after provider
|
|
497
|
+
acceptance, not after append. Shared object references become independent JSON
|
|
498
|
+
values. The task carries the snapshotted JSON schema input. At delivery, the
|
|
499
|
+
ordinary top-level append validates that input, so a schema
|
|
500
|
+
transform produces the stored payload from the original input. The events have
|
|
501
|
+
`cause: null` and enter normal handlers, lanes, state, and streams.
|
|
502
|
+
|
|
503
|
+
There is no cancellation, reschedule, cron, or timer-listing API. See
|
|
504
|
+
[Timers and delays](/guides/timers) for stale-timer guards, provider limits,
|
|
505
|
+
and deployment compatibility.
|
|
506
|
+
|
|
384
507
|
### `session.history()`
|
|
385
508
|
|
|
386
509
|
```ts
|
|
387
|
-
session.history(): Promise<Event[]>
|
|
510
|
+
session.history(options?: { gte?: number; lte?: number }): Promise<Event[]>
|
|
388
511
|
```
|
|
389
512
|
|
|
390
|
-
|
|
513
|
+
Raw events from the session, oldest first. `gte` and `lte` are inclusive event
|
|
514
|
+
indexes; omit a bound to leave that end of the log open.
|
|
515
|
+
Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
|
|
391
516
|
|
|
392
517
|
### `session.state(reducer)`
|
|
393
518
|
|
|
@@ -395,21 +520,89 @@ Every event in the session, oldest first. Always the raw log.
|
|
|
395
520
|
session.state(reducer: Reducer<S>): Promise<{ state: S; index: number }>
|
|
396
521
|
```
|
|
397
522
|
|
|
398
|
-
The log folded through
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
523
|
+
The log folded through one committed prefix. `index` is its exact boundary,
|
|
524
|
+
an append-order cursor rather than a handler-settlement marker. Hand it to the
|
|
525
|
+
client to resume there. Snapshot write-back uses platform `waitUntil`; this
|
|
526
|
+
read never dispatches handlers or waits for pending work.
|
|
402
527
|
|
|
403
528
|
### `session.stream(options?)`
|
|
404
529
|
|
|
405
530
|
```ts
|
|
406
|
-
session.stream(options?: {
|
|
531
|
+
session.stream(options?: { startAfter?: number }): AsyncIterable<Event>
|
|
532
|
+
session.stream(options: {
|
|
533
|
+
startAfter?: number
|
|
534
|
+
presence: true
|
|
535
|
+
}): AsyncIterable<Event | PresencePatch | PresenceSnapshot>
|
|
407
536
|
```
|
|
408
537
|
|
|
409
|
-
A live feed of the session's events
|
|
538
|
+
A live feed of the session's events. `startAfter` is a non-negative safe integer;
|
|
539
|
+
`startAfter: 20` begins with event 21.
|
|
410
540
|
Server-side only; expose it over SSE with `sseResponse`. Subscribing never
|
|
411
541
|
dispatches handlers.
|
|
412
542
|
|
|
543
|
+
With `presence: true`, the feed yields one `PresenceSnapshot` first:
|
|
544
|
+
`{ snapshot }`, the current pruned map with each field's own `value`,
|
|
545
|
+
`seen`, and `at` stamp. Live presence patches then interleave with
|
|
546
|
+
events. `sseResponse` sends both as named SSE frames, so clients that
|
|
547
|
+
don't know them skip them. The return type widens only under the
|
|
548
|
+
literal `presence: true`; without it, existing consumers keep
|
|
549
|
+
`AsyncIterable<Event>`. The option itself exists only on sessions of
|
|
550
|
+
contracts that declare `presence`; elsewhere it is a type error, not a
|
|
551
|
+
runtime throw. A `PresencePatch` is `{ participant, values, seen, at }`
|
|
552
|
+
with `values` typed by the contract's presence schemas (`null` marks a
|
|
553
|
+
cleared field).
|
|
554
|
+
|
|
555
|
+
### `session.setPresence(patch)`
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
session.setPresence(patch: {
|
|
559
|
+
participant: string
|
|
560
|
+
values: { [field]: payload | null } // partial; null clears a field
|
|
561
|
+
seen?: number
|
|
562
|
+
at?: number // the sender's LWW stamp, epoch ms; receipt time when absent
|
|
563
|
+
}): Promise<void>
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Validates each field against the contract's `presence` schemas, then
|
|
567
|
+
broadcasts. Last writer wins per participant per field, ordered by
|
|
568
|
+
`at`, the sender's stamp in epoch milliseconds (the browser client
|
|
569
|
+
stamps its sends automatically). When `at` is absent, receipt time
|
|
570
|
+
stands in: a stampless caller is a single writer, so receipt order is
|
|
571
|
+
its send order. No log row, no
|
|
572
|
+
dispatch, no scheduler arm; `history()` and `state()` never see it. A
|
|
573
|
+
bad payload throws `INVALID_PAYLOAD`; an unknown field throws
|
|
574
|
+
`UNKNOWN_PRESENCE_FIELD`; a non-finite or negative `at` throws a
|
|
575
|
+
`TypeError`. `seen` is the sender's stream frontier, the
|
|
576
|
+
stamp view code uses to reconcile presence against the fold (see
|
|
577
|
+
[Presence](/guides/presence#reconciling-the-planes-seen)). Values expire
|
|
578
|
+
by backend TTL when a participant goes silent (default 60 seconds; set
|
|
579
|
+
`createServer({ presence: { ttlMs } })` to change it). Expiry counts on
|
|
580
|
+
the storage clock, not the sender stamp, so a hostile stamp can only
|
|
581
|
+
vandalize its own field and still expires on schedule.
|
|
582
|
+
|
|
583
|
+
The patch is structurally the `presence` object `parsePushBody` returns,
|
|
584
|
+
so a push route forwards it whole: `session.setPresence(presence)`. The
|
|
585
|
+
API mirrors the wire.
|
|
586
|
+
|
|
587
|
+
The handler-scoped form `ctx.session.setPresence(...)` is the same
|
|
588
|
+
operation; `seen` defaults to the triggering event's `index`.
|
|
589
|
+
|
|
590
|
+
### `session.presence()`
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
session.presence(): Promise<{
|
|
594
|
+
[participant: string]: {
|
|
595
|
+
[field: string]: { value: unknown; seen: number; at: Date }
|
|
596
|
+
}
|
|
597
|
+
}>
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The current presence map, expired values pruned. A point-in-time read
|
|
601
|
+
for server renders; live consumers use `stream({ presence: true })`.
|
|
602
|
+
Each entry's `at` is the sender's stamp for when the value was set,
|
|
603
|
+
the last-writer-wins comparator; how long a value stays painted is
|
|
604
|
+
still view logic, decided at render time.
|
|
605
|
+
|
|
413
606
|
## Event
|
|
414
607
|
|
|
415
608
|
```ts
|
|
@@ -456,23 +649,54 @@ transition and the destination should adopt the same optimistic session.
|
|
|
456
649
|
| `sessionId` | which session to subscribe to |
|
|
457
650
|
| `initialState` | server-rendered state |
|
|
458
651
|
| `initialIndex` | the fold's frontier, where the stream resumes |
|
|
459
|
-
| `initialEvents` | optional
|
|
652
|
+
| `initialEvents` | optional earlier raw events for a history UI |
|
|
653
|
+
| `participant` | this client's presence identity; required to call `setPresence` |
|
|
460
654
|
|
|
461
655
|
Opens the stream on mount, closes it on unmount, reconnects with
|
|
462
|
-
backoff from the current frontier.
|
|
656
|
+
backoff from the current frontier. `participant` binds at the session's
|
|
657
|
+
first resolution; a changed provider prop is ignored for the session's
|
|
658
|
+
runtime lifetime.
|
|
659
|
+
|
|
660
|
+
`initialState` and `initialIndex` are the complete server-to-client handoff.
|
|
661
|
+
The stream resumes after `initialIndex`, including events appended between the
|
|
662
|
+
server render and the connection. `initialEvents` only preloads the raw
|
|
663
|
+
`events` feed for a UI that needs entries from before that frontier. It does
|
|
664
|
+
not affect state hydration or stream resumption.
|
|
463
665
|
|
|
464
666
|
### `useSession()`
|
|
465
667
|
|
|
466
668
|
```ts
|
|
467
|
-
const { state, push, events, index, connection } =
|
|
669
|
+
const { state, push, events, index, connection, presence, setPresence } =
|
|
670
|
+
useSession()
|
|
468
671
|
```
|
|
469
672
|
|
|
470
|
-
`state`
|
|
471
|
-
|
|
472
|
-
`
|
|
673
|
+
`state` starts from the initial snapshot and folds live events through the
|
|
674
|
+
shared reducer. `events` is the raw observed feed. Unless `initialEvents`
|
|
675
|
+
explicitly seeds earlier entries, it begins after `initialIndex`. `index` is
|
|
676
|
+
the stream frontier, the `lastSeenIndex` for
|
|
677
|
+
[cancellation](/guides/cancellation).
|
|
678
|
+
|
|
679
|
+
`presence` is the replicated ephemeral map,
|
|
680
|
+
`Record<participantId, { [field]: { value, seen, at } }>`, including
|
|
681
|
+
this client. `setPresence(values)` is fire-and-forget: validated
|
|
682
|
+
locally, merged into the local map immediately, coalesced on the wire
|
|
683
|
+
to a fixed cadence (a leading send immediately, then at most one
|
|
684
|
+
merged send per interval; later values win per field), stamped with
|
|
685
|
+
the frontier at send time as `seen`. Parallel sends are safe: merges
|
|
686
|
+
are last-writer-wins by `at`, and the client's stamps are monotonic
|
|
687
|
+
per session (wall-seeded, guarded so overlapping sends can never
|
|
688
|
+
invert). No ack, no `confirmed`, no retry.
|
|
689
|
+
|
|
690
|
+
Both members exist only when the contract declares `presence`; their
|
|
691
|
+
value and field types come from its schemas, through the reducer, with
|
|
692
|
+
no type arguments (the reducer is the client's typed handle on the
|
|
693
|
+
contract; it still never folds presence). `setPresence` requires the
|
|
694
|
+
provider's `participant`. If the map holds only your own echo with
|
|
695
|
+
participants active, the GET route forgot `presence: true`. See
|
|
696
|
+
[Presence](/guides/presence).
|
|
473
697
|
|
|
474
698
|
`push` appends optimistically: validated locally, rolled back on
|
|
475
|
-
rejection, retried only for `
|
|
699
|
+
rejection, retried only for `STORE_UNAVAILABLE`. Awaiting it gives the
|
|
476
700
|
server ack; the same result carries `confirmed`, a lazy promise for
|
|
477
701
|
the later moment when the live stream has delivered the batch back and
|
|
478
702
|
the view shows server truth:
|
|
@@ -485,6 +709,12 @@ await result.confirmed // the stream round-trip: ack→stream latency
|
|
|
485
709
|
const roundtripMs = performance.now() - t0
|
|
486
710
|
```
|
|
487
711
|
|
|
712
|
+
Overlapping calls through the same session client update the view
|
|
713
|
+
immediately, then enter the transport in invocation order. Each call waits
|
|
714
|
+
only for the previous call's ack or final rejection, not for `confirmed`.
|
|
715
|
+
Other session IDs and clients remain concurrent; they converge on the log's
|
|
716
|
+
durable order.
|
|
717
|
+
|
|
488
718
|
A rejected push rejects both promises with the same `A2Error`;
|
|
489
719
|
`confirmed` is materialized only when accessed, so ignoring it costs
|
|
490
720
|
nothing. It also reads as intent: `await push(...).confirmed` is
|
|
@@ -527,14 +757,47 @@ definition exposes only `contract` and `reducer`.
|
|
|
527
757
|
|
|
528
758
|
Built-in event names cannot be replaced by `options.events`.
|
|
529
759
|
|
|
760
|
+
### `handlerContext(agent)`
|
|
761
|
+
|
|
762
|
+
Reads the durable A2 handler context ambient to the current tool
|
|
763
|
+
execution. Tools are plain AI SDK `tool()` definitions; A2 passes their
|
|
764
|
+
options through to the AI SDK without interpreting them and runs each
|
|
765
|
+
local `execute` inside an ambient context scope. Call
|
|
766
|
+
`handlerContext(agent)` (or pass a contract) anywhere below `execute`,
|
|
767
|
+
including awaited helpers and async iteration. The argument types the
|
|
768
|
+
result and is verified against the executing server's contract: a
|
|
769
|
+
mismatched agent, or a call outside any tool execution, throws a
|
|
770
|
+
`TypeError`.
|
|
771
|
+
|
|
772
|
+
The returned `AgentToolContext<D>` is a union of the current A2
|
|
773
|
+
handler's `event`, `attempt`, `session`, and `signal`. Automatic
|
|
774
|
+
execution has an `ai.tool.called` event; execution after approval has an
|
|
775
|
+
`ai.approval.responded` event. Narrow `ctx.event.type` when
|
|
776
|
+
event-specific payload fields matter. Both variants have a session typed
|
|
777
|
+
from the supplied agent's complete event vocabulary. The agent does not
|
|
778
|
+
hold a scheduler. Calls such as `ctx.session.schedule()` use the
|
|
779
|
+
scheduler of the server executing the tool.
|
|
780
|
+
|
|
781
|
+
The context is runtime capability, not event data. A2 creates it for the
|
|
782
|
+
current attempt, never persists it, and never sends it to the model or a
|
|
783
|
+
provider-executed tool. Each retry reads that attempt's fresh context.
|
|
784
|
+
Model-phase tool options (`contextSchema`, dynamic descriptions,
|
|
785
|
+
`needsApproval` predicates, input lifecycle callbacks) are the AI SDK's
|
|
786
|
+
own; A2 forwards them untouched, and durable execution passes no AI SDK
|
|
787
|
+
`context` option. `needsApproval` and `generation.toolApproval`
|
|
788
|
+
predicates run during the model step and are re-evaluated on recovery
|
|
789
|
+
for the same durable tool call, so keep them deterministic over the tool
|
|
790
|
+
input. Model-phase hooks see tool definitions without local `execute`
|
|
791
|
+
functions.
|
|
792
|
+
|
|
530
793
|
### `inputs`
|
|
531
794
|
|
|
532
795
|
Pure typed inputs for `append()` and `push()`:
|
|
533
796
|
|
|
534
797
|
| Input | Events |
|
|
535
798
|
| --- | --- |
|
|
536
|
-
| `inputs.message(message)` | `ai.message.created`;
|
|
537
|
-
| `inputs.seed(message)` | `ai.message.created`
|
|
799
|
+
| `inputs.message(message, { generate? })` | `ai.message.created`; pass `generate: false` to record context without scheduling |
|
|
800
|
+
| `inputs.seed(message)` | `ai.message.created` with `generate: false`; non-user roles require a trusted append |
|
|
538
801
|
| `inputs.approval(response)` | `ai.approval.responded` only |
|
|
539
802
|
| `inputs.input(response)` | `ai.input.responded` only |
|
|
540
803
|
| `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
|
|
@@ -547,8 +810,20 @@ when an application uses them. Input event ids are stable for the interaction
|
|
|
547
810
|
they describe, so a lost append acknowledgment can be resent safely. Browser
|
|
548
811
|
ingress accepts user messages, approval and input responses, interruptions,
|
|
549
812
|
and explicit retries. Only built-in server handlers append generation requests
|
|
550
|
-
and AI lifecycle events. `inputs.
|
|
551
|
-
trusted server appends.
|
|
813
|
+
and AI lifecycle events. `inputs.requestInput()` and non-user messages built
|
|
814
|
+
with `inputs.seed()` are for trusted server appends.
|
|
815
|
+
|
|
816
|
+
`inputs.message(message, { generate: false })` is valid browser input. It keeps
|
|
817
|
+
the user message in `AIState.messages` without starting a model turn. The next
|
|
818
|
+
user message that allows generation includes passive user messages before it
|
|
819
|
+
in model context. Passive user messages after that trigger wait for a later
|
|
820
|
+
generating message. Omitting the option preserves the default scheduling
|
|
821
|
+
behavior and the existing `{ message }` event payload. `inputs.seed()` writes
|
|
822
|
+
the same passive flag. Trusted assistant and system seeds are context, not user
|
|
823
|
+
queue cutpoints. The server never schedules them, regardless of the `generate`
|
|
824
|
+
field. Model output is generation progress rather than another
|
|
825
|
+
`ai.message.created` fact, so an assistant response cannot recursively start a
|
|
826
|
+
new turn.
|
|
552
827
|
|
|
553
828
|
Approval and input request/response payloads require the active
|
|
554
829
|
`generationId`. Clients copy it from the pending request, which prevents a
|
|
@@ -561,10 +836,22 @@ delayed response from satisfying a newer model step.
|
|
|
561
836
|
`ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
|
|
562
837
|
continuation before another model step starts.
|
|
563
838
|
|
|
564
|
-
`ai.message.interrupted` carries `{ messageId,
|
|
565
|
-
lastSeenIndex? }
|
|
566
|
-
|
|
567
|
-
generation
|
|
839
|
+
`ai.message.interrupted` carries either `{ messageId, requestId, reason?,
|
|
840
|
+
lastSeenIndex? }` or `{ messageId, generationId, reason?, lastSeenIndex? }`.
|
|
841
|
+
Exactly one owner ID is required. Clients copy `requestId` from
|
|
842
|
+
`activeRequestId` before generation start and switch to the observed
|
|
843
|
+
`activeGeneration.generationId` after start. A request-owned interruption
|
|
844
|
+
remains valid if that same request starts before the append commits. A delayed
|
|
845
|
+
interruption for an older owner is a projection and cancellation no-op.
|
|
846
|
+
The message and owner IDs are validated as one pair. If either ID does not
|
|
847
|
+
identify that same response owner, the interruption is stale and is a no-op.
|
|
848
|
+
|
|
849
|
+
An accepted interruption terminally fences its request or generation. Later
|
|
850
|
+
generation, tool, approval, input, and compaction events remain in raw history
|
|
851
|
+
but cannot alter `AIState`, satisfy pending work, or reactivate the response.
|
|
852
|
+
Completed tool results at or before `lastSeenIndex` remain completed. A final
|
|
853
|
+
tool result that races an ordinary generation failure remains authoritative in
|
|
854
|
+
either commit order, while the generation stays failed.
|
|
568
855
|
|
|
569
856
|
### `events` and `createEvents(options?)`
|
|
570
857
|
|
|
@@ -581,15 +868,24 @@ createReducer({ contract, name? }): Reducer<AIState>
|
|
|
581
868
|
Builds the standard AI projection for a compatible contract. `AIState`
|
|
582
869
|
contains session lifecycle, messages, generation status, pending approvals
|
|
583
870
|
and input, tool activity, compaction, usage, the last error, and
|
|
584
|
-
`activeRequestId
|
|
585
|
-
`responseGenerationIds: Record<string, string
|
|
871
|
+
`activeRequestId`, `activeResponseMessageId`,
|
|
872
|
+
`responseGenerationIds: Record<string, string>`, and terminal request and
|
|
873
|
+
generation ownership fences.
|
|
586
874
|
`activeRequestId` is the server-authorized generation request and fences
|
|
587
875
|
delayed requests before their generation starts. `activeResponseMessageId`
|
|
588
876
|
identifies the requested response until `activeGeneration` exists.
|
|
589
|
-
`activeProjection` holds the indexed
|
|
590
|
-
generation
|
|
591
|
-
|
|
592
|
-
|
|
877
|
+
`activeProjection` holds the indexed generation frontier while a generation is
|
|
878
|
+
active, after a generation step completes while its response waits on tool,
|
|
879
|
+
approval, or input barriers, and after a generation fails while it awaits retry
|
|
880
|
+
or interruption. Response completion, interruption, supersession, retry, or a
|
|
881
|
+
later generation clears or replaces it. `responseGenerationIds` keeps the
|
|
882
|
+
latest generation owner for each response message, so late events from a
|
|
883
|
+
superseded owner cannot alter the projection. `terminalRequestIds` and
|
|
884
|
+
`terminalGenerations` preserve accepted completion, failure, interruption,
|
|
885
|
+
and supersession fences across snapshots and recovery. They are optional
|
|
886
|
+
snapshot-compatible fields with the shapes `Record<string, true>` and
|
|
887
|
+
`Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
|
|
888
|
+
Extension events are ignored. The default reducer name is `a2.ai.state.v8`.
|
|
593
889
|
|
|
594
890
|
### `deriveUIMessages(history)` and `reduceAIState(state, event)`
|
|
595
891
|
|
|
@@ -609,10 +905,11 @@ createAgentServer({
|
|
|
609
905
|
instructions?,
|
|
610
906
|
generation?,
|
|
611
907
|
maxSteps?,
|
|
908
|
+
messageMetadata?,
|
|
612
909
|
generate?,
|
|
613
910
|
compaction?,
|
|
614
911
|
progress?,
|
|
615
|
-
|
|
912
|
+
store?, scheduler?, telemetry?, handlers?,
|
|
616
913
|
}): A2Server
|
|
617
914
|
```
|
|
618
915
|
|
|
@@ -626,12 +923,23 @@ async resolvers.
|
|
|
626
923
|
|
|
627
924
|
`generation` contains per-step settings such as `temperature`,
|
|
628
925
|
`maxOutputTokens`, `topP`, provider options, and tool approval policy. A2 owns
|
|
629
|
-
the one-step stop condition, local tool execution, and continuation.
|
|
630
|
-
|
|
631
|
-
individual model settings depends on
|
|
632
|
-
`generation` excludes `stopWhen`, tool
|
|
633
|
-
|
|
634
|
-
|
|
926
|
+
the one-step stop condition, local tool execution, and continuation. Responses
|
|
927
|
+
have no step limit by default. Set `maxSteps` to a positive integer to limit one
|
|
928
|
+
complete assistant response. Support for individual model settings depends on
|
|
929
|
+
the selected model and provider. `generation` excludes `stopWhen`, tool
|
|
930
|
+
execution callbacks, tool callers, sandbox execution, and the tool approval
|
|
931
|
+
secret. Everything else, including `toolsContext`, passes through to the AI SDK
|
|
932
|
+
untouched. A local tool reads its durable context with `handlerContext()` when
|
|
933
|
+
its handler executes. Model-step timeouts remain available; tool-execution
|
|
934
|
+
timeouts are not.
|
|
935
|
+
|
|
936
|
+
`messageMetadata(context)` synchronously maps each default AI SDK stream part
|
|
937
|
+
to the metadata type of the agent's `UIMessage`. The context includes the
|
|
938
|
+
generation state and the current `TextStreamPart`. Metadata returned for
|
|
939
|
+
standard `start` and `finish` parts is recorded with generation progress and
|
|
940
|
+
shallow-merged onto the projected assistant message. Use the `finish` part's
|
|
941
|
+
`totalUsage` to retain final token usage. A custom `generate` function owns its
|
|
942
|
+
metadata chunks and does not use this mapper.
|
|
635
943
|
|
|
636
944
|
`generate(context)` optionally replaces the default AI SDK generation. It
|
|
637
945
|
receives messages, the resolved model and instructions, tools, generation
|
|
@@ -675,6 +983,13 @@ When selected, both the request and the replacement messages enter the log.
|
|
|
675
983
|
|
|
676
984
|
This entry point is server-only and resolves to a throwing browser stub.
|
|
677
985
|
|
|
986
|
+
### `runWithHandlerContext(ctx, fn)`
|
|
987
|
+
|
|
988
|
+
The testing seam for context-reading tools: runs `fn` with `ctx`
|
|
989
|
+
installed as the ambient handler context, so a tool's `execute` can be
|
|
990
|
+
called directly in tests. Inside `fn`, `handlerContext()` returns `ctx`
|
|
991
|
+
for any agent argument.
|
|
992
|
+
|
|
678
993
|
### `createHandlers(options)`
|
|
679
994
|
|
|
680
995
|
Returns the built-in A2 handler table without constructing a server. Spread
|
|
@@ -691,7 +1006,7 @@ handler. Custom assemblies pass `validateAgentPush` as
|
|
|
691
1006
|
|
|
692
1007
|
Accepts the browser interaction allowlist: user messages, approval and input
|
|
693
1008
|
responses, interruptions, and explicit retries. It rejects server-authored
|
|
694
|
-
scheduling and lifecycle events,
|
|
1009
|
+
scheduling and lifecycle events, seeded non-user messages, and input requests.
|
|
695
1010
|
`createAgentServer()` installs it automatically.
|
|
696
1011
|
|
|
697
1012
|
See [Durable AI agents](/guides/ai-agents) for the protocol and complete
|
|
@@ -702,38 +1017,60 @@ examples.
|
|
|
702
1017
|
```ts
|
|
703
1018
|
createClient(options: {
|
|
704
1019
|
reducer: Reducer
|
|
705
|
-
api:
|
|
1020
|
+
api: ClientApi
|
|
706
1021
|
gcTime?: number // idle session lifetime; 5 minutes by default
|
|
707
1022
|
}): A2Client
|
|
1023
|
+
|
|
1024
|
+
type ClientApi =
|
|
1025
|
+
| string // one route: GET SSE stream + POST push
|
|
1026
|
+
| { type: 'http'; push: string; stream: string } // split routes
|
|
1027
|
+
| { type: 'ws'; url: string } // one socket, both directions
|
|
708
1028
|
```
|
|
709
1029
|
|
|
710
1030
|
The framework-agnostic session client `experimental-a2/react` is built on: the SSE
|
|
711
1031
|
subscription with frontier resume and reconnection, the optimistic push
|
|
712
1032
|
queue with ack/rollback, and the local fold. `client.session(id, {
|
|
713
|
-
initialState?, initialIndex?, initialEvents? })` returns a
|
|
714
|
-
`getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
715
|
-
`push()`, `connect()`, and `close()`. Snapshots carry
|
|
716
|
-
`index`, and `connection` (the same fields
|
|
717
|
-
`push` returns the same ack-then-`confirmed`
|
|
718
|
-
|
|
1033
|
+
initialState?, initialIndex?, initialEvents?, participant? })` returns a
|
|
1034
|
+
handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
1035
|
+
contract), `push()`, `connect()`, and `close()`. Snapshots carry
|
|
1036
|
+
`state`, `events`, `index`, and `connection` (the same fields
|
|
1037
|
+
`useSession` exposes), and `push` returns the same ack-then-`confirmed`
|
|
1038
|
+
result. On contracts that declare `presence` the handle also carries
|
|
1039
|
+
`setPresence()` and snapshots carry the `presence` map, exactly like
|
|
1040
|
+
the hook; `participant` is the identity `setPresence` sends under. Use
|
|
1041
|
+
it directly from any other framework, or none.
|
|
719
1042
|
|
|
720
1043
|
Within one `A2Client`, repeated `session(id)` calls return the same live
|
|
721
|
-
handle.
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1044
|
+
handle. A later call with a further server-rendered frontier advances the
|
|
1045
|
+
handle and does not drop pending optimistic events; a stale render cannot move
|
|
1046
|
+
it backward. Optional `initialEvents` seed earlier raw events for history UIs
|
|
1047
|
+
and merge into that feed. They do not affect the folded state or stream
|
|
1048
|
+
frontier. Idle handles are evicted after `gcTime`. This memory layer is
|
|
1049
|
+
separate from `experimental-a2/cache-indexeddb`: memory preserves identity
|
|
1050
|
+
across route transitions, while IndexedDB preserves the replica across reloads.
|
|
727
1051
|
|
|
728
1052
|
## `experimental-a2/http`
|
|
729
1053
|
|
|
730
1054
|
| Helper | What it does |
|
|
731
1055
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
|
732
|
-
| `
|
|
733
|
-
| `
|
|
1056
|
+
| `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
|
|
1057
|
+
| `parsePushBody(req)` | validates the push envelope `{ sessionId, events, presence? }`, throws `INVALID_PAYLOAD` |
|
|
1058
|
+
| `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
|
|
1059
|
+
| `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
|
|
734
1060
|
| `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
|
|
735
1061
|
| `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
|
|
736
1062
|
|
|
1063
|
+
`schedulerHandler(...servers)` is the application-facing scheduler route. It
|
|
1064
|
+
requires at least one A2 server. Every server must have a scheduler, use the
|
|
1065
|
+
exact same scheduler instance, and have a unique contract name. Non-A2 values
|
|
1066
|
+
also fail. These checks throw before the request handler is returned, so bad
|
|
1067
|
+
wiring fails when the route module loads. The helper then delegates delivery
|
|
1068
|
+
to the shared adapter's `A2Scheduler.handler(...)` method.
|
|
1069
|
+
|
|
1070
|
+
Different scheduler instances use different routes. Match each QStash route to
|
|
1071
|
+
that instance's resolved `url`; additional QStash routes pass an explicit
|
|
1072
|
+
`url`. Match each Vercel Queues route and trigger to that instance's `topic`.
|
|
1073
|
+
|
|
737
1074
|
Together the last two are the `A2Error` wire format that `push` and the
|
|
738
1075
|
push route share. See [Errors](/reference/errors#over-the-wire).
|
|
739
1076
|
|
|
@@ -767,7 +1104,7 @@ Four spans, all carrying `a2.contract` and `a2.session_id`:
|
|
|
767
1104
|
|
|
768
1105
|
| Span | Wraps |
|
|
769
1106
|
| ----------- | ------------------------------------------------------ |
|
|
770
|
-
| `a2.append` | validation + the
|
|
1107
|
+
| `a2.append` | validation + the store write (one batch) |
|
|
771
1108
|
| `a2.drain` | one drain pass over a session's backlog |
|
|
772
1109
|
| `a2.event` | one claimed dispatch of one event |
|
|
773
1110
|
| `a2.state` | one `state()` read: snapshot-plus-tail load + fold |
|
|
@@ -783,7 +1120,7 @@ alert on are all mid-span.
|
|
|
783
1120
|
| `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
|
|
784
1121
|
| `a2.append.types` | `a2.append` | start | comma-joined event types |
|
|
785
1122
|
| `a2.append.count` | `a2.append` | start | batch size |
|
|
786
|
-
| `a2.append.armed` | `a2.append` | mid | `false` when the
|
|
1123
|
+
| `a2.append.armed` | `a2.append` | mid | `false` when the scheduler arm failed and this append degraded to append-driven healing |
|
|
787
1124
|
| `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
|
|
788
1125
|
| `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
|
|
789
1126
|
| `a2.event.type` | `a2.event` | start | the event's type |
|
|
@@ -792,7 +1129,7 @@ alert on are all mid-span.
|
|
|
792
1129
|
| `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
|
|
793
1130
|
| `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
|
|
794
1131
|
| `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
|
|
795
|
-
| `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` |
|
|
1132
|
+
| `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` \| `surrendered` |
|
|
796
1133
|
| `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
|
|
797
1134
|
| `a2.state.reducer` | `a2.state` | start | the reducer's name |
|
|
798
1135
|
| `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
|
|
@@ -814,6 +1151,54 @@ log.
|
|
|
814
1151
|
renames and additions are breaking for dashboards, and are called out in the
|
|
815
1152
|
package's `CHANGELOG.md`.
|
|
816
1153
|
|
|
1154
|
+
## `experimental-a2/devtools`
|
|
1155
|
+
|
|
1156
|
+
The shared, isomorphic protocol for browser Devtools, the CLI, and `.a2log`
|
|
1157
|
+
captures.
|
|
1158
|
+
|
|
1159
|
+
| Export | Contract |
|
|
1160
|
+
| --- | --- |
|
|
1161
|
+
| `DEVTOOLS_PROTOCOL_VERSION` | Current HTTP wire protocol version |
|
|
1162
|
+
| `DEVTOOLS_CAPTURE_VERSION` | Current `.a2log` record format version |
|
|
1163
|
+
| `DEVTOOLS_CAPTURE_MEDIA_TYPE` | `application/x-ndjson` |
|
|
1164
|
+
| `DEVTOOLS_SESSION_PAGE_LIMIT` | Default event count requested per session page |
|
|
1165
|
+
| `DEVTOOLS_SESSION_PAGE_MAX_LIMIT` | Maximum event count accepted per session page |
|
|
1166
|
+
| `DEVTOOLS_CAPABILITIES` | Protocol resources and capture format advertised by the server |
|
|
1167
|
+
| `devtoolsSessionRevision({ events, snapshots })` | Compute the lifecycle invalidation token after assembling pages |
|
|
1168
|
+
| `encodeDevtoolsCapture(detail, { capturedAt? })` | Encode one session detail as exact NDJSON and add its SHA-256 footer |
|
|
1169
|
+
| `parseDevtoolsCapture(value)` | Parse NDJSON text or bytes into a `DevtoolsCapture` while retaining unknown fields on known records |
|
|
1170
|
+
| `verifyDevtoolsCapture(capture)` | Verify versions, record shapes, session identity, indexes, counts, dates, and the SHA-256 digest |
|
|
1171
|
+
|
|
1172
|
+
The wire types are `DevtoolsWireSessionSummary`, `DevtoolsWireEvent`,
|
|
1173
|
+
`DevtoolsWireSnapshot`, `DevtoolsContractsResponse`,
|
|
1174
|
+
`DevtoolsSessionsResponse`, `DevtoolsSessionDetail`, `DevtoolsSessionPage`, and
|
|
1175
|
+
`DevtoolsCapabilities`. Capture records use `DevtoolsCaptureManifest`,
|
|
1176
|
+
`DevtoolsCaptureEvent`, `DevtoolsCaptureSnapshot`,
|
|
1177
|
+
`DevtoolsCaptureFooter`, and `DevtoolsCaptureRecord`. A parsed
|
|
1178
|
+
`DevtoolsCapture` exposes the same canonical sequence through `records`.
|
|
1179
|
+
|
|
1180
|
+
```ts test/verify-a2log.ts
|
|
1181
|
+
import { readFile } from 'node:fs/promises'
|
|
1182
|
+
import {
|
|
1183
|
+
parseDevtoolsCapture,
|
|
1184
|
+
verifyDevtoolsCapture,
|
|
1185
|
+
} from 'experimental-a2/devtools'
|
|
1186
|
+
|
|
1187
|
+
export async function verifyA2Log(path: string) {
|
|
1188
|
+
const capture = parseDevtoolsCapture(await readFile(path))
|
|
1189
|
+
await verifyDevtoolsCapture(capture)
|
|
1190
|
+
return capture
|
|
1191
|
+
}
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
The manifest records the contract, session, revision, capture time, protocol
|
|
1195
|
+
version, and capture version. Event records contain the exact durable payload
|
|
1196
|
+
and operational bookkeeping. Snapshot records contain metadata but never
|
|
1197
|
+
cached reducer state. The final `end` record contains counts, the highest event
|
|
1198
|
+
index, and a digest of every preceding encoded record. The session revision is
|
|
1199
|
+
an invalidation token, not the integrity digest. Encoding rejects durable
|
|
1200
|
+
values that canonical JSON would coerce or omit.
|
|
1201
|
+
|
|
817
1202
|
## `experimental-a2/devtools/server`
|
|
818
1203
|
|
|
819
1204
|
```ts
|
|
@@ -838,14 +1223,274 @@ from stored events. The initial page response includes the selected dashboard
|
|
|
838
1223
|
data so the browser does not need a contracts, sessions, and detail request
|
|
839
1224
|
waterfall. It never drains or heals a session.
|
|
840
1225
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1226
|
+
Every route is a `GET`. The versioned resources are `capabilities`,
|
|
1227
|
+
`contracts`, `sessions`, `session`, `watch`, and `export` under the handler's
|
|
1228
|
+
`_a2/` path. `export` returns an integrity-checkable `.a2log` with the media
|
|
1229
|
+
type from `DEVTOOLS_CAPTURE_MEDIA_TYPE`.
|
|
1230
|
+
|
|
1231
|
+
`session` without pagination parameters returns one complete detail. Add a
|
|
1232
|
+
positive `limit` to receive a `DevtoolsSessionPage`. Its `throughIndex` is the
|
|
1233
|
+
inclusive event frontier frozen by the first request. Pass its opaque `cursor`
|
|
1234
|
+
back until the cursor is null. Snapshot metadata appears on the first page
|
|
1235
|
+
only. The cursor is bound to its contract and session, and the server rejects
|
|
1236
|
+
reuse against another log. The advertised maximum page size is clamped by the
|
|
1237
|
+
server.
|
|
1238
|
+
|
|
1239
|
+
The browser, CLI, watch loop, and export route walk these bounded pages for
|
|
1240
|
+
you. They reject a gap or early end. An exact export includes every event
|
|
1241
|
+
through one finite frontier, even when a backend provider limits one range
|
|
1242
|
+
response. Events appended during the walk belong to a later read.
|
|
1243
|
+
|
|
1244
|
+
Without `authorize`, the handler is available only when `NODE_ENV` is exactly
|
|
1245
|
+
`development`. Any other value, including unset, returns 404. When `authorize`
|
|
1246
|
+
is present, only a literal `true` grants access. Returning `false` also returns
|
|
1247
|
+
404. Returning a `Response` passes that response through, which supports
|
|
1248
|
+
redirects and authentication challenges. The mounted handler is the
|
|
1249
|
+
authorization boundary for the browser, CLI, SSE stream, and exact capture
|
|
1250
|
+
download. A2 does not add a separate Devtools credential store.
|
|
1251
|
+
|
|
1252
|
+
The built-in memory, SQLite, Postgres, and Redis stores support inspection.
|
|
1253
|
+
Custom `A2Store` implementations can omit the optional `inspect` interface; the
|
|
1254
|
+
dashboard returns 501 for those logs. A custom inspection implementation may
|
|
1255
|
+
add `readEvents(sessionId, { afterIndex, throughIndex?, limit })` for efficient
|
|
1256
|
+
bounded reads. It returns `{ events, throughIndex }`, where the first call
|
|
1257
|
+
freezes the inclusive frontier and continuations preserve it. Without this
|
|
1258
|
+
method, A2 reads the complete log for each page and slices it in memory.
|
|
1259
|
+
|
|
1260
|
+
## `experimental-a2/scheduler-vercel`
|
|
1261
|
+
|
|
1262
|
+
`vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
|
|
1263
|
+
|
|
1264
|
+
```ts server/scheduler-vercel.ts
|
|
1265
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
1266
|
+
|
|
1267
|
+
export const scheduler = vercelQueues()
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
| Option | Type | Default |
|
|
1271
|
+
| --- | --- | --- |
|
|
1272
|
+
| `topic` | `string` matching `[A-Za-z0-9_-]+` | `'a2'` |
|
|
1273
|
+
| `scheduledAppends` | `'deployment' \| 'deploymentless'` | `'deployment'` |
|
|
1274
|
+
| `transport` | `QueueTransport` | lazy `@vercel/queue` client |
|
|
1275
|
+
| `clock` | `{ now(): Date }` | system clock |
|
|
1276
|
+
|
|
1277
|
+
The default pins recovery watchdogs and scheduled appends to the deployment
|
|
1278
|
+
that publishes them. Recovery always remains pinned. Set `scheduledAppends` to
|
|
1279
|
+
`'deploymentless'` only when every eligible deploymentless consumer for the
|
|
1280
|
+
topic can decode pending tasks and validate their events. This option removes
|
|
1281
|
+
deployment affinity. It does not select the latest production deployment.
|
|
1282
|
+
|
|
1283
|
+
The adapter retains every task for seven days and accepts delays through six
|
|
1284
|
+
days, leaving the final day for delivery and retries. `transport` and `clock`
|
|
1285
|
+
are advanced injection seams for tests and custom delivery. An injected
|
|
1286
|
+
transport receives the selected deployment affinity as an optional fourth send
|
|
1287
|
+
argument while its message remains an exact `SchedulerTask`. It must honor that
|
|
1288
|
+
hint for `scheduledAppends: 'deploymentless'` to affect its provider-specific
|
|
1289
|
+
routing. The built-in transport adds its callback-routing metadata only after
|
|
1290
|
+
this injection seam.
|
|
1291
|
+
|
|
1292
|
+
## `experimental-a2/scheduler-qstash`
|
|
1293
|
+
|
|
1294
|
+
`qstash(options?)` returns an `A2Scheduler` backed by QStash HTTP delivery.
|
|
1295
|
+
Vercel Queues remains the recommended default on Vercel. Use QStash when you
|
|
1296
|
+
want a scheduler that calls a stable public endpoint.
|
|
1297
|
+
|
|
1298
|
+
```ts server/scheduler-qstash.ts
|
|
1299
|
+
import { qstash } from 'experimental-a2/scheduler-qstash'
|
|
1300
|
+
|
|
1301
|
+
export const scheduler = qstash()
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
| Option | Type | Default |
|
|
1305
|
+
| --- | --- | --- |
|
|
1306
|
+
| `url` | `string` | conventional local or Vercel callback; required for process-less runtimes, other production, and custom routes |
|
|
1307
|
+
| `token` | `string` | `QSTASH_TOKEN` |
|
|
1308
|
+
| `currentSigningKey` | `string` | `QSTASH_CURRENT_SIGNING_KEY` |
|
|
1309
|
+
| `nextSigningKey` | `string` | `QSTASH_NEXT_SIGNING_KEY` |
|
|
1310
|
+
| `headers` | `Readonly<Record<string, string>>` | no additional callback headers |
|
|
1311
|
+
| `client` | `QStashPublishingClient` | lazy `@upstash/qstash` client |
|
|
1312
|
+
| `retries` | non-negative integer | the QStash provider default |
|
|
1313
|
+
| `maxBodyBytes` | positive integer | `1_048_576` (1 MiB) for callbacks and reserved outbound bodies |
|
|
1314
|
+
| `transport` | `QStashTransport` | lazy `@upstash/qstash` client and receiver |
|
|
1315
|
+
| `clock` | `{ now(): Date }` | system clock |
|
|
1316
|
+
|
|
1317
|
+
`headers` adds static headers to every callback delivery, including recovery
|
|
1318
|
+
successors. A2 validates and snapshots the record when `qstash()` runs, then
|
|
1319
|
+
asks QStash to redact every configured value from its dashboard and API. QStash
|
|
1320
|
+
still receives, stores, and delivers the original values. Header names are
|
|
1321
|
+
case-insensitive. Provider-owned `Upstash-*` headers, `Content-Type*`, HTTP
|
|
1322
|
+
framing headers, and A2's managed Vercel identity headers are reserved.
|
|
1323
|
+
|
|
1324
|
+
`client` accepts the structural `publishJSON(...)` part of a configured
|
|
1325
|
+
`@upstash/qstash` `Client`. A2 still creates the `Receiver` lazily from the
|
|
1326
|
+
signing-key options. `client` cannot be combined with `token` or `transport`.
|
|
1327
|
+
Use it to configure the QStash API endpoint, publish-request retry policy, or
|
|
1328
|
+
telemetry. Use A2's `headers` option for callback credentials. Client-global
|
|
1329
|
+
callback headers and redaction are outside A2's merge contract. The injected
|
|
1330
|
+
client receives raw callback credentials, including an automatic preview
|
|
1331
|
+
identity, and must honor the per-publish redaction request.
|
|
1332
|
+
|
|
1333
|
+
`transport` and `clock` are advanced injection seams for tests and custom
|
|
1334
|
+
delivery. A `QStashTransport` implements `publish(request)` and
|
|
1335
|
+
`verify(request)`. Publish requests contain the callback `url`, a complete
|
|
1336
|
+
`SchedulerTask` body, optional callback `headers`, optional `redact.header`
|
|
1337
|
+
names, optional Unix-seconds `notBefore`, and a deterministic
|
|
1338
|
+
`deduplicationId`. Store-outage successors add provider-private generation
|
|
1339
|
+
metadata to that body. Custom transports must preserve the body, deliver every
|
|
1340
|
+
header, and apply every redaction request. An explicit `retries` value is
|
|
1341
|
+
forwarded; omission lets QStash use its provider default. Verify requests
|
|
1342
|
+
contain the signature, raw body, exact callback URL, and optional Upstash
|
|
1343
|
+
region.
|
|
1344
|
+
|
|
1345
|
+
An explicit `url` always wins. Otherwise, Vercel production resolves to
|
|
1346
|
+
`https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler` and preview resolves
|
|
1347
|
+
to `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`. Outside those deployments,
|
|
1348
|
+
a runtime that exposes `process.env` and is not in `NODE_ENV=production`
|
|
1349
|
+
resolves to `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and passes
|
|
1350
|
+
`devMode: true` to the SDK client and receiver. `QSTASH_DEV` is not required.
|
|
1351
|
+
`PORT` must be an integer from 1 through 65535. `QSTASH_DEV_PORT` configures the
|
|
1352
|
+
local QStash server, not the application callback. Automatic startup requires
|
|
1353
|
+
`@upstash/qstash` 2.11.0 or newer in a Node.js runtime. The lazy transport
|
|
1354
|
+
starts it on the first publish or verification. The direct SDK override means
|
|
1355
|
+
`QSTASH_DEV=false` does not disable an inferred local route.
|
|
1356
|
+
|
|
1357
|
+
A non-Vercel production runtime or a process-less runtime requires an explicit
|
|
1358
|
+
absolute URL. Pass one for a custom host or path, an application port not
|
|
1359
|
+
available through `PORT`, or Docker networking. An Edge runtime that exposes
|
|
1360
|
+
`process.env` can infer the local URL, but it must start the QStash development
|
|
1361
|
+
server separately. Outside production, an explicit URL does not override the
|
|
1362
|
+
SDK's development mode, so `QSTASH_DEV` and manually supplied `QSTASH_URL` plus
|
|
1363
|
+
credentials retain their SDK-defined behavior. A2 passes `devMode: false` in
|
|
1364
|
+
production. The inferred Vercel hosts are moving aliases, so pending tasks can
|
|
1365
|
+
reach later deployments on the same production or branch URL.
|
|
1366
|
+
|
|
1367
|
+
Mount the application handler once and pass every server that shares the
|
|
1368
|
+
scheduler:
|
|
1369
|
+
|
|
1370
|
+
```ts app/api/a2/scheduler/route.ts
|
|
1371
|
+
import { schedulerHandler } from 'experimental-a2/http'
|
|
1372
|
+
import { ordersServer, billingServer } from '@/server'
|
|
1373
|
+
|
|
1374
|
+
export const POST = schedulerHandler(ordersServer, billingServer)
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
The route must be reachable by the selected QStash server after any platform
|
|
1378
|
+
edge authentication. The development server calls localhost directly. The
|
|
1379
|
+
adapter reads its raw body once and verifies `Upstash-Signature`, the exact URL,
|
|
1380
|
+
and `Upstash-Region` before it parses the delivery or touches a server. Missing
|
|
1381
|
+
or invalid signatures return 401, so ordinary unsigned traffic cannot execute
|
|
1382
|
+
scheduler work. A signed malformed envelope returns 489 with
|
|
1383
|
+
`Upstash-NonRetryable-Error: true`. The same non-retryable response applies
|
|
1384
|
+
when the declared or streamed body exceeds `maxBodyBytes`. Before an initial
|
|
1385
|
+
publish, the adapter checks the normalized task with enough reserved room for
|
|
1386
|
+
its largest possible private store-outage lineage. Append tasks reserve a
|
|
1387
|
+
maximum safe-integer generation. Drain tasks also reserve their actual root
|
|
1388
|
+
`dueAt`. Store-outage successors check their actual body. The adapter therefore
|
|
1389
|
+
cannot initially accept a task that would exceed the same limit solely when
|
|
1390
|
+
lineage is added. Raise the option to match a larger paid-plan payload limit
|
|
1391
|
+
when needed. Keep one value across every deployment that can receive this
|
|
1392
|
+
callback URL. A lower value deployed while older tasks remain can return a
|
|
1393
|
+
non-retryable 489 for a task accepted by the previous value.
|
|
1394
|
+
|
|
1395
|
+
For an inferred Vercel preview callback, the adapter reads the current
|
|
1396
|
+
invocation's `x-vercel-oidc-token` for each publish. When it is a parseable JWT
|
|
1397
|
+
whose expiration remains at least 60 seconds after the task's first due time,
|
|
1398
|
+
the adapter supplies it as `x-vercel-trusted-oidc-idp-token` and requests QStash
|
|
1399
|
+
header redaction. A matching Vercel Trusted Sources rule can then admit the
|
|
1400
|
+
first delivery. QStash receives the credential even though its dashboard and
|
|
1401
|
+
API redact it.
|
|
1402
|
+
|
|
1403
|
+
This is opportunistic, not durable. Missing, malformed, and near-expiry tokens
|
|
1404
|
+
fall back to an ordinary publish, preserving public-preview behavior. An
|
|
1405
|
+
explicit `url` opts out. Inferred production and local callbacks never forward
|
|
1406
|
+
the token. QStash stores the static header and reuses it on retries, so delayed
|
|
1407
|
+
tasks and retries can outlive it. Use an explicit automation bypass or public
|
|
1408
|
+
ingress when every protected delivery must remain authorized. Applications can
|
|
1409
|
+
supply a dedicated bypass through `headers`; A2 requests redaction for its
|
|
1410
|
+
value, but pending messages retain the captured credential after rotation. A
|
|
1411
|
+
delivery-time Vercel relay remains a separate application architecture that
|
|
1412
|
+
requires a Trusted Sources rule, strict target allowlisting, and a versioned
|
|
1413
|
+
routing envelope. QStash signature verification remains mandatory after the
|
|
1414
|
+
edge-access check.
|
|
1415
|
+
|
|
1416
|
+
Settled deliveries return 200. Busy deliveries await a durable successor
|
|
1417
|
+
watchdog before returning 200. Stalled deliveries publish a fresh successor
|
|
1418
|
+
before returning 200, which resets the per-message retry budget. A retryable
|
|
1419
|
+
store outage also hands both drain and append tasks to a fresh successor before
|
|
1420
|
+
acknowledging. Store-outage successors wait 30 seconds, then multiply the delay
|
|
1421
|
+
by four for each signed generation, capped at one day. Their generation keeps
|
|
1422
|
+
increasing after the cap so each handoff has a new deduplication id. Drain
|
|
1423
|
+
lineage includes the first failing task's `dueAt`, contract, and session. Append
|
|
1424
|
+
lineage includes its task id, contract, and session. Duplicate delivery of one
|
|
1425
|
+
generation therefore publishes the same successor key without merging a later
|
|
1426
|
+
drain episode. Append tasks otherwise return 200 after their ordinary fixed-id
|
|
1427
|
+
append succeeds and any recovery watchdog for pending handler work is durable.
|
|
1428
|
+
Unknown contracts, missing scheduler configuration, schema failures, and
|
|
1429
|
+
successor publish failures keep the current delivery retryable. Delivery is at
|
|
1430
|
+
least once, so handler side effects still need idempotency keys.
|
|
1431
|
+
|
|
1432
|
+
The adapter leaves `retries` unset by default so QStash applies its provider
|
|
1433
|
+
default. An explicit non-negative value is passed through. Every delivery
|
|
1434
|
+
attempt is billable. A prolonged outage in which QStash cannot reach the
|
|
1435
|
+
callback still requires DLQ monitoring and redrive because no running callback
|
|
1436
|
+
can publish a successor. QStash's
|
|
1437
|
+
documented 10-minute deduplication window can reduce duplicate watchdogs but is
|
|
1438
|
+
not part of A2's correctness model. Stable scheduled-event ids also make timer
|
|
1439
|
+
redelivery safe after that window. One-shot delays are currently capped at 7
|
|
1440
|
+
days on free plans and 1 year on usage-based plans, with no fixed maximum on
|
|
1441
|
+
fixed plans. Leave at least one second of margin at a plan maximum because
|
|
1442
|
+
`notBefore` uses whole Unix seconds. The adapter carries both recovery watchdogs and
|
|
1443
|
+
`session.schedule` tasks.
|
|
1444
|
+
|
|
1445
|
+
The built-in QStash and Vercel adapters preserve the original provider error
|
|
1446
|
+
from a rejected send. A recognized definite 4xx status or error is terminal
|
|
1447
|
+
except for 408, 409, 425, and 429. Those statuses, 5xx responses, network
|
|
1448
|
+
failures, and errors with no recognized status remain retryable. The Vercel
|
|
1449
|
+
adapter also recognizes the SDK's name-only `BadRequestError`,
|
|
1450
|
+
`UnauthorizedError`, and `ForbiddenError` as terminal. Its native
|
|
1451
|
+
duplicate-message error is success. A2's durable AI tool execution uses this
|
|
1452
|
+
private classification. Direct `session.schedule()` calls still reject with
|
|
1453
|
+
the original error object.
|
|
1454
|
+
|
|
1455
|
+
## `a2 devtools`
|
|
1456
|
+
|
|
1457
|
+
The package installs an `a2` binary with a read-only `devtools` namespace.
|
|
1458
|
+
|
|
1459
|
+
| Command | Purpose |
|
|
1460
|
+
| --- | --- |
|
|
1461
|
+
| `contracts` | List contracts mounted at the Devtools URL |
|
|
1462
|
+
| `sessions --contract NAME` | List session summaries, with optional `--limit` and `--cursor` |
|
|
1463
|
+
| `show --contract NAME --session ID` | Read one session's exact durable detail |
|
|
1464
|
+
| `export --contract NAME --session ID` | Verify and save an `.a2log`; use `--output` and opt into replacement with `--force` |
|
|
1465
|
+
| `check --contract NAME --session ID` | Verify a live capture and optionally require `--settled`, `--no-dead-letters`, `--no-caught-failures`, or `--max-redispatches N` |
|
|
1466
|
+
| `verify FILE` | Verify a saved capture offline |
|
|
1467
|
+
|
|
1468
|
+
Live commands read the mounted URL from `--url` or `A2_DEVTOOLS_URL`.
|
|
1469
|
+
`A2_DEVTOOLS_TOKEN` supplies the default bearer token. `--bearer-env ENV` and
|
|
1470
|
+
repeatable `--header-env HEADER=ENV` reference other environment variables.
|
|
1471
|
+
Credential values are never accepted as CLI arguments or URL components.
|
|
1472
|
+
Output uses `--format human`, `json`, or `ndjson`.
|
|
1473
|
+
|
|
1474
|
+
## `experimental-a2/testing`
|
|
1475
|
+
|
|
1476
|
+
Node-only helpers for verified capture loading and pure reducer replay.
|
|
1477
|
+
|
|
1478
|
+
| Export | Contract |
|
|
1479
|
+
| --- | --- |
|
|
1480
|
+
| `loadCapture(source, contract)` | Read a filesystem path or URL, verify its integrity, validate its contract and payloads, and return typed events |
|
|
1481
|
+
| `prepareCapture(capture, contract)` | Apply the same verification and contract typing to an already parsed capture |
|
|
1482
|
+
| `replayCapture(loaded, reducer, { throughIndex? })` | Fold captured events through the reducer, stopping at an optional inclusive index |
|
|
1483
|
+
|
|
1484
|
+
`LoadedCapture` contains the verified `capture`, the `contract`, typed public
|
|
1485
|
+
`events` with `createdAt` revived as `Date`, and unmodified operational
|
|
1486
|
+
`rawEvents`.
|
|
1487
|
+
`CaptureReplay` returns `{ state, index }`. A cutpoint of 0 returns the initial
|
|
1488
|
+
state and index 0. Supporting types are `CaptureSource`, `LoadedCapture`,
|
|
1489
|
+
`ReplayCaptureOptions`, and `CaptureReplay`.
|
|
845
1490
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1491
|
+
Replay does not use cached snapshot state, construct a server, dispatch a
|
|
1492
|
+
handler, invoke an AI model or tool, or repeat external effects. It reproduces
|
|
1493
|
+
only the current reducer's pure projection over already recorded events.
|
|
849
1494
|
|
|
850
1495
|
## Entry points
|
|
851
1496
|
|
|
@@ -855,18 +1500,22 @@ dashboard returns 501 for those logs.
|
|
|
855
1500
|
| `experimental-a2/server` | `createServer`: implement a contract | none |
|
|
856
1501
|
| `experimental-a2/client` | `createClient`: framework-agnostic session client | none |
|
|
857
1502
|
| `experimental-a2/react` | `createReact` | `react` |
|
|
858
|
-
| `experimental-a2/ai` | agent contract, reducer, and append builders: isomorphic | `ai` |
|
|
1503
|
+
| `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
|
|
859
1504
|
| `experimental-a2/ai/server` | AI SDK runner and built-in handlers | `ai` |
|
|
860
|
-
| `experimental-a2/http` | route-side transport helpers
|
|
861
|
-
| `experimental-a2/
|
|
862
|
-
| `experimental-a2/
|
|
863
|
-
| `experimental-a2/
|
|
864
|
-
| `experimental-a2/
|
|
865
|
-
| `experimental-a2/
|
|
1505
|
+
| `experimental-a2/http` | route-side transport and scheduler helpers | none |
|
|
1506
|
+
| `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
|
|
1507
|
+
| `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
|
|
1508
|
+
| `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
|
|
1509
|
+
| `experimental-a2/store-sqlite` | SQLite store backend | none |
|
|
1510
|
+
| `experimental-a2/store-memory` | in-memory store backend | none |
|
|
1511
|
+
| `experimental-a2/scheduler-vercel` | `vercelQueues`: recovery and timers | `@vercel/queue` |
|
|
1512
|
+
| `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
|
|
866
1513
|
| `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
|
|
867
1514
|
| `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
|
|
1515
|
+
| `experimental-a2/devtools` | versioned wire protocol and exact capture codec | none |
|
|
868
1516
|
| `experimental-a2/devtools/server` | durable read-only dashboard handler | none |
|
|
1517
|
+
| `experimental-a2/testing` | Node-only capture loading and pure reducer replay | none |
|
|
869
1518
|
|
|
870
1519
|
Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
|
|
871
|
-
test in CI, not just convention. Importing `experimental-a2/
|
|
1520
|
+
test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
|
|
872
1521
|
pulls in Postgres, never `experimental-a2` itself, and never your client bundle.
|