@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/json-trace-buffer-store.d.ts +170 -4
- package/dist/json-trace-buffer-store.d.ts.map +1 -1
- package/dist/json-trace-buffer-store.js +571 -73
- package/dist/json-trace-buffer-store.js.map +1 -1
- package/dist/protocol/generator.js +1 -1
- package/dist/protocol/generator.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/append-trace.d.ts +24 -1
- package/dist/tools/append-trace.d.ts.map +1 -1
- package/dist/tools/append-trace.js +232 -25
- package/dist/tools/append-trace.js.map +1 -1
- package/dist/tools/execute-step.d.ts +21 -1
- package/dist/tools/execute-step.d.ts.map +1 -1
- package/dist/tools/execute-step.js +94 -1
- package/dist/tools/execute-step.js.map +1 -1
- package/dist/tools/get-workflow-protocol.d.ts.map +1 -1
- package/dist/tools/get-workflow-protocol.js +13 -1
- package/dist/tools/get-workflow-protocol.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
|
124
|
-
|
|
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 #
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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-
|