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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +166 -10
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +17 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/AutonomousSessions.d.ts +17 -2
- package/dist/core/AutonomousSessions.d.ts.map +1 -1
- package/dist/core/AutonomousSessions.js +30 -4
- package/dist/core/AutonomousSessions.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +326 -0
- package/dist/core/CoherenceJournal.d.ts.map +1 -0
- package/dist/core/CoherenceJournal.js +999 -0
- package/dist/core/CoherenceJournal.js.map +1 -0
- package/dist/core/CoherenceJournalReader.d.ts +158 -0
- package/dist/core/CoherenceJournalReader.d.ts.map +1 -0
- package/dist/core/CoherenceJournalReader.js +450 -0
- package/dist/core/CoherenceJournalReader.js.map +1 -0
- package/dist/core/JournalSyncApplier.d.ts +272 -0
- package/dist/core/JournalSyncApplier.d.ts.map +1 -0
- package/dist/core/JournalSyncApplier.js +835 -0
- package/dist/core/JournalSyncApplier.js.map +1 -0
- package/dist/core/LearningVelocityScorer.d.ts +40 -0
- package/dist/core/LearningVelocityScorer.d.ts.map +1 -0
- package/dist/core/LearningVelocityScorer.js +67 -0
- package/dist/core/LearningVelocityScorer.js.map +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +17 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/StateManager.d.ts +41 -0
- package/dist/core/StateManager.d.ts.map +1 -1
- package/dist/core/StateManager.js +100 -0
- package/dist/core/StateManager.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +14 -0
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +16 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +11 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +116 -3
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lint-cas-emit-placement.js +87 -0
- package/scripts/lint-journal-actuation-ban.js +63 -0
- package/scripts/lint-state-registry.js +313 -0
- package/src/data/builtin-manifest.json +63 -63
- package/src/data/state-coherence-registry.json +880 -0
- package/src/scaffold/templates.ts +11 -0
- package/upgrades/1.3.340.md +132 -0
- package/upgrades/coherence-journal-p1-1.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-2.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-3.eli16.md +13 -0
- package/upgrades/side-effects/coherence-journal-p1-1.md +68 -0
- package/upgrades/side-effects/coherence-journal-p1-2.md +54 -0
- package/upgrades/side-effects/coherence-journal-p1-3.md +49 -0
- package/upgrades/side-effects/exo3-g5-ci-greening.md +29 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoherenceJournal — per-machine, per-kind append-only event streams
|
|
3
|
+
* (P1.1 of multi-machine coherence).
|
|
4
|
+
*
|
|
5
|
+
* Spec: docs/specs/COHERENCE-JOURNAL-SPEC.md §3.1 (writer rules), §3.2 (typed
|
|
6
|
+
* schemas), §3.7 (per-kind retention).
|
|
7
|
+
*
|
|
8
|
+
* This class is ONLY the writer + a minimal tolerant reader for the read API
|
|
9
|
+
* (built later). It performs its OWN file I/O — it is not a StateManager
|
|
10
|
+
* method — so the standby read-only guard is an injected seam
|
|
11
|
+
* (`guardWrite?: (path) => void`); another workstream wires the real
|
|
12
|
+
* `StateManager.guardJournalWrite` entrypoint into it.
|
|
13
|
+
*
|
|
14
|
+
* The load-bearing safety rule (§3.1): emit() is a NON-BLOCKING memory
|
|
15
|
+
* operation. It validates + enqueues + returns in microseconds; a background
|
|
16
|
+
* flusher drains the queue with single-line O_APPEND writes and batched
|
|
17
|
+
* fdatasync on a cadence (default 250ms). NO synchronous I/O ever runs in a
|
|
18
|
+
* caller's stack — the host has a documented event-loop-starvation history and
|
|
19
|
+
* a blocking fsync at a placement/session/autonomous code path would reproduce
|
|
20
|
+
* the originating incident class.
|
|
21
|
+
*
|
|
22
|
+
* Crash-safe meta ordering (§3.4 rule 3): data lines are appended +
|
|
23
|
+
* fdatasync'd FIRST, then `meta.highWaterSeq` is advanced via atomic temp-file
|
|
24
|
+
* rename — so `durable_tail >= highWaterSeq` holds at every instant, including
|
|
25
|
+
* a kill-9 between the two steps. The incarnation token is re-minted IFF on
|
|
26
|
+
* open the file's last seq is strictly below `meta.highWaterSeq` (a genuine
|
|
27
|
+
* rewind / restore-from-backup); a trailing-partial-line repair is NEVER a
|
|
28
|
+
* re-mint.
|
|
29
|
+
*
|
|
30
|
+
* The journal subsystem never emits journal events about itself.
|
|
31
|
+
*/
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { SafeFsExecutor } from './SafeFsExecutor.js';
|
|
36
|
+
export const JOURNAL_KINDS = ['topic-placement', 'session-lifecycle', 'autonomous-run'];
|
|
37
|
+
export const DEFAULT_RETENTION = {
|
|
38
|
+
'topic-placement': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 0 },
|
|
39
|
+
'session-lifecycle': { maxFileBytes: 16 * 1024 * 1024, rotateKeep: 4 },
|
|
40
|
+
'autonomous-run': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 8 },
|
|
41
|
+
};
|
|
42
|
+
export const DEFAULT_FLUSH_INTERVAL_MS = 250;
|
|
43
|
+
export const DEFAULT_MAX_ENTRY_BYTES = 8 * 1024;
|
|
44
|
+
/** Tail-scan window for op-key dedupe reconstruction (§3.1). */
|
|
45
|
+
export const DEDUPE_WINDOW = 200;
|
|
46
|
+
/** Token-bucket defaults per kind (emits/sec sustained; burst = capacity). */
|
|
47
|
+
export const DEFAULT_RATE_CAP = { capacity: 100, refillPerSec: 50 };
|
|
48
|
+
/** Reverse-tail read byte ceiling for the tolerant reader. */
|
|
49
|
+
export const DEFAULT_READ_BYTE_CEILING = 1 * 1024 * 1024;
|
|
50
|
+
function realFs() {
|
|
51
|
+
return {
|
|
52
|
+
openSync: fs.openSync,
|
|
53
|
+
writeSync: fs.writeSync,
|
|
54
|
+
fdatasyncSync: fs.fdatasyncSync,
|
|
55
|
+
closeSync: fs.closeSync,
|
|
56
|
+
existsSync: fs.existsSync,
|
|
57
|
+
statSync: fs.statSync,
|
|
58
|
+
renameSync: fs.renameSync,
|
|
59
|
+
writeFileSync: fs.writeFileSync,
|
|
60
|
+
readFileSync: fs.readFileSync,
|
|
61
|
+
readdirSync: fs.readdirSync,
|
|
62
|
+
truncateSync: fs.truncateSync,
|
|
63
|
+
mkdirSync: fs.mkdirSync,
|
|
64
|
+
readSync: fs.readSync,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Sanitize a machine id for use in a file name. Mirrors MachineHeartbeat:
|
|
69
|
+
* percent-encode anything outside [A-Za-z0-9_-] so a stray slash or `..`
|
|
70
|
+
* cannot escape, and the mapping is injective + traversal-safe.
|
|
71
|
+
*/
|
|
72
|
+
export function sanitizeMachineId(machineId) {
|
|
73
|
+
return machineId.replace(/[^A-Za-z0-9_-]/g, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
|
|
74
|
+
}
|
|
75
|
+
export class CoherenceJournal {
|
|
76
|
+
stateDir;
|
|
77
|
+
machineId;
|
|
78
|
+
safeMachineId;
|
|
79
|
+
flushIntervalMs;
|
|
80
|
+
maxEntryBytes;
|
|
81
|
+
retention;
|
|
82
|
+
rateCapCfg;
|
|
83
|
+
artifactRoots;
|
|
84
|
+
guardWrite;
|
|
85
|
+
now;
|
|
86
|
+
logger;
|
|
87
|
+
io;
|
|
88
|
+
state = 'closed';
|
|
89
|
+
incarnation = '';
|
|
90
|
+
/** Next seq to assign at enqueue, per kind (in-memory counter seeded at open). */
|
|
91
|
+
nextSeq = { 'topic-placement': 1, 'session-lifecycle': 1, 'autonomous-run': 1 };
|
|
92
|
+
/** Durable highWaterSeq per kind (advanced after data fdatasync). */
|
|
93
|
+
highWaterSeq = {
|
|
94
|
+
'topic-placement': 0,
|
|
95
|
+
'session-lifecycle': 0,
|
|
96
|
+
'autonomous-run': 0,
|
|
97
|
+
};
|
|
98
|
+
/** In-memory enqueue order; drained by the flusher in seq order per kind. */
|
|
99
|
+
queue = [];
|
|
100
|
+
/** Recent-window op-key set per kind (reconstructed on open by tail-scan). */
|
|
101
|
+
opKeys = {
|
|
102
|
+
'topic-placement': new Set(),
|
|
103
|
+
'session-lifecycle': new Set(),
|
|
104
|
+
'autonomous-run': new Set(),
|
|
105
|
+
};
|
|
106
|
+
rateBuckets;
|
|
107
|
+
timer = null;
|
|
108
|
+
lockFd = null;
|
|
109
|
+
flushing = false;
|
|
110
|
+
/** Monotonic suffix so rapid rotations within one ms get unique archive names. */
|
|
111
|
+
rotationCounter = 0;
|
|
112
|
+
degradation = {
|
|
113
|
+
repairs: 0,
|
|
114
|
+
remints: 0,
|
|
115
|
+
schemaRejects: 0,
|
|
116
|
+
droppedFields: 0,
|
|
117
|
+
oversize: 0,
|
|
118
|
+
rateLimited: 0,
|
|
119
|
+
jailRejects: 0,
|
|
120
|
+
guardSkips: 0,
|
|
121
|
+
flushErrors: 0,
|
|
122
|
+
dedupeHits: 0,
|
|
123
|
+
};
|
|
124
|
+
/** Has the one-time repair signal already fired this lifetime? */
|
|
125
|
+
repairSignalled = false;
|
|
126
|
+
loggedClasses = new Set();
|
|
127
|
+
constructor(config) {
|
|
128
|
+
this.stateDir = config.stateDir;
|
|
129
|
+
this.machineId = config.machineId;
|
|
130
|
+
this.safeMachineId = sanitizeMachineId(config.machineId);
|
|
131
|
+
this.flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
132
|
+
this.maxEntryBytes = config.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
|
|
133
|
+
this.retention = {
|
|
134
|
+
'topic-placement': config.retention?.['topic-placement'] ?? DEFAULT_RETENTION['topic-placement'],
|
|
135
|
+
'session-lifecycle': config.retention?.['session-lifecycle'] ?? DEFAULT_RETENTION['session-lifecycle'],
|
|
136
|
+
'autonomous-run': config.retention?.['autonomous-run'] ?? DEFAULT_RETENTION['autonomous-run'],
|
|
137
|
+
};
|
|
138
|
+
this.rateCapCfg = config.rateCap ?? DEFAULT_RATE_CAP;
|
|
139
|
+
this.artifactRoots = (config.artifactRoots ?? [path.join(this.stateDir, 'autonomous'), this.stateDir]).map((r) => {
|
|
140
|
+
const resolved = path.resolve(r);
|
|
141
|
+
// Realpath the root too, so a realpath'd candidate (e.g. macOS
|
|
142
|
+
// /var → /private/var) compares against a realpath'd root, not a raw one.
|
|
143
|
+
try {
|
|
144
|
+
return fs.realpathSync(resolved);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
this.guardWrite = config.guardWrite;
|
|
151
|
+
this.now = config.now ?? (() => new Date());
|
|
152
|
+
this.logger = config.logger;
|
|
153
|
+
this.io = config.fsImpl ?? realFs();
|
|
154
|
+
const initMs = this.now().getTime();
|
|
155
|
+
this.rateBuckets = {
|
|
156
|
+
'topic-placement': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
|
|
157
|
+
'session-lifecycle': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
|
|
158
|
+
'autonomous-run': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// ---- lifecycle ----------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Acquire the single-process lock, recover any torn trailing lines, seed the
|
|
164
|
+
* seq counters + op-key index, and start the background flusher. Idempotent —
|
|
165
|
+
* a second `open()` on an already-open instance is a no-op.
|
|
166
|
+
*/
|
|
167
|
+
open() {
|
|
168
|
+
if (this.state !== 'closed')
|
|
169
|
+
return;
|
|
170
|
+
this.ensureDirs();
|
|
171
|
+
if (!this.acquireLock()) {
|
|
172
|
+
// Loser: disable the writer. Never block / throw into callers.
|
|
173
|
+
this.state = 'writer-locked-out';
|
|
174
|
+
this.log('lock', `[coherence-journal] writer locked out for ${this.machineId} — another process holds the lock`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.loadOrInitMeta();
|
|
178
|
+
for (const kind of JOURNAL_KINDS) {
|
|
179
|
+
this.recoverKind(kind);
|
|
180
|
+
}
|
|
181
|
+
this.state = 'active';
|
|
182
|
+
this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
183
|
+
if (typeof this.timer.unref === 'function')
|
|
184
|
+
this.timer.unref();
|
|
185
|
+
}
|
|
186
|
+
/** Stop the flusher, drain once synchronously (best-effort), release the lock. */
|
|
187
|
+
close() {
|
|
188
|
+
if (this.timer) {
|
|
189
|
+
clearInterval(this.timer);
|
|
190
|
+
this.timer = null;
|
|
191
|
+
}
|
|
192
|
+
if (this.state === 'active') {
|
|
193
|
+
this.flush(); // final best-effort drain
|
|
194
|
+
}
|
|
195
|
+
this.releaseLock();
|
|
196
|
+
this.state = 'closed';
|
|
197
|
+
}
|
|
198
|
+
/** Writer status (queryable from the instance per §3.1). */
|
|
199
|
+
get status() {
|
|
200
|
+
return this.state;
|
|
201
|
+
}
|
|
202
|
+
get isLockedOut() {
|
|
203
|
+
return this.state === 'writer-locked-out';
|
|
204
|
+
}
|
|
205
|
+
getDegradation() {
|
|
206
|
+
return { ...this.degradation };
|
|
207
|
+
}
|
|
208
|
+
/** Per-stream status (incarnation, lastSeq, highWaterSeq) for the read API. */
|
|
209
|
+
streamStatuses() {
|
|
210
|
+
return JOURNAL_KINDS.map((kind) => ({
|
|
211
|
+
kind,
|
|
212
|
+
machine: this.machineId,
|
|
213
|
+
incarnation: this.incarnation,
|
|
214
|
+
lastSeq: Math.max(this.highWaterSeq[kind], this.nextSeq[kind] - 1),
|
|
215
|
+
highWaterSeq: this.highWaterSeq[kind],
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
/** Number of entries enqueued but not yet flushed (tests / observability). */
|
|
219
|
+
get pendingCount() {
|
|
220
|
+
return this.queue.length;
|
|
221
|
+
}
|
|
222
|
+
// ---- emit (the hot path — NON-BLOCKING) --------------------------------
|
|
223
|
+
/** topic-placement (§3.2). Op key: (topic, epoch). */
|
|
224
|
+
emitPlacement(topic, data) {
|
|
225
|
+
this.emit('topic-placement', topic, data, `${topic}:${data?.epoch}`);
|
|
226
|
+
}
|
|
227
|
+
/** session-lifecycle (§3.2). Op key: (sessionId, status). topic optional. */
|
|
228
|
+
emitLifecycle(data, topic) {
|
|
229
|
+
this.emit('session-lifecycle', topic, data, `${data?.sessionId}:${data?.status}`);
|
|
230
|
+
}
|
|
231
|
+
/** autonomous-run (§3.2). Op key: (topic, runId, action). */
|
|
232
|
+
emitAutonomousRun(topic, data) {
|
|
233
|
+
this.emit('autonomous-run', topic, data, `${topic}:${data?.runId}:${data?.action}`);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Generic non-blocking emit: validate + jail + dedupe + rate-cap + serialize,
|
|
237
|
+
* assign a seq, enqueue, and RETURN. No synchronous file I/O on this stack —
|
|
238
|
+
* the flusher does all of it. Never throws into the caller.
|
|
239
|
+
*/
|
|
240
|
+
emit(kind, topic, rawData, opKey) {
|
|
241
|
+
try {
|
|
242
|
+
if (this.state !== 'active')
|
|
243
|
+
return; // locked-out or closed: silent no-op (never block)
|
|
244
|
+
// Rate cap (token bucket) — over-cap emits drop + count.
|
|
245
|
+
if (!this.takeToken(kind)) {
|
|
246
|
+
this.degradation.rateLimited++;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Typed-schema validation: rejects free text, coerces enums, DROPS unknown
|
|
250
|
+
// fields (counted), jails artifactPaths.
|
|
251
|
+
const validated = this.validate(kind, rawData);
|
|
252
|
+
if (!validated) {
|
|
253
|
+
this.degradation.schemaRejects++;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Op-key idempotency (restart-proof — the index is reconstructed on open).
|
|
257
|
+
if (this.opKeys[kind].has(opKey)) {
|
|
258
|
+
this.degradation.dedupeHits++;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const seq = this.nextSeq[kind];
|
|
262
|
+
const entry = {
|
|
263
|
+
seq,
|
|
264
|
+
ts: this.now().toISOString(),
|
|
265
|
+
machine: this.machineId,
|
|
266
|
+
kind,
|
|
267
|
+
...(typeof topic === 'number' ? { topic } : {}),
|
|
268
|
+
data: validated,
|
|
269
|
+
};
|
|
270
|
+
const line = JSON.stringify(entry);
|
|
271
|
+
// Per-entry size cap (default 8KB) — over-cap drop + count.
|
|
272
|
+
if (Buffer.byteLength(line, 'utf-8') > this.maxEntryBytes) {
|
|
273
|
+
this.degradation.oversize++;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Commit to in-memory state: bump the counter, mark the op key, enqueue.
|
|
277
|
+
this.nextSeq[kind] = seq + 1;
|
|
278
|
+
this.opKeys[kind].add(opKey);
|
|
279
|
+
this.trimOpKeys(kind);
|
|
280
|
+
this.queue.push({ kind, line, seq });
|
|
281
|
+
}
|
|
282
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
283
|
+
// The journal never throws into its caller (§3.1).
|
|
284
|
+
this.log('emit', `[coherence-journal] emit failed (swallowed): ${e?.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// ---- validation + jail --------------------------------------------------
|
|
288
|
+
/**
|
|
289
|
+
* §3.2 typed schema. Returns the validated `data` object (only known fields,
|
|
290
|
+
* enums enforced) or null to reject. Unknown fields are dropped + counted.
|
|
291
|
+
*/
|
|
292
|
+
validate(kind, raw) {
|
|
293
|
+
if (!raw || typeof raw !== 'object')
|
|
294
|
+
return null;
|
|
295
|
+
const seen = Object.keys(raw);
|
|
296
|
+
let out = null;
|
|
297
|
+
let known = [];
|
|
298
|
+
if (kind === 'topic-placement') {
|
|
299
|
+
const owner = raw.owner;
|
|
300
|
+
const epoch = raw.epoch;
|
|
301
|
+
const reason = raw.reason;
|
|
302
|
+
if (typeof owner !== 'string' || !owner)
|
|
303
|
+
return null;
|
|
304
|
+
if (typeof epoch !== 'number' || !Number.isFinite(epoch))
|
|
305
|
+
return null;
|
|
306
|
+
const reasons = ['user-move', 'placed', 'failover', 'released', 'quota-block-move'];
|
|
307
|
+
if (typeof reason !== 'string' || !reasons.includes(reason))
|
|
308
|
+
return null;
|
|
309
|
+
out = { owner, epoch, reason };
|
|
310
|
+
known = ['owner', 'epoch', 'reason', 'prevOwner'];
|
|
311
|
+
if (raw.prevOwner !== undefined) {
|
|
312
|
+
if (typeof raw.prevOwner !== 'string')
|
|
313
|
+
return null;
|
|
314
|
+
out.prevOwner = raw.prevOwner;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (kind === 'session-lifecycle') {
|
|
318
|
+
const sessionId = raw.sessionId;
|
|
319
|
+
const status = raw.status;
|
|
320
|
+
if (typeof sessionId !== 'string' || !sessionId)
|
|
321
|
+
return null;
|
|
322
|
+
const statuses = ['created', 'completed', 'killed', 'reaped', 'failed'];
|
|
323
|
+
if (typeof status !== 'string' || !statuses.includes(status))
|
|
324
|
+
return null;
|
|
325
|
+
out = { sessionId, status };
|
|
326
|
+
known = ['sessionId', 'status', 'reapReason', 'reapLogRef'];
|
|
327
|
+
if (raw.reapReason !== undefined) {
|
|
328
|
+
if (typeof raw.reapReason !== 'string')
|
|
329
|
+
return null;
|
|
330
|
+
out.reapReason = raw.reapReason;
|
|
331
|
+
}
|
|
332
|
+
if (raw.reapLogRef !== undefined) {
|
|
333
|
+
if (typeof raw.reapLogRef !== 'string')
|
|
334
|
+
return null;
|
|
335
|
+
out.reapLogRef = raw.reapLogRef;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (kind === 'autonomous-run') {
|
|
339
|
+
const action = raw.action;
|
|
340
|
+
const runId = raw.runId;
|
|
341
|
+
const actions = ['started', 'stopped'];
|
|
342
|
+
if (typeof action !== 'string' || !actions.includes(action))
|
|
343
|
+
return null;
|
|
344
|
+
if (typeof runId !== 'string' || !runId)
|
|
345
|
+
return null;
|
|
346
|
+
const paths = raw.artifactPaths;
|
|
347
|
+
if (!Array.isArray(paths))
|
|
348
|
+
return null;
|
|
349
|
+
const jailed = this.jailArtifactPaths(paths);
|
|
350
|
+
if (jailed === null)
|
|
351
|
+
return null; // any path failed the jail → reject the entry
|
|
352
|
+
out = { action, runId, artifactPaths: jailed };
|
|
353
|
+
known = ['action', 'runId', 'artifactPaths'];
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
// Count dropped unknown fields (defense-in-depth against free text /
|
|
359
|
+
// secret-shaped fields entering the stream — they structurally cannot).
|
|
360
|
+
for (const k of seen) {
|
|
361
|
+
if (!known.includes(k))
|
|
362
|
+
this.degradation.droppedFields++;
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* §3.1 write-time jail. Each path must resolve (and, if it exists, realpath)
|
|
368
|
+
* to a location contained under an allowlisted root. `..` segments, absolute
|
|
369
|
+
* paths outside the jail, and symlink escapes are rejected. Returns the
|
|
370
|
+
* canonicalized path list, or null if ANY path escapes (the whole entry is
|
|
371
|
+
* then rejected + counted by the caller).
|
|
372
|
+
*/
|
|
373
|
+
jailArtifactPaths(paths) {
|
|
374
|
+
const out = [];
|
|
375
|
+
for (const p of paths) {
|
|
376
|
+
if (typeof p !== 'string' || !p) {
|
|
377
|
+
this.degradation.jailRejects++;
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(this.stateDir, p);
|
|
381
|
+
// realpath the deepest existing ancestor to defeat symlink escapes.
|
|
382
|
+
const real = this.realpathContained(resolved);
|
|
383
|
+
if (real === null) {
|
|
384
|
+
this.degradation.jailRejects++;
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
out.push(real);
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Returns the realpath-resolved candidate IF it is contained under an
|
|
393
|
+
* allowlisted root (symlink-safe), else null. We realpath the deepest
|
|
394
|
+
* existing ancestor and re-append the non-existent tail so a path that does
|
|
395
|
+
* not exist yet still gets symlink-escape protection on its existing prefix.
|
|
396
|
+
*/
|
|
397
|
+
realpathContained(candidate) {
|
|
398
|
+
let existing = candidate;
|
|
399
|
+
const tail = [];
|
|
400
|
+
while (!this.io.existsSync(existing)) {
|
|
401
|
+
const parent = path.dirname(existing);
|
|
402
|
+
if (parent === existing)
|
|
403
|
+
break; // reached fs root
|
|
404
|
+
tail.unshift(path.basename(existing));
|
|
405
|
+
existing = parent;
|
|
406
|
+
}
|
|
407
|
+
let realExisting;
|
|
408
|
+
try {
|
|
409
|
+
realExisting = fs.realpathSync(existing);
|
|
410
|
+
}
|
|
411
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
412
|
+
realExisting = existing;
|
|
413
|
+
}
|
|
414
|
+
const finalPath = tail.length ? path.join(realExisting, ...tail) : realExisting;
|
|
415
|
+
for (const root of this.artifactRoots) {
|
|
416
|
+
if (this.isContained(root, realExisting) && this.isContained(root, finalPath)) {
|
|
417
|
+
return finalPath;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
isContained(root, p) {
|
|
423
|
+
const rel = path.relative(root, p);
|
|
424
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
425
|
+
}
|
|
426
|
+
// ---- rate cap (token bucket) -------------------------------------------
|
|
427
|
+
takeToken(kind) {
|
|
428
|
+
const b = this.rateBuckets[kind];
|
|
429
|
+
const nowMs = this.now().getTime();
|
|
430
|
+
const elapsedSec = Math.max(0, (nowMs - b.lastRefillMs) / 1000);
|
|
431
|
+
if (elapsedSec > 0) {
|
|
432
|
+
b.tokens = Math.min(this.rateCapCfg.capacity, b.tokens + elapsedSec * this.rateCapCfg.refillPerSec);
|
|
433
|
+
b.lastRefillMs = nowMs;
|
|
434
|
+
}
|
|
435
|
+
if (b.tokens >= 1) {
|
|
436
|
+
b.tokens -= 1;
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
// ---- flusher (background — the ONLY thing that touches stream files) ----
|
|
442
|
+
/**
|
|
443
|
+
* Drain the queue: per kind, append data lines (single-line O_APPEND writes),
|
|
444
|
+
* batch fdatasync, THEN advance meta.highWaterSeq via atomic temp rename.
|
|
445
|
+
* Crash-safe ordering: data durable BEFORE meta. Never throws.
|
|
446
|
+
*/
|
|
447
|
+
flush() {
|
|
448
|
+
if (this.state !== 'active')
|
|
449
|
+
return;
|
|
450
|
+
if (this.flushing)
|
|
451
|
+
return; // re-entrancy guard (the timer can overlap a slow flush)
|
|
452
|
+
if (this.queue.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
this.flushing = true;
|
|
455
|
+
try {
|
|
456
|
+
// Group the drained queue by kind, preserving seq order.
|
|
457
|
+
const batch = this.queue;
|
|
458
|
+
this.queue = [];
|
|
459
|
+
const byKind = new Map();
|
|
460
|
+
for (const q of batch) {
|
|
461
|
+
const arr = byKind.get(q.kind);
|
|
462
|
+
if (arr)
|
|
463
|
+
arr.push(q);
|
|
464
|
+
else
|
|
465
|
+
byKind.set(q.kind, [q]);
|
|
466
|
+
}
|
|
467
|
+
for (const [kind, items] of byKind) {
|
|
468
|
+
items.sort((a, b) => a.seq - b.seq);
|
|
469
|
+
// Rotate-if-full BEFORE appending, so a current file always exists after
|
|
470
|
+
// a flush and seq continues into a fresh file (§3.7). Seq is untouched.
|
|
471
|
+
this.maybeRotate(kind);
|
|
472
|
+
const filePath = this.currentFilePath(kind);
|
|
473
|
+
let appended = [];
|
|
474
|
+
try {
|
|
475
|
+
// Injected standby guard — throwing skips this kind's batch + counts.
|
|
476
|
+
if (this.guardWrite)
|
|
477
|
+
this.guardWrite(filePath);
|
|
478
|
+
}
|
|
479
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
480
|
+
this.degradation.guardSkips++;
|
|
481
|
+
this.log('guard', `[coherence-journal] guardWrite refused ${kind}: ${e?.message}`);
|
|
482
|
+
// Re-queue this kind's items so a transient guard refusal doesn't lose them.
|
|
483
|
+
this.queue.push(...items);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
let fd = null;
|
|
487
|
+
let bytesWritten = false; // any byte appended to the O_APPEND file?
|
|
488
|
+
try {
|
|
489
|
+
fd = this.io.openSync(filePath, 'a');
|
|
490
|
+
for (const it of items) {
|
|
491
|
+
const buf = Buffer.from(it.line + '\n', 'utf-8');
|
|
492
|
+
this.io.writeSync(fd, buf, 0, buf.length);
|
|
493
|
+
bytesWritten = true;
|
|
494
|
+
appended.push(it);
|
|
495
|
+
}
|
|
496
|
+
this.io.fdatasyncSync(fd);
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
this.degradation.flushErrors++;
|
|
500
|
+
this.log('flush', `[coherence-journal] append/fsync failed for ${kind}: ${e?.message}`);
|
|
501
|
+
if (!bytesWritten) {
|
|
502
|
+
// Open failed (or threw before any write): zero bytes hit the file,
|
|
503
|
+
// so re-queue ALL items — no torn line, no duplicate seq on disk.
|
|
504
|
+
this.queue.push(...items);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
// Some bytes were appended to the O_APPEND file but fsync did not
|
|
508
|
+
// confirm durability. Re-queuing would risk duplicate seqs on disk
|
|
509
|
+
// (the bytes may already be persisted). Per §3.1 the flush window is
|
|
510
|
+
// the accepted loss bound, so we DROP these (counted) rather than
|
|
511
|
+
// fork the on-disk seq line. Recovery-on-open repairs any torn tail.
|
|
512
|
+
}
|
|
513
|
+
appended = [];
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
if (fd !== null) {
|
|
517
|
+
try {
|
|
518
|
+
this.io.closeSync(fd);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* best-effort */
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (appended.length > 0) {
|
|
526
|
+
// Data is durable — NOW advance meta.highWaterSeq (atomic rename).
|
|
527
|
+
const top = appended[appended.length - 1].seq;
|
|
528
|
+
this.highWaterSeq[kind] = Math.max(this.highWaterSeq[kind], top);
|
|
529
|
+
this.persistMeta();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
534
|
+
this.degradation.flushErrors++;
|
|
535
|
+
this.log('flush', `[coherence-journal] flush failed (swallowed): ${e?.message}`);
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
this.flushing = false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// ---- meta (incarnation + highWaterSeq) ---------------------------------
|
|
542
|
+
loadOrInitMeta() {
|
|
543
|
+
const metaPath = this.metaPath();
|
|
544
|
+
let meta = null;
|
|
545
|
+
if (this.io.existsSync(metaPath)) {
|
|
546
|
+
try {
|
|
547
|
+
const raw = this.io.readFileSync(metaPath, 'utf-8');
|
|
548
|
+
const obj = JSON.parse(raw);
|
|
549
|
+
if (obj && typeof obj.incarnation === 'string' && obj.kinds && typeof obj.kinds === 'object') {
|
|
550
|
+
meta = obj;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
554
|
+
meta = null; // malformed meta → re-init below
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (meta) {
|
|
558
|
+
this.incarnation = meta.incarnation;
|
|
559
|
+
for (const kind of JOURNAL_KINDS) {
|
|
560
|
+
this.highWaterSeq[kind] = Math.max(0, Number(meta.kinds[kind]?.highWaterSeq ?? 0) | 0);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
this.incarnation = this.mintIncarnation();
|
|
565
|
+
for (const kind of JOURNAL_KINDS)
|
|
566
|
+
this.highWaterSeq[kind] = 0;
|
|
567
|
+
this.persistMeta();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
persistMeta() {
|
|
571
|
+
const meta = {
|
|
572
|
+
incarnation: this.incarnation,
|
|
573
|
+
kinds: {
|
|
574
|
+
'topic-placement': { highWaterSeq: this.highWaterSeq['topic-placement'] },
|
|
575
|
+
'session-lifecycle': { highWaterSeq: this.highWaterSeq['session-lifecycle'] },
|
|
576
|
+
'autonomous-run': { highWaterSeq: this.highWaterSeq['autonomous-run'] },
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
const metaPath = this.metaPath();
|
|
580
|
+
const tmp = metaPath + '.tmp';
|
|
581
|
+
this.io.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o644 });
|
|
582
|
+
this.io.renameSync(tmp, metaPath);
|
|
583
|
+
}
|
|
584
|
+
mintIncarnation() {
|
|
585
|
+
return crypto.randomBytes(12).toString('hex');
|
|
586
|
+
}
|
|
587
|
+
// ---- open-time recovery (per kind) -------------------------------------
|
|
588
|
+
/**
|
|
589
|
+
* Open a kind's current stream file: repair any torn trailing line, seed
|
|
590
|
+
* `nextSeq` from the durable tail, reconstruct the op-key window, and apply
|
|
591
|
+
* the incarnation re-mint rule (re-mint IFF last seq < highWaterSeq).
|
|
592
|
+
*/
|
|
593
|
+
recoverKind(kind) {
|
|
594
|
+
const filePath = this.currentFilePath(kind);
|
|
595
|
+
if (!this.io.existsSync(filePath)) {
|
|
596
|
+
// Fresh stream: nextSeq seeds from highWaterSeq+1 (handles a brand-new
|
|
597
|
+
// stream where highWaterSeq is 0, and a meta-ahead-of-missing-file case).
|
|
598
|
+
this.nextSeq[kind] = this.highWaterSeq[kind] + 1;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
let content;
|
|
602
|
+
try {
|
|
603
|
+
content = this.io.readFileSync(filePath, 'utf-8');
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
this.log('recover', `[coherence-journal] could not read ${kind} on open: ${e?.message}`);
|
|
607
|
+
this.nextSeq[kind] = this.highWaterSeq[kind] + 1;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
// Repair a torn trailing line: a file that does NOT end in '\n' has a
|
|
611
|
+
// partial final line from a crash mid-append. Truncate to the last newline.
|
|
612
|
+
if (content.length > 0 && !content.endsWith('\n')) {
|
|
613
|
+
const lastNl = content.lastIndexOf('\n');
|
|
614
|
+
const keepLen = lastNl + 1; // bytes up to and including the last newline (0 if none)
|
|
615
|
+
try {
|
|
616
|
+
this.io.truncateSync(filePath, Buffer.byteLength(content.slice(0, keepLen), 'utf-8'));
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
this.log('repair', `[coherence-journal] truncate repair failed for ${kind}: ${e?.message}`);
|
|
620
|
+
}
|
|
621
|
+
content = content.slice(0, keepLen);
|
|
622
|
+
this.degradation.repairs++;
|
|
623
|
+
if (!this.repairSignalled) {
|
|
624
|
+
this.repairSignalled = true;
|
|
625
|
+
this.log('repair-signal', `[coherence-journal] repaired a torn trailing line in ${kind} (one-time signal)`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Find the last well-formed entry's seq (the durable tail).
|
|
629
|
+
const lines = content.length > 0 ? content.split('\n') : [];
|
|
630
|
+
let lastSeq = 0;
|
|
631
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
632
|
+
const ln = lines[i];
|
|
633
|
+
if (!ln)
|
|
634
|
+
continue;
|
|
635
|
+
try {
|
|
636
|
+
const obj = JSON.parse(ln);
|
|
637
|
+
if (typeof obj.seq === 'number') {
|
|
638
|
+
lastSeq = obj.seq;
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// interior corruption — keep scanning back for the last good seq
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// §3.4 rule 3: re-mint IFF the file's last seq is STRICTLY below the durable
|
|
647
|
+
// highWaterSeq (a genuine rewind / restore-from-backup). A trailing-partial
|
|
648
|
+
// repair is NOT a re-mint: the repaired tail still has lastSeq == highWater
|
|
649
|
+
// because the meta is only advanced AFTER data is fsync'd.
|
|
650
|
+
if (lastSeq < this.highWaterSeq[kind]) {
|
|
651
|
+
this.incarnation = this.mintIncarnation();
|
|
652
|
+
this.degradation.remints++;
|
|
653
|
+
this.log('remint', `[coherence-journal] re-minted incarnation for ${kind} (rewind: last ${lastSeq} < hw ${this.highWaterSeq[kind]})`);
|
|
654
|
+
// Adopt the file tail as the new durable truth and persist the new meta.
|
|
655
|
+
this.highWaterSeq[kind] = lastSeq;
|
|
656
|
+
this.persistMeta();
|
|
657
|
+
}
|
|
658
|
+
else if (lastSeq > this.highWaterSeq[kind]) {
|
|
659
|
+
// Durable data ahead of meta (kill-9 AFTER data fsync, BEFORE meta write).
|
|
660
|
+
// Adopt the file tail; this is NOT a rewind. Advance meta to match.
|
|
661
|
+
this.highWaterSeq[kind] = lastSeq;
|
|
662
|
+
this.persistMeta();
|
|
663
|
+
}
|
|
664
|
+
this.nextSeq[kind] = Math.max(lastSeq, this.highWaterSeq[kind]) + 1;
|
|
665
|
+
this.reconstructOpKeys(kind, lines);
|
|
666
|
+
}
|
|
667
|
+
/** Tail-scan the last DEDUPE_WINDOW lines to rebuild the op-key index. */
|
|
668
|
+
reconstructOpKeys(kind, lines) {
|
|
669
|
+
const start = Math.max(0, lines.length - DEDUPE_WINDOW - 1);
|
|
670
|
+
for (let i = start; i < lines.length; i++) {
|
|
671
|
+
const ln = lines[i];
|
|
672
|
+
if (!ln)
|
|
673
|
+
continue;
|
|
674
|
+
try {
|
|
675
|
+
const obj = JSON.parse(ln);
|
|
676
|
+
const key = this.opKeyOf(kind, obj);
|
|
677
|
+
if (key)
|
|
678
|
+
this.opKeys[kind].add(key);
|
|
679
|
+
}
|
|
680
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
681
|
+
// skip corrupt lines
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
this.trimOpKeys(kind);
|
|
685
|
+
}
|
|
686
|
+
opKeyOf(kind, entry) {
|
|
687
|
+
const d = entry.data;
|
|
688
|
+
if (kind === 'topic-placement') {
|
|
689
|
+
if (typeof entry.topic !== 'number' || typeof d.epoch !== 'number')
|
|
690
|
+
return null;
|
|
691
|
+
return `${entry.topic}:${d.epoch}`;
|
|
692
|
+
}
|
|
693
|
+
if (kind === 'session-lifecycle') {
|
|
694
|
+
if (typeof d.sessionId !== 'string' || typeof d.status !== 'string')
|
|
695
|
+
return null;
|
|
696
|
+
return `${d.sessionId}:${d.status}`;
|
|
697
|
+
}
|
|
698
|
+
if (kind === 'autonomous-run') {
|
|
699
|
+
if (typeof entry.topic !== 'number' || typeof d.runId !== 'string' || typeof d.action !== 'string')
|
|
700
|
+
return null;
|
|
701
|
+
return `${entry.topic}:${d.runId}:${d.action}`;
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/** Keep the op-key window bounded (No Unbounded Loops). */
|
|
706
|
+
trimOpKeys(kind) {
|
|
707
|
+
const set = this.opKeys[kind];
|
|
708
|
+
const cap = DEDUPE_WINDOW * 2;
|
|
709
|
+
if (set.size <= cap)
|
|
710
|
+
return;
|
|
711
|
+
// Sets preserve insertion order; drop the oldest down to DEDUPE_WINDOW.
|
|
712
|
+
const drop = set.size - DEDUPE_WINDOW;
|
|
713
|
+
let i = 0;
|
|
714
|
+
for (const k of set) {
|
|
715
|
+
if (i++ >= drop)
|
|
716
|
+
break;
|
|
717
|
+
set.delete(k);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// ---- rotation (§3.7) ----------------------------------------------------
|
|
721
|
+
/**
|
|
722
|
+
* Rotate the current file when it exceeds maxFileBytes. rotateKeep N>0 keeps
|
|
723
|
+
* N archives + deletes older; rotateKeep 0 rotates but NEVER deletes. Seq
|
|
724
|
+
* continues across rotation (the in-memory counter is untouched).
|
|
725
|
+
*/
|
|
726
|
+
maybeRotate(kind) {
|
|
727
|
+
const ret = this.retention[kind];
|
|
728
|
+
const filePath = this.currentFilePath(kind);
|
|
729
|
+
let size = 0;
|
|
730
|
+
try {
|
|
731
|
+
size = this.io.statSync(filePath).size;
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (size < ret.maxFileBytes)
|
|
737
|
+
return;
|
|
738
|
+
// Archive name: <safeMachineId>.<kind>.<ts><counter>.jsonl. The numeric
|
|
739
|
+
// suffix is (ts * 1000 + counter), so it stays in the `\d+` archive regex,
|
|
740
|
+
// sorts oldest→newest, AND is unique even for multiple rotations in one ms.
|
|
741
|
+
const stamp = `${this.now().getTime() * 1000 + (this.rotationCounter++ % 1000)}`;
|
|
742
|
+
const archive = path.join(this.dirPath(), `${this.safeMachineId}.${kind}.${stamp}.jsonl`);
|
|
743
|
+
try {
|
|
744
|
+
// Guard the rename target too (own-stream prefix).
|
|
745
|
+
if (this.guardWrite)
|
|
746
|
+
this.guardWrite(archive);
|
|
747
|
+
this.io.renameSync(filePath, archive);
|
|
748
|
+
}
|
|
749
|
+
catch (e) {
|
|
750
|
+
this.log('rotate', `[coherence-journal] rotate failed for ${kind}: ${e?.message}`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (ret.rotateKeep > 0) {
|
|
754
|
+
this.pruneArchives(kind, ret.rotateKeep);
|
|
755
|
+
}
|
|
756
|
+
// rotateKeep === 0 → never delete (history forever in bounded files).
|
|
757
|
+
}
|
|
758
|
+
pruneArchives(kind, keep) {
|
|
759
|
+
const archives = this.listArchives(kind);
|
|
760
|
+
if (archives.length <= keep)
|
|
761
|
+
return;
|
|
762
|
+
// archives is sorted oldest→newest; delete the oldest beyond `keep`.
|
|
763
|
+
const toDelete = archives.slice(0, archives.length - keep);
|
|
764
|
+
for (const a of toDelete) {
|
|
765
|
+
try {
|
|
766
|
+
SafeFsExecutor.safeRmSync(path.join(this.dirPath(), a), { force: true, operation: 'coherence-journal:prune-archive' });
|
|
767
|
+
}
|
|
768
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
769
|
+
this.log('prune', `[coherence-journal] prune failed for ${a}: ${e?.message}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/** Archive file names for a kind, sorted oldest→newest by the embedded ts. */
|
|
774
|
+
listArchives(kind) {
|
|
775
|
+
const prefix = `${this.safeMachineId}.${kind}.`;
|
|
776
|
+
let names;
|
|
777
|
+
try {
|
|
778
|
+
names = this.io.readdirSync(this.dirPath());
|
|
779
|
+
}
|
|
780
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
781
|
+
return [];
|
|
782
|
+
}
|
|
783
|
+
const re = new RegExp(`^${escapeRegExp(prefix)}(\\d+)\\.jsonl$`);
|
|
784
|
+
const matched = [];
|
|
785
|
+
for (const n of names) {
|
|
786
|
+
const m = re.exec(n);
|
|
787
|
+
if (m)
|
|
788
|
+
matched.push({ name: n, ts: Number(m[1]) });
|
|
789
|
+
}
|
|
790
|
+
matched.sort((a, b) => a.ts - b.ts);
|
|
791
|
+
return matched.map((m) => m.name);
|
|
792
|
+
}
|
|
793
|
+
// ---- minimal tolerant reader (for the read API, built later) -----------
|
|
794
|
+
/** Enumerate this writer's own stream files present on disk (current + archives). */
|
|
795
|
+
enumerateStreams() {
|
|
796
|
+
const out = [];
|
|
797
|
+
for (const kind of JOURNAL_KINDS) {
|
|
798
|
+
const cur = this.currentFilePath(kind);
|
|
799
|
+
if (this.io.existsSync(cur))
|
|
800
|
+
out.push({ kind, file: cur, isArchive: false });
|
|
801
|
+
for (const a of this.listArchives(kind)) {
|
|
802
|
+
out.push({ kind, file: path.join(this.dirPath(), a), isArchive: true });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Tolerantly read up to `limit` most-recent entries from a kind's CURRENT
|
|
809
|
+
* file via a bounded reverse tail read (per-line try/catch; corrupt lines are
|
|
810
|
+
* skipped + counted). Reads at most `byteCeiling` bytes from the file end.
|
|
811
|
+
*/
|
|
812
|
+
readTail(kind, limit = 100, byteCeiling = DEFAULT_READ_BYTE_CEILING) {
|
|
813
|
+
const filePath = this.currentFilePath(kind);
|
|
814
|
+
return readTailTolerant(this.io, filePath, limit, byteCeiling);
|
|
815
|
+
}
|
|
816
|
+
// ---- single-process lock ------------------------------------------------
|
|
817
|
+
acquireLock() {
|
|
818
|
+
const lockPath = this.lockPath();
|
|
819
|
+
try {
|
|
820
|
+
// 'wx' fails if the file exists → advisory lock. Stale locks from a crash
|
|
821
|
+
// are reclaimed if the recorded pid is no longer alive.
|
|
822
|
+
const fd = this.io.openSync(lockPath, 'wx');
|
|
823
|
+
this.lockFd = fd;
|
|
824
|
+
try {
|
|
825
|
+
const buf = Buffer.from(JSON.stringify({ pid: process.pid, at: this.now().toISOString() }), 'utf-8');
|
|
826
|
+
this.io.writeSync(fd, buf, 0, buf.length);
|
|
827
|
+
}
|
|
828
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
829
|
+
/* best-effort lock annotation */
|
|
830
|
+
}
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
834
|
+
// Lock exists — check whether the holder is still alive (stale reclaim).
|
|
835
|
+
if (this.reclaimStaleLock(lockPath)) {
|
|
836
|
+
try {
|
|
837
|
+
const fd = this.io.openSync(lockPath, 'wx');
|
|
838
|
+
this.lockFd = fd;
|
|
839
|
+
const buf = Buffer.from(JSON.stringify({ pid: process.pid, at: this.now().toISOString() }), 'utf-8');
|
|
840
|
+
try {
|
|
841
|
+
this.io.writeSync(fd, buf, 0, buf.length);
|
|
842
|
+
}
|
|
843
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
844
|
+
/* best-effort */
|
|
845
|
+
}
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/** Returns true (and removes the lock) if the recorded pid is dead. */
|
|
856
|
+
reclaimStaleLock(lockPath) {
|
|
857
|
+
try {
|
|
858
|
+
const raw = this.io.readFileSync(lockPath, 'utf-8');
|
|
859
|
+
const obj = JSON.parse(raw);
|
|
860
|
+
const pid = Number(obj?.pid);
|
|
861
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
862
|
+
return false;
|
|
863
|
+
if (pid === process.pid)
|
|
864
|
+
return false;
|
|
865
|
+
try {
|
|
866
|
+
process.kill(pid, 0); // throws if the process does not exist
|
|
867
|
+
return false; // still alive — do NOT reclaim
|
|
868
|
+
}
|
|
869
|
+
catch (err) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
870
|
+
if (err?.code === 'ESRCH') {
|
|
871
|
+
SafeFsExecutor.safeRmSync(lockPath, { force: true, operation: 'coherence-journal:reclaim-stale-lock' });
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
return false; // EPERM etc. — be conservative, don't reclaim
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
releaseLock() {
|
|
882
|
+
if (this.lockFd !== null) {
|
|
883
|
+
try {
|
|
884
|
+
this.io.closeSync(this.lockFd);
|
|
885
|
+
}
|
|
886
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
887
|
+
/* best-effort */
|
|
888
|
+
}
|
|
889
|
+
this.lockFd = null;
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
SafeFsExecutor.safeRmSync(this.lockPath(), { force: true, operation: 'coherence-journal:release-lock' });
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
/* best-effort */
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// ---- paths --------------------------------------------------------------
|
|
899
|
+
dirPath() {
|
|
900
|
+
return path.join(this.stateDir, 'state', 'coherence-journal');
|
|
901
|
+
}
|
|
902
|
+
currentFilePath(kind) {
|
|
903
|
+
return path.join(this.dirPath(), `${this.safeMachineId}.${kind}.jsonl`);
|
|
904
|
+
}
|
|
905
|
+
metaPath() {
|
|
906
|
+
return path.join(this.dirPath(), `${this.safeMachineId}.meta.json`);
|
|
907
|
+
}
|
|
908
|
+
lockPath() {
|
|
909
|
+
return path.join(this.dirPath(), `${this.safeMachineId}.lock`);
|
|
910
|
+
}
|
|
911
|
+
ensureDirs() {
|
|
912
|
+
const p = this.dirPath();
|
|
913
|
+
if (!this.io.existsSync(p))
|
|
914
|
+
this.io.mkdirSync(p, { recursive: true });
|
|
915
|
+
}
|
|
916
|
+
log(cls, msg) {
|
|
917
|
+
// One log line per failure class per lifetime (avoid log floods).
|
|
918
|
+
if (this.loggedClasses.has(cls))
|
|
919
|
+
return;
|
|
920
|
+
this.loggedClasses.add(cls);
|
|
921
|
+
this.logger?.(msg);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/** Escape a string for use inside a RegExp. */
|
|
925
|
+
function escapeRegExp(s) {
|
|
926
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Standalone tolerant reverse-tail reader — shared by the writer's `readTail`
|
|
930
|
+
* and (later) the read API's direct-file path. Reads at most `byteCeiling`
|
|
931
|
+
* bytes from the end of `filePath`, parses lines newest-first, skips + counts
|
|
932
|
+
* corrupt lines, and returns up to `limit` entries (newest first).
|
|
933
|
+
*/
|
|
934
|
+
export function readTailTolerant(io, filePath, limit, byteCeiling) {
|
|
935
|
+
const result = { entries: [], skippedCorrupt: 0, truncated: false };
|
|
936
|
+
if (!io.existsSync(filePath))
|
|
937
|
+
return result;
|
|
938
|
+
let size;
|
|
939
|
+
try {
|
|
940
|
+
size = io.statSync(filePath).size;
|
|
941
|
+
}
|
|
942
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
if (size === 0)
|
|
946
|
+
return result;
|
|
947
|
+
const readBytes = Math.min(size, byteCeiling);
|
|
948
|
+
if (readBytes < size)
|
|
949
|
+
result.truncated = true;
|
|
950
|
+
const start = size - readBytes;
|
|
951
|
+
let buf;
|
|
952
|
+
let fd = null;
|
|
953
|
+
try {
|
|
954
|
+
fd = io.openSync(filePath, 'r');
|
|
955
|
+
buf = Buffer.alloc(readBytes);
|
|
956
|
+
io.readSync(fd, buf, 0, readBytes, start);
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
return result;
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
if (fd !== null) {
|
|
963
|
+
try {
|
|
964
|
+
io.closeSync(fd);
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
/* best-effort */
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
let text = buf.toString('utf-8');
|
|
972
|
+
// If we started mid-file, the first (partial) line is not a whole record.
|
|
973
|
+
if (start > 0) {
|
|
974
|
+
const nl = text.indexOf('\n');
|
|
975
|
+
if (nl >= 0)
|
|
976
|
+
text = text.slice(nl + 1);
|
|
977
|
+
result.truncated = true;
|
|
978
|
+
}
|
|
979
|
+
const lines = text.split('\n');
|
|
980
|
+
for (let i = lines.length - 1; i >= 0 && result.entries.length < limit; i--) {
|
|
981
|
+
const ln = lines[i];
|
|
982
|
+
if (!ln)
|
|
983
|
+
continue;
|
|
984
|
+
try {
|
|
985
|
+
const obj = JSON.parse(ln);
|
|
986
|
+
if (typeof obj.seq === 'number' && typeof obj.kind === 'string') {
|
|
987
|
+
result.entries.push(obj);
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
result.skippedCorrupt++;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
result.skippedCorrupt++;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
//# sourceMappingURL=CoherenceJournal.js.map
|