@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.
- package/README.md +1 -0
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,351 @@
|
|
|
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.js";
|
|
22
|
+
import { activationKeyString, StaleIncarnationError, } from "./adapter.js";
|
|
23
|
+
import { parseSessionEvent } from "./events.js";
|
|
24
|
+
import { SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE, SESSION_SCHEMA_SQL, } from "./schema.js";
|
|
25
|
+
function isNonNegInt(value) {
|
|
26
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
27
|
+
}
|
|
28
|
+
function assertOffset(offset) {
|
|
29
|
+
if (!isNonNegInt(offset)) {
|
|
30
|
+
throw new RangeError(`session log offset must be a non-negative safe integer, got ${offset}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate a `replay(from, to)` window: `from` is a normal offset, and when a
|
|
35
|
+
* bounded `to` is given it must itself be a valid offset that is not below
|
|
36
|
+
* `from` — so an out-of-range or inverted bound fails fast instead of silently
|
|
37
|
+
* yielding a surprising `Array.slice`/SQL range.
|
|
38
|
+
*/
|
|
39
|
+
function assertReplayBounds(from, to) {
|
|
40
|
+
assertOffset(from);
|
|
41
|
+
if (to === undefined)
|
|
42
|
+
return;
|
|
43
|
+
if (!isNonNegInt(to)) {
|
|
44
|
+
throw new RangeError(`session log replay 'to' must be a non-negative safe integer, got ${to}`);
|
|
45
|
+
}
|
|
46
|
+
if (to < from) {
|
|
47
|
+
throw new RangeError(`session log replay 'to' (${to}) must be >= 'from' (${from})`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A checkpoint's own {@link SessionCheckpoint.incarnation} must equal the fence
|
|
52
|
+
* token it is written under — otherwise the row would be fenced with one
|
|
53
|
+
* incarnation but stamped with another, producing inconsistent durable data.
|
|
54
|
+
*/
|
|
55
|
+
function assertCheckpointIncarnation(incarnation, checkpoint) {
|
|
56
|
+
if (checkpoint.incarnation !== incarnation) {
|
|
57
|
+
throw new RangeError(`checkpoint ${checkpoint.id} incarnation (${checkpoint.incarnation}) must equal the write lease incarnation (${incarnation})`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The in-memory reference backend (the stub slices 2–5 code against and the tests
|
|
62
|
+
* exercise). Reuses the relay {@link IncarnationFence} verbatim and keeps each
|
|
63
|
+
* activation's full event array — the authoritative, non-evicting analogue of the
|
|
64
|
+
* relay ring's resume window.
|
|
65
|
+
*/
|
|
66
|
+
export class InMemorySessionLog {
|
|
67
|
+
#fence = new IncarnationFence();
|
|
68
|
+
#events = new Map();
|
|
69
|
+
#checkpoints = new Map();
|
|
70
|
+
#eventsFor(key) {
|
|
71
|
+
const s = activationKeyString(key);
|
|
72
|
+
let arr = this.#events.get(s);
|
|
73
|
+
if (arr === undefined) {
|
|
74
|
+
arr = [];
|
|
75
|
+
this.#events.set(s, arr);
|
|
76
|
+
}
|
|
77
|
+
return arr;
|
|
78
|
+
}
|
|
79
|
+
#checkpointsFor(key) {
|
|
80
|
+
const s = activationKeyString(key);
|
|
81
|
+
let arr = this.#checkpoints.get(s);
|
|
82
|
+
if (arr === undefined) {
|
|
83
|
+
arr = [];
|
|
84
|
+
this.#checkpoints.set(s, arr);
|
|
85
|
+
}
|
|
86
|
+
return arr;
|
|
87
|
+
}
|
|
88
|
+
#admit(key, incarnation) {
|
|
89
|
+
const s = activationKeyString(key);
|
|
90
|
+
if (!this.#fence.admit(s, incarnation)) {
|
|
91
|
+
throw new StaleIncarnationError(key, incarnation, this.#fence.current(s) ?? incarnation);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
lease(key, incarnation) {
|
|
95
|
+
this.#admit(key, incarnation);
|
|
96
|
+
}
|
|
97
|
+
currentIncarnation(key) {
|
|
98
|
+
return this.#fence.current(activationKeyString(key));
|
|
99
|
+
}
|
|
100
|
+
nextOffset(key) {
|
|
101
|
+
return this.#eventsFor(key).length;
|
|
102
|
+
}
|
|
103
|
+
append(key, incarnation, offset, event) {
|
|
104
|
+
assertOffset(offset);
|
|
105
|
+
this.#admit(key, incarnation);
|
|
106
|
+
const arr = this.#eventsFor(key);
|
|
107
|
+
if (offset > arr.length) {
|
|
108
|
+
throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${arr.length}`);
|
|
109
|
+
}
|
|
110
|
+
if (offset < arr.length) {
|
|
111
|
+
// Resuming: drop the now-superseded uncommitted tail before re-keying, and
|
|
112
|
+
// prune any checkpoint pinned above the resume boundary — it now points
|
|
113
|
+
// past the rewritten head and would mis-seed a later restore.
|
|
114
|
+
arr.length = offset;
|
|
115
|
+
this.#pruneCheckpointsAbove(key, offset);
|
|
116
|
+
}
|
|
117
|
+
const appended = { ...event, offset, incarnation };
|
|
118
|
+
arr.push(appended);
|
|
119
|
+
return appended;
|
|
120
|
+
}
|
|
121
|
+
/** Drop checkpoints whose offset sits above `offset` (past a rewritten head). */
|
|
122
|
+
#pruneCheckpointsAbove(key, offset) {
|
|
123
|
+
const arr = this.#checkpointsFor(key);
|
|
124
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
125
|
+
if (arr[i].offset > offset)
|
|
126
|
+
arr.splice(i, 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
putCheckpoint(key, incarnation, checkpoint) {
|
|
130
|
+
this.#admit(key, incarnation);
|
|
131
|
+
assertCheckpointIncarnation(incarnation, checkpoint);
|
|
132
|
+
const arr = this.#checkpointsFor(key);
|
|
133
|
+
// First-wins on checkpoint.id — mirrors the durable backend's
|
|
134
|
+
// ON CONFLICT(checkpoint_id) DO NOTHING so a retry never duplicates.
|
|
135
|
+
if (arr.some((cp) => cp.id === checkpoint.id))
|
|
136
|
+
return checkpoint;
|
|
137
|
+
arr.push(checkpoint);
|
|
138
|
+
return checkpoint;
|
|
139
|
+
}
|
|
140
|
+
latestCheckpoint(key) {
|
|
141
|
+
let best;
|
|
142
|
+
for (const cp of this.#checkpointsFor(key)) {
|
|
143
|
+
// Highest offset wins; a later insert at the same offset supersedes.
|
|
144
|
+
if (best === undefined || cp.offset >= best.offset)
|
|
145
|
+
best = cp;
|
|
146
|
+
}
|
|
147
|
+
return best;
|
|
148
|
+
}
|
|
149
|
+
getCheckpoint(key, id) {
|
|
150
|
+
return this.#checkpointsFor(key).find((cp) => cp.id === id);
|
|
151
|
+
}
|
|
152
|
+
replay(key, from, to) {
|
|
153
|
+
assertReplayBounds(from, to);
|
|
154
|
+
const arr = this.#eventsFor(key);
|
|
155
|
+
const end = to === undefined ? arr.length : to;
|
|
156
|
+
return arr.slice(from, end);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** The default clock: `Date.now()`. */
|
|
160
|
+
export const systemClock = { now: () => Date.now() };
|
|
161
|
+
function isRecord(value) {
|
|
162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
function isEffectLedger(value) {
|
|
165
|
+
if (!Array.isArray(value))
|
|
166
|
+
return false;
|
|
167
|
+
return value.every((entry) => isRecord(entry) && typeof entry.id === "string" && typeof entry.kind === "string");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The durable authoritative log over the app DataLayer/SQLite. The fence
|
|
171
|
+
* high-water lives in the activation row's `incarnation` column, so fencing
|
|
172
|
+
* survives a process restart — the durable counterpart of the in-memory
|
|
173
|
+
* {@link IncarnationFence}.
|
|
174
|
+
*/
|
|
175
|
+
export class SqliteSessionLog {
|
|
176
|
+
#db;
|
|
177
|
+
#clock;
|
|
178
|
+
constructor(db, options = {}) {
|
|
179
|
+
this.#db = db;
|
|
180
|
+
this.#clock = options.clock ?? systemClock;
|
|
181
|
+
}
|
|
182
|
+
/** Apply the canonical DDL (idempotent). Identical to the boot migration (drift-guarded). */
|
|
183
|
+
ensureSchema() {
|
|
184
|
+
this.#db.exec(SESSION_SCHEMA_SQL);
|
|
185
|
+
}
|
|
186
|
+
#logRow(key) {
|
|
187
|
+
return this.#db.all(`SELECT incarnation, next_offset FROM ${SESSION_LOG_TABLE} WHERE process_instance_key = ? AND element_id = ?`, [key.processInstanceKey, key.elementId])[0];
|
|
188
|
+
}
|
|
189
|
+
#admit(key, incarnation) {
|
|
190
|
+
if (!isNonNegInt(incarnation)) {
|
|
191
|
+
throw new RangeError(`incarnation must be a non-negative safe integer, got ${incarnation}`);
|
|
192
|
+
}
|
|
193
|
+
// Insert the activation row if it is missing, or advance its fence high-water when
|
|
194
|
+
// this lease is newer — as one atomic UPSERT. A plain ON CONFLICT DO NOTHING would
|
|
195
|
+
// let a concurrent first-lease race slip through: if another writer created the row
|
|
196
|
+
// between a pre-read and this INSERT, DO NOTHING would neither advance the fence nor
|
|
197
|
+
// reject a stale lease. Re-read and assert afterwards (insert-then-get, mirroring the
|
|
198
|
+
// transcript store) so a lease below the stored high-water is always fenced out.
|
|
199
|
+
this.#db.run(`INSERT INTO ${SESSION_LOG_TABLE} (process_instance_key, element_id, incarnation, created_at, next_offset)
|
|
200
|
+
VALUES (?, ?, ?, ?, 0)
|
|
201
|
+
ON CONFLICT(process_instance_key, element_id)
|
|
202
|
+
DO UPDATE SET incarnation = excluded.incarnation
|
|
203
|
+
WHERE excluded.incarnation > ${SESSION_LOG_TABLE}.incarnation`, [key.processInstanceKey, key.elementId, incarnation, new Date(this.#clock.now()).toISOString()]);
|
|
204
|
+
const row = this.#logRow(key);
|
|
205
|
+
if (row === undefined) {
|
|
206
|
+
throw new Error(`session log row vanished immediately after admit: ${activationKeyString(key)}`);
|
|
207
|
+
}
|
|
208
|
+
if (incarnation < row.incarnation) {
|
|
209
|
+
throw new StaleIncarnationError(key, incarnation, row.incarnation);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
lease(key, incarnation) {
|
|
213
|
+
this.#admit(key, incarnation);
|
|
214
|
+
}
|
|
215
|
+
currentIncarnation(key) {
|
|
216
|
+
return this.#logRow(key)?.incarnation;
|
|
217
|
+
}
|
|
218
|
+
nextOffset(key) {
|
|
219
|
+
return this.#logRow(key)?.next_offset ?? 0;
|
|
220
|
+
}
|
|
221
|
+
append(key, incarnation, offset, event) {
|
|
222
|
+
assertOffset(offset);
|
|
223
|
+
this.#admit(key, incarnation);
|
|
224
|
+
const next = this.nextOffset(key);
|
|
225
|
+
if (offset > next) {
|
|
226
|
+
throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${next}`);
|
|
227
|
+
}
|
|
228
|
+
const appended = { ...event, offset, incarnation };
|
|
229
|
+
return this.#atomic(() => {
|
|
230
|
+
if (offset < next) {
|
|
231
|
+
// Resuming: drop the now-superseded uncommitted tail before re-keying.
|
|
232
|
+
this.#db.run(`DELETE FROM ${SESSION_EVENT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ?`, [key.processInstanceKey, key.elementId, offset]);
|
|
233
|
+
// Prune checkpoints pinned above the resume boundary: they now point
|
|
234
|
+
// past the rewritten head and would mis-seed a later restore (a gap
|
|
235
|
+
// RangeError on the next emit).
|
|
236
|
+
this.#db.run(`DELETE FROM ${SESSION_CHECKPOINT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND checkpoint_offset > ?`, [key.processInstanceKey, key.elementId, offset]);
|
|
237
|
+
}
|
|
238
|
+
this.#db.run(`INSERT INTO ${SESSION_EVENT_TABLE}
|
|
239
|
+
(process_instance_key, element_id, event_offset, incarnation, event_id, parent_id, event_type, payload, appended_at)
|
|
240
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
241
|
+
key.processInstanceKey,
|
|
242
|
+
key.elementId,
|
|
243
|
+
offset,
|
|
244
|
+
incarnation,
|
|
245
|
+
event.id,
|
|
246
|
+
event.parentId,
|
|
247
|
+
event.type,
|
|
248
|
+
JSON.stringify(appended),
|
|
249
|
+
new Date(this.#clock.now()).toISOString(),
|
|
250
|
+
]);
|
|
251
|
+
this.#advanceWindow(key, offset);
|
|
252
|
+
return appended;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
putCheckpoint(key, incarnation, checkpoint) {
|
|
256
|
+
this.#admit(key, incarnation);
|
|
257
|
+
assertCheckpointIncarnation(incarnation, checkpoint);
|
|
258
|
+
this.#db.run(`INSERT INTO ${SESSION_CHECKPOINT_TABLE}
|
|
259
|
+
(process_instance_key, element_id, checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at)
|
|
260
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
261
|
+
ON CONFLICT(process_instance_key, element_id, checkpoint_id) DO NOTHING`, [
|
|
262
|
+
key.processInstanceKey,
|
|
263
|
+
key.elementId,
|
|
264
|
+
checkpoint.id,
|
|
265
|
+
checkpoint.offset,
|
|
266
|
+
incarnation,
|
|
267
|
+
checkpoint.commitSha,
|
|
268
|
+
JSON.stringify(checkpoint.effectLedger),
|
|
269
|
+
checkpoint.at,
|
|
270
|
+
]);
|
|
271
|
+
return checkpoint;
|
|
272
|
+
}
|
|
273
|
+
latestCheckpoint(key) {
|
|
274
|
+
const row = this.#db.all(`SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
|
|
275
|
+
FROM ${SESSION_CHECKPOINT_TABLE}
|
|
276
|
+
WHERE process_instance_key = ? AND element_id = ?
|
|
277
|
+
ORDER BY checkpoint_offset DESC, rowid DESC LIMIT 1`, [key.processInstanceKey, key.elementId])[0];
|
|
278
|
+
return row === undefined ? undefined : this.#toCheckpoint(row);
|
|
279
|
+
}
|
|
280
|
+
getCheckpoint(key, id) {
|
|
281
|
+
const row = this.#db.all(`SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
|
|
282
|
+
FROM ${SESSION_CHECKPOINT_TABLE}
|
|
283
|
+
WHERE process_instance_key = ? AND element_id = ? AND checkpoint_id = ?`, [key.processInstanceKey, key.elementId, id])[0];
|
|
284
|
+
return row === undefined ? undefined : this.#toCheckpoint(row);
|
|
285
|
+
}
|
|
286
|
+
#toCheckpoint(row) {
|
|
287
|
+
const ledgerRaw = JSON.parse(row.effect_ledger);
|
|
288
|
+
if (!isEffectLedger(ledgerRaw)) {
|
|
289
|
+
throw new SessionLogCorruptionError(`checkpoint ${row.checkpoint_id} effect_ledger is not a valid EffectLedger`);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
id: row.checkpoint_id,
|
|
293
|
+
offset: row.checkpoint_offset,
|
|
294
|
+
commitSha: row.commit_sha,
|
|
295
|
+
effectLedger: ledgerRaw,
|
|
296
|
+
incarnation: row.incarnation,
|
|
297
|
+
at: row.created_at,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
replay(key, from, to) {
|
|
301
|
+
assertReplayBounds(from, to);
|
|
302
|
+
const upper = to === undefined ? Number.MAX_SAFE_INTEGER : to;
|
|
303
|
+
return this.#db
|
|
304
|
+
.all(`SELECT event_offset, incarnation, payload FROM ${SESSION_EVENT_TABLE}
|
|
305
|
+
WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ? AND event_offset < ?
|
|
306
|
+
ORDER BY event_offset`, [key.processInstanceKey, key.elementId, from, upper])
|
|
307
|
+
.map((row) => {
|
|
308
|
+
const parsed = JSON.parse(row.payload);
|
|
309
|
+
const event = parseSessionEvent(parsed);
|
|
310
|
+
return { ...event, offset: row.event_offset, incarnation: row.incarnation };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Advance an activation's retained offset window (`first_offset`/`next_offset`,
|
|
315
|
+
* keyed by ActivationKey) to include the offset just appended. `append` always
|
|
316
|
+
* truncates the tail (`event_offset >= offset`) before inserting at `offset`, so
|
|
317
|
+
* the freshly stored offset is necessarily the new maximum (`next_offset =
|
|
318
|
+
* offset + 1`) and the minimum can only move down. Updating incrementally from
|
|
319
|
+
* the appended offset therefore keeps the window exact in O(1) — a full MIN/MAX
|
|
320
|
+
* scan of the event table on every append would be O(n) and make appends O(n²)
|
|
321
|
+
* as the log grows.
|
|
322
|
+
*/
|
|
323
|
+
#advanceWindow(key, offset) {
|
|
324
|
+
this.#db.run(`UPDATE ${SESSION_LOG_TABLE}
|
|
325
|
+
SET first_offset = MIN(COALESCE(first_offset, ?), ?), next_offset = ?
|
|
326
|
+
WHERE process_instance_key = ? AND element_id = ?`, [offset, offset, offset + 1, key.processInstanceKey, key.elementId]);
|
|
327
|
+
}
|
|
328
|
+
#atomic(body) {
|
|
329
|
+
this.#db.exec("SAVEPOINT nano_session_atomic");
|
|
330
|
+
try {
|
|
331
|
+
const result = body();
|
|
332
|
+
this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.#db.exec("ROLLBACK TO SAVEPOINT nano_session_atomic");
|
|
337
|
+
this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Raised when a row read back from the durable session log holds a value outside
|
|
344
|
+
* its domain (e.g. a corrupt effect-ledger JSON). Fail fast rather than coercing.
|
|
345
|
+
*/
|
|
346
|
+
export class SessionLogCorruptionError extends Error {
|
|
347
|
+
constructor(message) {
|
|
348
|
+
super(message);
|
|
349
|
+
this.name = "SessionLogCorruptionError";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code normalizer — ADR 0062 slice 3, reference dialect B.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `claude -p --output-format stream-json` (paired with
|
|
5
|
+
* `--input-format stream-json` to feed it), Claude Code streams one JSON object
|
|
6
|
+
* per line. A turn arrives as an `assistant` frame whose `message.content` is an
|
|
7
|
+
* array of typed parts — `text`, `thinking` (with an encrypted `signature`),
|
|
8
|
+
* `tool_use` — and tool outputs come back as a `user` frame carrying
|
|
9
|
+
* `tool_result` parts. Restore is the native `--resume <id>` (`-c` continues the
|
|
10
|
+
* latest, `--from-pr` seeds from a PR — both resume-by-latest, not by-id, so the
|
|
11
|
+
* id-restore shim is `--resume`). Streaming + resume-by-id → `durable-resume`.
|
|
12
|
+
*
|
|
13
|
+
* ## Resume-critical fidelity: `thinking.signature`
|
|
14
|
+
*
|
|
15
|
+
* Claude's `thinking` parts carry a `signature`: the provider's opaque, encrypted
|
|
16
|
+
* reasoning-continuation token that must be replayed verbatim to continue
|
|
17
|
+
* extended thinking across a resume (ADR 0062 §5). We map it to the canonical
|
|
18
|
+
* `ReasoningEvent.providerContinuation`, so the native transcript remains the
|
|
19
|
+
* authoritative restore path for Claude just as it does for Copilot.
|
|
20
|
+
*/
|
|
21
|
+
import { type HarnessNormalizer } from "./types.ts";
|
|
22
|
+
/** The Claude Code fallback normalizer. Streaming + resume-by-id → durable. */
|
|
23
|
+
export declare const claudeNormalizer: HarnessNormalizer;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code normalizer — ADR 0062 slice 3, reference dialect B.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `claude -p --output-format stream-json` (paired with
|
|
5
|
+
* `--input-format stream-json` to feed it), Claude Code streams one JSON object
|
|
6
|
+
* per line. A turn arrives as an `assistant` frame whose `message.content` is an
|
|
7
|
+
* array of typed parts — `text`, `thinking` (with an encrypted `signature`),
|
|
8
|
+
* `tool_use` — and tool outputs come back as a `user` frame carrying
|
|
9
|
+
* `tool_result` parts. Restore is the native `--resume <id>` (`-c` continues the
|
|
10
|
+
* latest, `--from-pr` seeds from a PR — both resume-by-latest, not by-id, so the
|
|
11
|
+
* id-restore shim is `--resume`). Streaming + resume-by-id → `durable-resume`.
|
|
12
|
+
*
|
|
13
|
+
* ## Resume-critical fidelity: `thinking.signature`
|
|
14
|
+
*
|
|
15
|
+
* Claude's `thinking` parts carry a `signature`: the provider's opaque, encrypted
|
|
16
|
+
* reasoning-continuation token that must be replayed verbatim to continue
|
|
17
|
+
* extended thinking across a resume (ADR 0062 §5). We map it to the canonical
|
|
18
|
+
* `ReasoningEvent.providerContinuation`, so the native transcript remains the
|
|
19
|
+
* authoritative restore path for Claude just as it does for Copilot.
|
|
20
|
+
*/
|
|
21
|
+
import { NormalizerDialectError } from "./types.js";
|
|
22
|
+
import { asArray, asRecord, isRecord, optNumber, optString, reqString } from "./record.js";
|
|
23
|
+
const HARNESS = "claude-code";
|
|
24
|
+
function messageContent(obj) {
|
|
25
|
+
const message = obj.message;
|
|
26
|
+
if (!isRecord(message)) {
|
|
27
|
+
throw new NormalizerDialectError(HARNESS, `${String(obj.type)} frame must carry a "message" object`);
|
|
28
|
+
}
|
|
29
|
+
const content = message.content;
|
|
30
|
+
// Claude also permits a bare-string message content for a plain text turn.
|
|
31
|
+
if (typeof content === "string")
|
|
32
|
+
return [{ type: "text", text: content }];
|
|
33
|
+
return asArray(HARNESS, content, "message.content");
|
|
34
|
+
}
|
|
35
|
+
function usageDrafts(usage, model) {
|
|
36
|
+
if (!isRecord(usage))
|
|
37
|
+
return [];
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
type: "usage",
|
|
41
|
+
inputTokens: optNumber(HARNESS, usage, "input_tokens") ?? 0,
|
|
42
|
+
outputTokens: optNumber(HARNESS, usage, "output_tokens") ?? 0,
|
|
43
|
+
...(model !== undefined ? { model } : {}),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
function toDrafts(record) {
|
|
48
|
+
const obj = asRecord(HARNESS, record);
|
|
49
|
+
switch (obj.type) {
|
|
50
|
+
case "system":
|
|
51
|
+
// The `init` system frame is session metadata (tools, model, cwd), not a
|
|
52
|
+
// conversational system turn — it carries no canonical text.
|
|
53
|
+
return [];
|
|
54
|
+
case "assistant": {
|
|
55
|
+
const message = isRecord(obj.message) ? obj.message : {};
|
|
56
|
+
const model = optString(HARNESS, message, "model");
|
|
57
|
+
const drafts = [];
|
|
58
|
+
for (const part of messageContent(obj)) {
|
|
59
|
+
if (!isRecord(part))
|
|
60
|
+
continue;
|
|
61
|
+
switch (part.type) {
|
|
62
|
+
case "text": {
|
|
63
|
+
const text = optString(HARNESS, part, "text");
|
|
64
|
+
if (text !== undefined && text.length > 0)
|
|
65
|
+
drafts.push({ type: "assistant", text });
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "thinking": {
|
|
69
|
+
const text = optString(HARNESS, part, "thinking");
|
|
70
|
+
const signature = optString(HARNESS, part, "signature");
|
|
71
|
+
drafts.push({
|
|
72
|
+
type: "reasoning",
|
|
73
|
+
...(text !== undefined ? { text } : {}),
|
|
74
|
+
...(signature !== undefined ? { providerContinuation: signature } : {}),
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "tool_use": {
|
|
79
|
+
const callId = reqString(HARNESS, part, "id");
|
|
80
|
+
drafts.push({
|
|
81
|
+
type: "tool-call",
|
|
82
|
+
id: `call:${callId}`,
|
|
83
|
+
callId,
|
|
84
|
+
name: reqString(HARNESS, part, "name"),
|
|
85
|
+
args: part.input,
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A turn's usage rides on its assistant message.
|
|
94
|
+
drafts.push(...usageDrafts(message.usage, model));
|
|
95
|
+
return drafts;
|
|
96
|
+
}
|
|
97
|
+
case "user": {
|
|
98
|
+
const drafts = [];
|
|
99
|
+
for (const part of messageContent(obj)) {
|
|
100
|
+
if (!isRecord(part))
|
|
101
|
+
continue;
|
|
102
|
+
if (part.type === "tool_result") {
|
|
103
|
+
const callId = reqString(HARNESS, part, "tool_use_id");
|
|
104
|
+
drafts.push({
|
|
105
|
+
type: "tool-result",
|
|
106
|
+
id: `result:${callId}`,
|
|
107
|
+
callId,
|
|
108
|
+
ok: part.is_error !== true,
|
|
109
|
+
result: part.content,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else if (part.type === "text") {
|
|
113
|
+
const text = optString(HARNESS, part, "text");
|
|
114
|
+
if (text !== undefined && text.length > 0)
|
|
115
|
+
drafts.push({ type: "user", text });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return drafts;
|
|
119
|
+
}
|
|
120
|
+
case "result":
|
|
121
|
+
// Terminal accounting frame: fold its top-level usage in whenever present.
|
|
122
|
+
// Like every other frame's usage (and every other normalizer), this maps to
|
|
123
|
+
// its own canonical `usage` event — assistant frames emit their own
|
|
124
|
+
// per-message usage and the linker never dedupes; consumers aggregate.
|
|
125
|
+
return usageDrafts(obj.usage, optString(HARNESS, obj, "model"));
|
|
126
|
+
default:
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** The Claude Code fallback normalizer. Streaming + resume-by-id → durable. */
|
|
131
|
+
export const claudeNormalizer = {
|
|
132
|
+
harness: HARNESS,
|
|
133
|
+
capabilities: { streaming: true, resumeById: true },
|
|
134
|
+
toDrafts,
|
|
135
|
+
resume(sessionId) {
|
|
136
|
+
return { transport: "cli", sessionId, args: ["--resume", sessionId] };
|
|
137
|
+
},
|
|
138
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@github/copilot` normalizer — ADR 0062 slice 3, reference dialect A.
|
|
3
|
+
*
|
|
4
|
+
* Copilot runs in-process through `copilot-sdk`: the mind source is its own
|
|
5
|
+
* `SessionEvent` stream (`session.on(...)`, or the `events.jsonl` transcript it
|
|
6
|
+
* writes), and restore is the SDK's `resumeSession(id)` (the `sessionFsProvider`
|
|
7
|
+
* seam is the same call under a different persistence root, so the resume shim is
|
|
8
|
+
* identical). Copilot both streams and resumes-by-id → it advertises
|
|
9
|
+
* `durable-resume`.
|
|
10
|
+
*
|
|
11
|
+
* ## Resume-critical fidelity: `reasoningOpaque`
|
|
12
|
+
*
|
|
13
|
+
* ADR 0062 §5 lets a native adapter *prefer the native transcript for restore*
|
|
14
|
+
* when it carries more than the ACP models. Copilot's reasoning events carry a
|
|
15
|
+
* `reasoningOpaque` blob — the provider reasoning-continuation handle that must
|
|
16
|
+
* be replayed verbatim to continue the model's reasoning across a resume. The
|
|
17
|
+
* canonical `ReasoningEvent` has a home for exactly this (`providerContinuation`),
|
|
18
|
+
* so we map it straight through, untouched. Dropping it (as a lossy ACP
|
|
19
|
+
* projection might) would silently break reasoning continuation on resume — so
|
|
20
|
+
* this dialect is the authoritative ingestion path for Copilot.
|
|
21
|
+
*/
|
|
22
|
+
import type { HarnessNormalizer } from "./types.ts";
|
|
23
|
+
/**
|
|
24
|
+
* The `@github/copilot` fallback normalizer. `streaming`/`resumeById` are both
|
|
25
|
+
* true, so {@link capabilityProbe} derives `durable-resume: true`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const copilotNormalizer: HarnessNormalizer;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { asRecord, contentText, optNumber, optString, reqString } from "./record.js";
|
|
2
|
+
const HARNESS = "@github/copilot";
|
|
3
|
+
/**
|
|
4
|
+
* Map one `copilot-sdk` `SessionEvent` to canonical drafts. Copilot's event
|
|
5
|
+
* `type`s are close to ours but not identical (`assistant_message` vs
|
|
6
|
+
* `assistant`, `reasoningOpaque` vs `providerContinuation`, `turn_started` vs
|
|
7
|
+
* `turn-start`); this is exactly the per-dialect translation the slice exists to
|
|
8
|
+
* own.
|
|
9
|
+
*/
|
|
10
|
+
function toDrafts(record) {
|
|
11
|
+
const obj = asRecord(HARNESS, record);
|
|
12
|
+
switch (obj.type) {
|
|
13
|
+
case "system":
|
|
14
|
+
case "system_message":
|
|
15
|
+
return [{ type: "system", text: reqString(HARNESS, obj, "text") }];
|
|
16
|
+
case "user":
|
|
17
|
+
case "user_message":
|
|
18
|
+
return [{ type: "user", text: reqString(HARNESS, obj, "text") }];
|
|
19
|
+
case "assistant":
|
|
20
|
+
case "assistant_message": {
|
|
21
|
+
const text = contentText(obj.text ?? obj.content);
|
|
22
|
+
return text === undefined ? [] : [{ type: "assistant", text }];
|
|
23
|
+
}
|
|
24
|
+
case "reasoning": {
|
|
25
|
+
const text = optString(HARNESS, obj, "text");
|
|
26
|
+
// Copilot names the continuation blob `reasoningOpaque`; accept the
|
|
27
|
+
// canonical spelling too so a pre-normalized feed round-trips.
|
|
28
|
+
const providerContinuation = optString(HARNESS, obj, "reasoningOpaque") ?? optString(HARNESS, obj, "providerContinuation");
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
type: "reasoning",
|
|
32
|
+
...(text !== undefined ? { text } : {}),
|
|
33
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
case "tool_call": {
|
|
38
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: "tool-call",
|
|
42
|
+
id: `call:${callId}`,
|
|
43
|
+
callId,
|
|
44
|
+
name: reqString(HARNESS, obj, "name"),
|
|
45
|
+
args: obj.arguments ?? obj.args,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
case "tool_result": {
|
|
50
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
51
|
+
// Copilot marks failure with an `isError` flag; the payload lives in
|
|
52
|
+
// `output` either way.
|
|
53
|
+
const ok = obj.isError === true ? false : obj.error == null;
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
type: "tool-result",
|
|
57
|
+
id: `result:${callId}`,
|
|
58
|
+
callId,
|
|
59
|
+
ok,
|
|
60
|
+
result: obj.output ?? obj.result ?? obj.error,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
case "turn_started":
|
|
65
|
+
return [{ type: "turn-start", turn: turnIndex(obj) }];
|
|
66
|
+
case "turn_completed":
|
|
67
|
+
case "turn_ended":
|
|
68
|
+
return [{ type: "turn-end", turn: turnIndex(obj) }];
|
|
69
|
+
case "usage": {
|
|
70
|
+
const model = optString(HARNESS, obj, "model");
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
type: "usage",
|
|
74
|
+
inputTokens: usageCount(obj, "inputTokens", "input_tokens"),
|
|
75
|
+
outputTokens: usageCount(obj, "outputTokens", "output_tokens"),
|
|
76
|
+
...(model !== undefined ? { model } : {}),
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
// Transport frames Copilot emits that carry no session-log meaning
|
|
82
|
+
// (heartbeats, session-ready acks) normalize to nothing.
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function turnIndex(obj) {
|
|
87
|
+
return optNumber(HARNESS, obj, "turn") ?? 0;
|
|
88
|
+
}
|
|
89
|
+
function usageCount(obj, camel, snake) {
|
|
90
|
+
return optNumber(HARNESS, obj, camel) ?? optNumber(HARNESS, obj, snake) ?? 0;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The `@github/copilot` fallback normalizer. `streaming`/`resumeById` are both
|
|
94
|
+
* true, so {@link capabilityProbe} derives `durable-resume: true`.
|
|
95
|
+
*/
|
|
96
|
+
export const copilotNormalizer = {
|
|
97
|
+
harness: HARNESS,
|
|
98
|
+
capabilities: { streaming: true, resumeById: true },
|
|
99
|
+
toDrafts,
|
|
100
|
+
resume(sessionId) {
|
|
101
|
+
// In-process SDK restore: `resumeSession(id)` (the sessionFsProvider seam is
|
|
102
|
+
// the same call under a different persistence root).
|
|
103
|
+
return { transport: "sdk", sessionId, call: "resumeSession", args: [sessionId] };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness normalizer — ADR 0062 slice 3.
|
|
3
|
+
*
|
|
4
|
+
* The DeepSeek Harness exposes a live `SessionEvent` feed rather than a spawned
|
|
5
|
+
* `stream-json` transport: its frames are already event-shaped (`kind`-tagged),
|
|
6
|
+
* so the dialect is a thin renaming onto the canonical union. Restore is the
|
|
7
|
+
* harness's in-process `seed`/`restore` pair, so the resume shim is the SDK
|
|
8
|
+
* `restore(id)` call. Streaming + resume-by-id → `durable-resume`.
|
|
9
|
+
*/
|
|
10
|
+
import type { HarnessNormalizer } from "./types.ts";
|
|
11
|
+
export declare const deepseekNormalizer: HarnessNormalizer;
|