@nanobpm/agentic 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/dist/transcript/index.d.ts +2 -2
  59. package/dist/transcript/index.js +1 -1
  60. package/dist/transcript/schema.d.ts +23 -1
  61. package/dist/transcript/schema.js +34 -1
  62. package/dist/transcript/store.d.ts +93 -5
  63. package/dist/transcript/store.js +287 -6
  64. package/package.json +17 -1
  65. package/src/demand/model.test.ts +82 -4
  66. package/src/demand/model.ts +30 -9
  67. package/src/demand/taskdef.test.ts +51 -6
  68. package/src/demand/taskdef.ts +31 -2
  69. package/src/index.ts +1 -0
  70. package/src/protocol/conformance/frames.ts +32 -4
  71. package/src/protocol/index.ts +4 -0
  72. package/src/protocol/payloads.test.ts +31 -1
  73. package/src/protocol/payloads.ts +110 -7
  74. package/src/session/acp/client.test.ts +222 -0
  75. package/src/session/acp/client.ts +356 -0
  76. package/src/session/acp/fake-agent.ts +71 -0
  77. package/src/session/acp/index.ts +68 -0
  78. package/src/session/acp/integration.test.ts +37 -0
  79. package/src/session/acp/jsonrpc.test.ts +75 -0
  80. package/src/session/acp/jsonrpc.ts +171 -0
  81. package/src/session/acp/normalize.test.ts +150 -0
  82. package/src/session/acp/normalize.ts +204 -0
  83. package/src/session/acp/protocol.ts +178 -0
  84. package/src/session/acp/spawn.test.ts +45 -0
  85. package/src/session/acp/spawn.ts +91 -0
  86. package/src/session/acp/transport.test.ts +82 -0
  87. package/src/session/acp/transport.ts +155 -0
  88. package/src/session/adapter.ts +159 -0
  89. package/src/session/backend.test.ts +198 -0
  90. package/src/session/backend.ts +128 -0
  91. package/src/session/events.test.ts +168 -0
  92. package/src/session/events.ts +347 -0
  93. package/src/session/index.ts +67 -0
  94. package/src/session/log.test.ts +215 -0
  95. package/src/session/log.ts +525 -0
  96. package/src/session/normalizer/backend-integration.test.ts +103 -0
  97. package/src/session/normalizer/claude.test.ts +68 -0
  98. package/src/session/normalizer/claude.ts +136 -0
  99. package/src/session/normalizer/copilot.test.ts +59 -0
  100. package/src/session/normalizer/copilot.ts +133 -0
  101. package/src/session/normalizer/deepseek.ts +80 -0
  102. package/src/session/normalizer/index.ts +61 -0
  103. package/src/session/normalizer/kimi.ts +82 -0
  104. package/src/session/normalizer/link.test.ts +24 -0
  105. package/src/session/normalizer/link.ts +81 -0
  106. package/src/session/normalizer/pi.ts +75 -0
  107. package/src/session/normalizer/probe.test.ts +49 -0
  108. package/src/session/normalizer/qwen.test.ts +20 -0
  109. package/src/session/normalizer/qwen.ts +77 -0
  110. package/src/session/normalizer/record.test.ts +68 -0
  111. package/src/session/normalizer/record.ts +88 -0
  112. package/src/session/normalizer/resume.test.ts +25 -0
  113. package/src/session/normalizer/types.ts +152 -0
  114. package/src/session/normalizer/vectors.test.ts +180 -0
  115. package/src/session/schema.test.ts +84 -0
  116. package/src/session/schema.ts +78 -0
  117. package/src/session/test-db.ts +56 -0
  118. package/src/transcript/index.ts +8 -0
  119. package/src/transcript/schema.test.ts +31 -4
  120. package/src/transcript/schema.ts +36 -1
  121. package/src/transcript/store.ts +438 -6
  122. package/src/transcript/turns.test.ts +334 -0
  123. package/dist/blackboard/test-db.d.ts +0 -5
  124. package/dist/blackboard/test-db.js +0 -42
  125. package/dist/presence/test-db.d.ts +0 -5
  126. package/dist/presence/test-db.js +0 -42
  127. package/dist/transcript/test-db.d.ts +0 -5
  128. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,525 @@
1
+ /**
2
+ * The authoritative session-log store — ADR 0062, slice 1.
3
+ *
4
+ * This **promotes** the advisory relay substrate (ADR 0056 §12: the bounded
5
+ * replay ring's resume-from-offset, plus generation/incarnation fencing) into an
6
+ * *authoritative* per-activation log. Two things change on promotion:
7
+ *
8
+ * - **Unbounded, durable retention.** The relay ring is a bounded in-memory
9
+ * resume window that evicts; the authoritative log retains every event of an
10
+ * activation (lifecycle-bounded like the S6 transcript, swept only when the
11
+ * activation is retired), so `restore` can always replay from offset 0.
12
+ * - **Durable fencing.** The relay {@link IncarnationFence} (which this module
13
+ * reuses verbatim for the in-memory backend) lives in memory; the SQLite
14
+ * backend persists the same high-water mark in the activation row's
15
+ * `incarnation` column, so a stale writer is fenced even across a restart.
16
+ *
17
+ * Transport is never re-implemented — this is a storage layer over the app
18
+ * DataLayer (or memory), exactly as the transcript store is. Nothing here rides
19
+ * the Camunda-8 engine (ADR 0056 boundary preserved).
20
+ */
21
+ import { IncarnationFence } from "../relay/incarnation.ts";
22
+ import {
23
+ type ActivationKey,
24
+ activationKeyString,
25
+ type SessionCheckpoint,
26
+ StaleIncarnationError,
27
+ } from "./adapter.ts";
28
+ import { type AppendedSessionEvent, parseSessionEvent, type SessionEvent } from "./events.ts";
29
+ import {
30
+ SESSION_CHECKPOINT_TABLE,
31
+ SESSION_EVENT_TABLE,
32
+ SESSION_LOG_TABLE,
33
+ SESSION_SCHEMA_SQL,
34
+ } from "./schema.ts";
35
+
36
+ /**
37
+ * The authoritative-log port the {@link SessionBackend} adapter writes through.
38
+ * Two backends implement it: {@link InMemorySessionLog} (the reference/stub) and
39
+ * {@link SqliteSessionLog} (durable, over the app DataLayer). All writes are
40
+ * fenced by `incarnation`; a stale writer throws {@link StaleIncarnationError}.
41
+ */
42
+ export interface SessionLog {
43
+ /**
44
+ * Take (or renew) the lease for `key` at `incarnation`, advancing the fence
45
+ * high-water mark. Throws {@link StaleIncarnationError} if a newer incarnation
46
+ * already owns the activation. Called once when an adapter is constructed so a
47
+ * re-lease fences prior incarnations immediately, before any write.
48
+ */
49
+ lease(key: ActivationKey, incarnation: number): void;
50
+
51
+ /** The current (highest leased) incarnation for `key`, or `undefined`. */
52
+ currentIncarnation(key: ActivationKey): number | undefined;
53
+
54
+ /** The offset the next appended event will occupy (also the event count). */
55
+ nextOffset(key: ActivationKey): number;
56
+
57
+ /**
58
+ * Append `event` at `offset` under `incarnation`, returning it stamped as an
59
+ * {@link AppendedSessionEvent}. Fenced. `offset` must be `<= nextOffset`: at
60
+ * `nextOffset` it extends the log; below it (a resume writing back into the log
61
+ * after `restore`) it first drops the now-superseded uncommitted tail
62
+ * `[offset, nextOffset)` — **and every checkpoint pinned above `offset`**, which
63
+ * would otherwise dangle past the rewritten head and mis-seed a later `restore`
64
+ * (a gap `RangeError` on the next `emit`) — and then writes: an idempotent
65
+ * re-key that keeps the authoritative log gap-free. An `offset > nextOffset` is
66
+ * a gap and throws.
67
+ */
68
+ append(
69
+ key: ActivationKey,
70
+ incarnation: number,
71
+ offset: number,
72
+ event: SessionEvent,
73
+ ): AppendedSessionEvent;
74
+
75
+ /** Persist a checkpoint (fenced by `incarnation`). Returns it unchanged. */
76
+ putCheckpoint(key: ActivationKey, incarnation: number, checkpoint: SessionCheckpoint): SessionCheckpoint;
77
+
78
+ /** The checkpoint with the highest offset (newest), or `undefined`. */
79
+ latestCheckpoint(key: ActivationKey): SessionCheckpoint | undefined;
80
+
81
+ /** A specific checkpoint by id, or `undefined`. */
82
+ getCheckpoint(key: ActivationKey, id: string): SessionCheckpoint | undefined;
83
+
84
+ /** The events with `from <= offset < to`, in offset order. `to` defaults to the head. */
85
+ replay(key: ActivationKey, from: number, to?: number): AppendedSessionEvent[];
86
+ }
87
+
88
+ function isNonNegInt(value: unknown): value is number {
89
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
90
+ }
91
+
92
+ function assertOffset(offset: number): void {
93
+ if (!isNonNegInt(offset)) {
94
+ throw new RangeError(`session log offset must be a non-negative safe integer, got ${offset}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate a `replay(from, to)` window: `from` is a normal offset, and when a
100
+ * bounded `to` is given it must itself be a valid offset that is not below
101
+ * `from` — so an out-of-range or inverted bound fails fast instead of silently
102
+ * yielding a surprising `Array.slice`/SQL range.
103
+ */
104
+ function assertReplayBounds(from: number, to: number | undefined): void {
105
+ assertOffset(from);
106
+ if (to === undefined) return;
107
+ if (!isNonNegInt(to)) {
108
+ throw new RangeError(`session log replay 'to' must be a non-negative safe integer, got ${to}`);
109
+ }
110
+ if (to < from) {
111
+ throw new RangeError(`session log replay 'to' (${to}) must be >= 'from' (${from})`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * A checkpoint's own {@link SessionCheckpoint.incarnation} must equal the fence
117
+ * token it is written under — otherwise the row would be fenced with one
118
+ * incarnation but stamped with another, producing inconsistent durable data.
119
+ */
120
+ function assertCheckpointIncarnation(incarnation: number, checkpoint: SessionCheckpoint): void {
121
+ if (checkpoint.incarnation !== incarnation) {
122
+ throw new RangeError(
123
+ `checkpoint ${checkpoint.id} incarnation (${checkpoint.incarnation}) must equal the write lease incarnation (${incarnation})`,
124
+ );
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The in-memory reference backend (the stub slices 2–5 code against and the tests
130
+ * exercise). Reuses the relay {@link IncarnationFence} verbatim and keeps each
131
+ * activation's full event array — the authoritative, non-evicting analogue of the
132
+ * relay ring's resume window.
133
+ */
134
+ export class InMemorySessionLog implements SessionLog {
135
+ readonly #fence = new IncarnationFence();
136
+ readonly #events = new Map<string, AppendedSessionEvent[]>();
137
+ readonly #checkpoints = new Map<string, SessionCheckpoint[]>();
138
+
139
+ #eventsFor(key: ActivationKey): AppendedSessionEvent[] {
140
+ const s = activationKeyString(key);
141
+ let arr = this.#events.get(s);
142
+ if (arr === undefined) {
143
+ arr = [];
144
+ this.#events.set(s, arr);
145
+ }
146
+ return arr;
147
+ }
148
+
149
+ #checkpointsFor(key: ActivationKey): SessionCheckpoint[] {
150
+ const s = activationKeyString(key);
151
+ let arr = this.#checkpoints.get(s);
152
+ if (arr === undefined) {
153
+ arr = [];
154
+ this.#checkpoints.set(s, arr);
155
+ }
156
+ return arr;
157
+ }
158
+
159
+ #admit(key: ActivationKey, incarnation: number): void {
160
+ const s = activationKeyString(key);
161
+ if (!this.#fence.admit(s, incarnation)) {
162
+ throw new StaleIncarnationError(key, incarnation, this.#fence.current(s) ?? incarnation);
163
+ }
164
+ }
165
+
166
+ lease(key: ActivationKey, incarnation: number): void {
167
+ this.#admit(key, incarnation);
168
+ }
169
+
170
+ currentIncarnation(key: ActivationKey): number | undefined {
171
+ return this.#fence.current(activationKeyString(key));
172
+ }
173
+
174
+ nextOffset(key: ActivationKey): number {
175
+ return this.#eventsFor(key).length;
176
+ }
177
+
178
+ append(
179
+ key: ActivationKey,
180
+ incarnation: number,
181
+ offset: number,
182
+ event: SessionEvent,
183
+ ): AppendedSessionEvent {
184
+ assertOffset(offset);
185
+ this.#admit(key, incarnation);
186
+ const arr = this.#eventsFor(key);
187
+ if (offset > arr.length) {
188
+ throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${arr.length}`);
189
+ }
190
+ if (offset < arr.length) {
191
+ // Resuming: drop the now-superseded uncommitted tail before re-keying, and
192
+ // prune any checkpoint pinned above the resume boundary — it now points
193
+ // past the rewritten head and would mis-seed a later restore.
194
+ arr.length = offset;
195
+ this.#pruneCheckpointsAbove(key, offset);
196
+ }
197
+ const appended: AppendedSessionEvent = { ...event, offset, incarnation };
198
+ arr.push(appended);
199
+ return appended;
200
+ }
201
+
202
+ /** Drop checkpoints whose offset sits above `offset` (past a rewritten head). */
203
+ #pruneCheckpointsAbove(key: ActivationKey, offset: number): void {
204
+ const arr = this.#checkpointsFor(key);
205
+ for (let i = arr.length - 1; i >= 0; i--) {
206
+ if (arr[i].offset > offset) arr.splice(i, 1);
207
+ }
208
+ }
209
+
210
+ putCheckpoint(key: ActivationKey, incarnation: number, checkpoint: SessionCheckpoint): SessionCheckpoint {
211
+ this.#admit(key, incarnation);
212
+ assertCheckpointIncarnation(incarnation, checkpoint);
213
+ const arr = this.#checkpointsFor(key);
214
+ // First-wins on checkpoint.id — mirrors the durable backend's
215
+ // ON CONFLICT(checkpoint_id) DO NOTHING so a retry never duplicates.
216
+ if (arr.some((cp) => cp.id === checkpoint.id)) return checkpoint;
217
+ arr.push(checkpoint);
218
+ return checkpoint;
219
+ }
220
+
221
+ latestCheckpoint(key: ActivationKey): SessionCheckpoint | undefined {
222
+ let best: SessionCheckpoint | undefined;
223
+ for (const cp of this.#checkpointsFor(key)) {
224
+ // Highest offset wins; a later insert at the same offset supersedes.
225
+ if (best === undefined || cp.offset >= best.offset) best = cp;
226
+ }
227
+ return best;
228
+ }
229
+
230
+ getCheckpoint(key: ActivationKey, id: string): SessionCheckpoint | undefined {
231
+ return this.#checkpointsFor(key).find((cp) => cp.id === id);
232
+ }
233
+
234
+ replay(key: ActivationKey, from: number, to?: number): AppendedSessionEvent[] {
235
+ assertReplayBounds(from, to);
236
+ const arr = this.#eventsFor(key);
237
+ const end = to === undefined ? arr.length : to;
238
+ return arr.slice(from, end);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The minimal synchronous SQLite handle the durable log needs — structurally the
244
+ * same surface the Urban runtime's DataLayer exposes (`host.openSqlite`), and
245
+ * identical to the presence/transcript stores' `SqliteDb`. Kept local so the log
246
+ * depends on a shape, not on the runtime package.
247
+ */
248
+ export interface SqliteDb {
249
+ exec(sql: string): void;
250
+ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint };
251
+ all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
252
+ }
253
+
254
+ /** A monotonic wall clock, injectable for deterministic tests. */
255
+ export interface Clock {
256
+ now(): number;
257
+ }
258
+
259
+ /** The default clock: `Date.now()`. */
260
+ export const systemClock: Clock = { now: () => Date.now() };
261
+
262
+ interface DbLogRow {
263
+ incarnation: number;
264
+ next_offset: number;
265
+ }
266
+
267
+ interface DbEventRow {
268
+ event_offset: number;
269
+ incarnation: number;
270
+ payload: string;
271
+ }
272
+
273
+ interface DbCheckpointRow {
274
+ checkpoint_id: string;
275
+ checkpoint_offset: number;
276
+ incarnation: number;
277
+ commit_sha: string;
278
+ effect_ledger: string;
279
+ created_at: string;
280
+ }
281
+
282
+ function isRecord(value: unknown): value is Record<string, unknown> {
283
+ return typeof value === "object" && value !== null && !Array.isArray(value);
284
+ }
285
+
286
+ function isEffectLedger(value: unknown): value is SessionCheckpoint["effectLedger"] {
287
+ if (!Array.isArray(value)) return false;
288
+ return value.every((entry) => isRecord(entry) && typeof entry.id === "string" && typeof entry.kind === "string");
289
+ }
290
+
291
+ /**
292
+ * The durable authoritative log over the app DataLayer/SQLite. The fence
293
+ * high-water lives in the activation row's `incarnation` column, so fencing
294
+ * survives a process restart — the durable counterpart of the in-memory
295
+ * {@link IncarnationFence}.
296
+ */
297
+ export class SqliteSessionLog implements SessionLog {
298
+ readonly #db: SqliteDb;
299
+ readonly #clock: Clock;
300
+
301
+ constructor(db: SqliteDb, options: { clock?: Clock } = {}) {
302
+ this.#db = db;
303
+ this.#clock = options.clock ?? systemClock;
304
+ }
305
+
306
+ /** Apply the canonical DDL (idempotent). Identical to the boot migration (drift-guarded). */
307
+ ensureSchema(): void {
308
+ this.#db.exec(SESSION_SCHEMA_SQL);
309
+ }
310
+
311
+ #logRow(key: ActivationKey): DbLogRow | undefined {
312
+ return this.#db.all<DbLogRow>(
313
+ `SELECT incarnation, next_offset FROM ${SESSION_LOG_TABLE} WHERE process_instance_key = ? AND element_id = ?`,
314
+ [key.processInstanceKey, key.elementId],
315
+ )[0];
316
+ }
317
+
318
+ #admit(key: ActivationKey, incarnation: number): void {
319
+ if (!isNonNegInt(incarnation)) {
320
+ throw new RangeError(`incarnation must be a non-negative safe integer, got ${incarnation}`);
321
+ }
322
+ // Insert the activation row if it is missing, or advance its fence high-water when
323
+ // this lease is newer — as one atomic UPSERT. A plain ON CONFLICT DO NOTHING would
324
+ // let a concurrent first-lease race slip through: if another writer created the row
325
+ // between a pre-read and this INSERT, DO NOTHING would neither advance the fence nor
326
+ // reject a stale lease. Re-read and assert afterwards (insert-then-get, mirroring the
327
+ // transcript store) so a lease below the stored high-water is always fenced out.
328
+ this.#db.run(
329
+ `INSERT INTO ${SESSION_LOG_TABLE} (process_instance_key, element_id, incarnation, created_at, next_offset)
330
+ VALUES (?, ?, ?, ?, 0)
331
+ ON CONFLICT(process_instance_key, element_id)
332
+ DO UPDATE SET incarnation = excluded.incarnation
333
+ WHERE excluded.incarnation > ${SESSION_LOG_TABLE}.incarnation`,
334
+ [key.processInstanceKey, key.elementId, incarnation, new Date(this.#clock.now()).toISOString()],
335
+ );
336
+ const row = this.#logRow(key);
337
+ if (row === undefined) {
338
+ throw new Error(`session log row vanished immediately after admit: ${activationKeyString(key)}`);
339
+ }
340
+ if (incarnation < row.incarnation) {
341
+ throw new StaleIncarnationError(key, incarnation, row.incarnation);
342
+ }
343
+ }
344
+
345
+ lease(key: ActivationKey, incarnation: number): void {
346
+ this.#admit(key, incarnation);
347
+ }
348
+
349
+ currentIncarnation(key: ActivationKey): number | undefined {
350
+ return this.#logRow(key)?.incarnation;
351
+ }
352
+
353
+ nextOffset(key: ActivationKey): number {
354
+ return this.#logRow(key)?.next_offset ?? 0;
355
+ }
356
+
357
+ append(
358
+ key: ActivationKey,
359
+ incarnation: number,
360
+ offset: number,
361
+ event: SessionEvent,
362
+ ): AppendedSessionEvent {
363
+ assertOffset(offset);
364
+ this.#admit(key, incarnation);
365
+ const next = this.nextOffset(key);
366
+ if (offset > next) {
367
+ throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${next}`);
368
+ }
369
+ const appended: AppendedSessionEvent = { ...event, offset, incarnation };
370
+ return this.#atomic(() => {
371
+ if (offset < next) {
372
+ // Resuming: drop the now-superseded uncommitted tail before re-keying.
373
+ this.#db.run(
374
+ `DELETE FROM ${SESSION_EVENT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ?`,
375
+ [key.processInstanceKey, key.elementId, offset],
376
+ );
377
+ // Prune checkpoints pinned above the resume boundary: they now point
378
+ // past the rewritten head and would mis-seed a later restore (a gap
379
+ // RangeError on the next emit).
380
+ this.#db.run(
381
+ `DELETE FROM ${SESSION_CHECKPOINT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND checkpoint_offset > ?`,
382
+ [key.processInstanceKey, key.elementId, offset],
383
+ );
384
+ }
385
+ this.#db.run(
386
+ `INSERT INTO ${SESSION_EVENT_TABLE}
387
+ (process_instance_key, element_id, event_offset, incarnation, event_id, parent_id, event_type, payload, appended_at)
388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
389
+ [
390
+ key.processInstanceKey,
391
+ key.elementId,
392
+ offset,
393
+ incarnation,
394
+ event.id,
395
+ event.parentId,
396
+ event.type,
397
+ JSON.stringify(appended),
398
+ new Date(this.#clock.now()).toISOString(),
399
+ ],
400
+ );
401
+ this.#advanceWindow(key, offset);
402
+ return appended;
403
+ });
404
+ }
405
+
406
+ putCheckpoint(key: ActivationKey, incarnation: number, checkpoint: SessionCheckpoint): SessionCheckpoint {
407
+ this.#admit(key, incarnation);
408
+ assertCheckpointIncarnation(incarnation, checkpoint);
409
+ this.#db.run(
410
+ `INSERT INTO ${SESSION_CHECKPOINT_TABLE}
411
+ (process_instance_key, element_id, checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
413
+ ON CONFLICT(process_instance_key, element_id, checkpoint_id) DO NOTHING`,
414
+ [
415
+ key.processInstanceKey,
416
+ key.elementId,
417
+ checkpoint.id,
418
+ checkpoint.offset,
419
+ incarnation,
420
+ checkpoint.commitSha,
421
+ JSON.stringify(checkpoint.effectLedger),
422
+ checkpoint.at,
423
+ ],
424
+ );
425
+ return checkpoint;
426
+ }
427
+
428
+ latestCheckpoint(key: ActivationKey): SessionCheckpoint | undefined {
429
+ const row = this.#db.all<DbCheckpointRow>(
430
+ `SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
431
+ FROM ${SESSION_CHECKPOINT_TABLE}
432
+ WHERE process_instance_key = ? AND element_id = ?
433
+ ORDER BY checkpoint_offset DESC, rowid DESC LIMIT 1`,
434
+ [key.processInstanceKey, key.elementId],
435
+ )[0];
436
+ return row === undefined ? undefined : this.#toCheckpoint(row);
437
+ }
438
+
439
+ getCheckpoint(key: ActivationKey, id: string): SessionCheckpoint | undefined {
440
+ const row = this.#db.all<DbCheckpointRow>(
441
+ `SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
442
+ FROM ${SESSION_CHECKPOINT_TABLE}
443
+ WHERE process_instance_key = ? AND element_id = ? AND checkpoint_id = ?`,
444
+ [key.processInstanceKey, key.elementId, id],
445
+ )[0];
446
+ return row === undefined ? undefined : this.#toCheckpoint(row);
447
+ }
448
+
449
+ #toCheckpoint(row: DbCheckpointRow): SessionCheckpoint {
450
+ const ledgerRaw: unknown = JSON.parse(row.effect_ledger);
451
+ if (!isEffectLedger(ledgerRaw)) {
452
+ throw new SessionLogCorruptionError(
453
+ `checkpoint ${row.checkpoint_id} effect_ledger is not a valid EffectLedger`,
454
+ );
455
+ }
456
+ return {
457
+ id: row.checkpoint_id,
458
+ offset: row.checkpoint_offset,
459
+ commitSha: row.commit_sha,
460
+ effectLedger: ledgerRaw,
461
+ incarnation: row.incarnation,
462
+ at: row.created_at,
463
+ };
464
+ }
465
+
466
+ replay(key: ActivationKey, from: number, to?: number): AppendedSessionEvent[] {
467
+ assertReplayBounds(from, to);
468
+ const upper = to === undefined ? Number.MAX_SAFE_INTEGER : to;
469
+ return this.#db
470
+ .all<DbEventRow>(
471
+ `SELECT event_offset, incarnation, payload FROM ${SESSION_EVENT_TABLE}
472
+ WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ? AND event_offset < ?
473
+ ORDER BY event_offset`,
474
+ [key.processInstanceKey, key.elementId, from, upper],
475
+ )
476
+ .map((row): AppendedSessionEvent => {
477
+ const parsed: unknown = JSON.parse(row.payload);
478
+ const event: SessionEvent = parseSessionEvent(parsed);
479
+ return { ...event, offset: row.event_offset, incarnation: row.incarnation };
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Advance an activation's retained offset window (`first_offset`/`next_offset`,
485
+ * keyed by ActivationKey) to include the offset just appended. `append` always
486
+ * truncates the tail (`event_offset >= offset`) before inserting at `offset`, so
487
+ * the freshly stored offset is necessarily the new maximum (`next_offset =
488
+ * offset + 1`) and the minimum can only move down. Updating incrementally from
489
+ * the appended offset therefore keeps the window exact in O(1) — a full MIN/MAX
490
+ * scan of the event table on every append would be O(n) and make appends O(n²)
491
+ * as the log grows.
492
+ */
493
+ #advanceWindow(key: ActivationKey, offset: number): void {
494
+ this.#db.run(
495
+ `UPDATE ${SESSION_LOG_TABLE}
496
+ SET first_offset = MIN(COALESCE(first_offset, ?), ?), next_offset = ?
497
+ WHERE process_instance_key = ? AND element_id = ?`,
498
+ [offset, offset, offset + 1, key.processInstanceKey, key.elementId],
499
+ );
500
+ }
501
+
502
+ #atomic<T>(body: () => T): T {
503
+ this.#db.exec("SAVEPOINT nano_session_atomic");
504
+ try {
505
+ const result = body();
506
+ this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
507
+ return result;
508
+ } catch (err) {
509
+ this.#db.exec("ROLLBACK TO SAVEPOINT nano_session_atomic");
510
+ this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
511
+ throw err;
512
+ }
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Raised when a row read back from the durable session log holds a value outside
518
+ * its domain (e.g. a corrupt effect-ledger JSON). Fail fast rather than coercing.
519
+ */
520
+ export class SessionLogCorruptionError extends Error {
521
+ constructor(message: string) {
522
+ super(message);
523
+ this.name = "SessionLogCorruptionError";
524
+ }
525
+ }
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { ActivationKey } from "../adapter.ts";
4
+ import { SessionBackend } from "../backend.ts";
5
+ import { InMemorySessionLog } from "../log.ts";
6
+ import { claudeNormalizer } from "./claude.ts";
7
+ import { copilotNormalizer } from "./copilot.ts";
8
+ import { normalizeSession } from "./link.ts";
9
+
10
+ const KEY: ActivationKey = { processInstanceKey: "pik-1", elementId: "implement-task" };
11
+
12
+ function seqIds(prefix: string): () => string {
13
+ let n = 0;
14
+ return () => `${prefix}-${n++}`;
15
+ }
16
+
17
+ /**
18
+ * ADR 0062 slice-3 acceptance, end to end for a reference dialect: a driven
19
+ * SDK/`-p` session emits normalized {@link SessionEvent}s into the slice-1
20
+ * authoritative log; the harness's native restore hands the mind back to a fresh
21
+ * incarnation, which continues the same session.
22
+ */
23
+ test("[@github/copilot] driven session emits normalized events, native resume restores, agent continues", () => {
24
+ // A driven copilot-sdk session's mind stream.
25
+ const firstLeg = normalizeSession(
26
+ copilotNormalizer,
27
+ [
28
+ { type: "user_message", text: "refactor the parser" },
29
+ { type: "reasoning", text: "planning", reasoningOpaque: "CONT-1" },
30
+ { type: "assistant_message", text: "starting" },
31
+ { type: "tool_call", id: "t1", name: "write_file", arguments: { path: "a.ts" } },
32
+ { type: "tool_result", id: "t1", output: "written" },
33
+ ],
34
+ { newId: seqIds("a") },
35
+ );
36
+
37
+ // Incarnation 1 taps the normalized mind into the authoritative log.
38
+ const log = new InMemorySessionLog();
39
+ const b1 = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
40
+ for (const ev of firstLeg) b1.emit(ev);
41
+ const cp = b1.checkpoint("sha-1", []);
42
+ assert.equal(cp.offset, firstLeg.length);
43
+
44
+ // The harness's native restore invocation is resolved by the resume shim
45
+ // (copilot-sdk `resumeSession(id)`), and a fresh incarnation replays the seed.
46
+ const shim = copilotNormalizer.resume("copilot-session-xyz");
47
+ assert.deepEqual(shim, { transport: "sdk", sessionId: "copilot-session-xyz", call: "resumeSession", args: ["copilot-session-xyz"] });
48
+
49
+ const b2 = new SessionBackend(log, KEY, 2, { newCheckpointId: seqIds("cp2") });
50
+ const seed = b2.restore();
51
+ assert.equal(seed.nextOffset, firstLeg.length, "the seed resumes at the checkpoint offset");
52
+ assert.deepEqual(
53
+ seed.events.map((e) => ({ id: e.id, type: e.type })),
54
+ firstLeg.map((e) => ({ id: e.id, type: e.type })),
55
+ "the restored seed is exactly the normalized first-leg mind",
56
+ );
57
+ // The resume-critical reasoning continuation survived the round trip.
58
+ const reasoning = seed.events.find((e) => e.type === "reasoning");
59
+ assert.ok(reasoning && reasoning.type === "reasoning" && reasoning.providerContinuation === "CONT-1");
60
+
61
+ // The resumed agent continues: its new mind is threaded onto the last restored
62
+ // event and appended after the checkpoint offset — one causal session.
63
+ const lastId = seed.events[seed.events.length - 1].id;
64
+ const secondLeg = normalizeSession(
65
+ copilotNormalizer,
66
+ [{ type: "assistant_message", text: "continuing after resume" }],
67
+ { parentId: lastId, newId: seqIds("b") },
68
+ );
69
+ const appended = secondLeg.map((e) => b2.emit(e));
70
+ assert.equal(appended[0].offset, firstLeg.length, "continues appending after the checkpoint");
71
+ assert.equal(appended[0].incarnation, 2, "the continuation is stamped with the new incarnation");
72
+ assert.equal(appended[0].parentId, lastId, "the continuation extends the pre-resume causal chain");
73
+ });
74
+
75
+ test("[claude-code] a driven -p stream-json session restores and continues via --resume", () => {
76
+ const firstLeg = normalizeSession(
77
+ claudeNormalizer,
78
+ [
79
+ { type: "user", message: { content: [{ type: "text", text: "add a test" }] } },
80
+ {
81
+ type: "assistant",
82
+ message: {
83
+ content: [
84
+ { type: "thinking", thinking: "reason", signature: "SIG" },
85
+ { type: "text", text: "on it" },
86
+ ],
87
+ },
88
+ },
89
+ ],
90
+ { newId: seqIds("c") },
91
+ );
92
+ const log = new InMemorySessionLog();
93
+ const b1 = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
94
+ for (const ev of firstLeg) b1.emit(ev);
95
+ b1.checkpoint("sha", []);
96
+
97
+ assert.deepEqual(claudeNormalizer.resume("sess-1"), { transport: "cli", sessionId: "sess-1", args: ["--resume", "sess-1"] });
98
+
99
+ const b2 = new SessionBackend(log, KEY, 2, { newCheckpointId: seqIds("cp2") });
100
+ const seed = b2.restore();
101
+ assert.equal(seed.events.length, firstLeg.length);
102
+ assert.equal(seed.nextOffset, firstLeg.length);
103
+ });
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { claudeNormalizer } from "./claude.ts";
4
+ import { normalizeSession } from "./link.ts";
5
+ import { NormalizerDialectError } from "./types.ts";
6
+
7
+ test("claude expands a multi-part assistant message into ordered canonical events", () => {
8
+ const events = normalizeSession(claudeNormalizer, [
9
+ {
10
+ type: "assistant",
11
+ message: {
12
+ model: "claude",
13
+ content: [
14
+ { type: "thinking", thinking: "let me plan", signature: "SIG-1" },
15
+ { type: "text", text: "here goes" },
16
+ { type: "tool_use", id: "u1", name: "edit", input: { path: "x" } },
17
+ ],
18
+ usage: { input_tokens: 5, output_tokens: 7 },
19
+ },
20
+ },
21
+ ]);
22
+ assert.deepEqual(events.map((e) => e.type), ["reasoning", "assistant", "tool-call", "usage"]);
23
+ const reasoning = events[0];
24
+ if (reasoning.type === "reasoning") assert.equal(reasoning.providerContinuation, "SIG-1");
25
+ const usage = events[3];
26
+ if (usage.type === "usage") assert.equal(usage.model, "claude");
27
+ });
28
+
29
+ test("claude maps a tool_result user frame, honouring is_error", () => {
30
+ const ok = normalizeSession(claudeNormalizer, [
31
+ { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "u1", content: "fine" }] } },
32
+ ]);
33
+ assert.equal(ok[0].type === "tool-result" && ok[0].ok, true);
34
+
35
+ const bad = normalizeSession(claudeNormalizer, [
36
+ { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "u1", content: "nope", is_error: true }] } },
37
+ ]);
38
+ assert.equal(bad[0].type === "tool-result" && bad[0].ok, false);
39
+ });
40
+
41
+ test("claude treats the init system frame as metadata (no canonical event)", () => {
42
+ assert.deepEqual(normalizeSession(claudeNormalizer, [{ type: "system", subtype: "init", tools: [], model: "m" }]), []);
43
+ });
44
+
45
+ test("claude accepts a bare-string message content", () => {
46
+ const events = normalizeSession(claudeNormalizer, [
47
+ { type: "assistant", message: { content: "just text" } },
48
+ ]);
49
+ assert.equal(events[0].type === "assistant" && events[0].text, "just text");
50
+ });
51
+
52
+ test("claude folds a terminal result frame's usage in", () => {
53
+ const events = normalizeSession(claudeNormalizer, [
54
+ { type: "result", subtype: "success", model: "m-2", usage: { input_tokens: 20, output_tokens: 8 } },
55
+ ]);
56
+ assert.equal(events.length, 1);
57
+ const usage = events[0];
58
+ assert.ok(usage.type === "usage");
59
+ if (usage.type === "usage") {
60
+ assert.equal(usage.inputTokens, 20);
61
+ assert.equal(usage.outputTokens, 8);
62
+ assert.equal(usage.model, "m-2");
63
+ }
64
+ });
65
+
66
+ test("claude fails loudly on an assistant frame with no message object", () => {
67
+ assert.throws(() => claudeNormalizer.toDrafts({ type: "assistant" }), NormalizerDialectError);
68
+ });