experimental-a2 0.22.0 → 0.24.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 +19 -0
- package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
- package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +2 -2
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
- package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +4 -4
- package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
- package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
- package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
- package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.js +25 -3
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
- package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-react.d.ts +4 -4
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.js +4 -4
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
- package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
- package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
- package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
- package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
- package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
- package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
- package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
- package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
- 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-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
- package/dist/server-B9gu0qi1.d.ts.map +1 -0
- package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
- package/dist/server-DX7mM6nm.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
- package/dist/session-clear-XJYpmUpg.js.map +1 -0
- package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
- package/dist/session-clear-vfonou9S.d.ts.map +1 -0
- package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
- package/dist/store-DEzcHZOA.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
- package/dist/store-codec-pAG2oPgQ.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +11 -4
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +26 -7
- package/dist/store-postgres.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-notify-CsJw_hfD.js → store-redis-notify-ys_-VrSP.js} +34 -18
- package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
- 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.d.ts.map +1 -1
- package/dist/store-sqlite.js +11 -4
- package/dist/store-sqlite.js.map +1 -1
- package/dist/store-valkey.d.ts +33 -0
- package/dist/store-valkey.d.ts.map +1 -0
- package/dist/store-valkey.js +218 -0
- package/dist/store-valkey.js.map +1 -0
- package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
- package/dist/wire-BFE9sIQf.js.map +1 -0
- package/docs/client/03-presence.mdx +1 -0
- package/docs/contract/03-sessions.mdx +51 -0
- package/docs/reference/01-core.mdx +1 -0
- package/docs/reference/02-server.mdx +44 -4
- package/docs/reference/06-errors.mdx +1 -0
- package/docs/stores/01-overview.mdx +1 -0
- package/docs/stores/02-redis.mdx +3 -1
- package/docs/stores/07-valkey.mdx +97 -0
- package/examples/playground/app/a2slack/consumers/search.ts +1 -1
- package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/a2slack/db/queries.ts +1 -1
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
- package/examples/playground/app/a2slack/search-index.ts +1 -1
- package/examples/playground/app/a2slack/server.ts +2 -2
- package/examples/playground/lib/store.ts +2 -0
- package/examples/playground/package.json +1 -1
- package/package.json +8 -2
- package/src/ai-fx.ts +46 -3
- package/src/errors.ts +39 -0
- package/src/idempotent-replay.ts +4 -3
- package/src/libfx.d.ts +2 -0
- package/src/server.ts +32 -9
- package/src/store-memory.ts +26 -4
- package/src/store-postgres.ts +36 -6
- package/src/store-redis-core.ts +40 -15
- package/src/store-redis-notify.ts +8 -2
- package/src/store-sqlite.ts +25 -4
- package/src/store-valkey.ts +376 -0
- package/src/store.ts +10 -0
- package/src/wire.ts +1 -0
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
- package/dist/server-DtFrWxi3.js.map +0 -1
- package/dist/server-qgez0Xny.d.ts.map +0 -1
- package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
- package/dist/session-clear-wHd5GCpZ.js.map +0 -1
- package/dist/store-D0MhBvg6.d.ts.map +0 -1
- package/dist/store-codec-DwrWlgQx.js.map +0 -1
- package/dist/store-redis-notify-CsJw_hfD.js.map +0 -1
- package/dist/wire-CV-Z7wz-.js.map +0 -1
|
@@ -340,7 +340,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
340
340
|
payload: { id: channel, by: pick(USERS) },
|
|
341
341
|
})
|
|
342
342
|
await until(
|
|
343
|
-
() => idle(store, ['welcome',
|
|
343
|
+
() => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
344
344
|
{
|
|
345
345
|
timeoutMs: 60_000,
|
|
346
346
|
},
|
|
@@ -350,7 +350,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
350
350
|
|
|
351
351
|
const posted = await post(to, MESSAGES, CONCURRENCY)
|
|
352
352
|
const catchUpMs = await until(
|
|
353
|
-
() => idle(store, ['welcome',
|
|
353
|
+
() => idle(store, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
354
354
|
{ timeoutMs: 10 * 60_000, everyMs: 100 },
|
|
355
355
|
)
|
|
356
356
|
const log = await logCounts(to[0]!)
|
|
@@ -361,7 +361,7 @@ async function loadRun(store: A2Store, label: string) {
|
|
|
361
361
|
await followers.close()
|
|
362
362
|
const tables = await tableCounts()
|
|
363
363
|
const db = await a2slack.database()
|
|
364
|
-
const feedPosition = await a2slack.queries.position(db,
|
|
364
|
+
const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
|
|
365
365
|
|
|
366
366
|
const [root] = await rows<{ id: string }>(
|
|
367
367
|
"select id from a2slack_messages where thread_id is null and channel = 'room-1' order by log_index desc limit 1",
|
|
@@ -449,23 +449,23 @@ it('rebuild: a renamed feed fills empty tables from the whole log', async () =>
|
|
|
449
449
|
const db = await a2slack.database()
|
|
450
450
|
await db.execute(
|
|
451
451
|
sql.raw(
|
|
452
|
-
|
|
452
|
+
`truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = '${a2slack.queries.FEED}'`,
|
|
453
453
|
),
|
|
454
454
|
)
|
|
455
455
|
const { telemetry, runs } = recorder()
|
|
456
|
-
const
|
|
456
|
+
const feedRebuild = a2slack
|
|
457
457
|
.consumer({
|
|
458
458
|
contract: a2slack.model.workspace,
|
|
459
|
-
name: 'a2slack-feed-
|
|
460
|
-
lane: 'feed-
|
|
459
|
+
name: 'a2slack-feed-rebuild',
|
|
460
|
+
lane: 'feed-rebuild',
|
|
461
461
|
backfill: true,
|
|
462
462
|
})
|
|
463
463
|
.run(async ({ event }) => {
|
|
464
464
|
await a2slack.feedEvent(await a2slack.database(), event)
|
|
465
465
|
})
|
|
466
|
-
const [server] = servers(redisStore, [
|
|
466
|
+
const [server] = servers(redisStore, [feedRebuild], telemetry, 1)
|
|
467
467
|
const t0 = performance.now()
|
|
468
|
-
const consumed = await session(server!).consume(
|
|
468
|
+
const consumed = await session(server!).consume(feedRebuild)
|
|
469
469
|
const ms = performance.now() - t0
|
|
470
470
|
const after = await tableCounts()
|
|
471
471
|
const result = {
|
|
@@ -524,9 +524,12 @@ it('poison: one bad event dead-letters its consumer alone, and redrive resumes i
|
|
|
524
524
|
await post(to, 50, 5)
|
|
525
525
|
|
|
526
526
|
const t0 = performance.now()
|
|
527
|
-
await until(
|
|
528
|
-
|
|
529
|
-
|
|
527
|
+
await until(
|
|
528
|
+
() => idle(redisStore, [a2slack.queries.FEED, 'a2slack-notify']),
|
|
529
|
+
{
|
|
530
|
+
timeoutMs: 60_000,
|
|
531
|
+
},
|
|
532
|
+
)
|
|
530
533
|
const othersCaughtUpMs = performance.now() - t0
|
|
531
534
|
const stuck = (await redisStore.cursors('a2slack\u001fworkspace')).find(
|
|
532
535
|
(cursor) => cursor.consumer === 'drill-seen',
|
|
@@ -625,14 +628,14 @@ it('outage: Postgres goes away mid-load, and every event still lands once', asyn
|
|
|
625
628
|
})
|
|
626
629
|
await outage
|
|
627
630
|
await until(
|
|
628
|
-
() => idle(redisStore, ['welcome',
|
|
631
|
+
() => idle(redisStore, ['welcome', a2slack.queries.FEED, 'a2slack-notify']),
|
|
629
632
|
{ timeoutMs: 5 * 60_000, everyMs: 200 },
|
|
630
633
|
)
|
|
631
634
|
const idleAt = performance.now()
|
|
632
635
|
const log = await logCounts(to[0]!)
|
|
633
636
|
const tables = await tableCounts()
|
|
634
637
|
const db = await a2slack.database()
|
|
635
|
-
const feedPosition = await a2slack.queries.position(db,
|
|
638
|
+
const feedPosition = await a2slack.queries.position(db, a2slack.queries.FEED)
|
|
636
639
|
const deadLetters = await session(to[0]!).deadLetters()
|
|
637
640
|
const result = {
|
|
638
641
|
appended: 3_000,
|
|
@@ -12,7 +12,7 @@ import { Redis, s } from '@upstash/redis'
|
|
|
12
12
|
import { searchFeed } from './db/queries'
|
|
13
13
|
import type { SearchResult } from './model'
|
|
14
14
|
|
|
15
|
-
const NAME = 'a2slack-messages-
|
|
15
|
+
const NAME = 'a2slack-messages-v2'
|
|
16
16
|
const PREFIX = `${NAME}:message:`
|
|
17
17
|
|
|
18
18
|
const schema = s.object({
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* either writes somewhere else or returns events into the log:
|
|
5
5
|
*
|
|
6
6
|
* - `welcome` posts a new channel's opening message.
|
|
7
|
-
* - `a2slack-feed` keeps the tables every page reads (db/schema.ts).
|
|
8
|
-
* - `a2slack-search-
|
|
7
|
+
* - `a2slack-feed-v2` keeps the tables every page reads (db/schema.ts).
|
|
8
|
+
* - `a2slack-search-v2` keeps the search index in Upstash Redis Search,
|
|
9
9
|
* where it's configured (search-index.ts).
|
|
10
10
|
* - `a2slack-notify` works out who should hear about each message, and
|
|
11
11
|
* returns a notification addressed to each of them.
|
|
@@ -8,6 +8,8 @@ const postgresUrl = process.env.DATABASE_URL?.replace(
|
|
|
8
8
|
'sslmode=verify-full',
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
// A new keyPrefix starts every log over: a2slack's feed (consumers/feed.ts)
|
|
12
|
+
// has to start over with it, under a new name and with its tables cleared.
|
|
11
13
|
export const store: RedisStore | PostgresStore | undefined = redisUrl
|
|
12
14
|
? redis({ url: redisUrl, keyPrefix: 'a2-playground-tagged' })
|
|
13
15
|
: postgresUrl
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"./store-postgres": "./dist/store-postgres.js",
|
|
62
62
|
"./store-redis": "./dist/store-redis.js",
|
|
63
63
|
"./store-redis-http": "./dist/store-redis-http.js",
|
|
64
|
+
"./store-valkey": "./dist/store-valkey.js",
|
|
64
65
|
"./scheduler-qstash": "./dist/scheduler-qstash.js",
|
|
65
66
|
"./scheduler-vercel": "./dist/scheduler-vercel.js",
|
|
66
67
|
"./cache-indexeddb": "./dist/cache-indexeddb.js",
|
|
@@ -77,7 +78,8 @@
|
|
|
77
78
|
"ioredis": "^5.4.0",
|
|
78
79
|
"libfx": "^0.0.12",
|
|
79
80
|
"pg": "^8.11.0",
|
|
80
|
-
"react": "^19.0.0"
|
|
81
|
+
"react": "^19.0.0",
|
|
82
|
+
"redis": "^5.8.0 || ^6.0.0"
|
|
81
83
|
},
|
|
82
84
|
"peerDependenciesMeta": {
|
|
83
85
|
"@opentelemetry/api": {
|
|
@@ -109,6 +111,9 @@
|
|
|
109
111
|
},
|
|
110
112
|
"react": {
|
|
111
113
|
"optional": true
|
|
114
|
+
},
|
|
115
|
+
"redis": {
|
|
116
|
+
"optional": true
|
|
112
117
|
}
|
|
113
118
|
},
|
|
114
119
|
"devDependencies": {
|
|
@@ -119,6 +124,7 @@
|
|
|
119
124
|
"@upstash/qstash": "^2.11.3",
|
|
120
125
|
"ai": "^7.0.58",
|
|
121
126
|
"ioredis": "^5.9.0",
|
|
127
|
+
"redis": "^6.2.1",
|
|
122
128
|
"@opentelemetry/api": "^1.9.0",
|
|
123
129
|
"@types/pg": "^8.15.0",
|
|
124
130
|
"@types/ws": "^8.18.1",
|
package/src/ai-fx.ts
CHANGED
|
@@ -70,6 +70,8 @@ const SNAPSHOT_VERSION = 1
|
|
|
70
70
|
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
71
71
|
/** The Gateway model catalog libfx reads for each new kernel. */
|
|
72
72
|
const CATALOG_PATH = '/coding-agent/v1/models'
|
|
73
|
+
/** Times a step rebuilds its kernel for a stopped turn FX failed on replay. */
|
|
74
|
+
const REPLAY_REBUILDS = 3
|
|
73
75
|
|
|
74
76
|
type StreamPart = { type: string } & Record<string, unknown>
|
|
75
77
|
/** A recorded tool call, with the input the kernel reads from its text. */
|
|
@@ -144,7 +146,13 @@ type KernelHooks = {
|
|
|
144
146
|
tool: (toolName: string, input: unknown) => unknown
|
|
145
147
|
}
|
|
146
148
|
|
|
147
|
-
type Kernel = {
|
|
149
|
+
type Kernel = {
|
|
150
|
+
agent: FxAgent
|
|
151
|
+
hooks: KernelHooks
|
|
152
|
+
waiting: Waiting[]
|
|
153
|
+
/** Whether FX answered the kernel's latest prompt with an error. */
|
|
154
|
+
failed: boolean
|
|
155
|
+
}
|
|
148
156
|
|
|
149
157
|
/**
|
|
150
158
|
* A kernel a step left waiting on its tool calls. The session's next step in
|
|
@@ -249,8 +257,12 @@ async function runStep(
|
|
|
249
257
|
await resume(controller, turns.at(-1)!, adopted)
|
|
250
258
|
return
|
|
251
259
|
}
|
|
252
|
-
|
|
260
|
+
let rebuilds = 0
|
|
261
|
+
let next = 0
|
|
262
|
+
while (next < turns.length) {
|
|
253
263
|
if (stopped || step.signal.aborted) return
|
|
264
|
+
const plan = turns[next]!
|
|
265
|
+
next += 1
|
|
254
266
|
// The step's abort cancels the turn; the turn outlives it when parked.
|
|
255
267
|
const fxTurn = kernel!.agent.prompt(plan.prompt)
|
|
256
268
|
const state: TurnState = {
|
|
@@ -282,6 +294,18 @@ async function runStep(
|
|
|
282
294
|
throw diverged(
|
|
283
295
|
`a recorded turn ended with ${result.stopReason}, not ${expected}`,
|
|
284
296
|
)
|
|
297
|
+
if (kernel!.failed) {
|
|
298
|
+
// libfx's cancel aborts the request FX waits on as it sends the
|
|
299
|
+
// stop, and FX fails the turn when it reads the abort first. That
|
|
300
|
+
// kernel holds no interruption where the log has one, so the turns
|
|
301
|
+
// replay on a new kernel.
|
|
302
|
+
if (rebuilds === REPLAY_REBUILDS)
|
|
303
|
+
throw diverged('FX failed a stopped turn instead of cancelling it')
|
|
304
|
+
rebuilds += 1
|
|
305
|
+
await kernel!.agent.close().catch(() => {})
|
|
306
|
+
kernel = await create(read.saved)
|
|
307
|
+
next = 0
|
|
308
|
+
}
|
|
285
309
|
continue
|
|
286
310
|
}
|
|
287
311
|
await finishLive(controller, state, drained)
|
|
@@ -577,7 +601,8 @@ async function runStep(
|
|
|
577
601
|
const create = async (
|
|
578
602
|
checkpoint: Uint8Array | undefined,
|
|
579
603
|
): Promise<Kernel> => {
|
|
580
|
-
const created = { hooks, waiting: [] as Waiting[] }
|
|
604
|
+
const created = { hooks, waiting: [] as Waiting[], failed: false }
|
|
605
|
+
let prompt: unknown
|
|
581
606
|
const agent = await createFxAgent({
|
|
582
607
|
apiKey: credential.token,
|
|
583
608
|
model: kernelModel(step),
|
|
@@ -591,6 +616,24 @@ async function runStep(
|
|
|
591
616
|
})),
|
|
592
617
|
...(checkpoint === undefined ? {} : { checkpoint }),
|
|
593
618
|
fetch: (input, init) => created.hooks.fetch(input, init),
|
|
619
|
+
// libfx reports a turn FX failed after its cancel as cancelled; only
|
|
620
|
+
// the ACP response to the turn's prompt tells the two apart.
|
|
621
|
+
onEvent: ({ type, message }) => {
|
|
622
|
+
const rpc = (message ?? {}) as {
|
|
623
|
+
id?: unknown
|
|
624
|
+
method?: unknown
|
|
625
|
+
error?: unknown
|
|
626
|
+
}
|
|
627
|
+
if (type === 'acp.send' && rpc.method === 'session/prompt') {
|
|
628
|
+
prompt = rpc.id
|
|
629
|
+
created.failed = false
|
|
630
|
+
} else if (
|
|
631
|
+
type === 'acp.receive' &&
|
|
632
|
+
rpc.method === undefined &&
|
|
633
|
+
rpc.id === prompt
|
|
634
|
+
)
|
|
635
|
+
created.failed = rpc.error !== undefined
|
|
636
|
+
},
|
|
594
637
|
})
|
|
595
638
|
return Object.assign(created, { agent })
|
|
596
639
|
}
|
package/src/errors.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type A2ServerErrorCode =
|
|
|
5
5
|
| 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
|
|
6
6
|
| 'SUPERSEDED_ATTEMPT' // consumer append from an attempt a recovery claim or a clear replaced
|
|
7
7
|
| 'SESSION_CLEARED' // a read or call reached across the session's latest clear
|
|
8
|
+
| 'LAST_INDEX_MISMATCH' // a conditional append's ifLastIndex was not the head — nothing was written
|
|
8
9
|
| 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
|
|
9
10
|
| 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
|
|
10
11
|
| 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables
|
|
@@ -48,6 +49,44 @@ export function supersededAttemptError(
|
|
|
48
49
|
)
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** A conditional append whose `ifLastIndex` is not the session's head. */
|
|
53
|
+
export function lastIndexMismatchError(
|
|
54
|
+
sessionId: string,
|
|
55
|
+
ifLastIndex: number,
|
|
56
|
+
head: number,
|
|
57
|
+
): A2Error {
|
|
58
|
+
return new A2Error(
|
|
59
|
+
'LAST_INDEX_MISMATCH',
|
|
60
|
+
`session '${sessionId}' is at index ${head}, not ${ifLastIndex}`,
|
|
61
|
+
{ details: { index: head } },
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A conditional append's condition and the head its store found. */
|
|
66
|
+
export type ConditionalHead = {
|
|
67
|
+
sessionId: string
|
|
68
|
+
ifLastIndex: number
|
|
69
|
+
head: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A batch whose ids conflict with committed events. In a conditional append
|
|
74
|
+
* whose head moved, the condition failed first, as when another writer's
|
|
75
|
+
* batch took those ids, so the conflict reports the head instead.
|
|
76
|
+
*/
|
|
77
|
+
export function duplicateBatchError(
|
|
78
|
+
message: string,
|
|
79
|
+
conditional: ConditionalHead | undefined,
|
|
80
|
+
): A2Error {
|
|
81
|
+
return conditional && conditional.head !== conditional.ifLastIndex
|
|
82
|
+
? lastIndexMismatchError(
|
|
83
|
+
conditional.sessionId,
|
|
84
|
+
conditional.ifLastIndex,
|
|
85
|
+
conditional.head,
|
|
86
|
+
)
|
|
87
|
+
: new A2Error('PARTIAL_DUPLICATE_BATCH', message)
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
/**
|
|
52
91
|
* Wrap an arbitrary thrown value as STORE_UNAVAILABLE, preserving the
|
|
53
92
|
* original as `cause`. A2Errors pass through untouched — they already
|
package/src/idempotent-replay.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { duplicateBatchError, type ConditionalHead } from './errors.ts'
|
|
2
2
|
import type { AppendEvent, StoredEvent } from './store.ts'
|
|
3
3
|
|
|
4
4
|
const canonicalJson = (value: unknown): string =>
|
|
@@ -20,6 +20,7 @@ const canonicalJson = (value: unknown): string =>
|
|
|
20
20
|
export const idempotentReplay = (
|
|
21
21
|
events: readonly AppendEvent[],
|
|
22
22
|
existing: readonly StoredEvent[],
|
|
23
|
+
conditional?: ConditionalHead,
|
|
23
24
|
): StoredEvent[] => {
|
|
24
25
|
const byId = new Map(existing.map((event) => [event.id, event]))
|
|
25
26
|
const ordered = events.map((event) =>
|
|
@@ -42,9 +43,9 @@ export const idempotentReplay = (
|
|
|
42
43
|
)
|
|
43
44
|
})
|
|
44
45
|
) {
|
|
45
|
-
throw
|
|
46
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
46
|
+
throw duplicateBatchError(
|
|
47
47
|
'already-appended events do not match the retried batch',
|
|
48
|
+
conditional,
|
|
48
49
|
)
|
|
49
50
|
}
|
|
50
51
|
|
package/src/libfx.d.ts
CHANGED
|
@@ -37,5 +37,7 @@ declare module 'libfx' {
|
|
|
37
37
|
input: string | URL | Request,
|
|
38
38
|
init?: RequestInit,
|
|
39
39
|
) => Promise<Response>
|
|
40
|
+
/** Diagnostics; `acp.send` and `acp.receive` carry each ACP message. */
|
|
41
|
+
onEvent?: (event: { type: string; message?: unknown }) => void
|
|
40
42
|
}): Promise<FxAgent>
|
|
41
43
|
}
|
package/src/server.ts
CHANGED
|
@@ -155,6 +155,15 @@ export type SessionDispatch<D extends EventDefs> = (
|
|
|
155
155
|
export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
|
|
156
156
|
/** Commit, then hand pending work directly to configured scheduler. */
|
|
157
157
|
dispatch: SessionDispatch<D>
|
|
158
|
+
/**
|
|
159
|
+
* Append only while `index` is the session's head, checked in the same
|
|
160
|
+
* atomic step as the write. Otherwise nothing is written and it throws
|
|
161
|
+
* `LAST_INDEX_MISMATCH`, with the head as `details.index`.
|
|
162
|
+
*/
|
|
163
|
+
ifLastIndex(
|
|
164
|
+
index: number,
|
|
165
|
+
...events: AppendInput<D>[]
|
|
166
|
+
): Promise<ContractEvent<D>[]>
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
export type ConsumerAppend<D extends EventDefs> = (
|
|
@@ -1395,6 +1404,9 @@ export function createServer<
|
|
|
1395
1404
|
})
|
|
1396
1405
|
}
|
|
1397
1406
|
|
|
1407
|
+
/** What the store checks before it writes, inside the append's atomic step. */
|
|
1408
|
+
type AppendCondition = { originIndex?: number; ifLastIndex?: number }
|
|
1409
|
+
|
|
1398
1410
|
/**
|
|
1399
1411
|
* Validate + write. `onAppended` runs inside the telemetry span, so
|
|
1400
1412
|
* the drain it schedules is created in the append's active context —
|
|
@@ -1409,7 +1421,7 @@ export function createServer<
|
|
|
1409
1421
|
cause?: EventCause,
|
|
1410
1422
|
generatedIds?: readonly boolean[],
|
|
1411
1423
|
requireDurableInitialArm = false,
|
|
1412
|
-
|
|
1424
|
+
condition: AppendCondition = {},
|
|
1413
1425
|
): Promise<ContractEvent<D>[]> =>
|
|
1414
1426
|
telemetry.span(
|
|
1415
1427
|
'a2.append',
|
|
@@ -1442,7 +1454,7 @@ export function createServer<
|
|
|
1442
1454
|
try {
|
|
1443
1455
|
const result = await store.append(nsId(sessionId), validated, {
|
|
1444
1456
|
consumers: storeConsumers,
|
|
1445
|
-
...
|
|
1457
|
+
...condition,
|
|
1446
1458
|
})
|
|
1447
1459
|
rows = result.events
|
|
1448
1460
|
// A consumer appending events no consumer takes adds no work, and
|
|
@@ -2243,7 +2255,7 @@ export function createServer<
|
|
|
2243
2255
|
throw new TypeError('schedule.clear requires an ifLastIndex')
|
|
2244
2256
|
}
|
|
2245
2257
|
const { ifLastIndex, ...timing } = clearOptions
|
|
2246
|
-
|
|
2258
|
+
assertIfLastIndex(ifLastIndex)
|
|
2247
2259
|
const dueAt = scheduleDueAt(
|
|
2248
2260
|
timing as ScheduleTiming,
|
|
2249
2261
|
triggerCreatedAt ?? Date.now(),
|
|
@@ -2848,7 +2860,7 @@ export function createServer<
|
|
|
2848
2860
|
attempt: ClearingAttempt | undefined,
|
|
2849
2861
|
): Promise<SessionClearResult> => {
|
|
2850
2862
|
const ifLastIndex = clearOptions?.ifLastIndex
|
|
2851
|
-
if (ifLastIndex !== undefined)
|
|
2863
|
+
if (ifLastIndex !== undefined) assertIfLastIndex(ifLastIndex)
|
|
2852
2864
|
const store = await resolveStore()
|
|
2853
2865
|
if (!store.clear || !store.reclaim) {
|
|
2854
2866
|
throw new TypeError(
|
|
@@ -4077,7 +4089,7 @@ export function createServer<
|
|
|
4077
4089
|
mode: 'inline' | 'dispatch',
|
|
4078
4090
|
events: AppendInput<D>[],
|
|
4079
4091
|
requireDurableInitialArm = false,
|
|
4080
|
-
|
|
4092
|
+
condition: AppendCondition = {},
|
|
4081
4093
|
): Promise<ContractEvent<D>[]> => {
|
|
4082
4094
|
return appendCore(
|
|
4083
4095
|
sessionId,
|
|
@@ -4088,7 +4100,7 @@ export function createServer<
|
|
|
4088
4100
|
undefined,
|
|
4089
4101
|
undefined,
|
|
4090
4102
|
requireDurableInitialArm,
|
|
4091
|
-
|
|
4103
|
+
condition,
|
|
4092
4104
|
)
|
|
4093
4105
|
}
|
|
4094
4106
|
|
|
@@ -4100,8 +4112,19 @@ export function createServer<
|
|
|
4100
4112
|
appendExternal(id, 'inline', events)
|
|
4101
4113
|
const appendDispatch = async (...events: AppendInput<D>[]) =>
|
|
4102
4114
|
appendExternal(id, 'dispatch', events)
|
|
4115
|
+
const appendIfLastIndex = async (
|
|
4116
|
+
index: number,
|
|
4117
|
+
...events: AppendInput<D>[]
|
|
4118
|
+
) => {
|
|
4119
|
+
assertIfLastIndex(index)
|
|
4120
|
+
if (events.length === 0) {
|
|
4121
|
+
throw new TypeError('append.ifLastIndex requires at least one event')
|
|
4122
|
+
}
|
|
4123
|
+
return appendExternal(id, 'inline', events, false, { ifLastIndex: index })
|
|
4124
|
+
}
|
|
4103
4125
|
const append = Object.assign(appendInline, {
|
|
4104
4126
|
dispatch: appendDispatch,
|
|
4127
|
+
ifLastIndex: appendIfLastIndex,
|
|
4105
4128
|
}) as SessionAppend<D>
|
|
4106
4129
|
return Object.assign(makeSession(id, append, null), {
|
|
4107
4130
|
deadLetters: () => listDeadLetters(id),
|
|
@@ -4169,7 +4192,7 @@ export function createServer<
|
|
|
4169
4192
|
'inline',
|
|
4170
4193
|
[...events],
|
|
4171
4194
|
true,
|
|
4172
|
-
originIndex,
|
|
4195
|
+
originIndex === undefined ? {} : { originIndex },
|
|
4173
4196
|
)
|
|
4174
4197
|
} catch (error) {
|
|
4175
4198
|
// The scheduling consumer's lifetime was cleared: an acknowledged no-op.
|
|
@@ -4186,7 +4209,7 @@ export function createServer<
|
|
|
4186
4209
|
(await readHistory(sessionId, { gte })).map(toPublic),
|
|
4187
4210
|
schedulerClear: async (sessionId, ifLastIndex) => {
|
|
4188
4211
|
assertSessionId(sessionId)
|
|
4189
|
-
|
|
4212
|
+
assertIfLastIndex(ifLastIndex)
|
|
4190
4213
|
await clearSession(sessionId, { ifLastIndex }, undefined)
|
|
4191
4214
|
},
|
|
4192
4215
|
})
|
|
@@ -4232,7 +4255,7 @@ function assertRedriveIndex(value: number): void {
|
|
|
4232
4255
|
}
|
|
4233
4256
|
}
|
|
4234
4257
|
|
|
4235
|
-
function
|
|
4258
|
+
function assertIfLastIndex(value: number): void {
|
|
4236
4259
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
4237
4260
|
throw new TypeError('ifLastIndex must be a non-negative safe integer')
|
|
4238
4261
|
}
|
package/src/store-memory.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { PresencePatch } from './contract.ts'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
A2Error,
|
|
12
|
+
duplicateBatchError,
|
|
13
|
+
lastIndexMismatchError,
|
|
14
|
+
supersededAttemptError,
|
|
15
|
+
} from './errors.ts'
|
|
11
16
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
12
17
|
import { nullProtoRecord } from './internal.ts'
|
|
13
18
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
@@ -664,6 +669,12 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
664
669
|
return { events: [], hasPending: hasPending(sessionId, consumers) }
|
|
665
670
|
}
|
|
666
671
|
|
|
672
|
+
const ifLastIndex = appendOptions?.ifLastIndex
|
|
673
|
+
const conditional =
|
|
674
|
+
ifLastIndex === undefined
|
|
675
|
+
? undefined
|
|
676
|
+
: { sessionId, ifLastIndex, head: headOf(sessions.get(sessionId)) }
|
|
677
|
+
|
|
667
678
|
// Batch-scoped idempotency (a2-implementation.md §3): all ids
|
|
668
679
|
// already present → lost-ack retry, return the original rows;
|
|
669
680
|
// some present → the caller mixed sent and fresh events.
|
|
@@ -671,13 +682,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
671
682
|
if (existing.length > 0) {
|
|
672
683
|
if (existing.length === events.length) {
|
|
673
684
|
return {
|
|
674
|
-
events: idempotentReplay(
|
|
685
|
+
events: idempotentReplay(
|
|
686
|
+
events,
|
|
687
|
+
existing.map(toStored),
|
|
688
|
+
conditional,
|
|
689
|
+
),
|
|
675
690
|
hasPending: hasPending(sessionId, consumers),
|
|
676
691
|
}
|
|
677
692
|
}
|
|
678
|
-
throw
|
|
679
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
693
|
+
throw duplicateBatchError(
|
|
680
694
|
`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
|
|
695
|
+
conditional,
|
|
681
696
|
)
|
|
682
697
|
}
|
|
683
698
|
|
|
@@ -692,6 +707,13 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
692
707
|
for (const event of events) {
|
|
693
708
|
if (event.cause) assertCurrentCause(sessionId, event.cause)
|
|
694
709
|
}
|
|
710
|
+
if (conditional && conditional.head !== conditional.ifLastIndex) {
|
|
711
|
+
throw lastIndexMismatchError(
|
|
712
|
+
sessionId,
|
|
713
|
+
conditional.ifLastIndex,
|
|
714
|
+
conditional.head,
|
|
715
|
+
)
|
|
716
|
+
}
|
|
695
717
|
|
|
696
718
|
const inserted = insert(sessionId, events, clock.now(), consumers)
|
|
697
719
|
notifyStreams(sessionId, inserted)
|