@cotal-ai/connector-core 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +12 -2
  3. package/dist/agent.js.map +1 -1
  4. package/dist/agui-holder.d.ts +89 -0
  5. package/dist/agui-holder.d.ts.map +1 -0
  6. package/dist/agui-holder.js +135 -0
  7. package/dist/agui-holder.js.map +1 -0
  8. package/dist/agui-render.d.ts +100 -0
  9. package/dist/agui-render.d.ts.map +1 -0
  10. package/dist/agui-render.js +283 -0
  11. package/dist/agui-render.js.map +1 -0
  12. package/dist/agui-wal-path.d.ts +103 -0
  13. package/dist/agui-wal-path.d.ts.map +1 -0
  14. package/dist/agui-wal-path.js +324 -0
  15. package/dist/agui-wal-path.js.map +1 -0
  16. package/dist/agui.d.ts +727 -0
  17. package/dist/agui.d.ts.map +1 -0
  18. package/dist/agui.js +1114 -0
  19. package/dist/agui.js.map +1 -0
  20. package/dist/docs-bundle.generated.d.ts.map +1 -1
  21. package/dist/docs-bundle.generated.js +16 -9
  22. package/dist/docs-bundle.generated.js.map +1 -1
  23. package/dist/durable-source.d.ts +127 -0
  24. package/dist/durable-source.d.ts.map +1 -0
  25. package/dist/durable-source.js +219 -0
  26. package/dist/durable-source.js.map +1 -0
  27. package/dist/event-wal.d.ts +265 -0
  28. package/dist/event-wal.d.ts.map +1 -0
  29. package/dist/event-wal.js +698 -0
  30. package/dist/event-wal.js.map +1 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +5 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/launch.d.ts +78 -2
  36. package/dist/launch.d.ts.map +1 -1
  37. package/dist/launch.js +120 -0
  38. package/dist/launch.js.map +1 -1
  39. package/dist/tool-specs.d.ts +40 -2
  40. package/dist/tool-specs.d.ts.map +1 -1
  41. package/dist/tool-specs.js +51 -1
  42. package/dist/tool-specs.js.map +1 -1
  43. package/dist/tools.d.ts.map +1 -1
  44. package/dist/tools.js +5 -6
  45. package/dist/tools.js.map +1 -1
  46. package/package.json +8 -2
@@ -0,0 +1,698 @@
1
+ /**
2
+ * The event emitter's write-ahead log: ONE file per thread, read once at boot.
3
+ *
4
+ * The emitter publishes to a per-principal, per-thread subject under an OPTIMISTIC-CONCURRENCY
5
+ * expectation, so it must know the tip it expects (`E`) and the dedup id it froze — and it must
6
+ * know them across a crash. That is all this file is: the durable half of the state machine in
7
+ * of the design, with the transitions kept honest by construction rather than by comment.
8
+ *
9
+ * THE ORDERING RULE THE WHOLE DESIGN RESTS ON. Transition 1 records `sent_unacked` and only THEN
10
+ * publishes; transition 2 records `acked` on a NON-duplicate ack, durably, BEFORE the frontier
11
+ * moves; transition 3 folds the frontier and clears pending. The `acked` state looks redundant
12
+ * until you remove it: an earlier draft committed the cursor and then unlinked pending, on the
13
+ * reasoning that a crash between them would "re-publish, and CAS or in-window dedupe absorbs it".
14
+ * That is false under these same rules. Pending still holds the PRE-STORE `E`, the tip has moved to
15
+ * `E+1`, so recovery takes a CAS loss — which is fail-loud — and the emitter wedges FOREVER on a
16
+ * frame that already landed. Making success durable before the frontier moves is what stops a
17
+ * completed store re-entering the fail-loud path.
18
+ *
19
+ * NOTHING HERE IS EVER REPAIRED. Every unreadable, mixed-vintage or impossible state fails loud.
20
+ * Discarding a WAL is indistinguishable from the case where the frame DID land, so a "recovery"
21
+ * heuristic here is a silent-loss path wearing a helpful face.
22
+ */
23
+ import { createHash, randomUUID } from "node:crypto";
24
+ import { constants } from "node:fs";
25
+ import { open, readFile, rename, unlink } from "node:fs/promises";
26
+ import { dirname, join } from "node:path";
27
+ import { assertIdToken } from "@cotal-ai/core";
28
+ /**
29
+ * Bump ONLY with a migration. An OLDER document is migrated forward; a NEWER one fails loud.
30
+ *
31
+ * **v2 added `brackets`** — the AG-UI bracket machine's state, persisted so a mid-run restart can
32
+ * continue instead of refusing the first event it re-reads.
33
+ *
34
+ * **v3 added `gen`** — a counter this writer bumps on every durable replace, so a write can tell
35
+ * whether the document it is about to replace is the one it last read. See {@link EventWal.write}.
36
+ * A v2 document has never been written by a generation-aware writer, so it migrates forward at
37
+ * generation 0; from v3 on the field is REQUIRED, because "absent" and "zero" would otherwise be
38
+ * the same value and stripping the field would disable the guard silently.
39
+ *
40
+ * **THE MIGRATION IS FORWARD-ONLY, AND THAT MAKES THE STATE OUTLIVE A CODE ROLLBACK.** Once a
41
+ * process writes v2, reverting the code does NOT revert the state: the older build refuses the
42
+ * document it now finds. That is the right trade — fail-loud beats silently reading a schema you do
43
+ * not understand — but it converts "revert the commit" into "revert the commit and hand-migrate the
44
+ * state", and the person doing the reverting will otherwise discover that at the worst moment. So
45
+ * the refusal below distinguishes NEWER-than-this-code from unknown, and says which migration.
46
+ * There is deliberately no downgrade path: a lossy downgrade is worse than a halt.
47
+ */
48
+ export const EVENT_WAL_VERSION = 3;
49
+ /** Every refusal in this file is one of these, so a caller can never mistake it for an I/O blip. */
50
+ export class WalCorruptError extends Error {
51
+ path;
52
+ invariant;
53
+ constructor(path, invariant, detail) {
54
+ super(`event WAL at ${path} is unusable (${invariant}): ${detail}`);
55
+ this.path = path;
56
+ this.invariant = invariant;
57
+ this.name = "WalCorruptError";
58
+ }
59
+ }
60
+ /**
61
+ * Thrown when this handle's document is not the one on disk any more.
62
+ *
63
+ * A DISTINCT type from {@link WalCorruptError} because the file is not corrupt: it is FINE, and it
64
+ * belongs to somebody else's newer view. An operator reading "corrupt" would go looking for a bad
65
+ * disk; the actual situation is two writers, and only one of them is current.
66
+ */
67
+ export class WalStaleWriterError extends Error {
68
+ path;
69
+ expectedGen;
70
+ foundGen;
71
+ constructor(path, expectedGen, foundGen) {
72
+ super(`event WAL at ${path} was written by another handle since this one read it (this handle expects ` +
73
+ `generation ${expectedGen}, the file is at ${foundGen === undefined ? "no file at all" : `generation ${foundGen}`}) — ` +
74
+ `refusing to overwrite it. A second emitter or a stale handle is writing this principal's log.`);
75
+ this.path = path;
76
+ this.expectedGen = expectedGen;
77
+ this.foundGen = foundGen;
78
+ this.name = "WalStaleWriterError";
79
+ }
80
+ }
81
+ const isSafeNonNegInt = (n) => Number.isSafeInteger(n) && n >= 0;
82
+ /**
83
+ * The generation a raw document carries, stated in ONE place so the load path and the write path
84
+ * cannot drift apart about what a missing field means.
85
+ *
86
+ * Pre-v3 documents predate the field entirely, and 0 is that fact rather than a default: they have
87
+ * never been through a generation-aware write, so the first v3 write stamps 1 and every later
88
+ * comparison is exact. From v3 on the field is required — an absent one is refused, because a
89
+ * writer that treated it as 0 would silently accept a document with the guard stripped out.
90
+ */
91
+ function docGeneration(path, doc) {
92
+ if (typeof doc.v === "number" && doc.v < 3)
93
+ return 0;
94
+ if (!isSafeNonNegInt(doc.gen))
95
+ throw new WalCorruptError(path, "gen is a safe non-negative integer", `found gen=${String(doc.gen)} on a v${String(doc.v)} document`);
96
+ return doc.gen;
97
+ }
98
+ /**
99
+ * Create a file EXCLUSIVELY and WITHOUT following a symlink, at mode 0600.
100
+ *
101
+ * **Exported so a test can drive the shipped flags rather than recompose them.** A cell that builds
102
+ * `O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW` itself is testing a COPY of this rule: it stays green
103
+ * while the production open drifts or loses a flag. That is not hypothetical here — the first
104
+ * version of those cells did exactly that, and a mutation reverting the real open to `"w"` left the
105
+ * suite fully green. Driving this function is what makes the mutation land.
106
+ *
107
+ * `O_EXCL` refuses a pre-existing file rather than adopting it — which matters because `"w"` does
108
+ * NOT re-chmod an existing inode, so an adopted 0644 temp would carry its mode onto the renamed WAL
109
+ * and expose `pending.id`. `O_NOFOLLOW` refuses a symlink rather than truncating its target.
110
+ */
111
+ export async function openExclusiveNoFollow(path) {
112
+ return open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
113
+ }
114
+ /**
115
+ * The boot invariant for an `acked` pending, checked in ONE place and stated positively.
116
+ *
117
+ * A WAL whose frontier is NEWER than its acked pending is a file of MIXED VINTAGE, and the reason
118
+ * that must fail loud rather than "already folded, drop it and continue" is not fussiness. If the
119
+ * frontier came from a later copy than the pending, its `sourceCursor` may ALREADY have advanced
120
+ * past events whose frames were never published — so resuming from it drops those events with no
121
+ * gap anywhere for a consumer to notice. Nothing inside the WAL distinguishes "genuinely folded
122
+ * already" from "this frontier is from a foreign vintage", which puts it in the same class as a
123
+ * corrupt or tuple-mismatched document.
124
+ *
125
+ * Named relations rather than one compound boolean: an operator who cannot see WHICH relation broke
126
+ * cannot act on it, and a single fused check gives a mutation only one cell to kill.
127
+ *
128
+ * **THERE IS DELIBERATELY NO `sourceCursor` RELATION HERE, AND THAT IS A CORRECTION.**
129
+ * An earlier version asserted `pending.sourceCursor >= frontier.sourceCursor` using string `<`.
130
+ * A cursor is **OPAQUE** — `DurableSource` defines it per source and states that a caller "never
131
+ * parses it". `JsonlFileSource` happens to emit `dev:ino:offset:seal`, and lexicographic order is
132
+ * NOT offset order once the digit width changes, so that check was wrong in BOTH directions:
133
+ *
134
+ * frontier offset 8, acked pending offset 45 → REFUSED a healthy WAL ("45" < "8")
135
+ * frontier offset 170, acked pending offset 98 → ACCEPTED a mixed-vintage one ("98" > "170")
136
+ *
137
+ * The first wedges recovery on ordinary source growth across any digit boundary — on the exact
138
+ * crash window the `acked` state exists to survive. The second silently admits the mixed vintage
139
+ * the relation was written to refuse. Found independently by two reviewers, then reproduced here.
140
+ *
141
+ * The root cause is not the operator; it is that **ordering an opaque value requires parsing it**,
142
+ * and parsing it would bind this WAL to one source's format — breaking the abstraction the moment
143
+ * a store-backed source uses something else (OpenCode's `part.id`, a rollout path, an API token).
144
+ * So the relation is not fixed, it is REMOVED: it cannot be stated correctly at this layer.
145
+ *
146
+ * What remains is sufficient and well-defined, because it uses the WAL's OWN monotonic counters
147
+ * rather than a foreign key: an acked frame must be exactly the frontier's successor, and its
148
+ * ackSeq must be ahead of the frontier's. A document whose halves come from different vintages
149
+ * disagrees on those too, since both sides are written by this file.
150
+ */
151
+ function assertPendingVintage(path, p, f) {
152
+ // ── RELATIONS THAT HOLD FOR *EITHER* TAG ──
153
+ //
154
+ // These were previously checked ONLY for `acked`, which left `sent_unacked` — the crash window
155
+ // transition 1 exists to survive — almost unguarded. Documents with `E` disagreeing with the
156
+ // frontier's tip, or a `seq` that is not the frontier's successor, LOADED and would then retry a
157
+ // frozen expectation that cannot be the honest successor of this frontier: a permanent CAS halt,
158
+ // or a publish at the wrong stream position, with no loud corrupt at open.
159
+ //
160
+ // The asymmetry was mine: I wrote three named relations for the path that has already succeeded
161
+ // and almost none for the path that is still in flight. Found by fmae-rev-sec, reproduced by
162
+ // fmae-rev-eng and fmae-rev-wal, then here.
163
+ if (p.E !== f.lastSubjectSeq)
164
+ throw new WalCorruptError(path, "pending.E === frontier.lastSubjectSeq", `pending.E=${p.E} frontier.lastSubjectSeq=${f.lastSubjectSeq} — the frozen expectation is not ` +
165
+ `the tip this WAL believes, so retrying it can only CAS-halt or append at the wrong position`);
166
+ if (p.seq !== f.seq + 1)
167
+ throw new WalCorruptError(path, "pending.seq === frontier.seq + 1", `pending.seq=${p.seq} frontier.seq=${f.seq} — a pending frame must be exactly the frontier's successor`);
168
+ if (p.state === "sent_unacked") {
169
+ // The tag says "not yet acked". An ackSeq here means the document contradicts its own tag, and
170
+ // guessing which half is true is exactly the repair this file never does.
171
+ if (p.ackSeq !== undefined)
172
+ throw new WalCorruptError(path, "sent_unacked has no ackSeq", `state is "sent_unacked" but ackSeq=${String(p.ackSeq)} — the document contradicts its own tag`);
173
+ return;
174
+ }
175
+ if (!isSafeNonNegInt(p.ackSeq))
176
+ throw new WalCorruptError(path, "acked.ackSeq present", `state is "acked" but ackSeq is ${String(p.ackSeq)}`);
177
+ if (!(p.ackSeq > f.lastSubjectSeq))
178
+ throw new WalCorruptError(path, "acked.ackSeq > frontier.lastSubjectSeq", `ackSeq=${p.ackSeq} frontier.lastSubjectSeq=${f.lastSubjectSeq} — the frontier is of a later ` +
179
+ `vintage than the acked frame, so its sourceCursor may already have passed events that were ` +
180
+ `never published; resuming would drop them with no gap for a consumer to see`);
181
+ // NO sourceCursor comparison — see the note above. The cursor is opaque; ordering it here is not
182
+ // a thing this layer can do correctly, and doing it with string `<` was wrong in both directions.
183
+ }
184
+ /**
185
+ * Validate a persisted bracket state, or refuse it.
186
+ *
187
+ * `nullable` distinguishes the document-level field (which may legitimately be `null` for unknown)
188
+ * from a pending frame's (which never may — a frame that was decided has a state that was decided
189
+ * with it). Passing the same shape check over both is what keeps the two from drifting.
190
+ *
191
+ * The arrays are checked ELEMENT BY ELEMENT rather than by `Array.isArray` alone. A JSON array is a
192
+ * shape a foreign or hand-edited producer can satisfy while carrying numbers or objects, and this
193
+ * value is loaded straight into a `Set` that decides whether an event is refused.
194
+ */
195
+ function parseBrackets(path, field, v, nullable) {
196
+ if (v === null && nullable)
197
+ return null;
198
+ if (typeof v !== "object" || v === null)
199
+ throw new WalCorruptError(path, `${field} is an object${nullable ? " or null" : ""}`, JSON.stringify(v));
200
+ const b = v;
201
+ if (b.run !== undefined && typeof b.run !== "string")
202
+ throw new WalCorruptError(path, `${field}.run is a string or absent`, JSON.stringify(b.run));
203
+ const lists = { text: b.text, reasoning: b.reasoning, tools: b.tools };
204
+ for (const [k, list] of Object.entries(lists)) {
205
+ if (!Array.isArray(list) || list.some((x) => typeof x !== "string"))
206
+ throw new WalCorruptError(path, `${field}.${k} is an array of strings`, JSON.stringify(list));
207
+ }
208
+ // A closed run with open messages is not a state any transition can produce: `RUN_FINISHED` is
209
+ // refused while anything it opened is still open. Refusing it here keeps a hand-edited document
210
+ // from restoring a machine into a state the machine itself would never enter.
211
+ if (b.run === undefined && b.text.concat(b.reasoning, b.tools).length > 0)
212
+ throw new WalCorruptError(path, `${field} has no open run while messages or tool calls are open`, JSON.stringify(b));
213
+ return { run: b.run, text: b.text, reasoning: b.reasoning, tools: b.tools };
214
+ }
215
+ function parseDoc(path, raw, space, threadId, principal) {
216
+ let d;
217
+ try {
218
+ d = JSON.parse(raw);
219
+ }
220
+ catch (e) {
221
+ throw new WalCorruptError(path, "parseable JSON", e.message);
222
+ }
223
+ if (typeof d !== "object" || d === null)
224
+ throw new WalCorruptError(path, "document is an object", typeof d);
225
+ const doc = d;
226
+ // Version FIRST: on an unknown version every field below is a guess about a foreign schema.
227
+ //
228
+ // NEWER and OLDER are different situations and are answered differently. Older is MIGRATED below.
229
+ // Newer is refused with a message that says the STATE IS NEWER THAN THE CODE and names what
230
+ // introduced it — because "unknown version" sends an operator looking for corruption, and the
231
+ // actual situation is a rollback that left state behind. There is no downgrade: a lossy one would
232
+ // be worse than this halt.
233
+ if (typeof doc.v !== "number" || !Number.isSafeInteger(doc.v) || doc.v < 1)
234
+ throw new WalCorruptError(path, "v is a positive integer", `found v=${String(doc.v)}`);
235
+ if (doc.v > EVENT_WAL_VERSION)
236
+ throw new WalCorruptError(path, `v <= ${EVENT_WAL_VERSION}`, `this WAL is v${doc.v} and this build understands v${EVENT_WAL_VERSION} — THE STATE IS NEWER ` +
237
+ `THAN THE CODE, which is what a code rollback across the v2 migration (persisted bracket ` +
238
+ `state) or the v3 one (the write generation) leaves behind. The migration is forward-only ` +
239
+ `by design: there is no downgrade, ` +
240
+ `because a lossy one would silently discard state this file exists to preserve. Run the ` +
241
+ `newer build, or move this WAL aside and accept that the thread restarts from a new epoch.`);
242
+ // The tuple: a WAL that belongs to a different space, thread or principal is not ours to resume
243
+ // from. All THREE are checked, because all three are hashed path components and the design stores
244
+ // the unhashed tuple inside the file precisely so a collided or mis-resolved directory is a loud
245
+ // mismatch instead of silent cross-talk.
246
+ if (doc.space !== space)
247
+ throw new WalCorruptError(path, "space matches", `WAL space=${String(doc.space)} caller=${space}`);
248
+ if (doc.threadId !== threadId)
249
+ throw new WalCorruptError(path, "threadId matches", `WAL threadId=${String(doc.threadId)} caller=${threadId}`);
250
+ if (doc.principal !== principal)
251
+ throw new WalCorruptError(path, "principal matches", `WAL principal=${String(doc.principal)} caller=${principal}`);
252
+ if (typeof doc.epoch !== "string" || doc.epoch.length === 0)
253
+ throw new WalCorruptError(path, "epoch is a non-empty string", String(doc.epoch));
254
+ const f = doc.frontier;
255
+ if (!f || !isSafeNonNegInt(f.seq) || !isSafeNonNegInt(f.lastSubjectSeq))
256
+ throw new WalCorruptError(path, "frontier is well-formed", JSON.stringify(doc.frontier));
257
+ if (f.sourceCursor !== undefined && typeof f.sourceCursor !== "string")
258
+ throw new WalCorruptError(path, "frontier.sourceCursor is a string or absent", typeof f.sourceCursor);
259
+ // A NONZERO FRONTIER MUST CARRY THE POSITION IT WAS DERIVED FROM.
260
+ //
261
+ // This guard was omitted on the reasoning that no shipped transition can produce a nonzero
262
+ // frontier without a cursor — which is exactly backwards. A recovery component must refuse the
263
+ // states its own writer CANNOT produce, because those are precisely the states corruption
264
+ // produces. The happy path is not the input domain.
265
+ //
266
+ // The cost of accepting it is silent loss, and it is not theoretical: the emitter resumes by
267
+ // reading forward from this cursor, and `read(undefined)` does not resume — it ADOPTS AT THE
268
+ // CURRENT END (`durable-source.ts:155-156`). So an absent cursor makes every unread record
269
+ // vanish, transition 4 durably commits the new end, and nothing is left to notice it: no frame
270
+ // published, and therefore no gap in the consumer's `seq` either.
271
+ //
272
+ // A cursor at a ZERO frontier is legal and must stay so: a cursor-only advance over a
273
+ // source range that mapped to no events is exactly that state, and it is the ONLY asymmetry
274
+ // here. `seq` and `lastSubjectSeq` move together in transition 3 and reset together in
275
+ // abandonment, so a mixed pair is impossible by every shipped transition and refused for the
276
+ // same reason as the missing cursor.
277
+ if ((f.seq === 0) !== (f.lastSubjectSeq === 0))
278
+ throw new WalCorruptError(path, "frontier.seq and lastSubjectSeq are both zero or both nonzero", JSON.stringify(f));
279
+ if ((f.seq > 0 || f.lastSubjectSeq > 0) && typeof f.sourceCursor !== "string")
280
+ throw new WalCorruptError(path, "a nonzero frontier carries its sourceCursor", JSON.stringify(f));
281
+ // A MISSING `pending` KEY IS NOT AN EXPLICIT `null`, AND THE DIFFERENCE IS THE WHOLE POINT.
282
+ // `null` is this writer stating that nothing is outstanding. An ABSENT key is a document that
283
+ // never said — a truncated tail, a hand edit, a foreign producer — and accepting it silently
284
+ // reclassifies "we may have published and do not know" as "we have nothing in flight", which is
285
+ // the one downgrade a write-ahead log must never make on its own authority.
286
+ //
287
+ // ONE check, not two. The first version guarded the absent key with `hasOwnProperty` AND the
288
+ // shape with a `typeof`, and the mutation proof caught the redundancy: deleting the
289
+ // `hasOwnProperty` half killed nothing, because an absent key is `undefined` and the shape check
290
+ // already refuses it with the same invariant. Two mechanisms preventing one outcome means a cell
291
+ // asserting that outcome proves neither of them — so the belt came off and the cells now bite on
292
+ // the one guard that does the work.
293
+ if (doc.pending !== null && typeof doc.pending !== "object")
294
+ throw new WalCorruptError(path, "pending is present (null or an object)", doc.pending === undefined ? "the key is absent, which is not the same as null" : typeof doc.pending);
295
+ let pending = null;
296
+ if (doc.pending !== null) {
297
+ const p = doc.pending;
298
+ if (p.state !== "sent_unacked" && p.state !== "acked")
299
+ throw new WalCorruptError(path, "pending.state is a known tag", String(p.state));
300
+ if (typeof p.id !== "string" || p.id.length === 0)
301
+ throw new WalCorruptError(path, "pending.id is a non-empty string", String(p.id));
302
+ // ...and it must satisfy the WIRE grammar, not merely be a string. `beginSend` validates with
303
+ // `assertIdToken` on the way IN; open accepted anything non-empty on the way OUT — so a corrupted
304
+ // or hand-edited id (`"has.dots"`) was ADOPTED at recovery and then rejected by
305
+ // `multicastExpecting` on every republish attempt. That turns disk corruption into a permanent
306
+ // runtime wedge instead of a refusal at open, which inverts this file's posture. Validated with
307
+ // the SHIPPED grammar rather than a second copy that could drift from it.
308
+ try {
309
+ assertIdToken(p.id, "event WAL pending.id");
310
+ }
311
+ catch (e) {
312
+ throw new WalCorruptError(path, "pending.id satisfies the wire id grammar", e.message);
313
+ }
314
+ if (!isSafeNonNegInt(p.E) || !isSafeNonNegInt(p.seq))
315
+ throw new WalCorruptError(path, "pending E/seq are safe non-negative integers", JSON.stringify(p));
316
+ if (typeof p.sourceCursor !== "string")
317
+ throw new WalCorruptError(path, "pending.sourceCursor is a string", typeof p.sourceCursor);
318
+ // The frozen body must still be PUBLISHABLE, for the reason the id check above exists: a value
319
+ // that the wire rejects, adopted at recovery, turns disk corruption into a permanent wedge
320
+ // rather than a refusal at open.
321
+ //
322
+ // The bar is `multicastExpecting`'s OWN precondition — a non-empty array — and deliberately not
323
+ // core's `isMessagePart`. That predicate is core's INBOUND validator; the publish path does not
324
+ // apply it, so mirroring it here would refuse documents that would in fact publish, and would
325
+ // make this file a second, drifting source of truth about what a part is.
326
+ if (!Array.isArray(p.body) || p.body.length === 0)
327
+ throw new WalCorruptError(path, "pending.body is a non-empty array of parts", JSON.stringify(p.body));
328
+ // A v1 document with a frame IN FLIGHT cannot be migrated, and this is the one migration case
329
+ // that must refuse rather than default. The frame's bracket state is not recoverable from
330
+ // anything in the file, and inventing one would restore a machine into a state that never
331
+ // existed — on the exact path (`sent_unacked` recovery) where a wrong answer republishes.
332
+ // A v1 WAL at REST migrates cleanly; only one mid-flight does not.
333
+ if (doc.v === 1)
334
+ throw new WalCorruptError(path, "a v1 WAL has no frame in flight", `this v1 document holds a ${String(p.state)} frame, and v2 requires the ` +
335
+ `bracket state that belongs to it — which v1 never recorded and nothing here can reconstruct. ` +
336
+ `Let the older build settle this frame first, then start the newer one.`);
337
+ p.brackets = parseBrackets(path, "pending.brackets", p.brackets, false);
338
+ pending = p;
339
+ assertPendingVintage(path, pending, f);
340
+ }
341
+ // v1 KNEW NOTHING ABOUT BRACKETS, so migrating one forward yields `null` — unknown — and NOT an
342
+ // empty state. The document genuinely cannot say what was open when it was written, and saying
343
+ // "nothing was open" on its behalf is inventing an observation it never made.
344
+ const brackets = doc.v === 1 ? null : parseBrackets(path, "brackets", doc.brackets, true);
345
+ return {
346
+ v: EVENT_WAL_VERSION, // MIGRATED IN MEMORY; it reaches disk on the next durable write.
347
+ // The generation as the FILE states it, so the first write from this handle compares against
348
+ // what it actually read rather than against its own migrated shape.
349
+ gen: docGeneration(path, doc),
350
+ space,
351
+ epoch: doc.epoch,
352
+ threadId,
353
+ principal,
354
+ frontier: f,
355
+ pending,
356
+ brackets,
357
+ };
358
+ }
359
+ export class EventWal {
360
+ path;
361
+ doc;
362
+ /**
363
+ * Every mutation runs one-at-a-time on this chain.
364
+ *
365
+ * Without it, two concurrent `beginSend` calls both read `this.doc.pending === null` before either
366
+ * durable replace finishes — the guard is an in-memory read that is NOT atomic with the write
367
+ * across its `await` points. Reviewers reproduced the split: one call fulfils, one rejects, and
368
+ * the process is left holding `pending.id === "A"` in memory while the disk says `"B"`. Recovery
369
+ * would then resume the frame on disk while the live emitter retries the other, which breaks the
370
+ * one thing this file exists to guarantee — that `id` and `E` are frozen and agreed.
371
+ *
372
+ * A per-instance chain is sufficient and honest about its scope, and the scope is narrower than it
373
+ * once claimed: it serializes THIS INSTANCE's callers. It does nothing about a SECOND `EventWal`
374
+ * on the same file, in this process or another — the chain is per object, so two objects are two
375
+ * chains and both of them "succeed". That gap was described here as "solved upstream by the
376
+ * principal-level lock" while no lock was ever acquired. Two things close it now, and neither is
377
+ * this chain: `acquirePrincipalLock` refuses a second emitter for the principal at start, and
378
+ * {@link EventWal.assertNotClobbering} refuses a stale handle's write even when it got past that.
379
+ */
380
+ chain = Promise.resolve();
381
+ /** Run `op` after every previously-queued mutation, whether they resolved or threw. */
382
+ serialize(op) {
383
+ const next = this.chain.then(op, op);
384
+ // Keep the chain alive after a rejection so one failed mutation cannot wedge every later one.
385
+ this.chain = next.catch(() => undefined);
386
+ return next;
387
+ }
388
+ constructor(path, doc) {
389
+ this.path = path;
390
+ this.doc = doc;
391
+ }
392
+ get epoch() { return this.doc.epoch; }
393
+ /** The principal this WAL was loaded FOR — exposed so a consumer can prove it is holding its own.
394
+ * `open()` already refuses a document whose stored principal disagrees, but that check protects
395
+ * the FILE, not the caller: an emitter handed the wrong WAL object entirely would sail past it. */
396
+ get principal() { return this.doc.principal; }
397
+ get threadId() { return this.doc.threadId; }
398
+ /** The bracket machine at the folded position, or `null` when the document cannot say (migrated
399
+ * from v1). The two are different facts; see {@link WalDoc.brackets}. */
400
+ get brackets() { return this.doc.brackets; }
401
+ get frontier() { return { ...this.doc.frontier }; }
402
+ get pending() { return this.doc.pending ? { ...this.doc.pending } : null; }
403
+ /**
404
+ * Load an existing WAL, or start a virgin one.
405
+ *
406
+ * `subjectMayExist` is the caller's honest statement about whether this principal+thread could
407
+ * already have published. It is NOT a convenience flag: with it true, a missing or empty WAL is a
408
+ * refusal, because the tip cannot be inferred — agent creds hold no read shape over the subject,
409
+ * and guessing `E := 0` either CAS-halts forever or appends under a stale expectation. Recovery
410
+ * from that state is an explicit operator act, never a startup heuristic.
411
+ */
412
+ static async open(path, opts) {
413
+ let raw;
414
+ let bytes;
415
+ try {
416
+ bytes = await readFile(path);
417
+ }
418
+ catch (e) {
419
+ if (e.code !== "ENOENT")
420
+ throw e;
421
+ }
422
+ if (bytes !== undefined) {
423
+ // FATAL UTF-8 — never `readFile(path, "utf8")`. Node's default decoder SUBSTITUTES U+FFFD for
424
+ // invalid bytes, so a corrupted file arrives as a changed-but-parseable document: a single raw
425
+ // 0xff inside the epoch string loaded cleanly with `epoch` silently rewritten. For a file whose
426
+ // whole posture is that every unreadable state fails loud, "quietly altered and accepted" is
427
+ // the one outcome it must not produce — the identity bytes recovery depends on would be the
428
+ // decoder's invention. `JsonlFileSource` in this same package already decodes fatally for
429
+ // exactly this class; this is that rule applied where it was missing, not a new one.
430
+ try {
431
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
432
+ }
433
+ catch {
434
+ throw new WalCorruptError(path, "the file is valid UTF-8", "invalid UTF-8 bytes; refusing rather than substituting U+FFFD");
435
+ }
436
+ }
437
+ if (raw === undefined) {
438
+ if (opts.subjectMayExist)
439
+ throw new WalCorruptError(path, "WAL exists when the subject may", "no WAL file, but this thread may already have published");
440
+ return new EventWal(path, EventWal.virgin(opts.space, opts.threadId, opts.principal));
441
+ }
442
+ // A ZERO-BYTE file is its own case, and the trap this design is shaped to fall into: it reads
443
+ // as "no content, therefore fresh thread, therefore E := 0" — the exact guess the missing-WAL
444
+ // rule already forbids by another route. An atomic temp+rename never produces one; a filesystem
445
+ // that lost the tail does. It is NEVER virgin.
446
+ if (raw.length === 0)
447
+ throw new WalCorruptError(path, "WAL is non-empty", "the file is zero bytes — distinct from missing and never treated as a virgin thread");
448
+ return new EventWal(path, parseDoc(path, raw, opts.space, opts.threadId, opts.principal));
449
+ }
450
+ static virgin(space, threadId, principal) {
451
+ return {
452
+ v: EVENT_WAL_VERSION,
453
+ // Nothing has been written yet, so the first durable replace stamps generation 1 and must find
454
+ // NO FILE. A file present at generation 0 means somebody else created it while this handle
455
+ // believed the thread was virgin, and that is refused rather than overwritten.
456
+ gen: 0,
457
+ space,
458
+ epoch: randomUUID(),
459
+ threadId,
460
+ principal,
461
+ frontier: { seq: 0, lastSubjectSeq: 0, sourceCursor: undefined },
462
+ pending: null,
463
+ // KNOWN empty, not unknown: a virgin thread has published nothing, so "nothing is open" is an
464
+ // observation this writer can actually make.
465
+ brackets: { run: undefined, text: [], reasoning: [], tools: [] },
466
+ };
467
+ }
468
+ /** Transition 1 — record the frame, with `id` and `E` frozen, BEFORE any publish. */
469
+ async beginSend(frame) {
470
+ return this.serialize(async () => {
471
+ if (this.doc.pending)
472
+ throw new Error(`event WAL ${this.path}: a frame is already pending; one emit unit is one pending frame`);
473
+ // THE SAME GRAMMAR THE WIRE USES, not a restatement of it. `beginSend` previously accepted ids
474
+ // (" ", a newline, 65 chars, dots) that `multicastExpecting`'s `assertIdToken` rejects — so T1
475
+ // could freeze an id on disk that can NEVER publish, wedging the emitter until an abandon. The
476
+ // id is supposed to be one token on the wire and in the WAL; importing the check is what makes
477
+ // that true rather than asserted.
478
+ assertIdToken(frame.id, "event WAL pending id");
479
+ if (frame.E !== this.doc.frontier.lastSubjectSeq)
480
+ throw new Error(`event WAL ${this.path}: E=${frame.E} is not the frontier's tip ${this.doc.frontier.lastSubjectSeq}`);
481
+ if (frame.seq !== this.doc.frontier.seq + 1)
482
+ throw new Error(`event WAL ${this.path}: seq=${frame.seq} is not the frontier's successor ${this.doc.frontier.seq + 1}`);
483
+ // Refuse an unpublishable body HERE rather than freezing it and discovering it on the retry
484
+ // after a crash — the same reason the id is validated on the way in. Same bar as the load
485
+ // guard and as `multicastExpecting`: non-empty array.
486
+ if (!Array.isArray(frame.body) || frame.body.length === 0)
487
+ throw new Error(`event WAL ${this.path}: a frame body must be a non-empty array of parts`);
488
+ await this.write({ ...this.doc, pending: { state: "sent_unacked", ...frame } });
489
+ });
490
+ }
491
+ /**
492
+ * Transition 2 — a NON-duplicate ack becomes durable before the frontier moves.
493
+ * A duplicate ack must never reach here; the caller fails loud on one.
494
+ */
495
+ async recordAck(ackSeq) {
496
+ return this.serialize(async () => {
497
+ const p = this.doc.pending;
498
+ if (!p || p.state !== "sent_unacked")
499
+ throw new Error(`event WAL ${this.path}: no sent_unacked frame to ack`);
500
+ // VALIDATE BEFORE THE DURABLE WRITE. `recordAck(-1)` was accepted, `fold()` then persisted
501
+ // `frontier.lastSubjectSeq = -1`, and the NEXT open refused the file — a single bad ack durably
502
+ // bricked the WAL while every call reported success. Fail-closed has to happen before the write,
503
+ // not on the boot after it.
504
+ if (!isSafeNonNegInt(ackSeq))
505
+ throw new Error(`event WAL ${this.path}: ackSeq must be a safe non-negative integer, got ${String(ackSeq)}`);
506
+ if (ackSeq <= this.doc.frontier.lastSubjectSeq)
507
+ throw new Error(`event WAL ${this.path}: ackSeq=${ackSeq} is not ahead of the frontier's tip ${this.doc.frontier.lastSubjectSeq}`);
508
+ await this.write({ ...this.doc, pending: { ...p, state: "acked", ackSeq } });
509
+ });
510
+ }
511
+ /** Transition 3 — fold the acked frame into the frontier and clear pending. */
512
+ async fold() {
513
+ return this.serialize(async () => {
514
+ const p = this.doc.pending;
515
+ if (!p || p.state !== "acked" || p.ackSeq === undefined)
516
+ throw new Error(`event WAL ${this.path}: no acked frame to fold`);
517
+ await this.write({
518
+ ...this.doc,
519
+ frontier: { seq: p.seq, lastSubjectSeq: p.ackSeq, sourceCursor: p.sourceCursor },
520
+ pending: null,
521
+ // The frame's frozen state becomes the document's, so `brackets` always describes exactly the
522
+ // events that are PUBLISHED AND FOLDED — never a batch that was validated and not yet sent.
523
+ brackets: p.brackets,
524
+ });
525
+ });
526
+ }
527
+ /**
528
+ * Transition 4 — a bounded source range that mapped to NOTHING.
529
+ *
530
+ * A mapper SUCCESS returning zero events advances the cursor atomically and alone: no
531
+ * `seq` consumed, no pending written, no publish. A mapper ERROR never advances it. Empty and
532
+ * failed must not share a path: conflating them turns a parser bug into silently skipped history.
533
+ */
534
+ async advanceCursorOnly(rangeEnd) {
535
+ return this.serialize(async () => {
536
+ if (this.doc.pending)
537
+ throw new Error(`event WAL ${this.path}: cannot advance the cursor while a frame is pending`);
538
+ await this.write({ ...this.doc, frontier: { ...this.doc.frontier, sourceCursor: rangeEnd } });
539
+ });
540
+ }
541
+ /**
542
+ * Abandonment — explicit, destructive and TOTAL. Mints a new epoch AND resets `seq`,
543
+ * `lastSubjectSeq` and `sourceCursor` together, reusing the same subject; the new epoch is what
544
+ * tells a consumer the chain broke. Partial abandonment is not a state: either all four move or
545
+ * the emitter stays halted. Required after a filtered channel purge, which returns the subject
546
+ * tip to 0 while the WAL still holds a non-zero `E`, permanently CAS-failing every later publish.
547
+ */
548
+ async abandon() {
549
+ return this.serialize(async () => {
550
+ await this.write({
551
+ ...this.doc,
552
+ epoch: randomUUID(),
553
+ frontier: { seq: 0, lastSubjectSeq: 0, sourceCursor: undefined },
554
+ pending: null,
555
+ // Abandonment is TOTAL, and the bracket machine is part of the total. A new epoch tells a
556
+ // consumer the chain broke, so carrying the old chain's open runs into it would be a partial
557
+ // abandonment — and partial abandonment is not a state.
558
+ brackets: { run: undefined, text: [], reasoning: [], tools: [] },
559
+ });
560
+ });
561
+ }
562
+ /**
563
+ * Durable replace: write a sibling temp file, fsync it, then rename over the target. The rename
564
+ * is what makes a reader see either the whole old document or the whole new one and never a torn
565
+ * prefix — which is precisely why a zero-byte WAL is treated as corruption rather than as virgin.
566
+ *
567
+ * **MODE 0600, AND THE REASON IS NOT TIDINESS: `pending.id` IS A PRE-PUBLICATION SECRET.**
568
+ * The dedup cache the frozen id is checked against is STREAM-WIDE, so anyone who learns an id
569
+ * BEFORE its frame is published can pre-seed it and make the real publish come back
570
+ * `duplicate: true`. The design attributes the safety of that entirely to `randomUUID()` entropy —
571
+ * which holds only while the id is unguessable AND unread. An id already on the wire is harmless
572
+ * (that message has landed); the only window where it is dangerous is exactly the window this
573
+ * file holds it in, between transition 1 and the ack.
574
+ *
575
+ * So the residual a reviewer raised as "gated on a local disk read rather than mesh access" is
576
+ * gated on a read OF THIS FILE. A world-readable WAL would convert a property the design credits
577
+ * to entropy into one credited to filesystem luck. Under our own rules the attack yields a LOUD
578
+ * halt rather than silent loss — a duplicate ack on a retry fails loud with the frontier and
579
+ * cursor unmoved — so this is denial of service, not corruption. Closing it by construction is
580
+ * cheap enough that naming it as an accepted residual would be the worse trade.
581
+ */
582
+ async write(next) {
583
+ // NOBODY ELSE HAS WRITTEN THIS FILE SINCE THIS HANDLE READ IT. Checked FIRST, because every
584
+ // line below replaces the document wholesale.
585
+ await this.assertNotClobbering();
586
+ const stamped = { ...next, gen: this.doc.gen + 1 };
587
+ // The temp name is RANDOM per write, and the open is EXCLUSIVE and NON-FOLLOWING.
588
+ //
589
+ // The previous version derived the name from `path` + `pid` — predictable — and used
590
+ // `open(tmp, "w", 0o600)`. Both halves were exploitable and both were reproduced:
591
+ // - `open(…, "w")` does not re-chmod an EXISTING inode; the mode argument applies only on
592
+ // create. A planted 0644 temp therefore survived as the WAL's mode, and the file holding
593
+ // `pending.id` — a pre-publication secret by this class's own argument — ended up
594
+ // world-readable while a comment three lines up claimed 0600.
595
+ // - `"w"` follows symlinks. A symlink planted at the predicted name made the next transition
596
+ // truncate and overwrite an arbitrary file the process could open. One plant, one write.
597
+ // Found by fmae-rev-sec, reproduced independently by fmae-rev-eng and fmae-rev-wal, then here.
598
+ //
599
+ // **The symlink half is the one I have no excuse for: I added `O_NOFOLLOW` to `JsonlFileSource`
600
+ // in this same session, for this same class, and did not carry it to the file this module
601
+ // writes.** A fence built on the read path while the write path stayed open.
602
+ //
603
+ // O_EXCL makes a pre-existing temp a hard failure rather than something to adopt; O_NOFOLLOW
604
+ // refuses a symlink outright; the random suffix removes the predictability that made planting
605
+ // reliable; and 0600 comes from the CREATE flags, which is the only place it can come from —
606
+ // `rename` preserves the inode's mode, so there is no post-rename chmod here and this comment
607
+ // does not claim one. The suite asserts the mode on the surviving file, which is where the
608
+ // guarantee has to hold; the code's part is refusing to adopt an existing inode at all.
609
+ // (An earlier version of this sentence said the mode was "asserted on the surviving inode" as
610
+ // though the production path checked it. It does not — the SUITE does. Flagged independently by
611
+ // two reviewers: a comment describing a check that lives somewhere else is the same overclaim
612
+ // class this file's own header warns about.)
613
+ const tmp = join(dirname(this.path), `.${createHash("sha256").update(this.path).digest("hex").slice(0, 12)}.${process.pid}.${randomUUID().slice(0, 8)}.wal.tmp`);
614
+ const body = JSON.stringify(stamped);
615
+ const fh = await openExclusiveNoFollow(tmp);
616
+ try {
617
+ await fh.writeFile(body, "utf8");
618
+ await fh.sync();
619
+ }
620
+ finally {
621
+ await fh.close();
622
+ }
623
+ try {
624
+ await rename(tmp, this.path);
625
+ }
626
+ catch (e) {
627
+ await unlink(tmp).catch(() => { });
628
+ throw e;
629
+ }
630
+ this.doc = stamped;
631
+ }
632
+ /**
633
+ * Refuse to replace a document this handle did not read.
634
+ *
635
+ * **THE FAILURE THIS EXISTS FOR WAS EXECUTED, NOT IMAGINED.** Two `EventWal` objects were opened
636
+ * on one file. A ran the full cycle and folded a frontier of `{seq:1, lastSubjectSeq:5}`. B, whose
637
+ * in-memory document was frozen back at the pending write, then called `recordAck(99)` and
638
+ * `fold()` — both SUCCEEDED, each replacing the whole file, and the WAL came back up claiming a
639
+ * durable tip of 99: a subject sequence the broker never assigned. The next publish freezes
640
+ * `E := 99` against a stream whose real tip is 5, so the emitter either CAS-halts forever or
641
+ * recovers a frontier that never existed. Nothing about that is loud; it reads as a healthy WAL.
642
+ *
643
+ * The per-instance `serialize` chain cannot see it (two instances, two chains) and neither can the
644
+ * principal lock (B's handle predates any lock B would take, and a lock is not held against a
645
+ * process's own second object). The guard has to be HERE, on the write, where the two views
646
+ * finally meet.
647
+ *
648
+ * **This is a check, not a transaction, and the difference is stated rather than glossed.** The
649
+ * read and the `rename` are separate syscalls, so a writer that lands in between is not caught by
650
+ * this; what is caught is every stale handle — the case that actually occurs, because a stale
651
+ * handle stays stale for as long as it exists rather than for a syscall's width. The lock is what
652
+ * keeps a second live writer from starting; this is what keeps one that already exists from
653
+ * winning.
654
+ */
655
+ async assertNotClobbering() {
656
+ let bytes;
657
+ try {
658
+ bytes = await readFile(this.path);
659
+ }
660
+ catch (e) {
661
+ if (e.code !== "ENOENT")
662
+ throw e;
663
+ }
664
+ if (bytes === undefined) {
665
+ // Only a handle that has never written may create the file. A handle whose own document is on
666
+ // disk and now finds nothing has had it removed underneath it, and re-creating it would resume
667
+ // a thread from a state somebody deliberately took away.
668
+ if (this.doc.gen !== 0)
669
+ throw new WalStaleWriterError(this.path, this.doc.gen, undefined);
670
+ return;
671
+ }
672
+ // Same fatal decode as `open`: a document that is not valid UTF-8 is not one this writer wrote,
673
+ // and the substituting decoder would turn it into one that merely looks like it.
674
+ let raw;
675
+ try {
676
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
677
+ }
678
+ catch {
679
+ throw new WalCorruptError(this.path, "the file is valid UTF-8", "invalid UTF-8 bytes on the file about to be replaced; refusing rather than substituting U+FFFD");
680
+ }
681
+ let d;
682
+ try {
683
+ d = JSON.parse(raw);
684
+ }
685
+ catch (e) {
686
+ throw new WalCorruptError(this.path, "parseable JSON", e.message);
687
+ }
688
+ if (typeof d !== "object" || d === null)
689
+ throw new WalCorruptError(this.path, "document is an object", typeof d);
690
+ // ONLY the generation is read here. Re-running the load guards would refuse this write for
691
+ // properties of a document that is on its way out, and would answer a question this function is
692
+ // not asking.
693
+ const onDisk = docGeneration(this.path, d);
694
+ if (onDisk !== this.doc.gen)
695
+ throw new WalStaleWriterError(this.path, this.doc.gen, onDisk);
696
+ }
697
+ }
698
+ //# sourceMappingURL=event-wal.js.map