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