@nanobpm/agentic 0.1.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.
Files changed (117) hide show
  1. package/README.md +1 -0
  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/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The canonical `SessionEvent` model — ADR 0062, slice 1 (the shared contract).
3
+ *
4
+ * This is **Nano's own** agent-session event model: the single schema every
5
+ * harness dialect (ACP, stream-json, a native normalizer, …) normalizes *into*.
6
+ * We never adopt an external harness schema as ours — those are ingestion
7
+ * details owned by the later slices; this union is the stable interface they all
8
+ * target.
9
+ *
10
+ * ## The causal chain
11
+ *
12
+ * A session is an append-only log of events. Two orthogonal orderings make the
13
+ * log both replayable and mergeable:
14
+ *
15
+ * - a **monotonic, gap-free `offset`** assigned by the authoritative log on
16
+ * append (see {@link AppendedSessionEvent}); it is the resume coordinate —
17
+ * `restore` hands back everything up to a checkpoint offset.
18
+ * - a **causal `parentId`** the producer stamps: the id of the event this one
19
+ * logically follows (`null` for the first event of a session). Offset gives a
20
+ * total order for replay; `parentId` records the *causal* edge, which survives
21
+ * a compaction that rewrites offsets.
22
+ *
23
+ * The producer owns identity (`id`) and causality (`parentId`); the log owns
24
+ * ordering (`offset`) and fencing (`incarnation`). Keeping those responsibilities
25
+ * split is what lets a resumed incarnation continue the same causal chain at a
26
+ * fresh offset without the producer knowing the log's internal cursor.
27
+ */
28
+
29
+ /** Discriminates a {@link SessionEvent}. One member per row in the union below. */
30
+ export type SessionEventType =
31
+ | "system"
32
+ | "user"
33
+ | "assistant"
34
+ | "reasoning"
35
+ | "tool-call"
36
+ | "tool-result"
37
+ | "compaction"
38
+ | "usage"
39
+ | "turn-start"
40
+ | "turn-end";
41
+
42
+ /** The set of valid event types, for a runtime membership check at the DB boundary. */
43
+ export const SESSION_EVENT_TYPES: readonly SessionEventType[] = [
44
+ "system",
45
+ "user",
46
+ "assistant",
47
+ "reasoning",
48
+ "tool-call",
49
+ "tool-result",
50
+ "compaction",
51
+ "usage",
52
+ "turn-start",
53
+ "turn-end",
54
+ ];
55
+
56
+ /**
57
+ * The fields every event carries regardless of type. `offset` is deliberately
58
+ * absent — the producer does not assign it; the authoritative log does, yielding
59
+ * an {@link AppendedSessionEvent}.
60
+ */
61
+ export interface SessionEventEnvelope {
62
+ /** Producer-assigned unique id for this event (the causal-chain node id). */
63
+ readonly id: string;
64
+ /** The id of the causal predecessor, or `null` for the first event of a session. */
65
+ readonly parentId: string | null;
66
+ }
67
+
68
+ /** A system/instruction message (the harness/system prompt turn). */
69
+ export interface SystemMessageEvent extends SessionEventEnvelope {
70
+ readonly type: "system";
71
+ readonly text: string;
72
+ }
73
+
74
+ /** A user message. */
75
+ export interface UserMessageEvent extends SessionEventEnvelope {
76
+ readonly type: "user";
77
+ readonly text: string;
78
+ }
79
+
80
+ /** An assistant (model) message — the visible answer text. */
81
+ export interface AssistantMessageEvent extends SessionEventEnvelope {
82
+ readonly type: "assistant";
83
+ readonly text: string;
84
+ }
85
+
86
+ /**
87
+ * Assistant reasoning (chain-of-thought / thinking) for a turn.
88
+ *
89
+ * `text` is the human-readable reasoning summary when the provider exposes one.
90
+ * `providerContinuation` is an **opaque provider reasoning-continuation blob**:
91
+ * some providers (e.g. encrypted reasoning tokens) return a handle that must be
92
+ * fed back verbatim to continue reasoning across a resume. Nano never parses,
93
+ * validates, or transforms it — it stores and replays it as an opaque string so
94
+ * a re-leased incarnation can resume the model's reasoning exactly.
95
+ */
96
+ export interface ReasoningEvent extends SessionEventEnvelope {
97
+ readonly type: "reasoning";
98
+ readonly text?: string;
99
+ readonly providerContinuation?: string;
100
+ }
101
+
102
+ /** A tool/function call the assistant requested. */
103
+ export interface ToolCallEvent extends SessionEventEnvelope {
104
+ readonly type: "tool-call";
105
+ /** Correlates this call with its {@link ToolResultEvent}. */
106
+ readonly callId: string;
107
+ readonly name: string;
108
+ /** The call arguments, as an opaque JSON-serialisable value. */
109
+ readonly args: unknown;
110
+ }
111
+
112
+ /** The result of a previously-emitted {@link ToolCallEvent}. */
113
+ export interface ToolResultEvent extends SessionEventEnvelope {
114
+ readonly type: "tool-result";
115
+ /** Matches the originating {@link ToolCallEvent.callId}. */
116
+ readonly callId: string;
117
+ /** `false` when the tool failed; the failure detail lives in `result`. */
118
+ readonly ok: boolean;
119
+ /** The tool output, as an opaque JSON-serialisable value. */
120
+ readonly result: unknown;
121
+ }
122
+
123
+ /**
124
+ * A compaction or truncation boundary: the events in the (inclusive-exclusive)
125
+ * offset range `[replacesFrom, replacesTo)` were summarised/dropped to bound
126
+ * context growth. `summary` is the replacement text (present for compaction,
127
+ * typically absent for a hard truncation). The original events keep their
128
+ * offsets in the authoritative log; this marker records that a *replay* should
129
+ * fold that range into the summary rather than replaying it verbatim.
130
+ */
131
+ export interface CompactionEvent extends SessionEventEnvelope {
132
+ readonly type: "compaction";
133
+ readonly reason: "compaction" | "truncation";
134
+ readonly replacesFrom: number;
135
+ readonly replacesTo: number;
136
+ readonly summary?: string;
137
+ }
138
+
139
+ /** A usage/accounting record for a turn (token counts, etc.). */
140
+ export interface UsageEvent extends SessionEventEnvelope {
141
+ readonly type: "usage";
142
+ readonly inputTokens: number;
143
+ readonly outputTokens: number;
144
+ /** Optional provider model identifier the usage is attributed to. */
145
+ readonly model?: string;
146
+ }
147
+
148
+ /** The start of a turn (a request/response cycle). `turn` is a monotonic index. */
149
+ export interface TurnStartEvent extends SessionEventEnvelope {
150
+ readonly type: "turn-start";
151
+ readonly turn: number;
152
+ }
153
+
154
+ /** The end of a turn matching a prior {@link TurnStartEvent}. */
155
+ export interface TurnEndEvent extends SessionEventEnvelope {
156
+ readonly type: "turn-end";
157
+ readonly turn: number;
158
+ }
159
+
160
+ /**
161
+ * The canonical session event — a discriminated union over {@link SessionEventType}.
162
+ * Every harness dialect normalises into exactly this shape.
163
+ */
164
+ export type SessionEvent =
165
+ | SystemMessageEvent
166
+ | UserMessageEvent
167
+ | AssistantMessageEvent
168
+ | ReasoningEvent
169
+ | ToolCallEvent
170
+ | ToolResultEvent
171
+ | CompactionEvent
172
+ | UsageEvent
173
+ | TurnStartEvent
174
+ | TurnEndEvent;
175
+
176
+ /**
177
+ * A {@link SessionEvent} after the authoritative log has appended it: the same
178
+ * event plus the log-assigned `offset` (its monotonic resume coordinate) and the
179
+ * `incarnation` (the generation of the writer that produced it — the fencing
180
+ * stamp). This is what {@link restore} replays as the mind seed.
181
+ */
182
+ export type AppendedSessionEvent = SessionEvent & {
183
+ readonly offset: number;
184
+ readonly incarnation: number;
185
+ };
186
+
187
+ /** Raised when a value read back from storage is not a well-formed session event. */
188
+ export class SessionEventShapeError extends Error {
189
+ constructor(message: string) {
190
+ super(message);
191
+ this.name = "SessionEventShapeError";
192
+ }
193
+ }
194
+
195
+ function isRecord(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }
198
+
199
+ function reqString(obj: Record<string, unknown>, field: string): string {
200
+ const v = obj[field];
201
+ if (typeof v !== "string") {
202
+ throw new SessionEventShapeError(`session event field "${field}" must be a string, got ${typeof v}`);
203
+ }
204
+ return v;
205
+ }
206
+
207
+ function optString(obj: Record<string, unknown>, field: string): string | undefined {
208
+ const v = obj[field];
209
+ if (v === undefined) return undefined;
210
+ if (typeof v !== "string") {
211
+ throw new SessionEventShapeError(`session event field "${field}" must be a string when present, got ${typeof v}`);
212
+ }
213
+ return v;
214
+ }
215
+
216
+ function reqNonNegInt(obj: Record<string, unknown>, field: string): number {
217
+ const v = obj[field];
218
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0) {
219
+ throw new SessionEventShapeError(
220
+ `session event field "${field}" must be a non-negative safe integer, got ${String(v)}`,
221
+ );
222
+ }
223
+ return v;
224
+ }
225
+
226
+ function reqBool(obj: Record<string, unknown>, field: string): boolean {
227
+ const v = obj[field];
228
+ if (typeof v !== "boolean") {
229
+ throw new SessionEventShapeError(`session event field "${field}" must be a boolean, got ${typeof v}`);
230
+ }
231
+ return v;
232
+ }
233
+
234
+ function parentId(obj: Record<string, unknown>): string | null {
235
+ const v = obj.parentId;
236
+ if (v === null) return null;
237
+ if (typeof v === "string") return v;
238
+ throw new SessionEventShapeError(`session event "parentId" must be a string or null, got ${typeof v}`);
239
+ }
240
+
241
+ /**
242
+ * Coerce a required opaque payload (a tool-call `args` / tool-result `result`) to
243
+ * a JSON-serialisable value. These fields are documented as opaque *JSON-
244
+ * serialisable* values, but a dialect can legitimately omit them (e.g. a tool
245
+ * call with no arguments surfaces as `obj.arguments ?? obj.args === undefined`).
246
+ * `undefined` is not JSON-serialisable — `JSON.stringify` drops the key — so an
247
+ * un-normalised `undefined` would persist an event that no longer round-trips to
248
+ * the same shape on replay. We normalise the absence to the canonical JSON "no
249
+ * value" (`null`) here, at the single boundary every dialect flows through, so
250
+ * they all get the same replay-stable guarantee (derivation over duplication).
251
+ */
252
+ function opaquePayload(value: unknown): unknown {
253
+ return value === undefined ? null : value;
254
+ }
255
+
256
+ /**
257
+ * Parse and validate an untyped value (e.g. `JSON.parse` of a stored row) into a
258
+ * {@link SessionEvent}, reconstructing the exact union member for its `type`.
259
+ * Throws {@link SessionEventShapeError} on any malformed field. This is the
260
+ * single trusted boundary between untyped storage and the typed union — it never
261
+ * uses an `as`-cast to fabricate a shape (see AGENTS.md), it *builds* one field
262
+ * by field, so a corrupt row fails loudly instead of masquerading as valid.
263
+ */
264
+ export function parseSessionEvent(value: unknown): SessionEvent {
265
+ if (!isRecord(value)) {
266
+ throw new SessionEventShapeError(`session event must be an object, got ${typeof value}`);
267
+ }
268
+ const id = reqString(value, "id");
269
+ const parent = parentId(value);
270
+ const type = value.type;
271
+ switch (type) {
272
+ case "system":
273
+ return { type, id, parentId: parent, text: reqString(value, "text") };
274
+ case "user":
275
+ return { type, id, parentId: parent, text: reqString(value, "text") };
276
+ case "assistant":
277
+ return { type, id, parentId: parent, text: reqString(value, "text") };
278
+ case "reasoning": {
279
+ const event: ReasoningEvent = { type, id, parentId: parent };
280
+ const text = optString(value, "text");
281
+ const cont = optString(value, "providerContinuation");
282
+ return {
283
+ ...event,
284
+ ...(text !== undefined ? { text } : {}),
285
+ ...(cont !== undefined ? { providerContinuation: cont } : {}),
286
+ };
287
+ }
288
+ case "tool-call":
289
+ return {
290
+ type,
291
+ id,
292
+ parentId: parent,
293
+ callId: reqString(value, "callId"),
294
+ name: reqString(value, "name"),
295
+ args: opaquePayload(value.args),
296
+ };
297
+ case "tool-result":
298
+ return {
299
+ type,
300
+ id,
301
+ parentId: parent,
302
+ callId: reqString(value, "callId"),
303
+ ok: reqBool(value, "ok"),
304
+ result: opaquePayload(value.result),
305
+ };
306
+ case "compaction": {
307
+ const reason = value.reason;
308
+ if (reason !== "compaction" && reason !== "truncation") {
309
+ throw new SessionEventShapeError(`compaction "reason" must be "compaction" or "truncation", got ${String(reason)}`);
310
+ }
311
+ const summary = optString(value, "summary");
312
+ const replacesFrom = reqNonNegInt(value, "replacesFrom");
313
+ const replacesTo = reqNonNegInt(value, "replacesTo");
314
+ if (replacesTo < replacesFrom) {
315
+ throw new SessionEventShapeError(
316
+ `compaction "replacesTo" (${replacesTo}) must be >= "replacesFrom" (${replacesFrom})`,
317
+ );
318
+ }
319
+ return {
320
+ type,
321
+ id,
322
+ parentId: parent,
323
+ reason,
324
+ replacesFrom,
325
+ replacesTo,
326
+ ...(summary !== undefined ? { summary } : {}),
327
+ };
328
+ }
329
+ case "usage": {
330
+ const model = optString(value, "model");
331
+ return {
332
+ type,
333
+ id,
334
+ parentId: parent,
335
+ inputTokens: reqNonNegInt(value, "inputTokens"),
336
+ outputTokens: reqNonNegInt(value, "outputTokens"),
337
+ ...(model !== undefined ? { model } : {}),
338
+ };
339
+ }
340
+ case "turn-start":
341
+ return { type, id, parentId: parent, turn: reqNonNegInt(value, "turn") };
342
+ case "turn-end":
343
+ return { type, id, parentId: parent, turn: reqNonNegInt(value, "turn") };
344
+ default:
345
+ throw new SessionEventShapeError(`unknown session event type: ${JSON.stringify(type)}`);
346
+ }
347
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `@nanobpm/agentic/session` — the canonical agent-session contract (ADR 0062,
3
+ * slice 1). The foundation the two ingestion backends (ACP, normalisers) and the
4
+ * nano-workforce world-restore all code against in parallel.
5
+ *
6
+ * Exports, in the order a consumer meets them:
7
+ * - the canonical {@link SessionEvent} union every harness dialect normalises
8
+ * into, plus {@link parseSessionEvent} (the untyped-storage boundary);
9
+ * - the three-method {@link SessionAdapter} interface (emit/checkpoint/restore)
10
+ * with its {@link ActivationKey}, {@link SessionCheckpoint} and
11
+ * {@link SessionSeed} types;
12
+ * - the {@link SessionLog} port and its two backends — the in-memory reference
13
+ * ({@link InMemorySessionLog}, the stub) and the durable, authoritative
14
+ * {@link SqliteSessionLog} that promotes the ADR 0056 §12 relay ring + fence;
15
+ * - {@link SessionBackend} (the one adapter implementation) and the
16
+ * {@link openInMemorySession} / {@link openSqliteSession} factories.
17
+ *
18
+ * The new DB schema ships as the forward-only migration
19
+ * `db/migrations/005_agentic_session.sql`, mirrored by {@link SESSION_SCHEMA_SQL}
20
+ * and kept in lockstep by a drift-guard test. Nothing here rides the Camunda-8
21
+ * engine — the log is app-tier (ADR 0056 boundary preserved).
22
+ */
23
+ export type {
24
+ AppendedSessionEvent,
25
+ AssistantMessageEvent,
26
+ CompactionEvent,
27
+ ReasoningEvent,
28
+ SessionEvent,
29
+ SessionEventEnvelope,
30
+ SessionEventType,
31
+ SystemMessageEvent,
32
+ ToolCallEvent,
33
+ ToolResultEvent,
34
+ TurnEndEvent,
35
+ TurnStartEvent,
36
+ UsageEvent,
37
+ UserMessageEvent,
38
+ } from "./events.ts";
39
+ export { parseSessionEvent, SESSION_EVENT_TYPES, SessionEventShapeError } from "./events.ts";
40
+
41
+ export type {
42
+ ActivationKey,
43
+ EffectEntry,
44
+ EffectLedger,
45
+ SessionAdapter,
46
+ SessionCheckpoint,
47
+ SessionSeed,
48
+ } from "./adapter.ts";
49
+ export { activationKeyString, StaleIncarnationError } from "./adapter.ts";
50
+
51
+ export type { Clock, SessionLog, SqliteDb } from "./log.ts";
52
+ export {
53
+ InMemorySessionLog,
54
+ SessionLogCorruptionError,
55
+ SqliteSessionLog,
56
+ systemClock,
57
+ } from "./log.ts";
58
+
59
+ export type { SessionBackendOptions } from "./backend.ts";
60
+ export { openInMemorySession, openSqliteSession, SessionBackend } from "./backend.ts";
61
+
62
+ export {
63
+ SESSION_CHECKPOINT_TABLE,
64
+ SESSION_EVENT_TABLE,
65
+ SESSION_LOG_TABLE,
66
+ SESSION_SCHEMA_SQL,
67
+ } from "./schema.ts";
@@ -0,0 +1,215 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { TestContext } from "node:test";
4
+ import type { ActivationKey } from "./adapter.ts";
5
+ import type { SessionEvent } from "./events.ts";
6
+ import { StaleIncarnationError } from "./adapter.ts";
7
+ import type { SessionCheckpoint } from "./adapter.ts";
8
+ import { InMemorySessionLog, SessionLogCorruptionError, type SqliteDb, SqliteSessionLog } from "./log.ts";
9
+ import { SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE } from "./schema.ts";
10
+ import { openTestDb } from "./test-db.ts";
11
+
12
+ const KEY: ActivationKey = { processInstanceKey: "pik", elementId: "el" };
13
+
14
+ function ev(id: string, offset: number): SessionEvent {
15
+ return { type: "user", id, parentId: offset === 0 ? null : `e${offset - 1}`, text: `t${offset}` };
16
+ }
17
+
18
+ function checkpoint(overrides: Partial<SessionCheckpoint> = {}): SessionCheckpoint {
19
+ return {
20
+ id: "c0",
21
+ offset: 0,
22
+ commitSha: "sha",
23
+ effectLedger: [],
24
+ incarnation: 1,
25
+ at: new Date(0).toISOString(),
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ test("append rejects a gap (offset beyond next)", () => {
31
+ const log = new InMemorySessionLog();
32
+ log.lease(KEY, 1);
33
+ assert.throws(() => log.append(KEY, 1, 1, ev("e", 1)), RangeError);
34
+ });
35
+
36
+ test("replay honours from/to bounds", () => {
37
+ const log = new InMemorySessionLog();
38
+ log.lease(KEY, 1);
39
+ for (let i = 0; i < 5; i++) log.append(KEY, 1, i, ev(`e${i}`, i));
40
+ assert.deepEqual(log.replay(KEY, 1, 3).map((e) => e.offset), [1, 2]);
41
+ assert.deepEqual(log.replay(KEY, 3).map((e) => e.offset), [3, 4]);
42
+ assert.deepEqual(log.replay(KEY, 0).length, 5);
43
+ });
44
+
45
+ for (const make of [
46
+ { name: "in-memory", open: (_t: TestContext) => new InMemorySessionLog() },
47
+ {
48
+ name: "sqlite",
49
+ open: (t: TestContext) => {
50
+ const log = new SqliteSessionLog(openTestDb(t));
51
+ log.ensureSchema();
52
+ return log;
53
+ },
54
+ },
55
+ ]) {
56
+ test(`[${make.name}] replay rejects an invalid 'to' bound just like 'from'`, (t) => {
57
+ const log = make.open(t);
58
+ log.lease(KEY, 1);
59
+ for (let i = 0; i < 3; i++) log.append(KEY, 1, i, ev(`e${i}`, i));
60
+ assert.throws(() => log.replay(KEY, 0, -1), RangeError, "negative to");
61
+ assert.throws(() => log.replay(KEY, 0, 1.5), RangeError, "non-integer to");
62
+ assert.throws(() => log.replay(KEY, 2, 1), RangeError, "to < from");
63
+ });
64
+ }
65
+
66
+ test("latestCheckpoint returns the highest-offset checkpoint", () => {
67
+ const log = new InMemorySessionLog();
68
+ log.lease(KEY, 1);
69
+ const at = new Date(0).toISOString();
70
+ log.putCheckpoint(KEY, 1, { id: "c0", offset: 1, commitSha: "s0", effectLedger: [], incarnation: 1, at });
71
+ log.putCheckpoint(KEY, 1, { id: "c1", offset: 4, commitSha: "s1", effectLedger: [], incarnation: 1, at });
72
+ log.putCheckpoint(KEY, 1, { id: "c2", offset: 2, commitSha: "s2", effectLedger: [], incarnation: 1, at });
73
+ assert.equal(log.latestCheckpoint(KEY)?.id, "c1");
74
+ assert.equal(log.getCheckpoint(KEY, "c2")?.offset, 2);
75
+ });
76
+
77
+ test("[sqlite] the offset window (first/next) tracks stored events", (t) => {
78
+ const db = openTestDb(t);
79
+ const log = new SqliteSessionLog(db);
80
+ log.ensureSchema();
81
+ log.lease(KEY, 1);
82
+ assert.equal(log.nextOffset(KEY), 0);
83
+ log.append(KEY, 1, 0, ev("e0", 0));
84
+ log.append(KEY, 1, 1, ev("e1", 1));
85
+ assert.equal(log.nextOffset(KEY), 2);
86
+ const row = db.all<{ first_offset: number; next_offset: number }>(
87
+ `SELECT first_offset, next_offset FROM agentic_session_log WHERE process_instance_key = ? AND element_id = ?`,
88
+ [KEY.processInstanceKey, KEY.elementId],
89
+ )[0];
90
+ assert.equal(row?.first_offset, 0);
91
+ assert.equal(row?.next_offset, 2);
92
+ });
93
+
94
+ test("[sqlite] a durable checkpoint round-trips its effect ledger", (t) => {
95
+ const db = openTestDb(t);
96
+ const log = new SqliteSessionLog(db);
97
+ log.ensureSchema();
98
+ log.lease(KEY, 1);
99
+ const ledger = [{ id: "e1", kind: "push", detail: { sha: "abc" } }];
100
+ log.putCheckpoint(KEY, 1, {
101
+ id: "c0",
102
+ offset: 0,
103
+ commitSha: "sha",
104
+ effectLedger: ledger,
105
+ incarnation: 1,
106
+ at: new Date(0).toISOString(),
107
+ });
108
+ assert.deepEqual(log.getCheckpoint(KEY, "c0")?.effectLedger, ledger);
109
+ });
110
+
111
+ test("[sqlite] a corrupt effect ledger fails fast on read", (t) => {
112
+ const db = openTestDb(t);
113
+ const log = new SqliteSessionLog(db);
114
+ log.ensureSchema();
115
+ log.lease(KEY, 1);
116
+ db.run(
117
+ `INSERT INTO ${SESSION_CHECKPOINT_TABLE}
118
+ (process_instance_key, element_id, checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
120
+ [KEY.processInstanceKey, KEY.elementId, "bad", 0, 1, "sha", '{"not":"an-array"}', new Date(0).toISOString()],
121
+ );
122
+ assert.throws(() => log.getCheckpoint(KEY, "bad"), SessionLogCorruptionError);
123
+ });
124
+
125
+ test("[sqlite] resuming into the log deletes the superseded tail rows", (t) => {
126
+ const db = openTestDb(t);
127
+ const log = new SqliteSessionLog(db);
128
+ log.ensureSchema();
129
+ log.lease(KEY, 1);
130
+ for (let i = 0; i < 4; i++) log.append(KEY, 1, i, ev(`e${i}`, i));
131
+ // Resume: rewrite from offset 2 under a newer incarnation.
132
+ log.lease(KEY, 2);
133
+ log.append(KEY, 2, 2, ev("e2b", 2));
134
+ const rows = db.all<{ n: number }>(
135
+ `SELECT COUNT(*) AS n FROM ${SESSION_EVENT_TABLE} WHERE process_instance_key = ? AND element_id = ?`,
136
+ [KEY.processInstanceKey, KEY.elementId],
137
+ )[0];
138
+ assert.equal(rows?.n, 3, "offsets 0,1,2 remain; the old 2 and 3 were dropped");
139
+ assert.equal(log.nextOffset(KEY), 3);
140
+ });
141
+
142
+ test("[sqlite] the offset window stays exact across a rewinding resume", (t) => {
143
+ const db = openTestDb(t);
144
+ const log = new SqliteSessionLog(db);
145
+ log.ensureSchema();
146
+ const window = () => {
147
+ const row = db.all<{ first_offset: number | null; next_offset: number }>(
148
+ `SELECT first_offset, next_offset FROM ${SESSION_LOG_TABLE} WHERE process_instance_key = ? AND element_id = ?`,
149
+ [KEY.processInstanceKey, KEY.elementId],
150
+ )[0];
151
+ return { first_offset: row?.first_offset ?? null, next_offset: row?.next_offset ?? null };
152
+ };
153
+ log.lease(KEY, 1);
154
+ for (let i = 0; i < 4; i++) log.append(KEY, 1, i, ev(`e${i}`, i));
155
+ assert.deepEqual(window(), { first_offset: 0, next_offset: 4 }, "grows to [0,4)");
156
+ // Resume above the floor: the tail is rewritten but the min is untouched.
157
+ log.lease(KEY, 2);
158
+ log.append(KEY, 2, 2, ev("e2b", 2));
159
+ assert.deepEqual(window(), { first_offset: 0, next_offset: 3 }, "min 0 kept; next follows the new tail");
160
+ // Resume at the floor: every prior event is dropped, so the min rewinds too.
161
+ log.lease(KEY, 3);
162
+ log.append(KEY, 3, 0, ev("e0b", 0));
163
+ assert.deepEqual(window(), { first_offset: 0, next_offset: 1 }, "min rewinds to the new sole event");
164
+ });
165
+
166
+ test("[sqlite] a concurrent first-lease race fences out the stale writer", (t) => {
167
+ // Simulate the TOCTOU window: a competing writer commits the activation row at a
168
+ // higher incarnation just before our INSERT lands. A plain ON CONFLICT DO NOTHING
169
+ // would let the stale lease proceed unfenced; #admit must reject it instead.
170
+ const base = openTestDb(t);
171
+ new SqliteSessionLog(base).ensureSchema();
172
+ const winner = new SqliteSessionLog(base);
173
+ let raced = false;
174
+ const racingDb: SqliteDb = {
175
+ exec: (sql) => base.exec(sql),
176
+ all: (sql, params) => base.all(sql, params),
177
+ run: (sql, params) => {
178
+ if (!raced && /^\s*INSERT\s+INTO\s+agentic_session_log\b/i.test(sql)) {
179
+ raced = true;
180
+ winner.lease(KEY, 5); // a newer incarnation grabs the lease first
181
+ }
182
+ return base.run(sql, params);
183
+ },
184
+ };
185
+ const stale = new SqliteSessionLog(racingDb);
186
+ assert.throws(() => stale.lease(KEY, 3), StaleIncarnationError);
187
+ assert.equal(new SqliteSessionLog(base).currentIncarnation(KEY), 5);
188
+ });
189
+
190
+ for (const make of [
191
+ { name: "in-memory", open: (_t: TestContext) => new InMemorySessionLog() },
192
+ {
193
+ name: "sqlite",
194
+ open: (t: TestContext) => {
195
+ const log = new SqliteSessionLog(openTestDb(t));
196
+ log.ensureSchema();
197
+ return log;
198
+ },
199
+ },
200
+ ]) {
201
+ test(`[${make.name}] putCheckpoint rejects a checkpoint whose incarnation differs from the lease`, (t) => {
202
+ const log = make.open(t);
203
+ log.lease(KEY, 2);
204
+ assert.throws(() => log.putCheckpoint(KEY, 2, checkpoint({ incarnation: 1 })), RangeError);
205
+ });
206
+
207
+ test(`[${make.name}] putCheckpoint is idempotent on checkpoint id (first-wins)`, (t) => {
208
+ const log = make.open(t);
209
+ log.lease(KEY, 1);
210
+ log.putCheckpoint(KEY, 1, checkpoint({ id: "c0", offset: 1 }));
211
+ log.putCheckpoint(KEY, 1, checkpoint({ id: "c0", offset: 2 }));
212
+ assert.equal(log.getCheckpoint(KEY, "c0")?.offset, 1, "the first write wins; a retry never duplicates");
213
+ assert.equal(log.latestCheckpoint(KEY)?.offset, 1);
214
+ });
215
+ }