mover-os 4.7.8 → 4.7.10

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 (43) hide show
  1. package/README.md +34 -24
  2. package/install.js +2229 -204
  3. package/package.json +3 -2
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
  36. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  37. package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/system/counts.json +7 -0
  41. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  42. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  43. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -0,0 +1,615 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * event-log.js — CORE-LOOP PHASE 1: the production event store.
5
+ *
6
+ * Scope fence (qa/campaign-2026-07/core-loop/SCHEMA-SPEC.md, task brief):
7
+ * this module is NOT wired to any workflow, server route, dashboard render,
8
+ * or hook. It is a standalone product-path library exercised only by its own
9
+ * tests until phase 2 wires a caller. Never point it at a real vault path
10
+ * outside tests.
11
+ *
12
+ * Envelope (SCHEMA-SPEC.md v0.2 amendments 1-2, sol-rescore-2.md blocking
13
+ * objections 1-2, answered):
14
+ * { v:1, id:<UUID>, ts:<epoch-ms number>, type:<string>, payload:<object>,
15
+ * dedupeKey:<sha256 hex>, source?:{path,sourceHash,parserVersion,locator} }
16
+ *
17
+ * - `ts` is frozen as an epoch-ms NUMBER (objection 1). The v0.1 draft's
18
+ * "ISO string with zone" contradicted the lab validator and is dead.
19
+ * Rendering to a display format happens at the UI edge, never here.
20
+ * - `id` is opaque event identity (a fresh UUID by default) — NOT a
21
+ * content hash. Two independently-real events can share content.
22
+ * - `dedupeKey` is the full sha256 of canonical `{type, payload}` (ts and
23
+ * id excluded on purpose — sol's answer #1: "including a changing
24
+ * timestamp in the content prevents retry deduplication"). Two retries
25
+ * of the SAME logical fact collapse to one append; this is a deliberate
26
+ * idempotency contract, not a hashing accident — see events.js.
27
+ * - `source` is REQUIRED by migration code (phase 2+, not built here) on
28
+ * backfilled events, optional on native live events. This module only
29
+ * validates its SHAPE when present; it does not enforce presence, since
30
+ * that policy differs by call site.
31
+ *
32
+ * Append strategy (SCHEMA-SPEC.md v0.2 amendment 4, objection 3 answered):
33
+ * the state-lab spike (test/state-lab/lib/event-log.js) rewrites the WHOLE
34
+ * log to a temp file and renames over the target on every append — O(n)
35
+ * write cost, explicitly flagged by the spike's own comments as unfit for
36
+ * forever retention. This module instead:
37
+ * 1. Takes an exclusive advisory lock (a sibling `<log>.lock` file created
38
+ * with O_EXCL; stale locks — dead pid or past a TTL — are reaped and
39
+ * retried, so a crashed holder can never wedge the log forever).
40
+ * 2. Reads the log ONCE to check for a duplicate `id`/`dedupeKey` (a read,
41
+ * not a rewrite — the cost the spike's own comment calls out is the
42
+ * WRITE, and dedup honesty requires seeing what's already there).
43
+ * 3. Appends exactly one line via `fs.appendFileSync` (flag 'a' ==
44
+ * O_WRONLY|O_CREAT|O_APPEND) — a single write() syscall that extends
45
+ * the file in place. The target path's inode never changes and no byte
46
+ * already on disk is ever rewritten. This is the O(1) part: proven in
47
+ * test/state-core.test.mjs by asserting the inode and the leading bytes
48
+ * of the file are IDENTICAL before and after thousands of further
49
+ * appends (a stronger, non-flaky proof than a timing benchmark).
50
+ * 4. Releases the lock.
51
+ *
52
+ * The quarantine reader (torn-tail / corrupt-line / duplicate-id) is a
53
+ * direct port of the state-lab approach, updated for the v0.2 envelope
54
+ * shape (`dedupeKey` now required, `ts` now a number).
55
+ */
56
+
57
+ const fs = require("fs");
58
+ const path = require("path");
59
+ const crypto = require("crypto");
60
+
61
+ const ENVELOPE_VERSION = 1;
62
+ const DEFAULT_STALE_LOCK_MS = 30000; // a lock older than this (or held by a dead pid) is reaped
63
+ const DEFAULT_LOCK_WAIT_MS = 15000; // how long a caller will wait for a live lock before giving up
64
+
65
+ // ── canonical serialization / hashing (shared by event-log, events, projections) ──
66
+
67
+ // Recursively sorts object keys so two values with identical content but
68
+ // different insertion order always serialize to identical bytes. Arrays keep
69
+ // their order (order is meaningful there). Used both for the envelope's
70
+ // on-disk line (fixed field order, written out explicitly below) and for
71
+ // dedupeKey / projection-hash inputs (arbitrary nested payloads).
72
+ function sortKeysDeep(value) {
73
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
74
+ if (value !== null && typeof value === "object") {
75
+ const out = {};
76
+ for (const k of Object.keys(value).sort()) out[k] = sortKeysDeep(value[k]);
77
+ return out;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function stableStringify(value) {
83
+ return JSON.stringify(sortKeysDeep(value));
84
+ }
85
+
86
+ function sha256Hex(input) {
87
+ return crypto.createHash("sha256").update(input, "utf8").digest("hex");
88
+ }
89
+
90
+ // ── envelope shape ──────────────────────────────────────────────────────────
91
+
92
+ function isValidSource(source) {
93
+ return source !== null && typeof source === "object" && !Array.isArray(source);
94
+ }
95
+
96
+ function isValidEnvelope(o) {
97
+ if (o === null || typeof o !== "object" || Array.isArray(o)) return false;
98
+ if (typeof o.v !== "number") return false;
99
+ if (typeof o.id !== "string" || o.id.length === 0) return false;
100
+ // ts frozen as epoch-ms NUMBER (objection 1) — an ISO string or anything
101
+ // else is an invalid envelope, not tolerated/coerced.
102
+ if (typeof o.ts !== "number" || !Number.isFinite(o.ts)) return false;
103
+ if (typeof o.type !== "string" || o.type.length === 0) return false;
104
+ if (!Object.prototype.hasOwnProperty.call(o, "payload")) return false;
105
+ if (typeof o.dedupeKey !== "string" || o.dedupeKey.length === 0) return false;
106
+ if (o.source !== undefined && !isValidSource(o.source)) return false;
107
+ return true;
108
+ }
109
+
110
+ // Fixed key order on disk so identical envelopes always serialize to
111
+ // identical bytes, and so a foreign reader can rely on field order.
112
+ function serializeEnvelope(evt) {
113
+ const obj = {
114
+ v: evt.v,
115
+ id: evt.id,
116
+ ts: evt.ts,
117
+ type: evt.type,
118
+ payload: evt.payload === undefined ? null : evt.payload,
119
+ dedupeKey: evt.dedupeKey,
120
+ };
121
+ if (evt.source !== undefined) obj.source = evt.source;
122
+ return JSON.stringify(obj);
123
+ }
124
+
125
+ // dedupeKey = sha256 of canonical {type, payload} — ts and id excluded so a
126
+ // retried/backfilled identical fact always produces the same key (sol's
127
+ // answer #1 in sol-rescore-2.md).
128
+ function computeDedupeKey(type, payload) {
129
+ return sha256Hex(stableStringify({ type, payload: payload === undefined ? null : payload }));
130
+ }
131
+
132
+ // Convenience constructor. `id` defaults to a fresh UUID (opaque identity,
133
+ // per sol's identity-model ruling); `dedupeKey` defaults to the canonical
134
+ // content hash unless the caller supplies one explicitly (events.js always
135
+ // lets this default).
136
+ function makeEvent(type, payload, opts = {}) {
137
+ const evt = {
138
+ v: opts.v != null ? opts.v : ENVELOPE_VERSION,
139
+ id: opts.id != null ? String(opts.id) : crypto.randomUUID(),
140
+ ts: opts.ts != null ? opts.ts : Date.now(),
141
+ type: String(type),
142
+ payload: payload === undefined ? null : payload,
143
+ dedupeKey: opts.dedupeKey != null ? String(opts.dedupeKey) : computeDedupeKey(type, payload),
144
+ };
145
+ if (opts.source !== undefined) evt.source = opts.source;
146
+ return evt;
147
+ }
148
+
149
+ // ── tolerant parse + quarantine (state-lab pattern, ported for v0.2) ───────
150
+
151
+ function parseLog(raw) {
152
+ const events = [];
153
+ const quarantined = [];
154
+ if (raw === "" || raw == null) return { events, quarantined };
155
+
156
+ const hadTrailingNewline = raw.endsWith("\n");
157
+ const lines = raw.split("\n");
158
+ if (hadTrailingNewline) lines.pop();
159
+
160
+ const seenIds = new Set();
161
+ const seenDedupeKeys = new Set();
162
+ lines.forEach((line, idx) => {
163
+ if (line === "") return; // tolerate stray blank lines
164
+ const isLastLine = idx === lines.length - 1;
165
+ const tornTail = isLastLine && !hadTrailingNewline;
166
+
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(line);
170
+ } catch (e) {
171
+ quarantined.push({ lineNo: idx + 1, raw: line, reason: tornTail ? "torn-tail" : "corrupt-line", detail: e.message });
172
+ return;
173
+ }
174
+ if (!isValidEnvelope(parsed)) {
175
+ quarantined.push({ lineNo: idx + 1, raw: line, reason: tornTail ? "torn-tail" : "invalid-envelope", detail: "missing/malformed required v0.2 envelope fields" });
176
+ return;
177
+ }
178
+ if (seenIds.has(parsed.id)) {
179
+ quarantined.push({ lineNo: idx + 1, raw: line, reason: "duplicate-id", detail: `id ${parsed.id} already present earlier in the log` });
180
+ return;
181
+ }
182
+ // A duplicate dedupeKey with a different id is the same logical fact
183
+ // written twice (a lock failure or a foreign writer) — counting it twice
184
+ // would inflate every projection, so quarantine it like a duplicate id
185
+ // (terra attack 7: parseLog quarantined ids but never dedupeKeys).
186
+ if (seenDedupeKeys.has(parsed.dedupeKey)) {
187
+ quarantined.push({ lineNo: idx + 1, raw: line, reason: "duplicate-dedupe-key", detail: `dedupeKey ${parsed.dedupeKey.slice(0, 16)}... already present earlier in the log` });
188
+ return;
189
+ }
190
+ seenIds.add(parsed.id);
191
+ seenDedupeKeys.add(parsed.dedupeKey);
192
+ events.push(parsed);
193
+ });
194
+
195
+ return { events, quarantined };
196
+ }
197
+
198
+ // Sidecar bound (terra round 3 finding 7, which refuted the "unbounded
199
+ // growth needs a log rewrite" scoping): the newest SIDECAR_MAX_RECORDS
200
+ // quarantine records stay verbatim; older ones fold into a single aggregate
201
+ // first line keeping total + per-reason counts, plus a FIFO of folded
202
+ // identity hashes so a persistent corrupt line that was folded out is not
203
+ // re-appended as "fresh" on every subsequent read.
204
+ const SIDECAR_MAX_RECORDS = 200;
205
+ const SIDECAR_MAX_SEEN_KEYS = 2000;
206
+
207
+ function writeQuarantineSidecar(logPath, entries, fsImpl) {
208
+ if (!entries.length) return;
209
+ const sidecar = `${logPath}.quarantine`;
210
+ const ts = Date.now();
211
+ // Identity = lineNo + raw content, NUL-joined (raw content cannot contain
212
+ // an unescaped NUL after JSON round-tripping).
213
+ const idKey = (lineNo, raw) => `${lineNo}\u0000${raw}`;
214
+ try {
215
+ // Dedupe against what the sidecar already records: readEvents and
216
+ // appendEvent both report the same persistent quarantined bytes, so an
217
+ // unconditional append duplicated the audit record on every access
218
+ // (terra finding 8).
219
+ let existing = "";
220
+ try { existing = fsImpl.readFileSync(sidecar, "utf8"); } catch (_) {}
221
+ let aggregate = null;
222
+ const records = [];
223
+ const recorded = new Set();
224
+ for (const line of existing.split("\n")) {
225
+ if (!line) continue;
226
+ try {
227
+ const p = JSON.parse(line);
228
+ if (p && p.aggregate === true) { aggregate = p; continue; }
229
+ records.push(line);
230
+ recorded.add(idKey(p.lineNo, p.raw));
231
+ } catch (_) {}
232
+ }
233
+ const seenHashes = new Set((aggregate && aggregate.seenKeys) || []);
234
+ const fresh = entries.filter((e) => {
235
+ const k = idKey(e.lineNo, e.raw);
236
+ return !recorded.has(k) && !seenHashes.has(sha256Hex(k).slice(0, 16));
237
+ });
238
+ if (!fresh.length) return;
239
+ if (records.length + fresh.length > SIDECAR_MAX_RECORDS) {
240
+ for (const e of fresh) records.push(JSON.stringify({ ts, ...e }));
241
+ const folded = records.splice(0, records.length - SIDECAR_MAX_RECORDS);
242
+ aggregate = aggregate || { aggregate: true, foldedRecords: 0, byReason: {}, seenKeys: [] };
243
+ if (!Array.isArray(aggregate.seenKeys)) aggregate.seenKeys = [];
244
+ for (const line of folded) {
245
+ aggregate.foldedRecords += 1;
246
+ let reason = "unknown";
247
+ try {
248
+ const q = JSON.parse(line);
249
+ reason = q.reason || "unknown";
250
+ aggregate.seenKeys.push(sha256Hex(idKey(q.lineNo, q.raw)).slice(0, 16));
251
+ } catch (_) {}
252
+ aggregate.byReason[reason] = (aggregate.byReason[reason] || 0) + 1;
253
+ }
254
+ if (aggregate.seenKeys.length > SIDECAR_MAX_SEEN_KEYS) {
255
+ aggregate.seenKeys.splice(0, aggregate.seenKeys.length - SIDECAR_MAX_SEEN_KEYS);
256
+ }
257
+ aggregate.lastFoldedTs = ts;
258
+ const tmp = `${sidecar}.tmp-${process.pid}`;
259
+ fsImpl.mkdirSync(path.dirname(sidecar), { recursive: true });
260
+ fsImpl.writeFileSync(tmp, [JSON.stringify(aggregate), ...records].join("\n") + "\n", "utf8");
261
+ fsImpl.renameSync(tmp, sidecar);
262
+ return;
263
+ }
264
+ const lines = fresh.map((e) => JSON.stringify({ ts, ...e })).join("\n") + "\n";
265
+ fsImpl.mkdirSync(path.dirname(sidecar), { recursive: true });
266
+ fsImpl.appendFileSync(sidecar, lines, "utf8");
267
+ } catch (_) {
268
+ // best-effort sidecar; a failure here must not fail the (successful)
269
+ // read/append it is reporting on
270
+ }
271
+ }
272
+
273
+ function readEvents(logPath, opts = {}) {
274
+ const fsImpl = opts.fs || fs;
275
+ let raw = "";
276
+ try {
277
+ raw = fsImpl.readFileSync(logPath, "utf8");
278
+ } catch (e) {
279
+ if (e.code === "ENOENT") return { events: [], quarantined: [] };
280
+ throw e;
281
+ }
282
+ const { events, quarantined } = parseLog(raw);
283
+ if (quarantined.length) writeQuarantineSidecar(logPath, quarantined, fsImpl);
284
+ return { events, quarantined };
285
+ }
286
+
287
+ // ── exclusive advisory lock (sibling lockfile, O_EXCL, stale reaping) ──────
288
+
289
+ function pidAlive(pid) {
290
+ if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0) return false;
291
+ try {
292
+ process.kill(pid, 0);
293
+ return true;
294
+ } catch (e) {
295
+ return e.code === "EPERM"; // exists, owned by someone else on this host: treat as alive
296
+ }
297
+ }
298
+
299
+ // Process start time via `ps` (mac/linux), used to detect PID RECYCLING: a
300
+ // process that started AFTER the lockfile was written cannot be its writer
301
+ // (terra round 3 finding 4: a dead holder's pid reused by an unrelated live
302
+ // process wedged waiters until timeout). Returns null when unavailable
303
+ // (windows, ps failure); callers treat null conservatively - holder stays
304
+ // alive and the loud timeout below still fires.
305
+ function processStartMs(pid) {
306
+ try {
307
+ const out = require("child_process")
308
+ .execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
309
+ .trim();
310
+ if (!out) return null;
311
+ const t = Date.parse(out);
312
+ return Number.isFinite(t) ? t : null;
313
+ } catch (_) {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ // Liveness of a lock HOLDER: pid alive, and not provably a recycled pid.
319
+ // The start-time probe execs `ps`, so it is gated to locks older than 3s
320
+ // (recycling needs the holder to die AND the OS to reuse the pid; inside 3s
321
+ // the pre-existing loud timeout is the honest answer) and memoized per
322
+ // acquisition wait via `cache`.
323
+ function holderAlive(holder, cache) {
324
+ if (!pidAlive(holder.pid)) return false;
325
+ if (typeof holder.ts !== "number" || Date.now() - holder.ts <= 3000) return true;
326
+ let startMs;
327
+ if (cache && cache.has(holder.pid)) {
328
+ startMs = cache.get(holder.pid);
329
+ } else {
330
+ startMs = processStartMs(holder.pid);
331
+ if (cache) cache.set(holder.pid, startMs);
332
+ }
333
+ // 2s slack: `ps lstart` has second precision and a real holder stamps ts
334
+ // strictly after its own process start.
335
+ return !(startMs != null && startMs > holder.ts + 2000);
336
+ }
337
+
338
+ // Reap `lockPath` ONLY if it still holds exactly `expectedRaw` bytes.
339
+ // Blind unlink-by-path raced (terra round 3 finding 1, CRITICAL): between a
340
+ // waiter's read of a dead holder and its unlink, another waiter could reap
341
+ // and MINT, so the unlink deleted the NEW holder's valid lock and two
342
+ // writers overlapped. Reapers now serialize on a short-lived reap mutex and
343
+ // re-verify the victim's exact bytes under it; minting (linkSync) requires
344
+ // the path to be absent, so a verified-unchanged victim cannot have been
345
+ // replaced before the unlink. The mutex itself is cleared dead-pid-only,
346
+ // same doctrine as the main lock: a SIGSTOP-paused reaper holds it and
347
+ // waiters time out loudly instead of risking a steal.
348
+ function reapLockIfUnchanged(lockPath, expectedRaw) {
349
+ if (typeof expectedRaw !== "string") return false;
350
+ const reapPath = `${lockPath}.reap`;
351
+ const tmp = `${lockPath}.mint-reap-${process.pid}-${crypto.randomUUID().slice(0, 8)}`;
352
+ let acquired = false;
353
+ try {
354
+ fs.writeFileSync(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }), "utf8");
355
+ try {
356
+ fs.linkSync(tmp, reapPath);
357
+ acquired = true;
358
+ } catch (_) {}
359
+ } catch (_) {
360
+ } finally {
361
+ try { fs.unlinkSync(tmp); } catch (_) {}
362
+ }
363
+ if (!acquired) {
364
+ try {
365
+ const m = JSON.parse(fs.readFileSync(reapPath, "utf8"));
366
+ if (m && !pidAlive(m.pid)) fs.unlinkSync(reapPath);
367
+ } catch (_) {
368
+ // unreadable mutex: it only ever lives for three syscalls, so judge
369
+ // by its own age with a generous bound
370
+ try {
371
+ if (Math.abs(Date.now() - fs.statSync(reapPath).mtimeMs) > 5000) fs.unlinkSync(reapPath);
372
+ } catch (_) {}
373
+ }
374
+ return false;
375
+ }
376
+ try {
377
+ let raw = null;
378
+ try { raw = fs.readFileSync(lockPath, "utf8"); } catch (_) {}
379
+ if (raw === expectedRaw) {
380
+ try { fs.unlinkSync(lockPath); } catch (_) {}
381
+ }
382
+ return true;
383
+ } finally {
384
+ try { fs.unlinkSync(reapPath); } catch (_) {}
385
+ }
386
+ }
387
+
388
+ // A crash between temp-write and linkSync leaks a `.mint-*` file (terra
389
+ // round 3 finding 6). Leaks never block acquisition; this sweep is disk
390
+ // hygiene, run once per CONTENDED acquisition so the uncontended fast path
391
+ // stays readdir-free.
392
+ function sweepAbandonedMints(lockPath) {
393
+ try {
394
+ const dir = path.dirname(lockPath);
395
+ const prefix = `${path.basename(lockPath)}.mint-`;
396
+ for (const name of fs.readdirSync(dir)) {
397
+ if (!name.startsWith(prefix)) continue;
398
+ const p = path.join(dir, name);
399
+ try {
400
+ if (Date.now() - fs.statSync(p).mtimeMs > 60000) fs.unlinkSync(p);
401
+ } catch (_) {}
402
+ }
403
+ } catch (_) {}
404
+ }
405
+
406
+ // Synchronous sleep — appendEvent is a sync API (matches event-log.js/
407
+ // state-lab's sync contract), so lock-wait backoff must also be sync.
408
+ // Atomics.wait blocks the calling thread without a busy loop; it is
409
+ // available in every Node version this project targets.
410
+ function syncSleep(ms) {
411
+ if (ms <= 0) return;
412
+ try {
413
+ const sab = new SharedArrayBuffer(4);
414
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
415
+ } catch (_) {
416
+ const end = Date.now() + ms;
417
+ while (Date.now() < end) {
418
+ /* busy-wait fallback; Atomics.wait is unavailable in this host */
419
+ }
420
+ }
421
+ }
422
+
423
+ // Acquires `<logPath>.lock`. A holder is reaped (its lockfile removed and
424
+ // acquisition retried immediately, no backoff) ONLY when its pid is provably
425
+ // dead or the lockfile is unreadable garbage past a short settle window —
426
+ // this is what stops a crashed writer from wedging the log forever
427
+ // (objection 4). Age alone NEVER steals a LIVE holder's lock: terra's round
428
+ // (2026-07-11) reproduced two writers inside the critical section after an
429
+ // age-based reap of a paused-but-alive holder, followed by the old holder's
430
+ // unconditional release() deleting the new holder's lock. The cost is
431
+ // honest: a hung-but-alive holder makes waiters time out LOUDLY below
432
+ // instead of silently proceeding in parallel. Always uses the real `fs`
433
+ // module: locks are an OS-level cross-process primitive and a
434
+ // caller-injected fault-fs would not apply across processes.
435
+ function acquireLock(logPath, opts = {}) {
436
+ const lockPath = `${logPath}.lock`;
437
+ const staleMs = opts.staleLockMs != null ? opts.staleLockMs : DEFAULT_STALE_LOCK_MS;
438
+ const maxWaitMs = opts.lockWaitMs != null ? opts.lockWaitMs : DEFAULT_LOCK_WAIT_MS;
439
+ const start = Date.now();
440
+ const startTimeCache = new Map(); // pid -> processStartMs, per-wait memo
441
+ let attempt = 0;
442
+
443
+ for (;;) {
444
+ attempt += 1;
445
+ // Write-then-LINK creation (terra re-verdict #6): opening with wx and
446
+ // writing afterwards left a window where the lockfile existed EMPTY; a
447
+ // waiter's settle window then reaped a paused-but-live creator's lock.
448
+ // linkSync is atomic - the lockfile appears with its full content or
449
+ // not at all, so an unreadable lockfile now really is garbage.
450
+ const token = crypto.randomUUID();
451
+ const tmpLock = `${lockPath}.mint-${process.pid}-${token.slice(0, 8)}`;
452
+ try {
453
+ fs.writeFileSync(tmpLock, JSON.stringify({ pid: process.pid, ts: Date.now(), token }), "utf8");
454
+ fs.linkSync(tmpLock, lockPath); // EEXIST when held - same exclusivity as wx
455
+ fs.unlinkSync(tmpLock);
456
+ return {
457
+ release() {
458
+ try {
459
+ const cur = JSON.parse(fs.readFileSync(lockPath, "utf8"));
460
+ if (cur && cur.token === token) fs.unlinkSync(lockPath);
461
+ } catch (_) {
462
+ // gone or unreadable; never blind-unlink a lock that may not be ours
463
+ }
464
+ },
465
+ };
466
+ } catch (e) {
467
+ try { fs.unlinkSync(tmpLock); } catch (_) {}
468
+ if (e.code !== "EEXIST") throw e;
469
+
470
+ if (attempt === 1) sweepAbandonedMints(lockPath);
471
+
472
+ let rawHolder = null;
473
+ let holder = null;
474
+ try {
475
+ rawHolder = fs.readFileSync(lockPath, "utf8");
476
+ holder = JSON.parse(rawHolder);
477
+ } catch (_) {
478
+ holder = null; // unreadable lockfile: with link-creation this is garbage, judged by ITS OWN age below
479
+ }
480
+ if (!holder) {
481
+ // Judge garbage by the LOCKFILE's mtime, never by this waiter's own
482
+ // elapsed time (terra re-verdict #6: a waiter that had already
483
+ // waited 1s reaped a lockfile created 1ms earlier). A FUTURE mtime
484
+ // beyond clock skew is equally garbage - write-then-link means our
485
+ // own writer never produces one, and "settling" on it wedged
486
+ // waiters until timeout (terra round 3 finding 5).
487
+ let ageMs = Infinity;
488
+ try { ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; } catch (_) { continue; }
489
+ if (ageMs > Math.min(staleMs, 1000) || ageMs < -2000) {
490
+ if (rawHolder != null) reapLockIfUnchanged(lockPath, rawHolder);
491
+ continue;
492
+ }
493
+ if (Date.now() - start > maxWaitMs) {
494
+ throw new Error(`event-log: lock wait timeout after ${maxWaitMs}ms on ${lockPath} (unreadable lockfile, settling)`);
495
+ }
496
+ syncSleep(5);
497
+ continue;
498
+ }
499
+
500
+ // Reapable = holder pid provably dead, or provably a RECYCLED pid
501
+ // (holderAlive). Never by age alone - terra round 2 reproduced overlap
502
+ // after an age steal of a paused-but-alive holder.
503
+ const stale = !holderAlive(holder, startTimeCache);
504
+
505
+ if (stale) {
506
+ // Guarded reap (terra round 3 finding 1): verify-unchanged under the
507
+ // reap mutex, never blind unlink-by-path. False return = another
508
+ // reaper holds the mutex; just retry the acquisition loop.
509
+ reapLockIfUnchanged(lockPath, rawHolder);
510
+ continue; // immediate retry, no backoff, after a reap
511
+ }
512
+
513
+ if (Date.now() - start > maxWaitMs) {
514
+ throw new Error(`event-log: lock wait timeout after ${maxWaitMs}ms on ${lockPath} (held by pid ${holder && holder.pid})`);
515
+ }
516
+ syncSleep(Math.min(5 + attempt * 2, 40)); // small linear backoff, capped
517
+ }
518
+ }
519
+ }
520
+
521
+ // True O_APPEND can never HEAL a foreign torn write the way a full rewrite
522
+ // could (state-lab's temp+rename approach rebuilds from clean events, so a
523
+ // torn tail on disk is silently dropped from the physical file). We do not
524
+ // rewrite, so a torn tail left by a foreign writer stays on disk forever —
525
+ // quarantined on every future read, but still bytes on the page. What we
526
+ // MUST do is make sure OUR OWN new line never gets silently concatenated
527
+ // onto the end of that torn tail (which would corrupt our own valid event
528
+ // too). This is a single 1-byte read, still O(1), still touches no existing
529
+ // byte — it only decides whether to prefix our write with one extra "\n".
530
+ function fileEndsWithNewlineOrEmpty(logPath, fsImpl) {
531
+ let size;
532
+ try {
533
+ size = fsImpl.statSync(logPath).size;
534
+ } catch (e) {
535
+ if (e.code === "ENOENT") return true; // no file yet; nothing to pad against
536
+ throw e;
537
+ }
538
+ if (size === 0) return true;
539
+ const fd = fsImpl.openSync(logPath, "r");
540
+ try {
541
+ const buf = Buffer.alloc(1);
542
+ fsImpl.readSync(fd, buf, 0, 1, size - 1);
543
+ return buf.toString("utf8") === "\n";
544
+ } finally {
545
+ fsImpl.closeSync(fd);
546
+ }
547
+ }
548
+
549
+ // ── append ───────────────────────────────────────────────────────────────
550
+
551
+ // Appends one event, atomically w.r.t. other appendEvent callers (via the
552
+ // lock), idempotently (via the id/dedupeKey dedup check), as a true O(1)
553
+ // single-line write (via O_APPEND — never a whole-file rewrite).
554
+ //
555
+ // - `event` must already be a valid v0.2 envelope (build one with
556
+ // makeEvent / events.js's typed constructors, not by hand).
557
+ // - duplicate `id` OR duplicate `dedupeKey` (already present among the
558
+ // log's clean events): no-op, `{ appended:false, duplicate:true }`.
559
+ // - `opts.fs` lets a caller inject a wrapped fs for the READ/WRITE steps
560
+ // only (lock acquisition always uses the real fs — see acquireLock).
561
+ function appendEvent(logPath, event, opts = {}) {
562
+ const fsImpl = opts.fs || fs;
563
+ if (!isValidEnvelope(event)) {
564
+ throw new Error("event-log: appendEvent requires a valid v0.2 envelope (v, id, ts:number, type, payload, dedupeKey)");
565
+ }
566
+
567
+ const dir = path.dirname(logPath);
568
+ fsImpl.mkdirSync(dir, { recursive: true });
569
+
570
+ const lock = acquireLock(logPath, opts);
571
+ try {
572
+ const { events: existing, quarantined } = readEvents(logPath, { fs: fsImpl });
573
+ if (quarantined.length) writeQuarantineSidecar(logPath, quarantined, fsImpl);
574
+
575
+ const isDuplicate = existing.some((e) => e.id === event.id || e.dedupeKey === event.dedupeKey);
576
+ if (isDuplicate) return { appended: false, duplicate: true, total: existing.length };
577
+
578
+ const needsLeadingNewline = !fileEndsWithNewlineOrEmpty(logPath, fsImpl);
579
+ const line = (needsLeadingNewline ? "\n" : "") + serializeEnvelope(event) + "\n";
580
+ // A single appendFileSync call with flag 'a' (the default) opens with
581
+ // O_APPEND and issues one write() syscall: the OS atomically seeks to
582
+ // end-of-file and writes, so this can never overwrite or truncate a byte
583
+ // already on disk, and the target path's inode never changes. This is
584
+ // the O(1) append the SCHEMA-SPEC v0.2 amendment 4 / objection 3 ask for
585
+ // — no temp file, no rename, no rewrite of prior content.
586
+ fsImpl.appendFileSync(logPath, line, "utf8");
587
+
588
+ return { appended: true, duplicate: false, total: existing.length + 1 };
589
+ } finally {
590
+ lock.release();
591
+ }
592
+ }
593
+
594
+ module.exports = {
595
+ ENVELOPE_VERSION,
596
+ DEFAULT_STALE_LOCK_MS,
597
+ DEFAULT_LOCK_WAIT_MS,
598
+ sortKeysDeep,
599
+ stableStringify,
600
+ sha256Hex,
601
+ isValidEnvelope,
602
+ serializeEnvelope,
603
+ computeDedupeKey,
604
+ makeEvent,
605
+ parseLog,
606
+ readEvents,
607
+ acquireLock,
608
+ appendEvent,
609
+ pidAlive,
610
+ // exported for direct adversarial testing (terra round 3 fixes)
611
+ holderAlive,
612
+ processStartMs,
613
+ reapLockIfUnchanged,
614
+ SIDECAR_MAX_RECORDS,
615
+ };