instar 1.3.338 → 1.3.340

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 (61) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +166 -10
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +17 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/AutonomousSessions.d.ts +17 -2
  8. package/dist/core/AutonomousSessions.d.ts.map +1 -1
  9. package/dist/core/AutonomousSessions.js +30 -4
  10. package/dist/core/AutonomousSessions.js.map +1 -1
  11. package/dist/core/CoherenceJournal.d.ts +326 -0
  12. package/dist/core/CoherenceJournal.d.ts.map +1 -0
  13. package/dist/core/CoherenceJournal.js +999 -0
  14. package/dist/core/CoherenceJournal.js.map +1 -0
  15. package/dist/core/CoherenceJournalReader.d.ts +158 -0
  16. package/dist/core/CoherenceJournalReader.d.ts.map +1 -0
  17. package/dist/core/CoherenceJournalReader.js +450 -0
  18. package/dist/core/CoherenceJournalReader.js.map +1 -0
  19. package/dist/core/JournalSyncApplier.d.ts +272 -0
  20. package/dist/core/JournalSyncApplier.d.ts.map +1 -0
  21. package/dist/core/JournalSyncApplier.js +835 -0
  22. package/dist/core/JournalSyncApplier.js.map +1 -0
  23. package/dist/core/LearningVelocityScorer.d.ts +40 -0
  24. package/dist/core/LearningVelocityScorer.d.ts.map +1 -0
  25. package/dist/core/LearningVelocityScorer.js +67 -0
  26. package/dist/core/LearningVelocityScorer.js.map +1 -0
  27. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  28. package/dist/core/PostUpdateMigrator.js +17 -0
  29. package/dist/core/PostUpdateMigrator.js.map +1 -1
  30. package/dist/core/StateManager.d.ts +41 -0
  31. package/dist/core/StateManager.d.ts.map +1 -1
  32. package/dist/core/StateManager.js +100 -0
  33. package/dist/core/StateManager.js.map +1 -1
  34. package/dist/core/types.d.ts +35 -0
  35. package/dist/core/types.d.ts.map +1 -1
  36. package/dist/core/types.js.map +1 -1
  37. package/dist/messaging/TelegramAdapter.d.ts +14 -0
  38. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  39. package/dist/messaging/TelegramAdapter.js +16 -1
  40. package/dist/messaging/TelegramAdapter.js.map +1 -1
  41. package/dist/scaffold/templates.d.ts.map +1 -1
  42. package/dist/scaffold/templates.js +11 -0
  43. package/dist/scaffold/templates.js.map +1 -1
  44. package/dist/server/routes.d.ts.map +1 -1
  45. package/dist/server/routes.js +116 -3
  46. package/dist/server/routes.js.map +1 -1
  47. package/package.json +2 -2
  48. package/scripts/lint-cas-emit-placement.js +87 -0
  49. package/scripts/lint-journal-actuation-ban.js +63 -0
  50. package/scripts/lint-state-registry.js +313 -0
  51. package/src/data/builtin-manifest.json +63 -63
  52. package/src/data/state-coherence-registry.json +880 -0
  53. package/src/scaffold/templates.ts +11 -0
  54. package/upgrades/1.3.340.md +132 -0
  55. package/upgrades/coherence-journal-p1-1.eli16.md +11 -0
  56. package/upgrades/coherence-journal-p1-2.eli16.md +11 -0
  57. package/upgrades/coherence-journal-p1-3.eli16.md +13 -0
  58. package/upgrades/side-effects/coherence-journal-p1-1.md +68 -0
  59. package/upgrades/side-effects/coherence-journal-p1-2.md +54 -0
  60. package/upgrades/side-effects/coherence-journal-p1-3.md +49 -0
  61. package/upgrades/side-effects/exo3-g5-ci-greening.md +29 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * JournalSyncApplier — the RECEIVE (and own-stream SERVE) side of coherence
3
+ * journal replication (P1.3 of multi-machine coherence).
4
+ *
5
+ * Spec: docs/specs/COHERENCE-JOURNAL-SPEC.md §3.4 (replication trust model —
6
+ * ALL seven rules), §3.1 (stream/meta layout), §5 (trust model).
7
+ *
8
+ * This class is PURE and MESH-INDEPENDENT. It owns disk state only; another
9
+ * workstream wires the `journal-sync` MeshRpc verb, the `session-status`
10
+ * advert, and the delta-request loop on top of it. It NEVER imports MeshRpc,
11
+ * the server, or the mesh dispatcher — it is the engine those call.
12
+ *
13
+ * Replica layout (§3.1): peer copies of machine M's stream of kind K live at
14
+ * <stateDir>/state/coherence-journal/peers/<safeMachineId(M)>.<kind>.jsonl
15
+ * with one sidecar
16
+ * <stateDir>/state/coherence-journal/peers/<safeMachineId(M)>.meta.json
17
+ * holding the stream-set incarnation token (top-level, mirroring the writer's
18
+ * own meta so `CoherenceJournalReader.readIncarnation` parses it) plus per-kind
19
+ * apply bookkeeping (lastHeldSeq, status, suspect/flap counters, gap sentinels)
20
+ * and the bounded quarantine record.
21
+ *
22
+ * The receive-side §4.1 durability rule lives HERE: a replica append fsyncs
23
+ * BEFORE the entries are reported `applied`, so a journal-sync ack is an
24
+ * ack-after-durable-commit. Author-side durability is the writer's separate
25
+ * best-effort flush window — this is the receiver's contract.
26
+ *
27
+ * Trust model enforced (§3.4):
28
+ * 1. FIRST-HOP SENDER BINDING — every entry's `machine` must === the
29
+ * authenticated sender; the target file derives from the sender, NEVER a
30
+ * payload field. Forged entries are rejected + counted, never appended.
31
+ * 2. SCHEMA-VALIDATED APPLY — per entry: parseable + size-cap, seq exactly
32
+ * lastHeldSeq+1, ts parses, kind known-or-ignorable, data passes the kind's
33
+ * typed schema. ANY failure marks the stream `suspect` and STOPS the batch
34
+ * at the last valid line. `suspect` self-clears after K=20 consecutive
35
+ * valid in-order applies.
36
+ * 3. INCARNATION FENCING — a known stream arriving with a NEW incarnation
37
+ * quarantines the old replica (rename aside, ≤2 kept per stream), starts
38
+ * fresh, and surfaces a coalesced divergence signal (per-machine 10min
39
+ * window); >3 flips in the window → status `reset-flapping`, surfaced once.
40
+ * 4. TRUNCATION SIGNALS — a batch may carry `oldestRetainedSeq`; when
41
+ * lastHeldSeq+1 < oldestRetainedSeq the receiver records a gap sentinel in
42
+ * the replica meta (NOT a fake journal line), fast-forwards lastHeldSeq to
43
+ * oldestRetainedSeq-1, and marks the stream `gapped` until the next clean
44
+ * apply. (Gap re-request PACING is the caller's job; this exposes status.)
45
+ *
46
+ * All replica writes go through the injected guardWrite seam (same seam as the
47
+ * writer) and O_APPEND single-line writes. The applier is tolerant + bounded
48
+ * everywhere and NEVER throws into its caller; degradation is surfaced via
49
+ * counters and per-stream status.
50
+ */
51
+ import crypto from 'node:crypto';
52
+ import fs from 'node:fs';
53
+ import path from 'node:path';
54
+ import { JOURNAL_KINDS, sanitizeMachineId, readTailTolerant, } from './CoherenceJournal.js';
55
+ import { SafeFsExecutor } from './SafeFsExecutor.js';
56
+ // ---- tunables (§3.4) -------------------------------------------------------
57
+ /** Per-entry size cap on the receive side (§3.4 rule 2). Mirrors the writer's
58
+ * DEFAULT_MAX_ENTRY_BYTES (source of truth: CoherenceJournal.ts). */
59
+ export const APPLIER_MAX_ENTRY_BYTES = 8 * 1024;
60
+ /** Consecutive valid in-order applies that self-clear `suspect` (§3.4 rule 2). */
61
+ export const SUSPECT_CLEAR_THRESHOLD = 20;
62
+ /** Quarantined replica files kept per stream before evicting the oldest (rule 3). */
63
+ export const MAX_QUARANTINE_PER_STREAM = 2;
64
+ /** Incarnation-flap coalescing window per machine (rule 3). */
65
+ export const FLAP_WINDOW_MS = 10 * 60 * 1000;
66
+ /** Flips within the window past this → `reset-flapping`, surfaced once (rule 3). */
67
+ export const FLAP_RESET_THRESHOLD = 3;
68
+ /** Default serve-batch size cap (§3.4 rule 5 — journalSyncMaxBatchBytes). */
69
+ export const DEFAULT_MAX_BATCH_BYTES = 262144;
70
+ /** Reverse-tail read ceiling when reading own stream to serve. */
71
+ const SERVE_READ_BYTE_CEILING = 64 * 1024 * 1024;
72
+ function realFs() {
73
+ return {
74
+ openSync: fs.openSync,
75
+ writeSync: fs.writeSync,
76
+ fdatasyncSync: fs.fdatasyncSync,
77
+ closeSync: fs.closeSync,
78
+ existsSync: fs.existsSync,
79
+ statSync: fs.statSync,
80
+ renameSync: fs.renameSync,
81
+ writeFileSync: fs.writeFileSync,
82
+ readFileSync: fs.readFileSync,
83
+ readdirSync: fs.readdirSync,
84
+ truncateSync: fs.truncateSync,
85
+ mkdirSync: fs.mkdirSync,
86
+ readSync: fs.readSync,
87
+ };
88
+ }
89
+ function escapeRegExp(s) {
90
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ }
92
+ export class JournalSyncApplier {
93
+ stateDir;
94
+ guardWrite;
95
+ now;
96
+ logger;
97
+ io;
98
+ maxEntryBytes;
99
+ /** In-memory meta cache per peer machine (keyed by RAW machineId). */
100
+ metaCache = new Map();
101
+ degradation = {
102
+ applied: 0,
103
+ forgedEntries: 0,
104
+ duplicates: 0,
105
+ invalidEntries: 0,
106
+ suspectMarks: 0,
107
+ quarantines: 0,
108
+ gapsRecorded: 0,
109
+ guardSkips: 0,
110
+ appendErrors: 0,
111
+ signals: 0,
112
+ };
113
+ loggedClasses = new Set();
114
+ constructor(config) {
115
+ this.stateDir = config.stateDir;
116
+ this.guardWrite = config.guardWrite;
117
+ this.now = config.now ?? (() => new Date());
118
+ this.logger = config.logger;
119
+ this.io = config.fsImpl ?? realFs();
120
+ this.maxEntryBytes = config.maxEntryBytes ?? APPLIER_MAX_ENTRY_BYTES;
121
+ }
122
+ getDegradation() {
123
+ return { ...this.degradation };
124
+ }
125
+ // ---- advert state (for delta requests) ---------------------------------
126
+ /**
127
+ * What this machine holds per peer stream — `{ machineId → { kind →
128
+ * { incarnation, lastSeq } } }` — so the caller can compare against a peer's
129
+ * advert and request only the missing ranges (§3.4 rule 5). Sourced from the
130
+ * persisted peer meta (incarnation + per-kind lastHeldSeq).
131
+ */
132
+ getAdvertState() {
133
+ const out = {};
134
+ for (const machineId of this.discoverPeerMachines()) {
135
+ const meta = this.loadMeta(machineId);
136
+ const perKind = {};
137
+ for (const kind of JOURNAL_KINDS) {
138
+ const ks = meta.kinds[kind];
139
+ // Surface a stream only if we hold something for it (lastHeldSeq > 0)
140
+ // OR it has bookkeeping (a gap/suspect). A bare zero is "nothing held".
141
+ if (ks && (ks.lastHeldSeq > 0 || ks.status !== 'current' || ks.gaps.length > 0)) {
142
+ perKind[kind] = { incarnation: meta.incarnation, lastSeq: ks.lastHeldSeq };
143
+ }
144
+ }
145
+ if (Object.keys(perKind).length > 0)
146
+ out[machineId] = perKind;
147
+ }
148
+ return out;
149
+ }
150
+ /** Per-stream replication status (§3.4 rule 4 — caller drives gap pacing). */
151
+ getStreamStatus() {
152
+ const out = {};
153
+ for (const machineId of this.discoverPeerMachines()) {
154
+ const meta = this.loadMeta(machineId);
155
+ if (meta.resetFlapping) {
156
+ // A reset-flapping machine surfaces that status across all its kinds.
157
+ for (const kind of JOURNAL_KINDS) {
158
+ if (meta.kinds[kind])
159
+ out[`${machineId}.${kind}`] = 'reset-flapping';
160
+ }
161
+ }
162
+ for (const kind of JOURNAL_KINDS) {
163
+ const ks = meta.kinds[kind];
164
+ if (!ks)
165
+ continue;
166
+ const key = `${machineId}.${kind}`;
167
+ if (out[key])
168
+ continue; // reset-flapping already wins
169
+ out[key] = ks.status;
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ // ---- apply (the RECEIVE side, §3.4 rules 1-4) --------------------------
175
+ /**
176
+ * Apply an inbound batch from `senderMachineId` (the AUTHENTICATED envelope
177
+ * identity). Implements §3.4 rules 1-4. Never throws; the result is fully
178
+ * observable. The target replica files derive from `senderMachineId` only —
179
+ * NEVER from any payload field.
180
+ */
181
+ apply(senderMachineId, batch) {
182
+ const result = {
183
+ applied: 0,
184
+ forgedEntries: 0,
185
+ duplicates: 0,
186
+ invalidEntries: 0,
187
+ suspectStreams: 0,
188
+ quarantined: 0,
189
+ gapsRecorded: 0,
190
+ guardSkips: 0,
191
+ signals: [],
192
+ statuses: {},
193
+ };
194
+ try {
195
+ if (!senderMachineId || typeof senderMachineId !== 'string')
196
+ return result;
197
+ const meta = this.loadMeta(senderMachineId);
198
+ for (const stream of batch || []) {
199
+ try {
200
+ this.applyStream(senderMachineId, meta, stream, result);
201
+ }
202
+ catch (e) {
203
+ // A single bad stream slice must never poison the rest of the batch.
204
+ this.log('apply-stream', `[journal-sync] applyStream failed for ${stream?.kind}: ${e?.message}`);
205
+ }
206
+ }
207
+ // Persist the (possibly mutated) meta once per apply call.
208
+ this.persistMeta(senderMachineId, meta);
209
+ // Final per-stream statuses for the caller.
210
+ for (const kind of JOURNAL_KINDS) {
211
+ const ks = meta.kinds[kind];
212
+ if (ks) {
213
+ result.statuses[`${senderMachineId}.${kind}`] = meta.resetFlapping ? 'reset-flapping' : ks.status;
214
+ }
215
+ }
216
+ }
217
+ catch (e) {
218
+ this.log('apply', `[journal-sync] apply failed (swallowed): ${e?.message}`);
219
+ }
220
+ return result;
221
+ }
222
+ applyStream(senderMachineId, meta, stream, result) {
223
+ if (!stream || !JOURNAL_KINDS.includes(stream.kind)) {
224
+ // Unknown / ignorable kind: nothing applied, no poisoning (forward-compat).
225
+ return;
226
+ }
227
+ const kind = stream.kind;
228
+ const incarnation = typeof stream.incarnation === 'string' ? stream.incarnation : '';
229
+ // First batch ever for this peer: adopt its incarnation (no quarantine —
230
+ // there is no old history to fence against).
231
+ if (!meta.incarnation) {
232
+ meta.incarnation = incarnation || this.mintIncarnation();
233
+ }
234
+ // §3.4 rule 3 — INCARNATION FENCING. A KNOWN stream arriving with a NEW
235
+ // incarnation quarantines the old replica + starts fresh + signals.
236
+ if (incarnation && incarnation !== meta.incarnation) {
237
+ this.handleIncarnationFlip(senderMachineId, meta, kind, incarnation, result);
238
+ }
239
+ const ks = this.ensureKind(meta, kind);
240
+ // §3.4 rule 4 — TRUNCATION SIGNAL. If our next-needed seq fell below what
241
+ // the peer still retains, record a gap sentinel + fast-forward + mark gapped.
242
+ if (typeof stream.oldestRetainedSeq === 'number' && Number.isFinite(stream.oldestRetainedSeq)) {
243
+ const nextNeeded = ks.lastHeldSeq + 1;
244
+ if (nextNeeded < stream.oldestRetainedSeq) {
245
+ ks.gaps.push({
246
+ fromSeq: nextNeeded,
247
+ toSeq: stream.oldestRetainedSeq - 1,
248
+ recordedAt: this.now().toISOString(),
249
+ });
250
+ // Bound the gap record (No Unbounded Loops) — keep the most recent few.
251
+ if (ks.gaps.length > 16)
252
+ ks.gaps.splice(0, ks.gaps.length - 16);
253
+ ks.lastHeldSeq = stream.oldestRetainedSeq - 1;
254
+ ks.status = 'gapped';
255
+ ks.consecutiveValid = 0;
256
+ this.degradation.gapsRecorded++;
257
+ result.gapsRecorded++;
258
+ }
259
+ }
260
+ // §3.4 rules 1 + 2 — validate every entry IN ORDER, append durably, stop at
261
+ // the first failure (suspect), skip duplicates silently.
262
+ const toAppend = [];
263
+ let stopped = false;
264
+ for (const entry of stream.entries || []) {
265
+ const verdict = this.validateEntry(senderMachineId, kind, entry, ks.lastHeldSeq + toAppend.length);
266
+ if (verdict === 'forged') {
267
+ this.degradation.forgedEntries++;
268
+ result.forgedEntries++;
269
+ // Rule 1: a forged entry is rejected + counted. It is NOT a torn write
270
+ // (it's a trust violation), so it does NOT mark the stream suspect; we
271
+ // simply do not append it and stop the batch (cannot trust ordering past it).
272
+ stopped = true;
273
+ break;
274
+ }
275
+ if (verdict === 'duplicate') {
276
+ this.degradation.duplicates++;
277
+ result.duplicates++;
278
+ continue; // silent drop, keep scanning (rule 2)
279
+ }
280
+ if (verdict === 'invalid') {
281
+ this.degradation.invalidEntries++;
282
+ result.invalidEntries++;
283
+ // Rule 2: ANY failure marks suspect + STOPS the batch at the last valid.
284
+ this.markSuspect(ks);
285
+ this.degradation.suspectMarks++;
286
+ result.suspectStreams++;
287
+ stopped = true;
288
+ break;
289
+ }
290
+ // valid + in order → queue for durable append.
291
+ toAppend.push(JSON.stringify(entry));
292
+ }
293
+ if (toAppend.length === 0) {
294
+ return; // nothing to durably write (all dup/forged/invalid, or empty)
295
+ }
296
+ const appendedCount = this.durablyAppend(senderMachineId, kind, toAppend, result);
297
+ if (appendedCount > 0) {
298
+ ks.lastHeldSeq += appendedCount;
299
+ // §3.4 rule 2 — `suspect` self-clears after K consecutive valid in-order
300
+ // applies. A clean apply that advances the stream out of `gapped` returns
301
+ // it to `current`/`behind` honestly.
302
+ if (ks.status === 'suspect') {
303
+ ks.consecutiveValid += appendedCount;
304
+ if (ks.consecutiveValid >= SUSPECT_CLEAR_THRESHOLD) {
305
+ ks.status = 'current';
306
+ ks.consecutiveValid = 0;
307
+ }
308
+ }
309
+ else if (ks.status === 'gapped') {
310
+ // A clean apply after a gap returns the stream to current (the hole is
311
+ // recorded in meta; the read API reports it honestly via the sentinel).
312
+ ks.status = 'current';
313
+ ks.consecutiveValid = 0;
314
+ }
315
+ else {
316
+ ks.consecutiveValid += appendedCount;
317
+ ks.status = 'current';
318
+ }
319
+ }
320
+ // If the batch stopped short (invalid/forged) the stream is "behind" the
321
+ // peer's advert from the caller's POV — but suspect/quarantine status wins.
322
+ if (stopped && ks.status === 'current') {
323
+ ks.status = 'behind';
324
+ }
325
+ }
326
+ /**
327
+ * Per-entry verdict (§3.4 rules 1 + 2). `expectedSeq` is lastHeldSeq + count
328
+ * already queued this batch, so seq must be exactly contiguous.
329
+ */
330
+ validateEntry(senderMachineId, kind, entry, lastHeldSeq) {
331
+ if (!entry || typeof entry !== 'object')
332
+ return 'invalid';
333
+ // Rule 1: FIRST-HOP SENDER BINDING. entry.machine must === the sender.
334
+ if (typeof entry.machine !== 'string' || entry.machine !== senderMachineId) {
335
+ return 'forged';
336
+ }
337
+ // Size cap (parseable ≤ cap). The line we'd write is the serialized entry.
338
+ let line;
339
+ try {
340
+ line = JSON.stringify(entry);
341
+ }
342
+ catch {
343
+ return 'invalid';
344
+ }
345
+ if (Buffer.byteLength(line, 'utf-8') > this.maxEntryBytes)
346
+ return 'invalid';
347
+ // kind binding — the entry must belong to the stream slice it arrived in.
348
+ if (entry.kind !== kind)
349
+ return 'invalid';
350
+ // seq must be a finite integer; <= lastHeld is a duplicate (silent drop),
351
+ // != lastHeld+1 (a forward gap) is invalid (stop the batch).
352
+ const seq = entry.seq;
353
+ if (typeof seq !== 'number' || !Number.isFinite(seq) || Math.floor(seq) !== seq)
354
+ return 'invalid';
355
+ if (seq <= lastHeldSeq)
356
+ return 'duplicate';
357
+ if (seq !== lastHeldSeq + 1)
358
+ return 'invalid';
359
+ // ts must parse.
360
+ if (typeof entry.ts !== 'string' || Number.isNaN(Date.parse(entry.ts)))
361
+ return 'invalid';
362
+ // topic, when present, must be a finite number.
363
+ if (entry.topic !== undefined && (typeof entry.topic !== 'number' || !Number.isFinite(entry.topic))) {
364
+ return 'invalid';
365
+ }
366
+ // data must pass the kind's typed schema.
367
+ if (!this.validateData(kind, entry.data))
368
+ return 'invalid';
369
+ return 'valid';
370
+ }
371
+ /**
372
+ * §3.2 typed schema validation — MIRRORED from CoherenceJournal.validate()
373
+ * (source of truth: src/core/CoherenceJournal.ts). The applier validates
374
+ * STRICTLY (reject on unknown/extra fields too) so a peer's buggy/forged
375
+ * writer cannot smuggle free text past the receiver. Returns true if the
376
+ * data is a well-formed instance of the kind's schema.
377
+ */
378
+ validateData(kind, data) {
379
+ if (!data || typeof data !== 'object' || Array.isArray(data))
380
+ return false;
381
+ const raw = data;
382
+ const keys = Object.keys(raw);
383
+ if (kind === 'topic-placement') {
384
+ const reasons = ['user-move', 'placed', 'failover', 'released', 'quota-block-move'];
385
+ if (typeof raw.owner !== 'string' || !raw.owner)
386
+ return false;
387
+ if (typeof raw.epoch !== 'number' || !Number.isFinite(raw.epoch))
388
+ return false;
389
+ if (typeof raw.reason !== 'string' || !reasons.includes(raw.reason))
390
+ return false;
391
+ if (raw.prevOwner !== undefined && typeof raw.prevOwner !== 'string')
392
+ return false;
393
+ const known = ['owner', 'epoch', 'reason', 'prevOwner'];
394
+ return keys.every((k) => known.includes(k));
395
+ }
396
+ if (kind === 'session-lifecycle') {
397
+ const statuses = ['created', 'completed', 'killed', 'reaped', 'failed'];
398
+ if (typeof raw.sessionId !== 'string' || !raw.sessionId)
399
+ return false;
400
+ if (typeof raw.status !== 'string' || !statuses.includes(raw.status))
401
+ return false;
402
+ if (raw.reapReason !== undefined && typeof raw.reapReason !== 'string')
403
+ return false;
404
+ if (raw.reapLogRef !== undefined && typeof raw.reapLogRef !== 'string')
405
+ return false;
406
+ const known = ['sessionId', 'status', 'reapReason', 'reapLogRef'];
407
+ return keys.every((k) => known.includes(k));
408
+ }
409
+ if (kind === 'autonomous-run') {
410
+ const actions = ['started', 'stopped'];
411
+ if (typeof raw.action !== 'string' || !actions.includes(raw.action))
412
+ return false;
413
+ if (typeof raw.runId !== 'string' || !raw.runId)
414
+ return false;
415
+ if (!Array.isArray(raw.artifactPaths))
416
+ return false;
417
+ if (!raw.artifactPaths.every((p) => typeof p === 'string'))
418
+ return false;
419
+ const known = ['action', 'runId', 'artifactPaths'];
420
+ return keys.every((k) => known.includes(k));
421
+ }
422
+ return false;
423
+ }
424
+ // ---- incarnation fencing (§3.4 rule 3) ---------------------------------
425
+ handleIncarnationFlip(senderMachineId, meta, kind, newIncarnation, result) {
426
+ const oldIncarnation = meta.incarnation;
427
+ // Quarantine every existing replica file for this peer (rename aside),
428
+ // bounded at MAX_QUARANTINE_PER_STREAM per stream (oldest evicted).
429
+ for (const k of JOURNAL_KINDS) {
430
+ this.quarantineReplica(senderMachineId, k);
431
+ }
432
+ this.degradation.quarantines++;
433
+ result.quarantined++;
434
+ // Reset ALL per-kind apply state — a fresh incarnation is a fresh history.
435
+ meta.incarnation = newIncarnation;
436
+ meta.kinds = {};
437
+ // Record the flip in the coalescing window and decide flapping.
438
+ const nowMs = this.now().getTime();
439
+ meta.flipsMs.push(nowMs);
440
+ meta.flipsMs = meta.flipsMs.filter((t) => nowMs - t <= FLAP_WINDOW_MS);
441
+ if (meta.flipsMs.length > FLAP_RESET_THRESHOLD) {
442
+ // Past the threshold → reset-flapping, surfaced ONCE.
443
+ if (!meta.resetFlapping) {
444
+ meta.resetFlapping = true;
445
+ this.log(`flap-${senderMachineId}`, `[journal-sync] peer ${senderMachineId} is reset-flapping (${meta.flipsMs.length} incarnation flips in window)`);
446
+ }
447
+ // No per-flip divergence signal once flapping — coalesced to the one above.
448
+ }
449
+ else {
450
+ // Coalesce the divergence signal per machine within the window: emit one
451
+ // signal per flip up to the threshold (these are the "loud" ones), but
452
+ // only the FIRST flip in a fresh window emits to the caller — subsequent
453
+ // ones within the window are folded (coalesced) into the lifetime count.
454
+ const signal = {
455
+ machineId: senderMachineId,
456
+ kind,
457
+ oldIncarnation,
458
+ newIncarnation,
459
+ at: this.now().toISOString(),
460
+ };
461
+ // Coalescing: only surface to the caller if this is the first flip in the
462
+ // current window (flipsMs has exactly 1 entry after the filter above).
463
+ if (meta.flipsMs.length === 1) {
464
+ result.signals.push(signal);
465
+ this.degradation.signals++;
466
+ this.log(`diverge-${senderMachineId}`, `[journal-sync] divergence: peer ${senderMachineId} new incarnation ${newIncarnation.slice(0, 8)} (old ${oldIncarnation.slice(0, 8)})`);
467
+ }
468
+ }
469
+ }
470
+ /**
471
+ * Rename a peer replica file aside as `<file>.quarantine.<stamp>`, keeping at
472
+ * most MAX_QUARANTINE_PER_STREAM per stream (oldest evicted). Bounded disk.
473
+ */
474
+ quarantineReplica(senderMachineId, kind) {
475
+ const file = this.replicaFilePath(senderMachineId, kind);
476
+ if (!this.io.existsSync(file))
477
+ return;
478
+ const stamp = this.now().getTime();
479
+ const safe = sanitizeMachineId(senderMachineId);
480
+ const target = path.join(this.peersDirPath(), `${safe}.${kind}.quarantine.${stamp}.jsonl`);
481
+ try {
482
+ if (this.guardWrite)
483
+ this.guardWrite(target);
484
+ this.io.renameSync(file, target);
485
+ }
486
+ catch (e) {
487
+ this.log('quarantine', `[journal-sync] quarantine rename failed for ${kind}: ${e?.message}`);
488
+ return;
489
+ }
490
+ this.pruneQuarantine(senderMachineId, kind);
491
+ }
492
+ pruneQuarantine(senderMachineId, kind) {
493
+ const safe = sanitizeMachineId(senderMachineId);
494
+ const re = new RegExp(`^${escapeRegExp(`${safe}.${kind}.quarantine.`)}(\\d+)\\.jsonl$`);
495
+ let names;
496
+ try {
497
+ names = this.io.readdirSync(this.peersDirPath());
498
+ }
499
+ catch {
500
+ return;
501
+ }
502
+ const matched = [];
503
+ for (const n of names) {
504
+ const m = re.exec(n);
505
+ if (m)
506
+ matched.push({ name: n, stamp: Number(m[1]) });
507
+ }
508
+ if (matched.length <= MAX_QUARANTINE_PER_STREAM)
509
+ return;
510
+ matched.sort((a, b) => a.stamp - b.stamp); // oldest first
511
+ const drop = matched.slice(0, matched.length - MAX_QUARANTINE_PER_STREAM);
512
+ for (const d of drop) {
513
+ const p = path.join(this.peersDirPath(), d.name);
514
+ try {
515
+ // Receiver-managed peer replica eviction — through the destructive-fs
516
+ // funnel (mirrors CoherenceJournal.pruneArchives, the source of truth
517
+ // for journal file deletion).
518
+ SafeFsExecutor.safeRmSync(p, { force: true, operation: 'journal-sync:prune-quarantine' });
519
+ }
520
+ catch (e) {
521
+ this.log('quarantine-prune', `[journal-sync] quarantine prune failed for ${d.name}: ${e?.message}`);
522
+ }
523
+ }
524
+ }
525
+ // ---- durable append (§4.1 — ack-after-fsync) ---------------------------
526
+ /**
527
+ * Append validated lines to the peer replica file with O_APPEND single-line
528
+ * writes, then fdatasync BEFORE returning the applied count (§4.1 — the
529
+ * receiver's durability-before-ack rule lives HERE). Returns the number of
530
+ * lines durably committed (0 if the guard refused or the open/write failed
531
+ * before any byte landed). Never throws.
532
+ */
533
+ durablyAppend(senderMachineId, kind, lines, result) {
534
+ const file = this.replicaFilePath(senderMachineId, kind);
535
+ this.ensurePeersDir();
536
+ // Injected standby guard — throwing skips the batch + counts (never thrown).
537
+ try {
538
+ if (this.guardWrite)
539
+ this.guardWrite(file);
540
+ }
541
+ catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
542
+ this.degradation.guardSkips++;
543
+ result.guardSkips++;
544
+ this.log('guard', `[journal-sync] guardWrite refused ${kind}: ${e?.message}`);
545
+ return 0;
546
+ }
547
+ let fd = null;
548
+ let bytesWritten = false;
549
+ let committed = 0;
550
+ try {
551
+ fd = this.io.openSync(file, 'a');
552
+ for (const line of lines) {
553
+ const buf = Buffer.from(line + '\n', 'utf-8');
554
+ this.io.writeSync(fd, buf, 0, buf.length);
555
+ bytesWritten = true;
556
+ committed++;
557
+ }
558
+ // §4.1 — fsync BEFORE we report applied. This is the ack-after-durable
559
+ // boundary: nothing past this line treats the entries as committed until
560
+ // fdatasync has returned.
561
+ this.io.fdatasyncSync(fd);
562
+ }
563
+ catch (e) {
564
+ this.degradation.appendErrors++;
565
+ this.log('append', `[journal-sync] replica append/fsync failed for ${kind}: ${e?.message}`);
566
+ if (!bytesWritten) {
567
+ committed = 0; // nothing landed — caller advances nothing.
568
+ }
569
+ else {
570
+ // Bytes landed but fsync did not confirm. We cannot prove durability,
571
+ // so we do NOT report them applied (ack-after-durable-commit). A torn
572
+ // tail is repaired by the tolerant reader; the caller will re-request.
573
+ committed = 0;
574
+ }
575
+ }
576
+ finally {
577
+ if (fd !== null) {
578
+ try {
579
+ this.io.closeSync(fd);
580
+ }
581
+ catch {
582
+ /* best-effort */
583
+ }
584
+ }
585
+ }
586
+ if (committed > 0) {
587
+ this.degradation.applied += committed;
588
+ result.applied += committed;
589
+ }
590
+ return committed;
591
+ }
592
+ // ---- SERVE side (§3.4 rule 5/7 — own stream only, durably-flushed only) -
593
+ /**
594
+ * Build a delta batch from THIS machine's OWN stream for a peer that is
595
+ * behind (§3.4 rule 5). Reads the FILE (never any queue) so only
596
+ * durably-flushed entries are served — a §3.4-rule-3 corollary the writer
597
+ * guarantees by advertising only flushed seqs. First-hop only: this serves
598
+ * exactly one own stream of one kind.
599
+ *
600
+ * @param kind which own stream to serve.
601
+ * @param fromSeq serve entries with seq > fromSeq (the peer's lastSeq).
602
+ * @param maxBatchBytes total serialized byte cap (default DEFAULT_MAX_BATCH_BYTES).
603
+ * @param ownMachineId this machine's id (the producer of the served stream).
604
+ */
605
+ buildServeBatch(kind, fromSeq, ownMachineId, maxBatchBytes = DEFAULT_MAX_BATCH_BYTES) {
606
+ const out = {
607
+ kind,
608
+ incarnation: this.readOwnIncarnation(ownMachineId),
609
+ entries: [],
610
+ };
611
+ if (!JOURNAL_KINDS.includes(kind))
612
+ return out;
613
+ // Read own current file + archives newest→oldest, collect entries with
614
+ // seq > fromSeq, then return them ascending and byte-capped.
615
+ const files = this.ownStreamFilesNewestFirst(ownMachineId, kind);
616
+ const collected = [];
617
+ // Track the smallest seq we can still serve (for oldestRetainedSeq honesty).
618
+ let minServableSeq = Number.POSITIVE_INFINITY;
619
+ for (const f of files) {
620
+ const read = readTailTolerant(this.io, f, Number.MAX_SAFE_INTEGER, SERVE_READ_BYTE_CEILING);
621
+ for (const e of read.entries) {
622
+ if (typeof e.seq !== 'number')
623
+ continue;
624
+ if (e.seq < minServableSeq)
625
+ minServableSeq = e.seq;
626
+ if (e.seq > fromSeq)
627
+ collected.push(e);
628
+ }
629
+ }
630
+ // Ascending by seq (the apply side requires contiguous in-order).
631
+ collected.sort((a, b) => a.seq - b.seq);
632
+ // Byte-cap mid-batch: include entries until adding the next would exceed
633
+ // maxBatchBytes. At least one entry is always included if any exists and it
634
+ // fits; an entry that alone exceeds the cap is still included (so a single
635
+ // over-cap line is served rather than the stream stalling forever).
636
+ let bytes = 0;
637
+ for (const e of collected) {
638
+ const lineBytes = Buffer.byteLength(JSON.stringify(e), 'utf-8') + 1; // +newline
639
+ if (out.entries.length > 0 && bytes + lineBytes > maxBatchBytes)
640
+ break;
641
+ out.entries.push(e);
642
+ bytes += lineBytes;
643
+ }
644
+ // §3.4 rule 4 — include oldestRetainedSeq when fromSeq has rotated out: the
645
+ // peer asked from `fromSeq` but the oldest seq we can still serve is higher,
646
+ // so signal the truncation. Only set it when there IS a hole below what we hold.
647
+ if (Number.isFinite(minServableSeq) && minServableSeq > fromSeq + 1) {
648
+ out.oldestRetainedSeq = minServableSeq;
649
+ }
650
+ return out;
651
+ }
652
+ // ---- meta (load / persist / cache) -------------------------------------
653
+ ensureKind(meta, kind) {
654
+ let ks = meta.kinds[kind];
655
+ if (!ks) {
656
+ ks = { lastHeldSeq: 0, status: 'current', consecutiveValid: 0, gaps: [] };
657
+ meta.kinds[kind] = ks;
658
+ }
659
+ return ks;
660
+ }
661
+ markSuspect(ks) {
662
+ ks.status = 'suspect';
663
+ ks.consecutiveValid = 0;
664
+ }
665
+ loadMeta(machineId) {
666
+ const cached = this.metaCache.get(machineId);
667
+ if (cached)
668
+ return cached;
669
+ const metaPath = this.metaPath(machineId);
670
+ let meta = { incarnation: '', kinds: {}, flipsMs: [], resetFlapping: false };
671
+ if (this.io.existsSync(metaPath)) {
672
+ try {
673
+ const raw = this.io.readFileSync(metaPath, 'utf-8');
674
+ const obj = JSON.parse(raw);
675
+ if (obj && typeof obj === 'object') {
676
+ meta = {
677
+ incarnation: typeof obj.incarnation === 'string' ? obj.incarnation : '',
678
+ kinds: this.normalizeKinds(obj.kinds),
679
+ flipsMs: Array.isArray(obj.flipsMs) ? obj.flipsMs.filter((n) => typeof n === 'number') : [],
680
+ resetFlapping: obj.resetFlapping === true,
681
+ };
682
+ }
683
+ }
684
+ catch {
685
+ // malformed meta → start clean (tolerant).
686
+ }
687
+ }
688
+ this.metaCache.set(machineId, meta);
689
+ return meta;
690
+ }
691
+ normalizeKinds(raw) {
692
+ const out = {};
693
+ if (!raw || typeof raw !== 'object')
694
+ return out;
695
+ const r = raw;
696
+ const validStatus = ['current', 'behind', 'gapped', 'suspect', 'reset-flapping'];
697
+ for (const kind of JOURNAL_KINDS) {
698
+ const ks = r[kind];
699
+ if (!ks || typeof ks !== 'object')
700
+ continue;
701
+ out[kind] = {
702
+ lastHeldSeq: typeof ks.lastHeldSeq === 'number' && Number.isFinite(ks.lastHeldSeq) ? ks.lastHeldSeq : 0,
703
+ status: validStatus.includes(ks.status) ? ks.status : 'current',
704
+ consecutiveValid: typeof ks.consecutiveValid === 'number' ? ks.consecutiveValid : 0,
705
+ gaps: Array.isArray(ks.gaps)
706
+ ? ks.gaps.filter((g) => !!g && typeof g === 'object' && typeof g.fromSeq === 'number')
707
+ : [],
708
+ };
709
+ }
710
+ return out;
711
+ }
712
+ persistMeta(machineId, meta) {
713
+ this.ensurePeersDir();
714
+ const metaPath = this.metaPath(machineId);
715
+ const tmp = metaPath + '.tmp';
716
+ try {
717
+ if (this.guardWrite)
718
+ this.guardWrite(metaPath);
719
+ this.io.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o644 });
720
+ this.io.renameSync(tmp, metaPath);
721
+ }
722
+ catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
723
+ this.log('persist-meta', `[journal-sync] persistMeta failed for ${machineId}: ${e?.message}`);
724
+ }
725
+ }
726
+ mintIncarnation() {
727
+ return crypto.randomBytes(12).toString('hex');
728
+ }
729
+ // ---- peer / own discovery ----------------------------------------------
730
+ /** Discover peer machine ids from the peers/ meta sidecars + replica files. */
731
+ discoverPeerMachines() {
732
+ const dir = this.peersDirPath();
733
+ let names;
734
+ try {
735
+ names = this.io.readdirSync(dir);
736
+ }
737
+ catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
738
+ return [];
739
+ }
740
+ const ids = new Set();
741
+ // Pull machineIds from `<machineId>.meta.json` first (authoritative set).
742
+ for (const n of names) {
743
+ const m = /^(.+)\.meta\.json$/.exec(n);
744
+ if (m) {
745
+ ids.add(this.unsanitizeBestEffort(m[1]));
746
+ }
747
+ }
748
+ // Also pull from replica files for machines without a meta yet.
749
+ for (const kind of JOURNAL_KINDS) {
750
+ const k = escapeRegExp(kind);
751
+ const re = new RegExp(`^(.+)\\.${k}(?:\\.(?:\\d+|quarantine\\.\\d+))?\\.jsonl$`);
752
+ for (const n of names) {
753
+ const m = re.exec(n);
754
+ if (m)
755
+ ids.add(this.unsanitizeBestEffort(m[1]));
756
+ }
757
+ }
758
+ return [...ids];
759
+ }
760
+ /**
761
+ * The peer file grammar uses the SANITIZED machine id as the literal. The raw
762
+ * id is only needed for first-hop binding (which uses the sender id directly,
763
+ * not a derived one) — for advert/status keys we surface the on-disk literal
764
+ * (already sanitized + injective), so reverse-mapping is unnecessary and we
765
+ * keep the literal as the machine key. (sanitizeMachineId is injective, so
766
+ * the literal is a faithful, stable handle.)
767
+ */
768
+ unsanitizeBestEffort(safeLiteral) {
769
+ return safeLiteral;
770
+ }
771
+ /** Own current + archive stream files (newest-first) for serve reads. */
772
+ ownStreamFilesNewestFirst(ownMachineId, kind) {
773
+ const safe = sanitizeMachineId(ownMachineId);
774
+ const dir = this.dirPath();
775
+ const current = path.join(dir, `${safe}.${kind}.jsonl`);
776
+ const out = [];
777
+ if (this.io.existsSync(current))
778
+ out.push(current);
779
+ let names;
780
+ try {
781
+ names = this.io.readdirSync(dir);
782
+ }
783
+ catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
784
+ return out;
785
+ }
786
+ const re = new RegExp(`^${escapeRegExp(`${safe}.${kind}.`)}(\\d+)\\.jsonl$`);
787
+ const archives = [];
788
+ for (const n of names) {
789
+ const m = re.exec(n);
790
+ if (m)
791
+ archives.push({ file: path.join(dir, n), stamp: Number(m[1]) });
792
+ }
793
+ archives.sort((a, b) => b.stamp - a.stamp); // newest first
794
+ return [...out, ...archives.map((a) => a.file)];
795
+ }
796
+ readOwnIncarnation(ownMachineId) {
797
+ const safe = sanitizeMachineId(ownMachineId);
798
+ const metaPath = path.join(this.dirPath(), `${safe}.meta.json`);
799
+ try {
800
+ if (!this.io.existsSync(metaPath))
801
+ return '';
802
+ const raw = this.io.readFileSync(metaPath, 'utf-8');
803
+ const obj = JSON.parse(raw);
804
+ return typeof obj?.incarnation === 'string' ? obj.incarnation : '';
805
+ }
806
+ catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
807
+ return '';
808
+ }
809
+ }
810
+ // ---- paths -------------------------------------------------------------
811
+ dirPath() {
812
+ return path.join(this.stateDir, 'state', 'coherence-journal');
813
+ }
814
+ peersDirPath() {
815
+ return path.join(this.dirPath(), 'peers');
816
+ }
817
+ replicaFilePath(senderMachineId, kind) {
818
+ return path.join(this.peersDirPath(), `${sanitizeMachineId(senderMachineId)}.${kind}.jsonl`);
819
+ }
820
+ metaPath(machineId) {
821
+ return path.join(this.peersDirPath(), `${sanitizeMachineId(machineId)}.meta.json`);
822
+ }
823
+ ensurePeersDir() {
824
+ const p = this.peersDirPath();
825
+ if (!this.io.existsSync(p))
826
+ this.io.mkdirSync(p, { recursive: true });
827
+ }
828
+ log(cls, msg) {
829
+ if (this.loggedClasses.has(cls))
830
+ return;
831
+ this.loggedClasses.add(cls);
832
+ this.logger?.(msg);
833
+ }
834
+ }
835
+ //# sourceMappingURL=JournalSyncApplier.js.map