@sensigo/realm-mcp 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  // json-trace-buffer-store.ts — File-based TraceBufferStore using JSONL WAL files.
2
2
  import { existsSync } from 'node:fs';
3
3
  import { appendFile, readdir } from 'node:fs/promises';
4
- import { join } from 'node:path';
4
+ import { join, basename } from 'node:path';
5
5
  import lockfile from 'proper-lockfile';
6
- import { normalizeEntryForBuffer, BUFFER_LIMIT_COUNT, BUFFER_LIMIT_BYTES, FINAL_LIMIT_ENTRIES, FINAL_LIMIT_BYTES, readIfExists, deleteIfExists, statIfExists, toArtifactDeleteFailedError, } from '@sensigo/realm';
6
+ import { normalizeEntryForBuffer, BUFFER_LIMIT_COUNT, BUFFER_LIMIT_BYTES, FINAL_LIMIT_ENTRIES, FINAL_LIMIT_BYTES, BUFFER_BACKSTOP_COUNT, BUFFER_BACKSTOP_BYTES, SEALED_ARTIFACTS_LIMIT_PER_STEP, checkBufferBudget, bufferFullError, flattenWalBatches, readIfExists, deleteIfExists, statIfExists, toArtifactDeleteFailedError, linkNoClobberThenUnlink, errnoCode, FsIoError, } from '@sensigo/realm';
7
7
  import { WorkflowError } from '@sensigo/realm';
8
8
  /** WAL filename shape: `trace-buffer-<runId>-<base64url(stepId)>.jsonl`. `runId` is a
9
9
  * server-generated UUIDv4 — always exactly 36 characters (8-4-4-4-12 hex, RFC 4122 string
@@ -13,19 +13,155 @@ import { WorkflowError } from '@sensigo/realm';
13
13
  const WAL_PREFIX = 'trace-buffer-';
14
14
  const WAL_SUFFIX = '.jsonl';
15
15
  const RUN_ID_LENGTH = 36;
16
+ /** Sealed-artifact filename shape (issue #197 PR-1, the `seal` rung — design §4):
17
+ * `sealed-trace-<runId>-<base64url(stepId)>.<seq>.jsonl`. Distinct, non-overlapping prefix from
18
+ * `WAL_PREFIX` (`'sealed-trace-'` vs `'trace-buffer-'` — neither is a prefix of the other, so no
19
+ * filename can ever satisfy both matchers — see the collision test in the co-located spec). The
20
+ * `.`-delimited `seq` is unambiguous against the rest of the name: neither a UUID nor the
21
+ * base64url alphabet ever contains a literal `.`, so the LAST `.` in the middle segment is always
22
+ * the seq separator, never part of the runId or step segment. */
23
+ const SEALED_PREFIX = 'sealed-trace-';
24
+ /** Parses a sealed-artifact filename back into its constituent parts, mirroring
25
+ * `runIdFromWalPath`'s fixed-length-36 discipline for the runId portion. Returns `undefined` for
26
+ * anything that doesn't match the shape this store itself ever writes (defensive — never throws,
27
+ * never guesses) rather than a malformed/foreign file being mis-parsed. */
28
+ function parseSealedFilename(name) {
29
+ if (!name.startsWith(SEALED_PREFIX) || !name.endsWith(WAL_SUFFIX))
30
+ return undefined;
31
+ const middle = name.slice(SEALED_PREFIX.length, -WAL_SUFFIX.length); // "<runId>-<safeStepId>.<seq>"
32
+ const lastDot = middle.lastIndexOf('.');
33
+ if (lastDot === -1)
34
+ return undefined;
35
+ const beforeSeq = middle.slice(0, lastDot);
36
+ const seqStr = middle.slice(lastDot + 1);
37
+ if (!/^\d+$/.test(seqStr))
38
+ return undefined;
39
+ if (beforeSeq.length <= RUN_ID_LENGTH || beforeSeq[RUN_ID_LENGTH] !== '-')
40
+ return undefined;
41
+ const runId = beforeSeq.slice(0, RUN_ID_LENGTH);
42
+ const safeStepId = beforeSeq.slice(RUN_ID_LENGTH + 1);
43
+ return { runId, safeStepId, seq: Number(seqStr) };
44
+ }
45
+ /** Default profile: ~4s worst-case budget (6 retries, 50ms→1000ms exponential backoff) —
46
+ * outlasts any legitimate millisecond-scale critical section by orders of magnitude, but never
47
+ * hangs an engine path for minutes on genuine contention. */
48
+ const DEFAULT_LOCK_PROFILE = {
49
+ retries: { retries: 6, minTimeout: 50, maxTimeout: 1000 },
50
+ stale: 5000,
51
+ realpath: false,
52
+ };
53
+ /** `append`/`appendFenced` keep the pre-existing, more patient retry count (today's shipped
54
+ * behavior, unchanged) — appends are the highest-frequency, most latency-sensitive operation. */
55
+ const APPEND_RETRIES = 10;
56
+ /** Best-effort extraction of the runId from a WAL path's basename, for the `onCompromised` warn
57
+ * message only (issue #207 correction) — mirrors `listOrphans`'s fixed-length-36 slice (never
58
+ * string-splits on '-', which the base64url-encoded stepId segment can legitimately contain).
59
+ * Returns `undefined` if the basename doesn't match the expected shape; never throws — this is
60
+ * purely a best-effort logging aid, not a parser any control-flow depends on. */
61
+ function runIdFromWalPath(walPath) {
62
+ const base = basename(walPath);
63
+ if (!base.startsWith(WAL_PREFIX) || !base.endsWith(WAL_SUFFIX))
64
+ return undefined;
65
+ const afterPrefix = base.slice(WAL_PREFIX.length, -WAL_SUFFIX.length);
66
+ if (afterPrefix.length <= RUN_ID_LENGTH || afterPrefix[RUN_ID_LENGTH] !== '-')
67
+ return undefined;
68
+ return afterPrefix.slice(0, RUN_ID_LENGTH);
69
+ }
70
+ /** True iff `err` is `proper-lockfile`'s own lock-contention error (retry budget exhausted). */
71
+ function isLockContentionError(err) {
72
+ return err?.code === 'ELOCKED';
73
+ }
74
+ /** Classifies a lock-acquisition failure as `STATE_RUN_BUSY` (issue #207) — the same code/
75
+ * category/agentAction/retryable convention `JsonFileStore`'s own `runBusyError` uses for the
76
+ * analogous run-file-lock-contention case. Retryable: a live holder self-heals (the lock is
77
+ * released), and a genuinely stale lock is eventually stolen by the next contender. */
78
+ function lockBusyError(walPath) {
79
+ return new WorkflowError(`Could not acquire the trace-buffer lock for '${walPath}' — contention exceeded the retry budget`, {
80
+ code: 'STATE_RUN_BUSY',
81
+ category: 'STATE',
82
+ agentAction: 'report_to_user',
83
+ retryable: true,
84
+ details: { walPath },
85
+ });
86
+ }
16
87
  /**
17
88
  * File-based TraceBufferStore that persists WAL entries to JSONL files on disk.
18
89
  * WAL file path: <runsDir>/trace-buffer-<runId>-<base64url(stepId)>.jsonl
90
+ *
91
+ * Crash model: like `JsonFileStore`, this store never calls `fsync` — process-crash consistency
92
+ * comes from `appendFile`'s per-line granularity plus `readWal`'s per-line, torn-line-tolerant
93
+ * parsing (a crash mid-write can leave one unparseable trailing line, which is skipped; every
94
+ * earlier, already-written line survives intact). Durability across a true power loss (not just a
95
+ * process crash) is outside this store's contract, unchanged from its pre-#207 posture.
96
+ *
97
+ * Declares the fenced trio (issue #207): `read`, `delete`, and `deleteAllForRun` now serialize on
98
+ * the SAME per-(runId, stepId) critical section (a `proper-lockfile` lock on the WAL path)
99
+ * `appendFenced`/`deleteFenced`/`deleteAllForRunFenced` use — see `lockWal` and the interface's
100
+ * own doc for the full contract.
101
+ *
102
+ * Issue #197 PR-1 additionally declares BOTH capability-ladder rungs (`seal` and
103
+ * `writer_nonce_carriage`, `traceCapabilities`). `sealFenced` retires a live WAL file to a sealed
104
+ * artifact (`sealed-trace-<runId>-<base64url(stepId)>.<seq>.jsonl`) via the SAME `lockWal`
105
+ * chokepoint every other operation uses — see `sealFenced`'s own doc for the no-clobber move
106
+ * mechanics.
19
107
  */
20
108
  export class JsonTraceBufferStore {
109
+ traceCapabilities = new Set([
110
+ 'seal',
111
+ 'writer_nonce_carriage',
112
+ ]);
21
113
  runsDir;
22
- constructor(runsDir) {
114
+ lockProfile;
115
+ constructor(runsDir, lockProfile) {
23
116
  this.runsDir = runsDir;
117
+ this.lockProfile = { ...DEFAULT_LOCK_PROFILE, ...lockProfile };
24
118
  }
25
119
  walPath(runId, stepId) {
26
120
  const safeStepId = Buffer.from(stepId).toString('base64url');
27
121
  return join(this.runsDir, `trace-buffer-${runId}-${safeStepId}.jsonl`);
28
122
  }
123
+ /** Builds the on-disk path for one sealed artifact — see `SEALED_PREFIX`'s doc for the shape
124
+ * and why it never collides with a live WAL path. */
125
+ sealedWalPath(runId, stepId, seq) {
126
+ const safeStepId = Buffer.from(stepId).toString('base64url');
127
+ return join(this.runsDir, `${SEALED_PREFIX}${runId}-${safeStepId}.${seq}.jsonl`);
128
+ }
129
+ /**
130
+ * The SOLE `lockfile.lock(` call site in this file (issue #207) — every critical-section
131
+ * acquisition (`append`, `appendFenced`, `read`, `delete`, `deleteFenced`, `deleteAllForRun`,
132
+ * `deleteAllForRunFenced`) goes through this one chokepoint. Pins the shared base options
133
+ * (`stale`/`realpath`) and always installs an explicit `onCompromised` handler — a loud
134
+ * `console.warn` naming the WAL path — never `proper-lockfile`'s own default handler, which
135
+ * THROWS from inside a timer callback and crashes the process. `retriesOverride` lets
136
+ * `append`/`appendFenced` keep their own, more patient retry count; every other caller uses
137
+ * this store's configured `lockProfile.retries` (constructor-injectable — e.g. a conformance
138
+ * suite inflating it to make contention observable rather than exhausted-too-fast).
139
+ *
140
+ * Verified (issue #207): `lockfile.lock(path, { realpath: false })` acquires cleanly against a
141
+ * path that does not yet exist — no pre-lock placeholder file is needed for that. `append()`'s
142
+ * own placeholder-creation below predates this and stays byte-identical for that method, but no
143
+ * fenced method replicates it.
144
+ */
145
+ async lockWal(walPath, retriesOverride) {
146
+ return lockfile.lock(walPath, {
147
+ retries: retriesOverride ?? this.lockProfile.retries,
148
+ stale: this.lockProfile.stale,
149
+ realpath: this.lockProfile.realpath,
150
+ onCompromised: (err) => {
151
+ // issue #207 correction: decode the runId (fixed-length slice, mirrors listOrphans) so
152
+ // the warn carries genuine run/step context rather than only the raw path — the stepId
153
+ // segment stays base64url-encoded in that path (not decoded here; this is a best-effort
154
+ // logging aid, not a place to risk throwing on a malformed name).
155
+ const runId = runIdFromWalPath(walPath);
156
+ const context = runId !== undefined
157
+ ? ` (runId=${runId}; stepId is base64url-encoded within the path)`
158
+ : '';
159
+ console.warn(`[JsonTraceBufferStore] lock compromised for '${walPath}'${context}: ${err.message} — ` +
160
+ 'a stale lock was stolen; this is the accepted cost of tokenless mutual exclusion ' +
161
+ '(issue #207 residual 1), never a silent crash');
162
+ },
163
+ });
164
+ }
29
165
  async readWal(walPath) {
30
166
  // issue #183: readIfExists distinguishes absence (undefined → empty, the pre-existing
31
167
  // behavior) from a genuine I/O failure (now propagates). The old whole-buffer catch treated
@@ -53,17 +189,122 @@ export class JsonTraceBufferStore {
53
189
  const bytes = Buffer.byteLength(content);
54
190
  return { count, bytes, lines };
55
191
  }
56
- async append(runId, stepId, entries) {
192
+ /**
193
+ * Count + bytes for exactly the batches belonging to `writerNonce` (issue #197 PR-1, design §5's
194
+ * byte-attribution rule) — `undefined` = ⊥, the bare/anonymous writer class.
195
+ *
196
+ * A NONCED writer's stats are computed DIRECTLY: sum the `entries.length` and re-serialized
197
+ * byte size of exactly its own successfully-parsed lines. `JSON.stringify` of a parsed
198
+ * `WalLine` reproduces its ORIGINAL on-disk bytes exactly — this file only ever writes lines via
199
+ * `JSON.stringify({ts, entries[, nonce]})`, and V8 preserves string-key insertion order through
200
+ * parse→re-stringify, so re-serializing a parsed line is bit-for-bit identical to how it was
201
+ * actually written.
202
+ *
203
+ * ⊥'s stats are a RESIDUAL, not a direct sum: `bytes = fileBytes - Σ(every DISTINCT nonced
204
+ * partition's own bytes)`. This is what makes ⊥ "inherit all unattributable bytes" — a
205
+ * torn/unparseable line's raw bytes are captured in `fileBytes` (computed from the whole raw
206
+ * file content, not from re-stringifying parsed lines) but can never be subtracted out as part
207
+ * of any nonced partition (it never parsed into one), so they remain in ⊥'s residual exactly as
208
+ * they always silently were before this capability existed. For an all-bare file (no nonced
209
+ * lines at all), the residual is arithmetically the WHOLE file — byte-identical to the pre-#197
210
+ * formula, emergent rather than special-cased.
211
+ */
212
+ partitionStats(lines, fileBytes, writerNonce) {
213
+ if (writerNonce !== undefined) {
214
+ const own = lines.filter((l) => l.nonce === writerNonce);
215
+ const count = own.reduce((acc, l) => acc + l.entries.length, 0);
216
+ const bytes = own.reduce((acc, l) => acc + Buffer.byteLength(JSON.stringify(l) + '\n'), 0);
217
+ return { count, bytes };
218
+ }
219
+ const noncedLines = lines.filter((l) => l.nonce !== undefined);
220
+ const noncedBytes = noncedLines.reduce((acc, l) => acc + Buffer.byteLength(JSON.stringify(l) + '\n'), 0);
221
+ const bareCount = lines
222
+ .filter((l) => l.nonce === undefined)
223
+ .reduce((acc, l) => acc + l.entries.length, 0);
224
+ return { count: bareCount, bytes: fileBytes - noncedBytes };
225
+ }
226
+ /**
227
+ * The actual write logic, shared by `append` and `appendFenced` (issue #207) so BUFFER_FULL /
228
+ * normalization / `AppendResult` shape live in exactly one place. Assumes the caller already
229
+ * holds the per-path critical section. `writerNonce` (issue #197 PR-1) is `undefined` for a bare
230
+ * call — the byte-identical legacy path (this file's own `newLine` shape omits the `nonce` key
231
+ * entirely when so, exactly matching the pre-#197 JSONL bytes for all-bare traffic).
232
+ */
233
+ async appendWithinCS(walPath, entries, writerNonce) {
234
+ const { count: fileCountBefore, bytes: fileBytesBefore, lines } = await this.readWal(walPath);
235
+ const writerBefore = this.partitionStats(lines, fileBytesBefore, writerNonce);
236
+ const normalized = entries
237
+ .map((e) => normalizeEntryForBuffer(e))
238
+ .filter((e) => e !== null);
239
+ if (normalized.length === 0) {
240
+ return {
241
+ buffer_count: writerBefore.count,
242
+ buffer_bytes: writerBefore.bytes,
243
+ limit_count: BUFFER_LIMIT_COUNT,
244
+ limit_bytes: BUFFER_LIMIT_BYTES,
245
+ final_limit_entries: FINAL_LIMIT_ENTRIES,
246
+ final_limit_bytes: FINAL_LIMIT_BYTES,
247
+ file_count: fileCountBefore,
248
+ file_bytes: fileBytesBefore,
249
+ file_limit_count: BUFFER_BACKSTOP_COUNT,
250
+ file_limit_bytes: BUFFER_BACKSTOP_BYTES,
251
+ };
252
+ }
253
+ const newLineObj = writerNonce !== undefined
254
+ ? { ts: Date.now(), entries: normalized, nonce: writerNonce }
255
+ : { ts: Date.now(), entries: normalized };
256
+ const newLine = JSON.stringify(newLineObj);
257
+ const newBytes = Buffer.byteLength(newLine + '\n');
258
+ // JSONL is genuinely additive (unlike the in-memory store's whole-array restringify): the
259
+ // file-scope and this-writer's-own "after" numbers are simply "before" plus this one new
260
+ // line's own contribution — see `partitionStats`'s doc for why this holds for ⊥ too.
261
+ const writerCountAfter = writerBefore.count + normalized.length;
262
+ const writerBytesAfter = writerBefore.bytes + newBytes;
263
+ const fileCountAfter = fileCountBefore + normalized.length;
264
+ const fileBytesAfter = fileBytesBefore + newBytes;
265
+ const overflow = checkBufferBudget({
266
+ writerCountBefore: writerBefore.count,
267
+ writerBytesBefore: writerBefore.bytes,
268
+ writerCountAfter,
269
+ writerBytesAfter,
270
+ fileCountBefore,
271
+ fileBytesBefore,
272
+ fileCountAfter,
273
+ fileBytesAfter,
274
+ });
275
+ if (overflow) {
276
+ throw bufferFullError(overflow);
277
+ }
278
+ await appendFile(walPath, newLine + '\n', 'utf8');
279
+ return {
280
+ buffer_count: writerCountAfter,
281
+ buffer_bytes: writerBytesAfter,
282
+ limit_count: BUFFER_LIMIT_COUNT,
283
+ limit_bytes: BUFFER_LIMIT_BYTES,
284
+ final_limit_entries: FINAL_LIMIT_ENTRIES,
285
+ final_limit_bytes: FINAL_LIMIT_BYTES,
286
+ file_count: fileCountAfter,
287
+ file_bytes: fileBytesAfter,
288
+ file_limit_count: BUFFER_BACKSTOP_COUNT,
289
+ file_limit_bytes: BUFFER_BACKSTOP_BYTES,
290
+ };
291
+ }
292
+ async append(runId, stepId, entries, options) {
57
293
  const walPath = this.walPath(runId, stepId);
58
294
  if (entries.length === 0) {
59
- const { count, bytes } = await this.readWal(walPath);
295
+ const { count: fileCount, bytes: fileBytes, lines } = await this.readWal(walPath);
296
+ const writer = this.partitionStats(lines, fileBytes, options?.writerNonce);
60
297
  return {
61
- buffer_count: count,
62
- buffer_bytes: bytes,
298
+ buffer_count: writer.count,
299
+ buffer_bytes: writer.bytes,
63
300
  limit_count: BUFFER_LIMIT_COUNT,
64
301
  limit_bytes: BUFFER_LIMIT_BYTES,
65
302
  final_limit_entries: FINAL_LIMIT_ENTRIES,
66
303
  final_limit_bytes: FINAL_LIMIT_BYTES,
304
+ file_count: fileCount,
305
+ file_bytes: fileBytes,
306
+ file_limit_count: BUFFER_BACKSTOP_COUNT,
307
+ file_limit_bytes: BUFFER_BACKSTOP_BYTES,
67
308
  };
68
309
  }
69
310
  // Acquire lock (realpath: false because file may not yet exist).
@@ -73,44 +314,8 @@ export class JsonTraceBufferStore {
73
314
  }
74
315
  let release;
75
316
  try {
76
- release = await lockfile.lock(walPath, { retries: 10, stale: 5000, realpath: false });
77
- const { count: existingCount, bytes: existingBytes } = await this.readWal(walPath);
78
- const normalized = entries
79
- .map((e) => normalizeEntryForBuffer(e))
80
- .filter((e) => e !== null);
81
- if (normalized.length === 0) {
82
- return {
83
- buffer_count: existingCount,
84
- buffer_bytes: existingBytes,
85
- limit_count: BUFFER_LIMIT_COUNT,
86
- limit_bytes: BUFFER_LIMIT_BYTES,
87
- final_limit_entries: FINAL_LIMIT_ENTRIES,
88
- final_limit_bytes: FINAL_LIMIT_BYTES,
89
- };
90
- }
91
- const newLine = JSON.stringify({ ts: Date.now(), entries: normalized });
92
- const newBytes = Buffer.byteLength(newLine + '\n');
93
- if (existingCount + normalized.length > BUFFER_LIMIT_COUNT ||
94
- existingBytes + newBytes > BUFFER_LIMIT_BYTES) {
95
- throw new WorkflowError('Trace buffer full for step', {
96
- code: 'BUFFER_FULL',
97
- category: 'ENGINE',
98
- agentAction: 'provide_input',
99
- retryable: false,
100
- details: { buffer_count: existingCount, buffer_bytes: existingBytes },
101
- });
102
- }
103
- await appendFile(walPath, newLine + '\n', 'utf8');
104
- const updatedCount = existingCount + normalized.length;
105
- const updatedBytes = existingBytes + newBytes;
106
- return {
107
- buffer_count: updatedCount,
108
- buffer_bytes: updatedBytes,
109
- limit_count: BUFFER_LIMIT_COUNT,
110
- limit_bytes: BUFFER_LIMIT_BYTES,
111
- final_limit_entries: FINAL_LIMIT_ENTRIES,
112
- final_limit_bytes: FINAL_LIMIT_BYTES,
113
- };
317
+ release = await this.lockWal(walPath, APPEND_RETRIES);
318
+ return await this.appendWithinCS(walPath, entries, options?.writerNonce);
114
319
  }
115
320
  finally {
116
321
  if (release !== undefined) {
@@ -118,31 +323,193 @@ export class JsonTraceBufferStore {
118
323
  }
119
324
  }
120
325
  }
326
+ /**
327
+ * `guard` runs INSIDE the critical section, immediately before the physical write (issue #207)
328
+ * — see the interface doc for the full guard contract. NO pre-lock placeholder file: verified
329
+ * `lockfile.lock(path, { realpath: false })` acquires cleanly against a target that does not yet
330
+ * exist; the legacy `append()`'s placeholder above predates this and stays byte-identical there,
331
+ * but is not needed and is not replicated here — `appendFile`'s own `O_CREAT`, inside the
332
+ * critical section, is the sole creator of the WAL file on this path.
333
+ */
334
+ async appendFenced(runId, stepId, entries, guard, options) {
335
+ const walPath = this.walPath(runId, stepId);
336
+ const release = await this.lockWal(walPath, APPEND_RETRIES);
337
+ try {
338
+ await guard();
339
+ return await this.appendWithinCS(walPath, entries, options?.writerNonce);
340
+ }
341
+ finally {
342
+ await release();
343
+ }
344
+ }
345
+ /** `_nonce` is re-attached per-line ONLY when that line actually carried one — via the SAME
346
+ * `flattenWalBatches` core helper the in-memory store uses, so "never fabricate `_nonce` for a
347
+ * bare line" is enforced from exactly one shared code path rather than reimplemented twice. */
121
348
  async read(runId, stepId) {
122
349
  const walPath = this.walPath(runId, stepId);
123
- const { lines } = await this.readWal(walPath);
124
- return lines.flatMap((line) => line.entries.map((entry) => ({ ...entry, _internalTs: line.ts })));
350
+ const release = await this.lockWal(walPath);
351
+ try {
352
+ const { lines } = await this.readWal(walPath);
353
+ return flattenWalBatches(lines);
354
+ }
355
+ finally {
356
+ await release();
357
+ }
125
358
  }
126
359
  async delete(runId, stepId) {
127
- // issue #183: this single-step cleanup is best-effort BY CONVENTION at every call site (all
128
- // four callers execution-loop.ts ×3, reclaim-step.ts ×1 already wrap this call in their
129
- // own try/catch that converts a failure into a warning, never treating success as load-bearing
130
- // the way deleteAllForRun/purge do). Converting the raw unlink to deleteIfExists here doesn't
131
- // change that contract (delete() can still fail callers already expect and handle it); it
132
- // just stops a real I/O error from being invisibly swallowed with NO signal at all, which the
133
- // source-text guard (store-fs-guard.test.ts) also requires (no raw unlink in this file).
360
+ // issue #207: now serialized on the same per-path critical section append/appendFenced use
361
+ // declaring the fenced trio commits read/delete/deleteAllForRun to the same CS (see the
362
+ // interface doc).
363
+ //
364
+ // issue #183: this single-step cleanup is best-effort BY CONVENTION at MOST call sites but
365
+ // NOT ALL FOUR: execution-loop.ts's :1857 success-settle call site does NOT wrap this call in
366
+ // a try/catch today (a follow-up PR fixes that site directly); the other three
367
+ // (execution-loop.ts's other two + reclaim-step.ts's one) do. Converting the raw unlink to
368
+ // deleteIfExists here doesn't change that contract — delete() can still fail; it just stops a
369
+ // real I/O error from being invisibly swallowed with NO signal at all, which the source-text
370
+ // guard (store-fs-guard.test.ts) also requires (no raw unlink in this file).
134
371
  const walPath = this.walPath(runId, stepId);
135
- await deleteIfExists(walPath);
372
+ const release = await this.lockWal(walPath);
373
+ try {
374
+ await deleteIfExists(walPath);
375
+ }
376
+ finally {
377
+ await release();
378
+ }
136
379
  }
137
380
  /**
138
- * Deletes every orphaned WAL file for `runId` (issue #107).
381
+ * `guard` runs INSIDE the same per-path critical section `append`/`appendFenced` use,
382
+ * immediately before the delete (issue #207) — see the interface doc for the full guard
383
+ * contract. Returns the number of entries actually deleted (`0` = buffer already absent; the
384
+ * guard still ran first). Counts via the already-open `readWal` internals — NEVER the public
385
+ * `read()`, which would re-acquire this same lock (a critical section must never be re-entered
386
+ * from within itself — `proper-lockfile` is not reentrant).
387
+ */
388
+ async deleteFenced(runId, stepId, guard) {
389
+ const walPath = this.walPath(runId, stepId);
390
+ const release = await this.lockWal(walPath);
391
+ try {
392
+ await guard();
393
+ const { count } = await this.readWal(walPath);
394
+ await deleteIfExists(walPath);
395
+ return count;
396
+ }
397
+ finally {
398
+ await release();
399
+ }
400
+ }
401
+ /**
402
+ * `guard` runs INSIDE the SAME per-path critical section every other operation on this key
403
+ * uses (issue #197 PR-1, the `seal` rung — design §4: "no second locking path"), immediately
404
+ * before the seal-move. Atomically retires the live WAL file to a new sealed artifact via the
405
+ * no-clobber `link`-then-`unlink` primitive (`linkNoClobberThenUnlink`) — plain `rename()` is
406
+ * FORBIDDEN here, since it would silently overwrite an existing sealed artifact at the same
407
+ * `seq`.
139
408
  *
140
- * @param dirEntries Optional pre-scanned `readdir(runsDir)` listing supplied by a batch purge —
141
- * when present, this method filters it in-memory instead of re-scanning the directory itself
142
- * (O(N runs × readdir) O(readdir) for a batch of N). Falls back to its own `readdir` when
143
- * omitted, exactly as before.
409
+ * `seq` is probed by the link attempt ITSELF (no pre-listing, no separate TOCTOU-prone scan):
410
+ * starting at 0, an `EEXIST` on the link means that `seq` is already taken by an earlier seal —
411
+ * bump and retry, bounded by `SEALED_ARTIFACTS_LIMIT_PER_STEP`. Exhausting the bound without
412
+ * success returns `{sealed: false, reason: 'capped'}` — the caller falls back to the existing
413
+ * destructive drain (`deleteFenced`), never a silent eviction of an already-sealed artifact.
414
+ *
415
+ * `{sealed: false, reason: 'absent'}` when no live WAL file exists for this key AT ALL (checked
416
+ * via `statIfExists` — #183's ENOENT-is-absence discipline) — nothing to seal is success, not a
417
+ * failure. A present-but-empty file (e.g. `append()`'s legacy placeholder) is NOT "absent" — it
418
+ * gets sealed like any other live WAL (a harmless, if pointless, empty sealed artifact).
144
419
  */
145
- async deleteAllForRun(runId, dirEntries) {
420
+ async sealFenced(runId, stepId, guard) {
421
+ const walPath = this.walPath(runId, stepId);
422
+ const release = await this.lockWal(walPath);
423
+ try {
424
+ await guard();
425
+ const stat = await statIfExists(walPath);
426
+ if (stat === undefined) {
427
+ return { sealed: false, reason: 'absent' };
428
+ }
429
+ for (let seq = 0; seq < SEALED_ARTIFACTS_LIMIT_PER_STEP; seq++) {
430
+ const sealedPath = this.sealedWalPath(runId, stepId, seq);
431
+ try {
432
+ await linkNoClobberThenUnlink(walPath, sealedPath);
433
+ return { sealed: true };
434
+ }
435
+ catch (err) {
436
+ if (errnoCode(err) === 'EEXIST')
437
+ continue; // this seq already taken — bump and retry
438
+ throw err;
439
+ }
440
+ }
441
+ return { sealed: false, reason: 'capped' };
442
+ }
443
+ finally {
444
+ await release();
445
+ }
446
+ }
447
+ /**
448
+ * Lock-free point-in-time read of every sealed artifact for `runId`, across all its steps
449
+ * (issue #197 PR-1, the `seal` rung) — matches `readAllForRun`'s deliberately-unlocked posture.
450
+ * Parses each sealed file torn-tolerant, per-line, exactly like `readWal`/`readAllForRun` (a
451
+ * sealed artifact's raw bytes moved verbatim from the live WAL, including any trailing torn
452
+ * line it already had at seal time — this is a READ concern, not something sealing fixes).
453
+ */
454
+ async listSealedForRun(runId) {
455
+ let entries;
456
+ try {
457
+ entries = await readdir(this.runsDir);
458
+ }
459
+ catch (err) {
460
+ if (err.code === 'ENOENT')
461
+ return [];
462
+ throw err;
463
+ }
464
+ // Unlike `listOrphans` (which must DISCOVER an unknown runId from an arbitrary filename, and
465
+ // so needs `parseSealedFilename`'s fixed-length-36 recovery), `runId` here is already KNOWN —
466
+ // filtering by literal prefix is both sufficient and correct regardless of whether `runId`
467
+ // happens to be UUID-shaped (real server-generated runIds always are; test doubles need not
468
+ // be). Only the step + seq portion (genuinely unknown) needs parsing out of each match.
469
+ const prefix = `${SEALED_PREFIX}${runId}-`;
470
+ const matching = entries.filter((f) => f.startsWith(prefix) && f.endsWith(WAL_SUFFIX)).sort();
471
+ const result = [];
472
+ for (const file of matching) {
473
+ const afterPrefix = file.slice(prefix.length, -WAL_SUFFIX.length); // "<safeStepId>.<seq>"
474
+ const lastDot = afterPrefix.lastIndexOf('.');
475
+ if (lastDot === -1)
476
+ continue;
477
+ const safeStepId = afterPrefix.slice(0, lastDot);
478
+ const seqStr = afterPrefix.slice(lastDot + 1);
479
+ if (!/^\d+$/.test(seqStr))
480
+ continue;
481
+ const seq = Number(seqStr);
482
+ let stepId;
483
+ try {
484
+ stepId = Buffer.from(safeStepId, 'base64url').toString('utf8');
485
+ }
486
+ catch {
487
+ continue; // malformed filename — skip this one artifact, not the whole listing
488
+ }
489
+ // issue #183: readIfExists discriminates ENOENT (vanished between readdir and this read —
490
+ // a benign race) from a genuine I/O failure (now throws).
491
+ const content = await readIfExists(join(this.runsDir, file));
492
+ if (content === undefined)
493
+ continue;
494
+ const lines = [];
495
+ for (const raw of content.split('\n')) {
496
+ const trimmed = raw.trim();
497
+ if (trimmed.length === 0)
498
+ continue;
499
+ try {
500
+ lines.push(JSON.parse(trimmed));
501
+ }
502
+ catch {
503
+ console.warn(`⚠ realm: skipping unparseable sealed-trace WAL line in '${file}'`);
504
+ }
505
+ }
506
+ result.push({ step_id: stepId, seq, lines });
507
+ }
508
+ return result;
509
+ }
510
+ /** Resolves the candidate WAL files for `runId`, sorted deterministically — shared by
511
+ * `deleteAllForRun` and `deleteAllForRunFenced` (issue #207). */
512
+ async matchingWalFiles(runId, dirEntries) {
146
513
  const prefix = `trace-buffer-${runId}-`;
147
514
  let files;
148
515
  if (dirEntries !== undefined) {
@@ -163,10 +530,60 @@ export class JsonTraceBufferStore {
163
530
  }
164
531
  // SORTED candidates — deterministic residue (which artifact fails first is reproducible
165
532
  // across retries/tests, not dependent on readdir's unspecified ordering).
166
- const matching = [...files].filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl')).sort();
533
+ return [...files].filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl')).sort();
534
+ }
535
+ /** Resolves the candidate SEALED artifact files for `runId`, sorted deterministically — the
536
+ * same shared-`dirEntries`-or-own-`readdir` shape as `matchingWalFiles` (issue #197 PR-1: a
537
+ * sealed artifact is retained only until its owning run itself is purged — design §4). */
538
+ async matchingSealedFiles(runId, dirEntries) {
539
+ const prefix = `${SEALED_PREFIX}${runId}-`;
540
+ let files;
541
+ if (dirEntries !== undefined) {
542
+ files = dirEntries;
543
+ }
544
+ else {
545
+ try {
546
+ files = await readdir(this.runsDir);
547
+ }
548
+ catch (err) {
549
+ if (err.code === 'ENOENT') {
550
+ files = [];
551
+ }
552
+ else {
553
+ throw toArtifactDeleteFailedError(runId, 'JsonTraceBufferStore', [], this.runsDir, err);
554
+ }
555
+ }
556
+ }
557
+ return [...files].filter((f) => f.startsWith(prefix) && f.endsWith(WAL_SUFFIX)).sort();
558
+ }
559
+ /**
560
+ * Deletes every orphaned WAL file for `runId` (issue #107). Now serialized per-file on the same
561
+ * critical section `appendFenced`/`deleteFenced` use (issue #207) — declaring the fenced trio
562
+ * commits this legacy method too. Issue #197 PR-1: sealed artifacts for this run join the same
563
+ * sweep (`matchingSealedFiles`) — a sealed artifact outlives its step but not its run.
564
+ *
565
+ * @param dirEntries Optional pre-scanned `readdir(runsDir)` listing supplied by a batch purge —
566
+ * when present, this method filters it in-memory instead of re-scanning the directory itself
567
+ * (O(N runs × readdir) → O(readdir) for a batch of N). Falls back to its own `readdir` when
568
+ * omitted, exactly as before.
569
+ */
570
+ async deleteAllForRun(runId, dirEntries) {
571
+ const matching = [
572
+ ...(await this.matchingWalFiles(runId, dirEntries)),
573
+ ...(await this.matchingSealedFiles(runId, dirEntries)),
574
+ ];
167
575
  const deleted = [];
168
576
  for (const file of matching) {
169
577
  const path = join(this.runsDir, file);
578
+ let release;
579
+ try {
580
+ release = await this.lockWal(path);
581
+ }
582
+ catch (err) {
583
+ if (isLockContentionError(err))
584
+ throw lockBusyError(path);
585
+ throw err;
586
+ }
170
587
  try {
171
588
  const didDelete = await deleteIfExists(path);
172
589
  if (didDelete)
@@ -177,6 +594,77 @@ export class JsonTraceBufferStore {
177
594
  // has genuinely failed — report exactly what succeeded before the failure.
178
595
  throw toArtifactDeleteFailedError(runId, 'JsonTraceBufferStore', deleted, file, err);
179
596
  }
597
+ finally {
598
+ await release();
599
+ }
600
+ }
601
+ }
602
+ /**
603
+ * `guard` is RE-INVOKED inside EACH per-file critical section, immediately before that file's
604
+ * delete (issue #207) — a refusal on any one file aborts the whole sweep with that file's error
605
+ * (stop-on-first-error, matching the legacy method's own semantics). When zero files match
606
+ * `runId` at all, `guard` is still consulted at least once (issue #207 correction: the scan
607
+ * — resolving which files match — necessarily runs FIRST, since there is nothing to invoke a
608
+ * per-file guard against otherwise; the guard is then invoked once for the empty case). If it
609
+ * throws, the sweep rejects with that error exactly as it would for a non-empty sweep —
610
+ * propagation is UNIFORM across the zero-match and non-empty cases (the TCK asserts rejection
611
+ * here, not merely invocation count: a refusing guard makes even a zero-match sweep reject). A
612
+ * guard rejection, and a per-file lock-contention failure (classified `STATE_RUN_BUSY`,
613
+ * mirroring `deleteAllForRun`'s own classification above), both propagate UNWRAPPED — never
614
+ * touched by `toArtifactDeleteFailedError`, which still wraps genuine unlink/I-O failures (the
615
+ * #183 absence/unreachable/corrupt trichotomy, extended with this third, distinct guard-refusal
616
+ * outcome). The guard call itself sits OUTSIDE the try/catch scope that performs this wrapping
617
+ * (see the loop body below) — so a guard that happens to throw an `FsIoError` (e.g. its own
618
+ * lock-free `runStore.get` hitting EACCES) is never mistaken for `deleteIfExists`'s own failure.
619
+ *
620
+ * Issue #197 PR-1: sealed artifacts for this run join the same fenced sweep, same as the
621
+ * unfenced `deleteAllForRun` above.
622
+ */
623
+ async deleteAllForRunFenced(runId, guard, dirEntries) {
624
+ const matching = [
625
+ ...(await this.matchingWalFiles(runId, dirEntries)),
626
+ ...(await this.matchingSealedFiles(runId, dirEntries)),
627
+ ];
628
+ if (matching.length === 0) {
629
+ await guard();
630
+ return;
631
+ }
632
+ const deleted = [];
633
+ for (const file of matching) {
634
+ const path = join(this.runsDir, file);
635
+ let release;
636
+ try {
637
+ release = await this.lockWal(path);
638
+ }
639
+ catch (err) {
640
+ if (isLockContentionError(err))
641
+ throw lockBusyError(path);
642
+ throw err;
643
+ }
644
+ try {
645
+ // issue #207 correction: `guard()` sits OUTSIDE the FsIoError-wrap scope below — a guard
646
+ // rejection of ANY type, including one that happens to itself be an FsIoError (a
647
+ // realistic case: the guard's lock-free `runStore.get` hitting EACCES), must propagate
648
+ // exactly as thrown. The earlier shape wrapped BOTH the guard call and `deleteIfExists`
649
+ // in one try/catch keyed only on `err instanceof FsIoError` — which mistook a
650
+ // guard-thrown FsIoError for deleteIfExists's own failure and wrapped it too.
651
+ await guard();
652
+ try {
653
+ const didDelete = await deleteIfExists(path);
654
+ if (didDelete)
655
+ deleted.push(file);
656
+ }
657
+ catch (err) {
658
+ // Only deleteIfExists's own failure mode (FsIoError) gets wrapped here.
659
+ if (err instanceof FsIoError) {
660
+ throw toArtifactDeleteFailedError(runId, 'JsonTraceBufferStore', deleted, file, err);
661
+ }
662
+ throw err;
663
+ }
664
+ }
665
+ finally {
666
+ await release();
667
+ }
180
668
  }
181
669
  }
182
670
  /**
@@ -268,18 +756,28 @@ export class JsonTraceBufferStore {
268
756
  }
269
757
  const orphans = [];
270
758
  for (const name of entries) {
271
- if (!name.startsWith(WAL_PREFIX) || !name.endsWith(WAL_SUFFIX))
272
- continue;
273
- // Everything between the fixed prefix and suffix is "<uuid>-<b64url step>" — slice the
274
- // UUID off by its KNOWN length, then require the very next character to be the '-'
275
- // separator `walPath` always writes. A name that's too short, or missing that separator
276
- // at exactly this position, is malformed (never produced by this store) — skip it rather
277
- // than guess.
278
- const afterPrefix = name.slice(WAL_PREFIX.length, -WAL_SUFFIX.length);
279
- if (afterPrefix.length <= RUN_ID_LENGTH || afterPrefix[RUN_ID_LENGTH] !== '-')
759
+ let runId;
760
+ if (name.startsWith(WAL_PREFIX) && name.endsWith(WAL_SUFFIX)) {
761
+ // Everything between the fixed prefix and suffix is "<uuid>-<b64url step>" — slice the
762
+ // UUID off by its KNOWN length, then require the very next character to be the '-'
763
+ // separator `walPath` always writes. A name that's too short, or missing that separator
764
+ // at exactly this position, is malformed (never produced by this store) — skip it rather
765
+ // than guess.
766
+ const afterPrefix = name.slice(WAL_PREFIX.length, -WAL_SUFFIX.length);
767
+ if (afterPrefix.length > RUN_ID_LENGTH && afterPrefix[RUN_ID_LENGTH] === '-') {
768
+ runId = afterPrefix.slice(0, RUN_ID_LENGTH);
769
+ }
770
+ }
771
+ else if (name.startsWith(SEALED_PREFIX) && name.endsWith(WAL_SUFFIX)) {
772
+ // issue #197 PR-1: a sealed artifact is an orphan candidate too — same run-liveness rule,
773
+ // parsed via `parseSealedFilename` instead (distinct, non-overlapping prefix — see its
774
+ // doc — so this branch and the one above are mutually exclusive for any given `name`).
775
+ runId = parseSealedFilename(name)?.runId;
776
+ }
777
+ else {
280
778
  continue;
281
- const runId = afterPrefix.slice(0, RUN_ID_LENGTH);
282
- if (liveRunIds.has(runId))
779
+ }
780
+ if (runId === undefined || liveRunIds.has(runId))
283
781
  continue;
284
782
  const path = join(this.runsDir, name);
285
783
  // #183 discipline: statIfExists returns undefined only on ENOENT (a benign vanished-