@sensigo/realm-cli 0.22.0 → 0.24.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/commands/export.d.ts +44 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +58 -4
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gc.d.ts +50 -0
- package/dist/commands/gc.d.ts.map +1 -1
- package/dist/commands/gc.js +215 -35
- package/dist/commands/gc.js.map +1 -1
- package/dist/commands/purge.d.ts +23 -6
- package/dist/commands/purge.d.ts.map +1 -1
- package/dist/commands/purge.js +56 -19
- package/dist/commands/purge.js.map +1 -1
- package/dist/commands/register.d.ts +9 -2
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +41 -9
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +81 -21
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +17 -3
- package/dist/commands/watch.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/loader-warnings.d.ts +15 -0
- package/dist/lib/loader-warnings.d.ts.map +1 -0
- package/dist/lib/loader-warnings.js +33 -0
- package/dist/lib/loader-warnings.js.map +1 -0
- package/package.json +4 -4
|
@@ -1,18 +1,49 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import type { RunRecord, RunStore, FailedAttemptRecord, TraceBufferStore } from '@sensigo/realm';
|
|
3
|
+
/** One artifact `buildExportBundle` could not read (issue #186) — never silently omitted. */
|
|
4
|
+
export interface ExportArtifactError {
|
|
5
|
+
/** A human label for the artifact — the STORE owns the actual on-disk path/filename; the
|
|
6
|
+
* command never reconstructs one, matching this file's own no-filename-layout discipline. */
|
|
7
|
+
artifact: string;
|
|
8
|
+
/** The errno/FsIoError code, or `'UNKNOWN'` if the thrown value carried none. */
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
3
12
|
export interface ExportBundle {
|
|
4
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Bumped 1 → 2 (issue #186): a v1 bundle predates the `complete`/`artifact_errors` fields, so a
|
|
15
|
+
* v1 bundle's completeness is UNKNOWN to a reader — it was written back when export was
|
|
16
|
+
* all-or-nothing (any read failure meant no bundle at all), so v1's mere existence was itself a
|
|
17
|
+
* (weaker) completeness signal, but nothing in a v1 bundle states it explicitly. v2 always
|
|
18
|
+
* states it explicitly via `complete`.
|
|
19
|
+
*/
|
|
20
|
+
realm_export_version: 2;
|
|
5
21
|
/** ISO-8601, stamped by the CLI at assembly time. */
|
|
6
22
|
exported_at: string;
|
|
7
23
|
/** The full RunRecord — steps, evidence, skip_details, claims, etc. */
|
|
8
24
|
run: RunRecord;
|
|
9
|
-
/** Parsed failed-attempt records; `[]` if none were ever recorded for this run
|
|
25
|
+
/** Parsed failed-attempt records; `[]` if none were ever recorded for this run OR if the sidecar
|
|
26
|
+
* read failed (see `artifact_errors` — the empty array does not by itself mean "none recorded"
|
|
27
|
+
* in that case; check `complete` first). */
|
|
10
28
|
attempts: FailedAttemptRecord[];
|
|
11
29
|
/** True if the run hit the failed-attempt sidecar's 256KB ceiling — later attempts were dropped
|
|
12
|
-
* at write time (append-and-stop), so `attempts` is NOT exhaustive.
|
|
30
|
+
* at write time (append-and-stop), so `attempts` is NOT exhaustive. Independent of `complete`:
|
|
31
|
+
* a sidecar can be BOTH fully readable AND capped (a known, positively-characterized
|
|
32
|
+
* truncation) — that is a different signal from a read that failed outright. */
|
|
13
33
|
attempts_capped: boolean;
|
|
14
|
-
/** `{ <stepId>: [...] }` — every buffered/WAL entry for the run, across all steps; `{}` if none
|
|
34
|
+
/** `{ <stepId>: [...] }` — every buffered/WAL entry for the run, across all steps; `{}` if none
|
|
35
|
+
* OR if the WAL read failed (see `artifact_errors` — check `complete` first). */
|
|
15
36
|
wal: Record<string, unknown[]>;
|
|
37
|
+
/**
|
|
38
|
+
* False iff any artifact below (`attempts`/`wal`) could not be read (issue #186) — a real I/O
|
|
39
|
+
* failure, not a genuine ENOENT absence (an absent artifact is legitimately `[]`/`{}` and
|
|
40
|
+
* `complete: true`). The bundle NEVER lies by omission: every readable artifact is still
|
|
41
|
+
* written, and the run record itself is always present when `complete` is `false` (a run-record
|
|
42
|
+
* read failure means no bundle is written at all — see `buildExportBundle`).
|
|
43
|
+
*/
|
|
44
|
+
complete: boolean;
|
|
45
|
+
/** The artifacts that failed to read; always `[]` when `complete` is `true`. */
|
|
46
|
+
artifact_errors: ExportArtifactError[];
|
|
16
47
|
}
|
|
17
48
|
export interface ExportRunResult {
|
|
18
49
|
bundle: ExportBundle;
|
|
@@ -39,6 +70,15 @@ export interface ExportStores {
|
|
|
39
70
|
* Assembles the export bundle for `runId` from each store's existing read methods — never writes,
|
|
40
71
|
* never locks, never touches `runsDir`. Throws `STATE_RUN_NOT_FOUND` (propagated from
|
|
41
72
|
* `runStore.get`) for a nonexistent run; the CLI action maps that to an error message + exit(1).
|
|
73
|
+
*
|
|
74
|
+
* issue #186 — graceful degradation, never all-or-nothing: `run` is the bundle's core, so a
|
|
75
|
+
* failure reading IT propagates (there is genuinely nothing to hand off — no bundle at all). But
|
|
76
|
+
* `attempts` and `wal` are each read in their OWN try/catch: a real I/O failure on either (post
|
|
77
|
+
* #183, `failedAttemptStore.read`/`traceBufferStore.readAllForRun` throw on a genuine I/O error,
|
|
78
|
+
* not just a benign ENOENT-absence, which still legitimately yields `[]`/`{}`) substitutes the
|
|
79
|
+
* empty value, records the failure in `artifact_errors`, and the OTHER artifact is still
|
|
80
|
+
* attempted — the bundle self-describes its own incompleteness via `complete` instead of
|
|
81
|
+
* disappearing entirely.
|
|
42
82
|
*/
|
|
43
83
|
export declare function buildExportBundle(runId: string, stores: ExportStores, now?: Date): Promise<ExportRunResult>;
|
|
44
84
|
export interface ResolveExportPathOptions {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"export.d.ts","sourceRoot":"","sources":["../../src/commands/export.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGjG,MAAM,WAAW,YAAY;IAC3B,oBAAoB,EAAE,CAAC,CAAC;IACxB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,GAAG,EAAE,SAAS,CAAC;IACf
|
|
1
|
+
{"version":3,"file":"export.d.ts","sourceRoot":"","sources":["../../src/commands/export.ts"],"names":[],"mappings":"AAqBA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAGjG,6FAA6F;AAC7F,MAAM,WAAW,mBAAmB;IAClC;kGAC8F;IAC9F,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,oBAAoB,EAAE,CAAC,CAAC;IACxB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,GAAG,EAAE,SAAS,CAAC;IACf;;iDAE6C;IAC7C,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;qFAGiF;IACjF,eAAe,EAAE,OAAO,CAAC;IACzB;sFACkF;IAClF,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,gFAAgF;IAChF,eAAe,EAAE,mBAAmB,EAAE,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IAChC,kBAAkB,EAAE;QAClB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;YAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC;YAAC,MAAM,EAAE,OAAO,CAAA;SAAE,CAAC,CAAC;KACnF,CAAC;IACF,gBAAgB,EAAE,IAAI,CAAC,gBAAgB,EAAE,eAAe,CAAC,CAAC;CAC3D;AASD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,YAAY,EACpB,GAAG,GAAE,IAAiB,GACrB,OAAO,CAAC,eAAe,CAAC,CA+C1B;AAED,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,wBAAwB,GAAG,MAAM,CAqCxE;AAQD,eAAO,MAAM,aAAa,SA4DtB,CAAC"}
|
package/dist/commands/export.js
CHANGED
|
@@ -20,23 +20,65 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
20
20
|
import { writeFile } from 'node:fs/promises';
|
|
21
21
|
import { join, resolve, sep } from 'node:path';
|
|
22
22
|
import { Command } from 'commander';
|
|
23
|
-
import { WorkflowError, isTerminalPhase } from '@sensigo/realm';
|
|
23
|
+
import { WorkflowError, isTerminalPhase, FsIoError } from '@sensigo/realm';
|
|
24
|
+
/** Extracts an errno/FsIoError code from a thrown value; `'UNKNOWN'` if it carries none. */
|
|
25
|
+
function errnoCodeOf(err) {
|
|
26
|
+
if (err instanceof FsIoError)
|
|
27
|
+
return err.code;
|
|
28
|
+
const code = err?.code;
|
|
29
|
+
return typeof code === 'string' ? code : 'UNKNOWN';
|
|
30
|
+
}
|
|
24
31
|
/**
|
|
25
32
|
* Assembles the export bundle for `runId` from each store's existing read methods — never writes,
|
|
26
33
|
* never locks, never touches `runsDir`. Throws `STATE_RUN_NOT_FOUND` (propagated from
|
|
27
34
|
* `runStore.get`) for a nonexistent run; the CLI action maps that to an error message + exit(1).
|
|
35
|
+
*
|
|
36
|
+
* issue #186 — graceful degradation, never all-or-nothing: `run` is the bundle's core, so a
|
|
37
|
+
* failure reading IT propagates (there is genuinely nothing to hand off — no bundle at all). But
|
|
38
|
+
* `attempts` and `wal` are each read in their OWN try/catch: a real I/O failure on either (post
|
|
39
|
+
* #183, `failedAttemptStore.read`/`traceBufferStore.readAllForRun` throw on a genuine I/O error,
|
|
40
|
+
* not just a benign ENOENT-absence, which still legitimately yields `[]`/`{}`) substitutes the
|
|
41
|
+
* empty value, records the failure in `artifact_errors`, and the OTHER artifact is still
|
|
42
|
+
* attempted — the bundle self-describes its own incompleteness via `complete` instead of
|
|
43
|
+
* disappearing entirely.
|
|
28
44
|
*/
|
|
29
45
|
export async function buildExportBundle(runId, stores, now = new Date()) {
|
|
30
46
|
const run = await stores.runStore.get(runId);
|
|
31
|
-
const
|
|
32
|
-
|
|
47
|
+
const artifactErrors = [];
|
|
48
|
+
let attempts = [];
|
|
49
|
+
let attemptsCapped = false;
|
|
50
|
+
try {
|
|
51
|
+
const result = await stores.failedAttemptStore.read(runId);
|
|
52
|
+
attempts = result.records;
|
|
53
|
+
attemptsCapped = result.capped;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
artifactErrors.push({
|
|
57
|
+
artifact: 'failed-attempt sidecar',
|
|
58
|
+
code: errnoCodeOf(err),
|
|
59
|
+
message: err instanceof Error ? err.message : String(err),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
let wal = {};
|
|
63
|
+
try {
|
|
64
|
+
wal = await stores.traceBufferStore.readAllForRun(runId);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
artifactErrors.push({
|
|
68
|
+
artifact: 'trace-buffer WAL',
|
|
69
|
+
code: errnoCodeOf(err),
|
|
70
|
+
message: err instanceof Error ? err.message : String(err),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
33
73
|
const bundle = {
|
|
34
|
-
realm_export_version:
|
|
74
|
+
realm_export_version: 2,
|
|
35
75
|
exported_at: now.toISOString(),
|
|
36
76
|
run,
|
|
37
77
|
attempts,
|
|
38
78
|
attempts_capped: attemptsCapped,
|
|
39
79
|
wal,
|
|
80
|
+
complete: artifactErrors.length === 0,
|
|
81
|
+
artifact_errors: artifactErrors,
|
|
40
82
|
};
|
|
41
83
|
const warning = !isTerminalPhase(run.run_phase)
|
|
42
84
|
? `best-effort snapshot: run '${runId}' is still ${run.run_phase}; its artifacts are read at ` +
|
|
@@ -119,6 +161,18 @@ export const exportCommand = new Command('export')
|
|
|
119
161
|
const target = resolveExportPath({ runId, out: opts.out, runsDir });
|
|
120
162
|
const json = JSON.stringify(bundle, null, 2);
|
|
121
163
|
await writeFile(target, json, 'utf8');
|
|
164
|
+
// issue #186: an incomplete bundle is still written (for recovery) but never reported as a
|
|
165
|
+
// plain success — the exit code carries the signal for CI/scripts, and the operator sees
|
|
166
|
+
// exactly which artifact(s) failed and why. No --allow-incomplete flag: honesty here is not
|
|
167
|
+
// opt-in.
|
|
168
|
+
if (!bundle.complete) {
|
|
169
|
+
console.error(`⚠ INCOMPLETE export: ${bundle.artifact_errors.length} artifact(s) could not be read`);
|
|
170
|
+
for (const e of bundle.artifact_errors) {
|
|
171
|
+
console.error(` ✗ ${e.artifact} (${e.code}): ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
console.error(`The bundle was still written to '${target}' — inspect it for what could be recovered.`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
122
176
|
const walStepCount = Object.keys(bundle.wal).length;
|
|
123
177
|
console.log(`Exported run '${runId}' to '${target}' (${formatBytes(Buffer.byteLength(json))}).\n` +
|
|
124
178
|
` phase: ${bundle.run.run_phase}, attempts: ${bundle.attempts.length}, WAL steps: ${walStepCount}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/commands/export.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,EAAE;AACF,mGAAmG;AACnG,6FAA6F;AAC7F,kGAAkG;AAClG,oGAAoG;AACpG,iGAAiG;AACjG,gGAAgG;AAChG,EAAE;AACF,iGAAiG;AACjG,iGAAiG;AACjG,mGAAmG;AACnG,oGAAoG;AACpG,yDAAyD;AACzD,EAAE;AACF,oGAAoG;AACpG,oGAAoG;AACpG,oEAAoE;AACpE,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/commands/export.ts"],"names":[],"mappings":"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"}
|
package/dist/commands/gc.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import type { OrphanSweepableStore } from '@sensigo/realm';
|
|
2
3
|
export interface SweepOrphansOptions {
|
|
3
4
|
/** Minimum age (ms) a `.tmp` must have to be reaped. Rejected below `FLOOR_MS` — see above. */
|
|
4
5
|
olderThanMs: number;
|
|
@@ -43,5 +44,54 @@ export interface SweepOrphansResult {
|
|
|
43
44
|
* `unlink`ed — success → `reaped`; ENOENT → `already_gone`; anything else → `failed`.
|
|
44
45
|
*/
|
|
45
46
|
export declare function sweepOrphans(runsDir: string, options: SweepOrphansOptions): Promise<SweepOrphansResult>;
|
|
47
|
+
/** One orphan artifact's outcome in a sweep report — carries the runId alongside the path so an
|
|
48
|
+
* operator can correlate a reaped file back to which run it belonged to. */
|
|
49
|
+
export interface OrphanArtifactEntry {
|
|
50
|
+
path: string;
|
|
51
|
+
runId: string;
|
|
52
|
+
}
|
|
53
|
+
export interface OrphanArtifactSweepResult {
|
|
54
|
+
/** Reaped (force mode) or would-be-reaped (dry-run) artifacts. */
|
|
55
|
+
reaped: OrphanArtifactEntry[];
|
|
56
|
+
/** A candidate vanished on its own (unlink hit ENOENT) — benign, never a failure. */
|
|
57
|
+
already_gone: OrphanArtifactEntry[];
|
|
58
|
+
/** A genuine delete failure (permissions, I/O) — loud, never silently swallowed. */
|
|
59
|
+
failed: Array<{
|
|
60
|
+
path: string;
|
|
61
|
+
runId: string;
|
|
62
|
+
error: string;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reaps run-less orphaned WAL/sidecar artifacts (issue #163) — the second sweep behind
|
|
67
|
+
* `realm run gc`. `stores` is every `OrphanSweepableStore` gc knows about (today:
|
|
68
|
+
* `JsonTraceBufferStore`, `FailedAttemptStore`); `liveRunIds` is the caller's ALREADY-COMPUTED
|
|
69
|
+
* `JsonFileStore.listRunIds()` result (computed once, shared across every store, and — this is
|
|
70
|
+
* load-bearing — the caller's responsibility to have fail-closed on: see the CLI action for why
|
|
71
|
+
* this function never calls `listRunIds()` itself).
|
|
72
|
+
*
|
|
73
|
+
* **The floor is the create-temp-window guard.** `append_trace` calls `runStore.get(runId)`
|
|
74
|
+
* BEFORE ever writing a WAL entry, so a WAL's `<runId>.json` provably existed at WAL-creation
|
|
75
|
+
* time and exists for the run's entire life (see this module's own header for the full
|
|
76
|
+
* correctness backbone). The ONLY way "artifact present, run file absent" can be true for a
|
|
77
|
+
* FRESH run is the sub-second window between the run file being written as a temp
|
|
78
|
+
* (`<id>.json.<pid>.tmp`) and its atomic rename to `<id>.json` — during which `listRunIds()`
|
|
79
|
+
* would not yet see it. `FLOOR_MS` (1h) dwarfs that window by many orders of magnitude, so a
|
|
80
|
+
* fresh in-flight run's WAL is always younger than the floor and never selected; a WAL/sidecar
|
|
81
|
+
* OLDER than the floor with no matching run file is genuinely run-less.
|
|
82
|
+
*
|
|
83
|
+
* Each `OrphanSweepableStore.listOrphans()` call is fail-closed BY THE STORE'S OWN CONTRACT (see
|
|
84
|
+
* `orphan-sweepable-store.ts`) — a non-ENOENT enumeration/stat failure throws, propagating out of
|
|
85
|
+
* THIS function too (uncaught here, deliberately): this sweep has nothing safe to report if it
|
|
86
|
+
* cannot trust what "orphaned" even means for that store, so it aborts entirely rather than
|
|
87
|
+
* reaping a partial, possibly-wrong candidate set.
|
|
88
|
+
*/
|
|
89
|
+
export declare function sweepOrphanArtifacts(stores: readonly OrphanSweepableStore[], liveRunIds: ReadonlySet<string>, options: SweepOrphansOptions): Promise<OrphanArtifactSweepResult>;
|
|
90
|
+
/** gc's exit code: non-zero iff gc could not complete a sweep it attempted (issue #163
|
|
91
|
+
* exit-code correction). A failed unlink in EITHER sweep, OR an aborted orphan sweep
|
|
92
|
+
* (enumeration failed — `artifactSweepError` set), is a failure. Merely *finding* reapable
|
|
93
|
+
* residue is NOT — that holds in both dry-run and `--force`, so this one helper decides both
|
|
94
|
+
* branches' exit code instead of each re-deriving its own (inline) predicate. */
|
|
95
|
+
export declare function gcExitCode(tempResult: SweepOrphansResult, artifactResult: OrphanArtifactSweepResult | undefined, artifactSweepError: string | undefined): number;
|
|
46
96
|
export declare const gcCommand: Command;
|
|
47
97
|
//# sourceMappingURL=gc.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gc.d.ts","sourceRoot":"","sources":["../../src/commands/gc.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"gc.d.ts","sourceRoot":"","sources":["../../src/commands/gc.ts"],"names":[],"mappings":"AAiCA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,KAAK,EAAE,oBAAoB,EAAkB,MAAM,gBAAgB,CAAC;AAiC3E,MAAM,WAAW,mBAAmB;IAClC,+FAA+F;IAC/F,WAAW,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,MAAM,EAAE,OAAO,CAAC;IAChB,oFAAoF;IACpF,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,8FAA8F;IAC9F,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB;;wFAEoF;IACpF,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAuBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CA0D7B;AAED;6EAC6E;AAC7E,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,yBAAyB;IACxC,kEAAkE;IAClE,MAAM,EAAE,mBAAmB,EAAE,CAAC;IAC9B,qFAAqF;IACrF,YAAY,EAAE,mBAAmB,EAAE,CAAC;IACpC,oFAAoF;IACpF,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,SAAS,oBAAoB,EAAE,EACvC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,EAC/B,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,yBAAyB,CAAC,CA2CpC;AAqHD;;;;kFAIkF;AAClF,wBAAgB,UAAU,CACxB,UAAU,EAAE,kBAAkB,EAC9B,cAAc,EAAE,yBAAyB,GAAG,SAAS,EACrD,kBAAkB,EAAE,MAAM,GAAG,SAAS,GACrC,MAAM,CAMR;AAED,eAAO,MAAM,SAAS,SAuGlB,CAAC"}
|
package/dist/commands/gc.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// gc command — sweep orphaned atomic-write temps (issue #160
|
|
1
|
+
// gc command — sweep orphaned atomic-write temps (issue #160) AND run-less orphaned WAL/sidecar
|
|
2
|
+
// artifacts (issue #163). One operator command, two independent sweeps, same flags.
|
|
2
3
|
//
|
|
4
|
+
// --- Sweep 1: atomic-write temps (issue #160, Phase 1: .tmp only) ---
|
|
3
5
|
// atomicWriteFile (packages/core/src/store/atomic-write.ts) writes a unique sibling temp
|
|
4
6
|
// (`${path}.<pid>.<counter>.tmp`) then POSIX-renames it over the target. A process dying between
|
|
5
7
|
// the write and the rename orphans that temp forever — it is not runId-keyed for the key-pointer
|
|
@@ -14,23 +16,49 @@
|
|
|
14
16
|
// torn file). Combined with the 1h floor below, an in-flight write's temp (age ≪ floor) is never
|
|
15
17
|
// even selected.
|
|
16
18
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
19
|
+
// --- Sweep 2: run-less orphaned WAL/sidecar artifacts (issue #163) ---
|
|
20
|
+
// #163 was ORIGINALLY filed on a premise that does not hold: it claimed a WAL could orphan
|
|
21
|
+
// "between WAL-create and run-file-create." That path does not exist — `append_trace` calls
|
|
22
|
+
// `runStore.get(runId)` FIRST, so a WAL's `<runId>.json` provably existed at WAL-creation time and
|
|
23
|
+
// exists for the run's ENTIRE life. "Artifact present, run file absent" is therefore only true
|
|
24
|
+
// AFTER the run file has been deleted (a pre-#183 purge, a manual `rm`, disk corruption) or DURING
|
|
25
|
+
// the sub-second atomic-write temp-rename window (the run file is a `<id>.json.<pid>.tmp`, not yet
|
|
26
|
+
// renamed). This is remediation for the rare/residual case, not a rescue for an ongoing leak — the
|
|
27
|
+
// git history shows the pre-#183/#184 orphan-manufacturing window was ~48h and opt-in.
|
|
28
|
+
//
|
|
29
|
+
// `.lock` reaping is deliberately split to #164 (deferred — proper-lockfile self-heals a live-path
|
|
30
|
+
// lock; only a purged-target's lock lingers, which is negligible). Neither is this command's job
|
|
31
|
+
// yet — see the report footer.
|
|
20
32
|
import { readdir, lstat, unlink, stat } from 'node:fs/promises';
|
|
21
33
|
import { join } from 'node:path';
|
|
22
34
|
import { Command } from 'commander';
|
|
23
|
-
import { WorkflowError } from '@sensigo/realm';
|
|
35
|
+
import { WorkflowError, deleteIfExists } from '@sensigo/realm';
|
|
24
36
|
import { parseDuration } from '../lib/parse-duration.js';
|
|
25
37
|
/**
|
|
26
|
-
* Minimum `--older-than`
|
|
27
|
-
* non-urgent crash residue, and, forward-consistency-wise, the safety guard the deferred
|
|
28
|
-
* reaping (#164) will also require; enforcing it here means #164 inherits an
|
|
29
|
-
* Temps themselves are safe to reap at any age past a few seconds (see the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
38
|
+
* Minimum `--older-than` EITHER sweep will ever honor (1 hour) — a conservative floor for hygiene
|
|
39
|
+
* of non-urgent crash residue, and, forward-consistency-wise, the safety guard the deferred
|
|
40
|
+
* `.lock` reaping (#164) will also require; enforcing it here means #164 inherits an
|
|
41
|
+
* already-tested guard. Temps themselves are safe to reap at any age past a few seconds (see the
|
|
42
|
+
* module doc above); a run-less WAL/sidecar past this floor is genuinely orphaned, not merely
|
|
43
|
+
* in-flight (see `sweepOrphanArtifacts`'s own doc for why the floor is exactly the create-window
|
|
44
|
+
* guard there) — either way this floor is conservatism, not the per-sweep safety mechanism.
|
|
45
|
+
* Module-private: the only way to reach a delete in EITHER sweep is through `assertOlderThanFloor`,
|
|
46
|
+
* checked FIRST, before any filesystem access.
|
|
32
47
|
*/
|
|
33
48
|
const FLOOR_MS = 3_600_000;
|
|
49
|
+
/** Shared floor guard for both sweeps — checked before any filesystem access in either. */
|
|
50
|
+
function assertOlderThanFloor(olderThanMs, subject) {
|
|
51
|
+
if (olderThanMs < FLOOR_MS) {
|
|
52
|
+
throw new WorkflowError(`--older-than must resolve to at least 1h (got ${olderThanMs}ms) — gc refuses to reap ` +
|
|
53
|
+
`${subject} younger than that, even with --force.`, {
|
|
54
|
+
code: 'VALIDATION_INPUT_SCHEMA',
|
|
55
|
+
category: 'VALIDATION',
|
|
56
|
+
agentAction: 'provide_input',
|
|
57
|
+
retryable: false,
|
|
58
|
+
details: { olderThanMs, floorMs: FLOOR_MS },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
/**
|
|
35
63
|
* Every top-level `*.tmp` in `runsDir`, plus one level of recursion into `runsDir/keys/*.tmp` —
|
|
36
64
|
* `keys/` is the ONLY subdirectory any store ever creates in `runsDir` (verified: `JsonFileStore`'s
|
|
@@ -72,16 +100,7 @@ async function findTempCandidates(runsDir) {
|
|
|
72
100
|
* `unlink`ed — success → `reaped`; ENOENT → `already_gone`; anything else → `failed`.
|
|
73
101
|
*/
|
|
74
102
|
export async function sweepOrphans(runsDir, options) {
|
|
75
|
-
|
|
76
|
-
throw new WorkflowError(`--older-than must resolve to at least 1h (got ${options.olderThanMs}ms) — gc refuses to ` +
|
|
77
|
-
`reap crash residue younger than that, even with --force.`, {
|
|
78
|
-
code: 'VALIDATION_INPUT_SCHEMA',
|
|
79
|
-
category: 'VALIDATION',
|
|
80
|
-
agentAction: 'provide_input',
|
|
81
|
-
retryable: false,
|
|
82
|
-
details: { olderThanMs: options.olderThanMs, floorMs: FLOOR_MS },
|
|
83
|
-
});
|
|
84
|
-
}
|
|
103
|
+
assertOlderThanFloor(options.olderThanMs, 'crash residue');
|
|
85
104
|
const now = options.now ?? new Date();
|
|
86
105
|
const candidatePaths = await findTempCandidates(runsDir);
|
|
87
106
|
const result = { reaped: [], already_gone: [], failed: [] };
|
|
@@ -136,6 +155,70 @@ export async function sweepOrphans(runsDir, options) {
|
|
|
136
155
|
}
|
|
137
156
|
return result;
|
|
138
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Reaps run-less orphaned WAL/sidecar artifacts (issue #163) — the second sweep behind
|
|
160
|
+
* `realm run gc`. `stores` is every `OrphanSweepableStore` gc knows about (today:
|
|
161
|
+
* `JsonTraceBufferStore`, `FailedAttemptStore`); `liveRunIds` is the caller's ALREADY-COMPUTED
|
|
162
|
+
* `JsonFileStore.listRunIds()` result (computed once, shared across every store, and — this is
|
|
163
|
+
* load-bearing — the caller's responsibility to have fail-closed on: see the CLI action for why
|
|
164
|
+
* this function never calls `listRunIds()` itself).
|
|
165
|
+
*
|
|
166
|
+
* **The floor is the create-temp-window guard.** `append_trace` calls `runStore.get(runId)`
|
|
167
|
+
* BEFORE ever writing a WAL entry, so a WAL's `<runId>.json` provably existed at WAL-creation
|
|
168
|
+
* time and exists for the run's entire life (see this module's own header for the full
|
|
169
|
+
* correctness backbone). The ONLY way "artifact present, run file absent" can be true for a
|
|
170
|
+
* FRESH run is the sub-second window between the run file being written as a temp
|
|
171
|
+
* (`<id>.json.<pid>.tmp`) and its atomic rename to `<id>.json` — during which `listRunIds()`
|
|
172
|
+
* would not yet see it. `FLOOR_MS` (1h) dwarfs that window by many orders of magnitude, so a
|
|
173
|
+
* fresh in-flight run's WAL is always younger than the floor and never selected; a WAL/sidecar
|
|
174
|
+
* OLDER than the floor with no matching run file is genuinely run-less.
|
|
175
|
+
*
|
|
176
|
+
* Each `OrphanSweepableStore.listOrphans()` call is fail-closed BY THE STORE'S OWN CONTRACT (see
|
|
177
|
+
* `orphan-sweepable-store.ts`) — a non-ENOENT enumeration/stat failure throws, propagating out of
|
|
178
|
+
* THIS function too (uncaught here, deliberately): this sweep has nothing safe to report if it
|
|
179
|
+
* cannot trust what "orphaned" even means for that store, so it aborts entirely rather than
|
|
180
|
+
* reaping a partial, possibly-wrong candidate set.
|
|
181
|
+
*/
|
|
182
|
+
export async function sweepOrphanArtifacts(stores, liveRunIds, options) {
|
|
183
|
+
assertOlderThanFloor(options.olderThanMs, 'orphaned artifacts');
|
|
184
|
+
const now = options.now ?? new Date();
|
|
185
|
+
const candidates = [];
|
|
186
|
+
for (const store of stores) {
|
|
187
|
+
candidates.push(...(await store.listOrphans(liveRunIds)));
|
|
188
|
+
}
|
|
189
|
+
const result = { reaped: [], already_gone: [], failed: [] };
|
|
190
|
+
const toReap = [];
|
|
191
|
+
for (const artifact of candidates) {
|
|
192
|
+
const ageMs = now.getTime() - artifact.mtimeMs;
|
|
193
|
+
if (ageMs < 0)
|
|
194
|
+
continue; // future mtime (clock skew) — skip, never reap
|
|
195
|
+
if (ageMs <= options.olderThanMs)
|
|
196
|
+
continue; // too fresh — the create-temp-window guard above
|
|
197
|
+
toReap.push(artifact);
|
|
198
|
+
}
|
|
199
|
+
if (options.dryRun) {
|
|
200
|
+
result.reaped = toReap.map((a) => ({ path: a.path, runId: a.runId }));
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
for (const artifact of toReap) {
|
|
204
|
+
const entry = { path: artifact.path, runId: artifact.runId };
|
|
205
|
+
try {
|
|
206
|
+
// #183 discipline: deleteIfExists resolves false (not an error) on ENOENT — already gone
|
|
207
|
+
// is success, never a failure; any other errno throws.
|
|
208
|
+
const didDelete = await deleteIfExists(artifact.path);
|
|
209
|
+
if (didDelete) {
|
|
210
|
+
result.reaped.push(entry);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
result.already_gone.push(entry);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
result.failed.push({ ...entry, error: err instanceof Error ? err.message : String(err) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
139
222
|
/** Best-effort total bytes for a known list of paths — reporting-only, never gates reaping.
|
|
140
223
|
* Called against a fresh dry-run preview, so the paths are still on disk when stat'd. */
|
|
141
224
|
async function statPathBytes(paths) {
|
|
@@ -159,12 +242,21 @@ function formatBytes(n) {
|
|
|
159
242
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
160
243
|
}
|
|
161
244
|
/** So an operator doesn't distrust the tool when `runsDir` still holds residue gc deliberately
|
|
162
|
-
* does not touch — printed on every report, dry-run or force, empty or not.
|
|
163
|
-
|
|
164
|
-
|
|
245
|
+
* does not touch — printed on every report, dry-run or force, empty or not. issue #163: WALs and
|
|
246
|
+
* failed-attempt sidecars are now reaped (the second sweep above) — only `.lock` dirs remain
|
|
247
|
+
* deferred (issue #164). */
|
|
248
|
+
const NOT_REAPED_FOOTER = 'gc does NOT yet reap orphaned .lock dirs (deferred — issue #164). Their presence in runsDir ' +
|
|
249
|
+
'is expected and not a sign gc is broken.';
|
|
165
250
|
/** Prints the dry-run / force report shared by both code paths. `previewBytes` always comes from
|
|
166
|
-
* the initial (always non-destructive) preview pass — see the action below for why.
|
|
167
|
-
|
|
251
|
+
* the initial (always non-destructive) preview pass — see the action below for why.
|
|
252
|
+
*
|
|
253
|
+
* `artifactResult`/`artifactSweepError` are mutually exclusive (issue #163): a defined
|
|
254
|
+
* `artifactSweepError` means `listRunIds()` or a store's `listOrphans()` threw — the orphan
|
|
255
|
+
* sweep aborted loudly, reaping NOTHING, and there is no `artifactResult` to report. Both may be
|
|
256
|
+
* `undefined` only if the temp-only sweep somehow bypassed the orphan sweep entirely (never
|
|
257
|
+
* happens in the real CLI action — always one or the other).
|
|
258
|
+
*/
|
|
259
|
+
function printGcReport(result, previewBytes, dryRun, artifactResult, artifactSweepError) {
|
|
168
260
|
const nothingToReport = result.reaped.length === 0 && result.already_gone.length === 0 && result.failed.length === 0;
|
|
169
261
|
if (nothingToReport) {
|
|
170
262
|
console.log('No orphaned .tmp files found to reap.');
|
|
@@ -187,11 +279,54 @@ function printGcReport(result, previewBytes, dryRun) {
|
|
|
187
279
|
if (dryRun && !nothingToReport) {
|
|
188
280
|
console.log('\nRe-run with --force to actually delete.');
|
|
189
281
|
}
|
|
282
|
+
// --- orphan-artifacts section (issue #163) ---
|
|
283
|
+
if (artifactSweepError !== undefined) {
|
|
284
|
+
console.error(`\n✗ orphan-artifact sweep ABORTED (reaped nothing from it): ${artifactSweepError}`);
|
|
285
|
+
}
|
|
286
|
+
else if (artifactResult !== undefined) {
|
|
287
|
+
const nothingToReportArtifacts = artifactResult.reaped.length === 0 &&
|
|
288
|
+
artifactResult.already_gone.length === 0 &&
|
|
289
|
+
artifactResult.failed.length === 0;
|
|
290
|
+
if (nothingToReportArtifacts) {
|
|
291
|
+
console.log('\nNo run-less orphaned WAL/sidecar artifacts found to reap.');
|
|
292
|
+
}
|
|
293
|
+
else if (dryRun) {
|
|
294
|
+
console.log(`\n${artifactResult.reaped.length} run-less orphaned artifact(s) WOULD be reaped:`);
|
|
295
|
+
for (const a of artifactResult.reaped)
|
|
296
|
+
console.log(` • ${a.path} (run ${a.runId})`);
|
|
297
|
+
if (artifactResult.already_gone.length > 0) {
|
|
298
|
+
console.log(`(${artifactResult.already_gone.length} candidate(s) already vanished on their own.)`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
console.log(`\nReaped ${artifactResult.reaped.length} run-less orphaned artifact(s). ` +
|
|
303
|
+
`${artifactResult.already_gone.length} already gone, ${artifactResult.failed.length} failed.`);
|
|
304
|
+
for (const a of artifactResult.reaped)
|
|
305
|
+
console.log(` • ${a.path} (run ${a.runId})`);
|
|
306
|
+
}
|
|
307
|
+
for (const f of artifactResult.failed) {
|
|
308
|
+
console.error(` ✗ ${f.path} (run ${f.runId}): ${f.error}`);
|
|
309
|
+
}
|
|
310
|
+
if (dryRun && !nothingToReportArtifacts) {
|
|
311
|
+
console.log('Re-run with --force to actually delete.');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
190
314
|
console.log(`\n${NOT_REAPED_FOOTER}`);
|
|
191
315
|
}
|
|
316
|
+
/** gc's exit code: non-zero iff gc could not complete a sweep it attempted (issue #163
|
|
317
|
+
* exit-code correction). A failed unlink in EITHER sweep, OR an aborted orphan sweep
|
|
318
|
+
* (enumeration failed — `artifactSweepError` set), is a failure. Merely *finding* reapable
|
|
319
|
+
* residue is NOT — that holds in both dry-run and `--force`, so this one helper decides both
|
|
320
|
+
* branches' exit code instead of each re-deriving its own (inline) predicate. */
|
|
321
|
+
export function gcExitCode(tempResult, artifactResult, artifactSweepError) {
|
|
322
|
+
const anyFailure = tempResult.failed.length > 0 ||
|
|
323
|
+
(artifactResult?.failed.length ?? 0) > 0 ||
|
|
324
|
+
artifactSweepError !== undefined;
|
|
325
|
+
return anyFailure ? 1 : 0;
|
|
326
|
+
}
|
|
192
327
|
export const gcCommand = new Command('gc')
|
|
193
|
-
.description('Sweep orphaned atomic-write .tmp files
|
|
194
|
-
.requiredOption('--older-than <duration>', 'Reap
|
|
328
|
+
.description('Sweep orphaned atomic-write .tmp files and run-less orphaned WAL/sidecar artifacts (dry-run by default)')
|
|
329
|
+
.requiredOption('--older-than <duration>', 'Reap residue idle at least this long (minimum 1h; e.g. 1h, 6h, 30d)')
|
|
195
330
|
.option('--force', 'Actually delete (without this, gc only reports what WOULD be reaped)')
|
|
196
331
|
.action(async (opts) => {
|
|
197
332
|
let olderThanMs;
|
|
@@ -203,16 +338,22 @@ export const gcCommand = new Command('gc')
|
|
|
203
338
|
process.exit(1);
|
|
204
339
|
return;
|
|
205
340
|
}
|
|
206
|
-
// Defense-in-depth over sweepOrphans's own floor
|
|
207
|
-
// filesystem at all, with a CLI-friendly message naming the flag the
|
|
341
|
+
// Defense-in-depth over sweepOrphans's/sweepOrphanArtifacts's own floor checks — reject
|
|
342
|
+
// before touching the filesystem at all, with a CLI-friendly message naming the flag the
|
|
343
|
+
// operator just typed.
|
|
208
344
|
if (olderThanMs < FLOOR_MS) {
|
|
209
345
|
console.error(`--older-than must be at least 1h (got '${opts.olderThan}'). gc refuses to reap crash ` +
|
|
210
346
|
`residue younger than that, even with --force.`);
|
|
211
347
|
process.exit(1);
|
|
212
348
|
return;
|
|
213
349
|
}
|
|
214
|
-
const { JsonFileStore } = await import('@sensigo/realm');
|
|
215
|
-
const
|
|
350
|
+
const { JsonFileStore, FailedAttemptStore } = await import('@sensigo/realm');
|
|
351
|
+
const { JsonTraceBufferStore } = await import('@sensigo/realm-mcp');
|
|
352
|
+
const runStore = new JsonFileStore();
|
|
353
|
+
const runsDir = runStore.runsDirPath;
|
|
354
|
+
const failedAttemptStore = new FailedAttemptStore(runsDir);
|
|
355
|
+
const traceBufferStore = new JsonTraceBufferStore(runsDir);
|
|
356
|
+
const orphanSweepableStores = [traceBufferStore, failedAttemptStore];
|
|
216
357
|
const now = new Date();
|
|
217
358
|
try {
|
|
218
359
|
// Always preview first — this NEVER mutates, in either mode — because it is the only
|
|
@@ -223,14 +364,53 @@ export const gcCommand = new Command('gc')
|
|
|
223
364
|
// already_gone exists to absorb.
|
|
224
365
|
const preview = await sweepOrphans(runsDir, { olderThanMs, dryRun: true, now });
|
|
225
366
|
const bytes = await statPathBytes(preview.reaped);
|
|
367
|
+
// issue #163: FAIL-CLOSED. A `listRunIds()` (or a store's `listOrphans()`) failure aborts
|
|
368
|
+
// ONLY the orphan-artifact sweep, loudly — it must NEVER fabricate an empty `liveRunIds`,
|
|
369
|
+
// which would make every live run's artifacts look orphaned and reap them. The temp sweep
|
|
370
|
+
// above is fully independent of this and is completely unaffected either way.
|
|
371
|
+
let artifactPreview;
|
|
372
|
+
let artifactSweepError;
|
|
373
|
+
try {
|
|
374
|
+
const liveRunIds = await runStore.listRunIds();
|
|
375
|
+
artifactPreview = await sweepOrphanArtifacts(orphanSweepableStores, liveRunIds, {
|
|
376
|
+
olderThanMs,
|
|
377
|
+
dryRun: true,
|
|
378
|
+
now,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
artifactSweepError = err instanceof Error ? err.message : String(err);
|
|
383
|
+
}
|
|
226
384
|
if (opts.force !== true) {
|
|
227
|
-
printGcReport(preview, bytes, true);
|
|
385
|
+
printGcReport(preview, bytes, true, artifactPreview, artifactSweepError);
|
|
386
|
+
if (gcExitCode(preview, artifactPreview, artifactSweepError) !== 0) {
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
228
389
|
return;
|
|
229
390
|
}
|
|
230
391
|
const result = await sweepOrphans(runsDir, { olderThanMs, dryRun: false, now });
|
|
231
|
-
|
|
232
|
-
if (
|
|
392
|
+
let artifactResult;
|
|
393
|
+
if (artifactSweepError === undefined) {
|
|
394
|
+
// The preview above already proved listRunIds()/listOrphans() succeed — re-read for the
|
|
395
|
+
// real pass (a fresh liveRunIds, since force mode is a separate call; a run created or
|
|
396
|
+
// completed between the preview and here is a benign, narrow race — exactly the same
|
|
397
|
+
// shape the temp sweep's own preview/force split already accepts).
|
|
398
|
+
try {
|
|
399
|
+
const liveRunIds = await runStore.listRunIds();
|
|
400
|
+
artifactResult = await sweepOrphanArtifacts(orphanSweepableStores, liveRunIds, {
|
|
401
|
+
olderThanMs,
|
|
402
|
+
dryRun: false,
|
|
403
|
+
now,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
artifactSweepError = err instanceof Error ? err.message : String(err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
printGcReport(result, bytes, false, artifactResult, artifactSweepError);
|
|
411
|
+
if (gcExitCode(result, artifactResult, artifactSweepError) !== 0) {
|
|
233
412
|
process.exit(1);
|
|
413
|
+
}
|
|
234
414
|
}
|
|
235
415
|
catch (err) {
|
|
236
416
|
console.error(err instanceof Error ? err.message : String(err));
|