mover-os 4.7.8 → 4.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -24
- package/install.js +2197 -200
- package/package.json +1 -1
- package/src/dashboard/build.js +41 -3
- package/src/dashboard/lib/active-context-parser.js +16 -4
- package/src/dashboard/lib/agent-session.js +70 -49
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/daily-note-resolver.js +20 -3
- package/src/dashboard/lib/date-utils.js +9 -1
- package/src/dashboard/lib/distribution-parser.js +69 -10
- package/src/dashboard/lib/drift-history.js +6 -1
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +4 -1
- package/src/dashboard/lib/engine-writer.js +157 -11
- package/src/dashboard/lib/goal-forecast.js +154 -31
- package/src/dashboard/lib/library-indexer-v2.js +14 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +0 -0
- package/src/dashboard/lib/memory-index.js +61 -12
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/regenerate-manifest.js +0 -0
- package/src/dashboard/lib/run-registry.js +75 -15
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +3 -0
- package/src/dashboard/lib/suggested-now.js +2 -2
- package/src/dashboard/lib/transcript-parser.js +48 -8
- package/src/dashboard/server.js +422 -44
- package/src/dashboard/shortcut.js +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
- 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
|
+
};
|