@deepstrike/wasm 0.2.49 → 0.2.51
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 +2 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/runtime/canonical-kernel-step.d.ts +146 -0
- package/dist/runtime/canonical-kernel-step.js +1484 -0
- package/dist/runtime/execution-plane.d.ts +0 -3
- package/dist/runtime/execution-plane.js +0 -25
- package/dist/runtime/facade.js +3 -0
- package/dist/runtime/index.d.ts +7 -7
- package/dist/runtime/index.js +4 -3
- package/dist/runtime/kernel-event-log.d.ts +0 -1
- package/dist/runtime/kernel-event-log.js +7 -13
- package/dist/runtime/kernel-journal.d.ts +332 -0
- package/dist/runtime/kernel-journal.js +681 -0
- package/dist/runtime/kernel-step.d.ts +34 -89
- package/dist/runtime/kernel-step.js +67 -167
- package/dist/runtime/os-snapshot.d.ts +2 -2
- package/dist/runtime/os-snapshot.js +2 -6
- package/dist/runtime/payload-store.d.ts +30 -0
- package/dist/runtime/payload-store.js +66 -0
- package/dist/runtime/runner.d.ts +72 -79
- package/dist/runtime/runner.js +424 -637
- package/dist/runtime/session-log.d.ts +24 -27
- package/dist/runtime/session-log.js +11 -52
- package/dist/runtime/session-repair.d.ts +2 -36
- package/dist/runtime/session-repair.js +2 -47
- package/dist/runtime/sub-agent-orchestrator.d.ts +2 -2
- package/dist/runtime/sub-agent-orchestrator.js +36 -40
- package/dist/runtime/types/agent.d.ts +21 -16
- package/dist/runtime/types/agent.js +21 -41
- package/package.json +4 -3
- package/dist/runtime/kernel-rebuild.d.ts +0 -8
- package/dist/runtime/kernel-rebuild.js +0 -65
- package/dist/runtime/kernel-transaction-log.d.ts +0 -61
- package/dist/runtime/kernel-transaction-log.js +0 -140
- package/dist/runtime/large-result-spool.d.ts +0 -57
- package/dist/runtime/large-result-spool.js +0 -169
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `KernelJournal` — the durable transaction capability of the Canonical Kernel ABI (spec §9.1).
|
|
3
|
+
*
|
|
4
|
+
* The interface here is field-for-field the Node SDK's `node/src/runtime/kernel-journal.ts`, which is
|
|
5
|
+
* the authoritative shape for all four SDKs. Three rules give this file its shape:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Records are opaque.** core owns canonical serialization and hashing
|
|
8
|
+
* (`KernelRecord::record_bytes()` / `record_digest()` / `expected_head()`). The host stores the
|
|
9
|
+
* bytes verbatim and indexes them by the digest core handed it. A journal that re-serializes a
|
|
10
|
+
* record to recompute its hash would make "the host recomputed and disagreed" a reachable state;
|
|
11
|
+
* it is not one here.
|
|
12
|
+
* 2. **CAS is a storage-layer primitive, not a read-compare-write sequence.** §9.1 requires a real
|
|
13
|
+
* atomic operation (file lock / atomic-publish chained naming / conditional database update).
|
|
14
|
+
* `InMemoryKernelJournal` is atomic only within one process and says so in its own type;
|
|
15
|
+
* `DriverKernelJournal` is as atomic as the {@link JournalStorageDriver} the host injects.
|
|
16
|
+
* 3. **The journal's sequence space is `step_seq`** — the operation's record-chain position — and is
|
|
17
|
+
* completely independent of `SessionLog`'s business event `seq`. Pruning a journal prefix can
|
|
18
|
+
* never punch a hole in business event numbering (spec Task 8b, criterion 4).
|
|
19
|
+
*
|
|
20
|
+
* Failures are typed so a caller can tell "retry after rebuild" from "this storage is broken" from
|
|
21
|
+
* "someone handed me a corrupt chain" — a durable-step wrapper must never publish effects on any of
|
|
22
|
+
* them, and must never collapse them into one opaque `Error`.
|
|
23
|
+
*
|
|
24
|
+
* **Why this SDK has no file backend.** The WASM SDK targets browsers, Cloudflare Workers and Deno
|
|
25
|
+
* Deploy; it imports no host filesystem anywhere, so Node's `FileKernelJournal` (POSIX `link(2)` as
|
|
26
|
+
* the atomicity primitive) has nothing to stand on. The durable backend is therefore *injected*:
|
|
27
|
+
* the host supplies a {@link JournalStorageDriver} over whatever conditional-write primitive its
|
|
28
|
+
* platform actually has (R2/S3 `If-None-Match`, Durable Object storage, KV compare-and-set, OPFS),
|
|
29
|
+
* and {@link DriverKernelJournal} supplies the chain, checkpoint and prune protocol on top of it.
|
|
30
|
+
*/
|
|
31
|
+
/** Chain positions at or above this bound are rejected (parity with Node/Python/Rust). */
|
|
32
|
+
export const MAX_CHAIN_POSITION = 1_000_000_000_000;
|
|
33
|
+
/* ------------------------------------------------------------------ *
|
|
34
|
+
* Errors
|
|
35
|
+
* ------------------------------------------------------------------ */
|
|
36
|
+
/**
|
|
37
|
+
* The CAS precondition did not hold: the journal head (or checkpoint pointer) moved.
|
|
38
|
+
*
|
|
39
|
+
* Retryable — the protocol response is `abort(token)` → re-read head → rebuild → replay the input
|
|
40
|
+
* (spec §8.3, row "CAS conflict").
|
|
41
|
+
*/
|
|
42
|
+
export class JournalCasConflictError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "JournalCasConflictError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The journal contents contradict themselves or the caller's claim: a broken digest chain, a
|
|
50
|
+
* `step_seq` that does not follow its predecessor, a checkpoint whose `covered_head` does not match
|
|
51
|
+
* the record at its `through_step_seq`. Never retryable — retrying replays the same contradiction.
|
|
52
|
+
*/
|
|
53
|
+
export class JournalIntegrityError extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "JournalIntegrityError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The storage layer failed (quota exceeded, network error, driver rejected the write). Distinct from
|
|
61
|
+
* both of the above: the journal state is *unknown*, not known-conflicting and not known-corrupt.
|
|
62
|
+
*/
|
|
63
|
+
export class JournalIoError extends Error {
|
|
64
|
+
constructor(message, options) {
|
|
65
|
+
super(message, options);
|
|
66
|
+
this.name = "JournalIoError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/* ------------------------------------------------------------------ *
|
|
70
|
+
* Shared validation
|
|
71
|
+
* ------------------------------------------------------------------ */
|
|
72
|
+
function validateChainPosition(value, label) {
|
|
73
|
+
if (!Number.isSafeInteger(value) || value < 0 || value >= MAX_CHAIN_POSITION) {
|
|
74
|
+
throw new JournalIntegrityError(`${label} must be a non-negative integer below ${MAX_CHAIN_POSITION}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function validateRecord(record) {
|
|
78
|
+
validateChainPosition(record.step_seq, "journal record step_seq");
|
|
79
|
+
if (!record.record_digest)
|
|
80
|
+
throw new JournalIntegrityError("journal record requires a record_digest");
|
|
81
|
+
if (!(record.record_bytes instanceof Uint8Array)) {
|
|
82
|
+
throw new JournalIntegrityError("journal record requires opaque record_bytes");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function validateCandidate(checkpoint) {
|
|
86
|
+
if (!checkpoint.checkpoint_id)
|
|
87
|
+
throw new JournalIntegrityError("checkpoint requires a checkpoint_id");
|
|
88
|
+
validateChainPosition(checkpoint.through_step_seq, "checkpoint through_step_seq");
|
|
89
|
+
if (!checkpoint.state_digest)
|
|
90
|
+
throw new JournalIntegrityError("checkpoint requires a state_digest");
|
|
91
|
+
if (!(checkpoint.checkpoint_bytes instanceof Uint8Array)) {
|
|
92
|
+
throw new JournalIntegrityError("checkpoint requires opaque checkpoint_bytes");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check the CAS precondition against the observed head, and the chain position that follows from it.
|
|
97
|
+
* Shared by both implementations so their conflict/integrity taxonomy cannot drift apart.
|
|
98
|
+
*/
|
|
99
|
+
function checkAppendPrecondition(head, expectedHead, record) {
|
|
100
|
+
if (expectedHead === undefined) {
|
|
101
|
+
if (head) {
|
|
102
|
+
throw new JournalCasConflictError("journal genesis append requires an empty chain, but the operation already has a head");
|
|
103
|
+
}
|
|
104
|
+
if (record.step_seq !== 0) {
|
|
105
|
+
throw new JournalIntegrityError("journal genesis record must have step_seq 0");
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!head) {
|
|
110
|
+
throw new JournalCasConflictError("journal compare-and-append expected a head, but the chain is empty");
|
|
111
|
+
}
|
|
112
|
+
if (head.record_digest !== expectedHead) {
|
|
113
|
+
throw new JournalCasConflictError("journal head changed before compare-and-append");
|
|
114
|
+
}
|
|
115
|
+
if (record.step_seq !== head.step_seq + 1) {
|
|
116
|
+
throw new JournalIntegrityError(`journal record step_seq ${record.step_seq} does not follow head step_seq ${head.step_seq}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Verify one contiguous run of entries links head-to-tail. */
|
|
120
|
+
function verifyChain(entries) {
|
|
121
|
+
for (let i = 1; i < entries.length; i++) {
|
|
122
|
+
const previous = entries[i - 1];
|
|
123
|
+
const entry = entries[i];
|
|
124
|
+
if (entry.step_seq !== previous.step_seq + 1) {
|
|
125
|
+
throw new JournalIntegrityError(`journal chain has a gap: step_seq ${entry.step_seq} follows ${previous.step_seq}`);
|
|
126
|
+
}
|
|
127
|
+
if (entry.previous_record_digest !== previous.record_digest) {
|
|
128
|
+
throw new JournalIntegrityError("journal chain digest linkage is not continuous");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function checkCheckpointPrecondition(latest, previousCheckpointId, checkpoint) {
|
|
133
|
+
if (previousCheckpointId === undefined) {
|
|
134
|
+
if (latest) {
|
|
135
|
+
throw new JournalCasConflictError("checkpoint install without a predecessor requires an empty checkpoint pointer");
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!latest) {
|
|
140
|
+
throw new JournalCasConflictError("checkpoint install named a predecessor, but none is installed");
|
|
141
|
+
}
|
|
142
|
+
if (latest.checkpoint_id !== previousCheckpointId) {
|
|
143
|
+
throw new JournalCasConflictError("checkpoint pointer changed before compare-and-install");
|
|
144
|
+
}
|
|
145
|
+
if (checkpoint.through_step_seq < latest.through_step_seq) {
|
|
146
|
+
throw new JournalIntegrityError("checkpoint pointer must advance monotonically");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* §9.1: the store verifies that `covered_head` is the record digest at `through_step_seq`. It does
|
|
151
|
+
* NOT check that this is still the current head — §22.14 rejects that, since it would serialise
|
|
152
|
+
* checkpointing against ordinary transitions.
|
|
153
|
+
*/
|
|
154
|
+
function verifyCoveredHead(covered, pruned, coveredHead, throughStepSeq) {
|
|
155
|
+
if (covered) {
|
|
156
|
+
if (covered.record_digest !== coveredHead) {
|
|
157
|
+
throw new JournalIntegrityError("checkpoint covered_head does not match the record at its through_step_seq");
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (pruned && pruned.through_step_seq === throughStepSeq && pruned.covered_head === coveredHead)
|
|
162
|
+
return;
|
|
163
|
+
throw new JournalIntegrityError("checkpoint through_step_seq names no retained record");
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* **Single-process dev/test implementation.**
|
|
167
|
+
*
|
|
168
|
+
* CAS is genuinely atomic here — every check-then-mutate below runs to completion without an
|
|
169
|
+
* intervening `await`, so no other task can interleave on a single-threaded runtime — but that
|
|
170
|
+
* atomicity ends at the process boundary. Two workers/isolates sharing "the same" journal do not
|
|
171
|
+
* exist: each has its own `Map`. Production hosts must supply a `KernelJournal` whose CAS is a real
|
|
172
|
+
* storage-layer primitive (spec §9.1); {@link DriverKernelJournal} over a durable
|
|
173
|
+
* {@link JournalStorageDriver} is the reference for that.
|
|
174
|
+
*/
|
|
175
|
+
export class InMemoryKernelJournal {
|
|
176
|
+
operations = new Map();
|
|
177
|
+
state(operationId) {
|
|
178
|
+
let state = this.operations.get(operationId);
|
|
179
|
+
if (!state) {
|
|
180
|
+
state = { records: [], checkpoints: [] };
|
|
181
|
+
this.operations.set(operationId, state);
|
|
182
|
+
}
|
|
183
|
+
return state;
|
|
184
|
+
}
|
|
185
|
+
headOf(state) {
|
|
186
|
+
const last = state.records.at(-1);
|
|
187
|
+
if (last)
|
|
188
|
+
return { step_seq: last.step_seq, record_digest: last.record_digest };
|
|
189
|
+
if (state.pruned) {
|
|
190
|
+
return { step_seq: state.pruned.through_step_seq, record_digest: state.pruned.covered_head };
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
async compareAndAppend(operationId, expectedHead, record) {
|
|
195
|
+
validateRecord(record);
|
|
196
|
+
// Atomic region: no `await` from here to the push.
|
|
197
|
+
const state = this.state(operationId);
|
|
198
|
+
checkAppendPrecondition(this.headOf(state), expectedHead, record);
|
|
199
|
+
state.records.push({
|
|
200
|
+
step_seq: record.step_seq,
|
|
201
|
+
record_digest: record.record_digest,
|
|
202
|
+
record_bytes: Uint8Array.from(record.record_bytes),
|
|
203
|
+
...(expectedHead === undefined ? {} : { previous_record_digest: expectedHead }),
|
|
204
|
+
});
|
|
205
|
+
return { step_seq: record.step_seq, record_digest: record.record_digest };
|
|
206
|
+
}
|
|
207
|
+
async head(operationId) {
|
|
208
|
+
const state = this.operations.get(operationId);
|
|
209
|
+
return state ? this.headOf(state) : undefined;
|
|
210
|
+
}
|
|
211
|
+
async readFrom(operationId, fromStepSeq = 0) {
|
|
212
|
+
const records = this.operations.get(operationId)?.records ?? [];
|
|
213
|
+
// Retained records are dense and ordered, so the cursor is an index — not a scan. Callers hit
|
|
214
|
+
// this once per durable step to fetch the head; a filter here would make a run quadratic.
|
|
215
|
+
const base = records[0]?.step_seq ?? 0;
|
|
216
|
+
const entries = records.slice(Math.max(0, fromStepSeq - base));
|
|
217
|
+
verifyChain(entries);
|
|
218
|
+
return entries.map(entry => ({ ...entry, record_bytes: Uint8Array.from(entry.record_bytes) }));
|
|
219
|
+
}
|
|
220
|
+
async recordsAfter(operationId, afterHead) {
|
|
221
|
+
if (afterHead === undefined)
|
|
222
|
+
return this.readFrom(operationId, 0);
|
|
223
|
+
const state = this.operations.get(operationId);
|
|
224
|
+
const anchor = state?.records.find(entry => entry.record_digest === afterHead);
|
|
225
|
+
if (anchor)
|
|
226
|
+
return this.readFrom(operationId, anchor.step_seq + 1);
|
|
227
|
+
if (state?.pruned && state.pruned.covered_head === afterHead) {
|
|
228
|
+
return this.readFrom(operationId, state.pruned.through_step_seq + 1);
|
|
229
|
+
}
|
|
230
|
+
throw new JournalIntegrityError("journal cursor digest names no retained record");
|
|
231
|
+
}
|
|
232
|
+
async compareAndInstallCheckpoint(operationId, previousCheckpointId, coveredHead, checkpoint) {
|
|
233
|
+
validateCandidate(checkpoint);
|
|
234
|
+
// Atomic region: no `await` from here to the push.
|
|
235
|
+
const state = this.state(operationId);
|
|
236
|
+
const latest = state.checkpoints.at(-1);
|
|
237
|
+
checkCheckpointPrecondition(latest, previousCheckpointId, checkpoint);
|
|
238
|
+
verifyCoveredHead(state.records.find(entry => entry.step_seq === checkpoint.through_step_seq), state.pruned, coveredHead, checkpoint.through_step_seq);
|
|
239
|
+
const installed = {
|
|
240
|
+
...checkpoint,
|
|
241
|
+
checkpoint_bytes: Uint8Array.from(checkpoint.checkpoint_bytes),
|
|
242
|
+
ordinal: latest ? latest.ordinal + 1 : 0,
|
|
243
|
+
covered_head: coveredHead,
|
|
244
|
+
...(previousCheckpointId === undefined ? {} : { previous_checkpoint_id: previousCheckpointId }),
|
|
245
|
+
acknowledged: false,
|
|
246
|
+
};
|
|
247
|
+
state.checkpoints.push(installed);
|
|
248
|
+
return { ...installed, checkpoint_bytes: Uint8Array.from(installed.checkpoint_bytes) };
|
|
249
|
+
}
|
|
250
|
+
async latestCheckpoint(operationId) {
|
|
251
|
+
const latest = this.operations.get(operationId)?.checkpoints.at(-1);
|
|
252
|
+
return latest ? { ...latest, checkpoint_bytes: Uint8Array.from(latest.checkpoint_bytes) } : undefined;
|
|
253
|
+
}
|
|
254
|
+
async ackCheckpoint(operationId, checkpointId) {
|
|
255
|
+
const installed = this.operations
|
|
256
|
+
.get(operationId)
|
|
257
|
+
?.checkpoints.find(entry => entry.checkpoint_id === checkpointId);
|
|
258
|
+
if (!installed)
|
|
259
|
+
throw new JournalIntegrityError("cannot acknowledge an uninstalled checkpoint");
|
|
260
|
+
installed.acknowledged = true;
|
|
261
|
+
return { ...installed, checkpoint_bytes: Uint8Array.from(installed.checkpoint_bytes) };
|
|
262
|
+
}
|
|
263
|
+
async pruneAckedPrefix(operationId) {
|
|
264
|
+
const state = this.operations.get(operationId);
|
|
265
|
+
const acked = [...(state?.checkpoints ?? [])].reverse().find(entry => entry.acknowledged);
|
|
266
|
+
if (!state || !acked) {
|
|
267
|
+
return { pruned_through_step_seq: state?.pruned?.through_step_seq ?? -1, pruned_count: 0 };
|
|
268
|
+
}
|
|
269
|
+
const before = state.records.length;
|
|
270
|
+
state.records = state.records.filter(entry => entry.step_seq > acked.through_step_seq);
|
|
271
|
+
if (!state.pruned || state.pruned.through_step_seq < acked.through_step_seq) {
|
|
272
|
+
state.pruned = { through_step_seq: acked.through_step_seq, covered_head: acked.covered_head };
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
pruned_through_step_seq: state.pruned.through_step_seq,
|
|
276
|
+
pruned_count: before - state.records.length,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
async stageOutboundEnvelope(operationId, envelopeJson) {
|
|
280
|
+
if (!envelopeJson)
|
|
281
|
+
throw new JournalIntegrityError("outbound envelope must not be empty");
|
|
282
|
+
this.state(operationId).outboundEnvelope = envelopeJson;
|
|
283
|
+
}
|
|
284
|
+
async readOutboundEnvelope(operationId) {
|
|
285
|
+
return this.operations.get(operationId)?.outboundEnvelope;
|
|
286
|
+
}
|
|
287
|
+
async clearOutboundEnvelope(operationId) {
|
|
288
|
+
const state = this.operations.get(operationId);
|
|
289
|
+
if (state)
|
|
290
|
+
delete state.outboundEnvelope;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* **Single-process reference driver.** Backed by one `Map`, so `createIfAbsent` is atomic in the
|
|
295
|
+
* only sense a single isolate can offer: the check and the insert run with no `await` between them,
|
|
296
|
+
* so no other task interleaves. It is a complete, spec-conformant driver *within one isolate* — use
|
|
297
|
+
* it for dev, tests, and ephemeral Workers; use a durable driver anywhere state must outlive the
|
|
298
|
+
* isolate or be shared with another one.
|
|
299
|
+
*/
|
|
300
|
+
export class InMemoryJournalDriver {
|
|
301
|
+
entries = new Map();
|
|
302
|
+
async createIfAbsent(key, bytes) {
|
|
303
|
+
// Atomic region: no `await` between the check and the insert.
|
|
304
|
+
if (this.entries.has(key))
|
|
305
|
+
return false;
|
|
306
|
+
this.entries.set(key, Uint8Array.from(bytes));
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
async read(key) {
|
|
310
|
+
const value = this.entries.get(key);
|
|
311
|
+
return value ? Uint8Array.from(value) : undefined;
|
|
312
|
+
}
|
|
313
|
+
async list(prefix) {
|
|
314
|
+
return [...this.entries.keys()].filter(key => key.startsWith(prefix));
|
|
315
|
+
}
|
|
316
|
+
async put(key, bytes) {
|
|
317
|
+
this.entries.set(key, Uint8Array.from(bytes));
|
|
318
|
+
}
|
|
319
|
+
async remove(key) {
|
|
320
|
+
this.entries.delete(key);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/* ------------------------------------------------------------------ *
|
|
324
|
+
* Driver-backed implementation
|
|
325
|
+
* ------------------------------------------------------------------ */
|
|
326
|
+
const SEQ_DIGITS = 12;
|
|
327
|
+
const RECORD_NAME = /^(\d{12})\.rec$/;
|
|
328
|
+
const CHECKPOINT_NAME = /^(\d{12})\.ckpt$/;
|
|
329
|
+
const ACK_NAME = /^(\d{12})\.ack$/;
|
|
330
|
+
function pad(value) {
|
|
331
|
+
return String(value).padStart(SEQ_DIGITS, "0");
|
|
332
|
+
}
|
|
333
|
+
/** Injective encoding of an operation id into a key segment safe for path- and URL-shaped stores. */
|
|
334
|
+
function safeSegment(value) {
|
|
335
|
+
return value.replace(/[^A-Za-z0-9._-]/g, char => `~${char.charCodeAt(0).toString(16)}`);
|
|
336
|
+
}
|
|
337
|
+
const encoder = new TextEncoder();
|
|
338
|
+
const decoder = new TextDecoder();
|
|
339
|
+
function toBase64(bytes) {
|
|
340
|
+
let binary = "";
|
|
341
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
342
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
343
|
+
}
|
|
344
|
+
return btoa(binary);
|
|
345
|
+
}
|
|
346
|
+
function fromBase64(value) {
|
|
347
|
+
const binary = atob(value);
|
|
348
|
+
const bytes = new Uint8Array(binary.length);
|
|
349
|
+
for (let i = 0; i < binary.length; i++)
|
|
350
|
+
bytes[i] = binary.charCodeAt(i);
|
|
351
|
+
return bytes;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* **Durable reference implementation** of `KernelJournal` over a host-injected
|
|
355
|
+
* {@link JournalStorageDriver} (spec Task 8b) — the WASM SDK's counterpart to Node's
|
|
356
|
+
* `FileKernelJournal`.
|
|
357
|
+
*
|
|
358
|
+
* The atomicity primitive is `driver.createIfAbsent`. Content is published complete, under a name
|
|
359
|
+
* derived *only* from the CAS precondition, so a crash can never leave a half-written record and
|
|
360
|
+
* two racing writers can never land on different names.
|
|
361
|
+
*
|
|
362
|
+
* **Why a record's key is `<step_seq>.rec` and contains no digest.** The key *is* the collision
|
|
363
|
+
* domain. Two writers racing on the same head both compute the same next `step_seq`, so they
|
|
364
|
+
* contend for one key and exactly one wins. Folding a per-writer value (the new record's digest)
|
|
365
|
+
* into the key would give the racers *different* keys — both claims would succeed and the chain
|
|
366
|
+
* would fork. Only the predecessor-determined part of the identity may appear in the key.
|
|
367
|
+
*
|
|
368
|
+
* The pre-claim head check is not a TOCTOU hole: it can only *reject* an append the driver would
|
|
369
|
+
* have accepted (a stale `expectedHead` whose `step_seq` slot happens to be free), never accept one
|
|
370
|
+
* the driver would have rejected. Every acceptance is still decided by the atomic claim.
|
|
371
|
+
*
|
|
372
|
+
* Checkpoint installs use the same primitive on a separate ordinal space (`<ordinal>.ckpt`), so two
|
|
373
|
+
* writers installing on the same predecessor also contend for one key.
|
|
374
|
+
*/
|
|
375
|
+
export class DriverKernelJournal {
|
|
376
|
+
driver;
|
|
377
|
+
prefix;
|
|
378
|
+
constructor(driver, prefix = "kernel-journal") {
|
|
379
|
+
this.driver = driver;
|
|
380
|
+
this.prefix = prefix;
|
|
381
|
+
}
|
|
382
|
+
operationPrefix(operationId) {
|
|
383
|
+
return `${this.prefix}/${safeSegment(operationId)}`;
|
|
384
|
+
}
|
|
385
|
+
recordsPrefix(operationId) {
|
|
386
|
+
return `${this.operationPrefix(operationId)}/records/`;
|
|
387
|
+
}
|
|
388
|
+
checkpointsPrefix(operationId) {
|
|
389
|
+
return `${this.operationPrefix(operationId)}/checkpoints/`;
|
|
390
|
+
}
|
|
391
|
+
prunedKey(operationId) {
|
|
392
|
+
return `${this.operationPrefix(operationId)}/pruned`;
|
|
393
|
+
}
|
|
394
|
+
outboundKey(operationId) {
|
|
395
|
+
return `${this.operationPrefix(operationId)}/outbound`;
|
|
396
|
+
}
|
|
397
|
+
/** Every driver call funnels through these three so a driver fault is always a `JournalIoError`. */
|
|
398
|
+
async claim(key, payload) {
|
|
399
|
+
try {
|
|
400
|
+
return await this.driver.createIfAbsent(key, encoder.encode(JSON.stringify(payload)));
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
throw new JournalIoError("journal could not publish a durable record", { cause: err });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async load(key) {
|
|
407
|
+
let bytes;
|
|
408
|
+
try {
|
|
409
|
+
bytes = await this.driver.read(key);
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
throw new JournalIoError("journal could not read from its storage driver", { cause: err });
|
|
413
|
+
}
|
|
414
|
+
return bytes === undefined ? undefined : decoder.decode(bytes);
|
|
415
|
+
}
|
|
416
|
+
async names(prefix) {
|
|
417
|
+
let keys;
|
|
418
|
+
try {
|
|
419
|
+
keys = await this.driver.list(prefix);
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
throw new JournalIoError("journal could not list its storage driver", { cause: err });
|
|
423
|
+
}
|
|
424
|
+
return keys.map(key => key.slice(prefix.length));
|
|
425
|
+
}
|
|
426
|
+
/** Sorted `step_seq` values of the retained records. Anything not matching the rule is residue. */
|
|
427
|
+
async recordSeqs(operationId) {
|
|
428
|
+
const seqs = [];
|
|
429
|
+
for (const name of await this.names(this.recordsPrefix(operationId))) {
|
|
430
|
+
const match = RECORD_NAME.exec(name);
|
|
431
|
+
if (match)
|
|
432
|
+
seqs.push(Number(match[1]));
|
|
433
|
+
}
|
|
434
|
+
return seqs.sort((a, b) => a - b);
|
|
435
|
+
}
|
|
436
|
+
async readRecord(operationId, stepSeq) {
|
|
437
|
+
const raw = await this.load(`${this.recordsPrefix(operationId)}${pad(stepSeq)}.rec`);
|
|
438
|
+
if (raw === undefined)
|
|
439
|
+
return undefined;
|
|
440
|
+
let persisted;
|
|
441
|
+
try {
|
|
442
|
+
persisted = JSON.parse(raw);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
throw new JournalIntegrityError(`journal record ${pad(stepSeq)} is not readable: ${String(err)}`);
|
|
446
|
+
}
|
|
447
|
+
if (persisted.step_seq !== stepSeq) {
|
|
448
|
+
throw new JournalIntegrityError(`journal record ${pad(stepSeq)} disagrees with its own step_seq`);
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
step_seq: persisted.step_seq,
|
|
452
|
+
record_digest: persisted.record_digest,
|
|
453
|
+
record_bytes: fromBase64(persisted.record_bytes),
|
|
454
|
+
...(persisted.previous_record_digest === undefined
|
|
455
|
+
? {}
|
|
456
|
+
: { previous_record_digest: persisted.previous_record_digest }),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async prunedAnchor(operationId) {
|
|
460
|
+
const raw = await this.load(this.prunedKey(operationId));
|
|
461
|
+
if (raw === undefined)
|
|
462
|
+
return undefined;
|
|
463
|
+
try {
|
|
464
|
+
return JSON.parse(raw);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
throw new JournalIntegrityError(`journal pruned anchor is not readable: ${String(err)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async head(operationId) {
|
|
471
|
+
const seqs = await this.recordSeqs(operationId);
|
|
472
|
+
const last = seqs.at(-1);
|
|
473
|
+
if (last !== undefined) {
|
|
474
|
+
const entry = await this.readRecord(operationId, last);
|
|
475
|
+
if (entry)
|
|
476
|
+
return { step_seq: entry.step_seq, record_digest: entry.record_digest };
|
|
477
|
+
}
|
|
478
|
+
const pruned = await this.prunedAnchor(operationId);
|
|
479
|
+
return pruned ? { step_seq: pruned.through_step_seq, record_digest: pruned.covered_head } : undefined;
|
|
480
|
+
}
|
|
481
|
+
async compareAndAppend(operationId, expectedHead, record) {
|
|
482
|
+
validateRecord(record);
|
|
483
|
+
checkAppendPrecondition(await this.head(operationId), expectedHead, record);
|
|
484
|
+
const persisted = {
|
|
485
|
+
step_seq: record.step_seq,
|
|
486
|
+
record_digest: record.record_digest,
|
|
487
|
+
...(expectedHead === undefined ? {} : { previous_record_digest: expectedHead }),
|
|
488
|
+
record_bytes: toBase64(record.record_bytes),
|
|
489
|
+
};
|
|
490
|
+
const won = await this.claim(`${this.recordsPrefix(operationId)}${pad(record.step_seq)}.rec`, persisted);
|
|
491
|
+
if (!won) {
|
|
492
|
+
throw new JournalCasConflictError(`journal step_seq ${record.step_seq} was claimed by a concurrent writer`);
|
|
493
|
+
}
|
|
494
|
+
return { step_seq: record.step_seq, record_digest: record.record_digest };
|
|
495
|
+
}
|
|
496
|
+
async readFrom(operationId, fromStepSeq = 0) {
|
|
497
|
+
const entries = [];
|
|
498
|
+
for (const seq of await this.recordSeqs(operationId)) {
|
|
499
|
+
if (seq < fromStepSeq)
|
|
500
|
+
continue;
|
|
501
|
+
const entry = await this.readRecord(operationId, seq);
|
|
502
|
+
if (entry)
|
|
503
|
+
entries.push(entry);
|
|
504
|
+
}
|
|
505
|
+
verifyChain(entries);
|
|
506
|
+
return entries;
|
|
507
|
+
}
|
|
508
|
+
async recordsAfter(operationId, afterHead) {
|
|
509
|
+
if (afterHead === undefined)
|
|
510
|
+
return this.readFrom(operationId, 0);
|
|
511
|
+
for (const seq of await this.recordSeqs(operationId)) {
|
|
512
|
+
const entry = await this.readRecord(operationId, seq);
|
|
513
|
+
if (entry?.record_digest === afterHead)
|
|
514
|
+
return this.readFrom(operationId, seq + 1);
|
|
515
|
+
}
|
|
516
|
+
const pruned = await this.prunedAnchor(operationId);
|
|
517
|
+
if (pruned?.covered_head === afterHead)
|
|
518
|
+
return this.readFrom(operationId, pruned.through_step_seq + 1);
|
|
519
|
+
throw new JournalIntegrityError("journal cursor digest names no retained record");
|
|
520
|
+
}
|
|
521
|
+
/** Sorted ordinals of installed checkpoints. */
|
|
522
|
+
async checkpointOrdinals(operationId) {
|
|
523
|
+
const ordinals = [];
|
|
524
|
+
for (const name of await this.names(this.checkpointsPrefix(operationId))) {
|
|
525
|
+
const match = CHECKPOINT_NAME.exec(name);
|
|
526
|
+
if (match)
|
|
527
|
+
ordinals.push(Number(match[1]));
|
|
528
|
+
}
|
|
529
|
+
return ordinals.sort((a, b) => a - b);
|
|
530
|
+
}
|
|
531
|
+
async ackedOrdinals(operationId) {
|
|
532
|
+
const acked = new Set();
|
|
533
|
+
for (const name of await this.names(this.checkpointsPrefix(operationId))) {
|
|
534
|
+
const match = ACK_NAME.exec(name);
|
|
535
|
+
if (match)
|
|
536
|
+
acked.add(Number(match[1]));
|
|
537
|
+
}
|
|
538
|
+
return acked;
|
|
539
|
+
}
|
|
540
|
+
async readCheckpoint(operationId, ordinal, acked) {
|
|
541
|
+
const raw = await this.load(`${this.checkpointsPrefix(operationId)}${pad(ordinal)}.ckpt`);
|
|
542
|
+
if (raw === undefined)
|
|
543
|
+
return undefined;
|
|
544
|
+
let persisted;
|
|
545
|
+
try {
|
|
546
|
+
persisted = JSON.parse(raw);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
throw new JournalIntegrityError(`checkpoint ${pad(ordinal)} is not readable: ${String(err)}`);
|
|
550
|
+
}
|
|
551
|
+
const acknowledged = (acked ?? (await this.ackedOrdinals(operationId))).has(ordinal);
|
|
552
|
+
return {
|
|
553
|
+
ordinal: persisted.ordinal,
|
|
554
|
+
checkpoint_id: persisted.checkpoint_id,
|
|
555
|
+
...(persisted.previous_checkpoint_id === undefined
|
|
556
|
+
? {}
|
|
557
|
+
: { previous_checkpoint_id: persisted.previous_checkpoint_id }),
|
|
558
|
+
covered_head: persisted.covered_head,
|
|
559
|
+
through_step_seq: persisted.through_step_seq,
|
|
560
|
+
state_digest: persisted.state_digest,
|
|
561
|
+
checkpoint_bytes: fromBase64(persisted.checkpoint_bytes),
|
|
562
|
+
acknowledged,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
async latestCheckpoint(operationId) {
|
|
566
|
+
const ordinals = await this.checkpointOrdinals(operationId);
|
|
567
|
+
const last = ordinals.at(-1);
|
|
568
|
+
return last === undefined ? undefined : this.readCheckpoint(operationId, last);
|
|
569
|
+
}
|
|
570
|
+
async compareAndInstallCheckpoint(operationId, previousCheckpointId, coveredHead, checkpoint) {
|
|
571
|
+
validateCandidate(checkpoint);
|
|
572
|
+
const latest = await this.latestCheckpoint(operationId);
|
|
573
|
+
checkCheckpointPrecondition(latest, previousCheckpointId, checkpoint);
|
|
574
|
+
verifyCoveredHead(await this.readRecord(operationId, checkpoint.through_step_seq), await this.prunedAnchor(operationId), coveredHead, checkpoint.through_step_seq);
|
|
575
|
+
const ordinal = latest ? latest.ordinal + 1 : 0;
|
|
576
|
+
const persisted = {
|
|
577
|
+
ordinal,
|
|
578
|
+
checkpoint_id: checkpoint.checkpoint_id,
|
|
579
|
+
...(previousCheckpointId === undefined ? {} : { previous_checkpoint_id: previousCheckpointId }),
|
|
580
|
+
covered_head: coveredHead,
|
|
581
|
+
through_step_seq: checkpoint.through_step_seq,
|
|
582
|
+
state_digest: checkpoint.state_digest,
|
|
583
|
+
checkpoint_bytes: toBase64(checkpoint.checkpoint_bytes),
|
|
584
|
+
};
|
|
585
|
+
const won = await this.claim(`${this.checkpointsPrefix(operationId)}${pad(ordinal)}.ckpt`, persisted);
|
|
586
|
+
if (!won) {
|
|
587
|
+
throw new JournalCasConflictError(`checkpoint ordinal ${ordinal} was claimed by a concurrent installer`);
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
...checkpoint,
|
|
591
|
+
checkpoint_bytes: Uint8Array.from(checkpoint.checkpoint_bytes),
|
|
592
|
+
ordinal,
|
|
593
|
+
covered_head: coveredHead,
|
|
594
|
+
...(previousCheckpointId === undefined ? {} : { previous_checkpoint_id: previousCheckpointId }),
|
|
595
|
+
acknowledged: false,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
async ackCheckpoint(operationId, checkpointId) {
|
|
599
|
+
for (const ordinal of (await this.checkpointOrdinals(operationId)).reverse()) {
|
|
600
|
+
const installed = await this.readCheckpoint(operationId, ordinal);
|
|
601
|
+
if (!installed || installed.checkpoint_id !== checkpointId)
|
|
602
|
+
continue;
|
|
603
|
+
if (!installed.acknowledged) {
|
|
604
|
+
// A lost claim means another writer already acknowledged it — idempotent either way.
|
|
605
|
+
await this.claim(`${this.checkpointsPrefix(operationId)}${pad(ordinal)}.ack`, {
|
|
606
|
+
ordinal,
|
|
607
|
+
checkpoint_id: checkpointId,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return { ...installed, acknowledged: true };
|
|
611
|
+
}
|
|
612
|
+
throw new JournalIntegrityError("cannot acknowledge an uninstalled checkpoint");
|
|
613
|
+
}
|
|
614
|
+
async pruneAckedPrefix(operationId) {
|
|
615
|
+
const acked = await this.ackedOrdinals(operationId);
|
|
616
|
+
const existing = await this.prunedAnchor(operationId);
|
|
617
|
+
let boundary;
|
|
618
|
+
for (const ordinal of (await this.checkpointOrdinals(operationId)).reverse()) {
|
|
619
|
+
if (!acked.has(ordinal))
|
|
620
|
+
continue;
|
|
621
|
+
boundary = await this.readCheckpoint(operationId, ordinal, acked);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
if (!boundary) {
|
|
625
|
+
return { pruned_through_step_seq: existing?.through_step_seq ?? -1, pruned_count: 0 };
|
|
626
|
+
}
|
|
627
|
+
// Anchor first, delete second: a crash mid-prune leaves a resolvable head either way.
|
|
628
|
+
if (!existing || existing.through_step_seq < boundary.through_step_seq) {
|
|
629
|
+
await this.writeAnchor(operationId, {
|
|
630
|
+
through_step_seq: boundary.through_step_seq,
|
|
631
|
+
covered_head: boundary.covered_head,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
let pruned = 0;
|
|
635
|
+
for (const seq of await this.recordSeqs(operationId)) {
|
|
636
|
+
if (seq > boundary.through_step_seq)
|
|
637
|
+
break;
|
|
638
|
+
try {
|
|
639
|
+
await this.driver.remove(`${this.recordsPrefix(operationId)}${pad(seq)}.rec`);
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
throw new JournalIoError("journal could not reclaim a pruned record", { cause: err });
|
|
643
|
+
}
|
|
644
|
+
pruned++;
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
pruned_through_step_seq: Math.max(boundary.through_step_seq, existing?.through_step_seq ?? -1),
|
|
648
|
+
pruned_count: pruned,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/** The anchor only ever moves forward, so an overwriting `put` is the right primitive. */
|
|
652
|
+
async writeAnchor(operationId, anchor) {
|
|
653
|
+
try {
|
|
654
|
+
await this.driver.put(this.prunedKey(operationId), encoder.encode(JSON.stringify(anchor)));
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
throw new JournalIoError("journal could not record its pruned anchor", { cause: err });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async stageOutboundEnvelope(operationId, envelopeJson) {
|
|
661
|
+
if (!envelopeJson)
|
|
662
|
+
throw new JournalIntegrityError("outbound envelope must not be empty");
|
|
663
|
+
try {
|
|
664
|
+
await this.driver.put(this.outboundKey(operationId), encoder.encode(envelopeJson));
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
throw new JournalIoError("journal could not stage a durable outbound envelope", { cause: err });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async readOutboundEnvelope(operationId) {
|
|
671
|
+
return this.load(this.outboundKey(operationId));
|
|
672
|
+
}
|
|
673
|
+
async clearOutboundEnvelope(operationId) {
|
|
674
|
+
try {
|
|
675
|
+
await this.driver.remove(this.outboundKey(operationId));
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
throw new JournalIoError("journal could not clear its outbound envelope", { cause: err });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|