@vimhead.dev/norn-cli 0.1.0-tip.36679386408.1 → 0.1.0-tip.36824462948.1
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/assets/docs/cli.md +25 -0
- package/assets/docs/recovery.md +4 -0
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/cli.ts +37 -0
- package/assets/packages/cli/src/client.ts +16 -0
- package/assets/packages/cli/src/generated-build-info.ts +2 -2
- package/assets/packages/cli/src/internal/run-lease.ts +24 -2
- package/assets/packages/cli/src/internal/run-pruning.ts +169 -0
- package/assets/packages/cli/src/internal/run-store.ts +76 -4
- package/assets/packages/core/src/atomic-files.ts +8 -1
- package/assets/packages/sdk/src/api.ts +20 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +8 -0
- package/dist/generated-build-info.d.ts +2 -2
- package/dist/generated-build-info.js +2 -2
- package/dist/internal/file-coordinator.js +4 -1
- package/dist/internal/launch-request.js +4 -1
- package/dist/internal/run-lease.d.ts +1 -0
- package/dist/internal/run-lease.js +27 -3
- package/dist/internal/run-log.js +4 -1
- package/dist/internal/run-pruning.d.ts +14 -0
- package/dist/internal/run-pruning.js +141 -0
- package/dist/internal/run-state.js +4 -1
- package/dist/internal/run-store.d.ts +5 -0
- package/dist/internal/run-store.js +66 -5
- package/package.json +2 -2
package/assets/docs/cli.md
CHANGED
|
@@ -179,6 +179,31 @@ norn runs delete <run>
|
|
|
179
179
|
|
|
180
180
|
`stop` signals SIGTERM; `kill` signals SIGKILL. Delete removes an inactive run and its evidence. Neither stopping nor deleting undoes external effects. Resume and rollback are documented in [recovery](recovery.md).
|
|
181
181
|
|
|
182
|
+
## Prune run storage
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
norn runs prune --dry-run
|
|
186
|
+
norn runs prune
|
|
187
|
+
norn runs prune --older-than 7d
|
|
188
|
+
norn runs prune --delete-runs
|
|
189
|
+
norn runs prune --delete-runs --all --dry-run
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Pruning is explicit and scoped to the current project. By default it discards all checkpoint manifests, saved file contents, and checkpoint references for failed or completed runs that finished more than 24 hours ago. Current workspace files, logs, sessions, state, and outcomes remain available. Pruned checkpoint history cannot be rolled back; checkpoint listing returns an empty list.
|
|
193
|
+
|
|
194
|
+
`--older-than` accepts a non-negative number followed by `ms`, `s`, `m`, `h`, `d`, or `w`; age is measured from completion/failure, not run creation. `--delete-runs` removes eligible run directories and all their evidence instead. It preserves the age filter unless combined with `--all`. `--all` requires `--delete-runs` and cannot be combined with `--older-than`.
|
|
195
|
+
|
|
196
|
+
Every mode preserves running, stopped, interrupted, pending-resume, and executor-owned runs. Eligibility is checked again under the existing run lease before deletion. `--dry-run` makes no filesystem changes.
|
|
197
|
+
|
|
198
|
+
The JSON result under `prune` includes `mode`, `dryRun`, effective `olderThan` (`null` with `--all`), and `runs` with identity, status (`planned`, `pruned`, `skipped`, or `failed`), and an optional reason. Per-run failures produce a nonzero exit status; other eligible runs can still be pruned. Repeating checkpoint pruning safely completes an interrupted cleanup.
|
|
199
|
+
|
|
200
|
+
| Decision | GOOD | BAD |
|
|
201
|
+
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------- |
|
|
202
|
+
| IF failed runs may need recovery, THEN retain their checkpoints or select an age beyond the repair window. ELSE prune after reviewing the dry-run result. | `runs prune --older-than 7d --dry-run` before deleting history. | Treat checkpoint pruning as reversible. |
|
|
203
|
+
| IF entire runs will be deleted, THEN preserve unique outputs elsewhere first. ELSE keep current files with default pruning. | Export private commits before `--delete-runs`. | Assume completed means outputs were integrated elsewhere. |
|
|
204
|
+
|
|
205
|
+
The JavaScript client exposes `client.runs.prune({ deleteRuns: false, all: false, olderThan: null, dryRun: true })`; `olderThan: null` uses the CLI default unless `all` is true.
|
|
206
|
+
|
|
182
207
|
## JavaScript client and other harnesses
|
|
183
208
|
|
|
184
209
|
Claude Code, Pi, Codex, and other CLI-capable harnesses can invoke Norn without an
|
package/assets/docs/recovery.md
CHANGED
|
@@ -19,6 +19,8 @@ Checkpoints are taken at run start, successful transitions, gate interruptions,
|
|
|
19
19
|
| `pendingResume` | IF retry is intended, THEN resume with no args. ELSE leave the restored boundary untouched. | `norn runs resume <run> </dev/null`. | Try to override arbitrary saved inputs through resume. |
|
|
20
20
|
| `running` with unhealthy inspection | IF the executor is no longer healthy, THEN inspect ownership and reconcile effects before recovery. ELSE monitor active execution. | Check run health and command evidence before retry. | Start a competing executor or equate stale status with successful delivery. |
|
|
21
21
|
|
|
22
|
+
[Run pruning](cli.md#prune-run-storage) permanently removes recovery history while retaining current evidence by default. Pruned runs expose no checkpoints; rollback reports that the history was pruned.
|
|
23
|
+
|
|
22
24
|
## Source repair and rollback
|
|
23
25
|
|
|
24
26
|
```bash
|
|
@@ -43,6 +45,8 @@ The [agent example's repair exercise](../examples/agent-then-analysis/README.md#
|
|
|
43
45
|
|
|
44
46
|
## Saved-run compatibility
|
|
45
47
|
|
|
48
|
+
New checkpoints are compressed; CLI and client calls are unchanged. Existing uncompressed checkpoints remain readable and are not converted automatically. Older runtimes without compressed-checkpoint support cannot restore new checkpoints.
|
|
49
|
+
|
|
46
50
|
Runs created before the complete-workflow API migration cannot be resumed or rolled back by this alpha. Their saved status remains inspectable, and existing files are preserved. Use the original runtime to continue those runs, or start a new run.
|
|
47
51
|
|
|
48
52
|
## Declared gates
|
package/assets/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.1.0-tip.
|
|
1
|
+
{"version":"0.1.0-tip.36824462948.1"}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import { readRunMetrics } from "./internal/metrics.ts";
|
|
51
51
|
import { getRunLeaseOwner, NornRunLease } from "./internal/run-lease.ts";
|
|
52
52
|
import { generateRunName } from "./internal/run-names.ts";
|
|
53
|
+
import { NornRunPruner, parseRunPruneOptions } from "./internal/run-pruning.ts";
|
|
53
54
|
import {
|
|
54
55
|
assertRunVersion,
|
|
55
56
|
getRunInfo,
|
|
@@ -433,6 +434,40 @@ const COMMANDS: readonly CliCommand[] = [
|
|
|
433
434
|
await signalRun(run, "SIGKILL");
|
|
434
435
|
},
|
|
435
436
|
},
|
|
437
|
+
{
|
|
438
|
+
id: "runs.prune",
|
|
439
|
+
path: ["runs", "prune"],
|
|
440
|
+
description:
|
|
441
|
+
"Discard checkpoint history or entire failed/completed runs, preserving recent and nonterminal runs.",
|
|
442
|
+
usage:
|
|
443
|
+
"norn runs prune [--delete-runs] [--older-than <duration>|--all] [--dry-run]",
|
|
444
|
+
options: [
|
|
445
|
+
"--delete-runs: delete entire eligible runs instead of only checkpoint storage",
|
|
446
|
+
"--older-than <duration>: time since completion/failure; default 24h; units ms, s, m, h, d, w",
|
|
447
|
+
"--all: ignore age; requires --delete-runs; incompatible with --older-than",
|
|
448
|
+
"--dry-run: report eligible runs without changing files",
|
|
449
|
+
],
|
|
450
|
+
output:
|
|
451
|
+
"JSON object under prune with mode, dryRun, olderThan, and per-run planned/pruned/skipped/failed results. Any failed result sets a nonzero exit status.",
|
|
452
|
+
examples: [
|
|
453
|
+
"norn runs prune --dry-run",
|
|
454
|
+
"norn runs prune --older-than 7d",
|
|
455
|
+
"norn runs prune --delete-runs --all",
|
|
456
|
+
],
|
|
457
|
+
execute: async (args) => {
|
|
458
|
+
const options = parseRunPruneOptions(args);
|
|
459
|
+
const project = await findNornProject(process.cwd());
|
|
460
|
+
const pruner = new NornRunPruner({
|
|
461
|
+
projectRoot: project.projectRoot,
|
|
462
|
+
options,
|
|
463
|
+
now: new Date(),
|
|
464
|
+
});
|
|
465
|
+
const result = await pruner.prune();
|
|
466
|
+
writeJson({ prune: result });
|
|
467
|
+
if (result.runs.some((run) => run.status === "failed"))
|
|
468
|
+
process.exitCode = 1;
|
|
469
|
+
},
|
|
470
|
+
},
|
|
436
471
|
{
|
|
437
472
|
id: "runs.delete",
|
|
438
473
|
path: ["runs", "delete"],
|
|
@@ -515,6 +550,7 @@ const HELP_COMMAND_ORDER = [
|
|
|
515
550
|
"runs.stop",
|
|
516
551
|
"runs.kill",
|
|
517
552
|
"runs.delete",
|
|
553
|
+
"runs.prune",
|
|
518
554
|
"project.init",
|
|
519
555
|
"project.inspect",
|
|
520
556
|
"pi",
|
|
@@ -545,6 +581,7 @@ const HUMAN_COMMAND_SUMMARIES: Readonly<Record<string, string>> = {
|
|
|
545
581
|
"runs.stop": "Stop a running run.",
|
|
546
582
|
"runs.kill": "Force-stop a running run.",
|
|
547
583
|
"runs.delete": "Delete an inactive run.",
|
|
584
|
+
"runs.prune": "Prune checkpoint storage or old failed/completed runs.",
|
|
548
585
|
"project.init": "Create a Norn project in the current directory.",
|
|
549
586
|
"project.inspect": "Inspect the Norn project.",
|
|
550
587
|
pi: "Run bundled Pi for authentication, providers, and model setup.",
|
|
@@ -9,6 +9,8 @@ import type {
|
|
|
9
9
|
NornWorkflowInspection,
|
|
10
10
|
NornRunCheckpoint,
|
|
11
11
|
NornRunInfo,
|
|
12
|
+
NornRunPruneOptions,
|
|
13
|
+
NornRunPruneResult,
|
|
12
14
|
NornRunMetrics,
|
|
13
15
|
} from "@vimhead.dev/norn";
|
|
14
16
|
import { loadNornProject } from "./workflow-loader.ts";
|
|
@@ -52,6 +54,7 @@ export type NornClient = {
|
|
|
52
54
|
stop(run: string): Promise<NornRunInfo>;
|
|
53
55
|
kill(run: string): Promise<NornRunInfo>;
|
|
54
56
|
delete(run: string): Promise<DeletedNornRunInfo>;
|
|
57
|
+
prune(options: NornRunPruneOptions): Promise<NornRunPruneResult>;
|
|
55
58
|
logs(
|
|
56
59
|
run: string,
|
|
57
60
|
options?: { readonly follow?: boolean },
|
|
@@ -169,6 +172,19 @@ export function createNornClient(input: NornClientInput = {}): NornClient {
|
|
|
169
172
|
run,
|
|
170
173
|
])
|
|
171
174
|
).deleted,
|
|
175
|
+
prune: async (options) =>
|
|
176
|
+
(
|
|
177
|
+
await processRunner.readJson<{ prune: NornRunPruneResult }>([
|
|
178
|
+
"runs",
|
|
179
|
+
"prune",
|
|
180
|
+
...(options.deleteRuns ? ["--delete-runs"] : []),
|
|
181
|
+
...(options.all ? ["--all"] : []),
|
|
182
|
+
...(options.olderThan === null
|
|
183
|
+
? []
|
|
184
|
+
: ["--older-than", options.olderThan]),
|
|
185
|
+
...(options.dryRun ? ["--dry-run"] : []),
|
|
186
|
+
])
|
|
187
|
+
).prune,
|
|
172
188
|
logs: (run, options) =>
|
|
173
189
|
processRunner.readJsonLines([
|
|
174
190
|
"runs",
|
|
@@ -2,8 +2,8 @@ import type { NornBuildInfo } from "./build-info.ts";
|
|
|
2
2
|
|
|
3
3
|
export const NORN_GENERATED_BUILD_INFO = {
|
|
4
4
|
"kind": "npm-registry",
|
|
5
|
-
"version": "0.1.0-tip.
|
|
6
|
-
"commit": "
|
|
5
|
+
"version": "0.1.0-tip.36824462948.1",
|
|
6
|
+
"commit": "c98b6febf7a42abc1201dfdceb1553fc9e91c08c",
|
|
7
7
|
"packageSpec": "@vimhead.dev/norn-cli@tip",
|
|
8
8
|
"upgrade": {
|
|
9
9
|
"supported": false,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import type { NornRunHealth } from "@vimhead.dev/norn";
|
|
5
5
|
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
6
6
|
import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
@@ -68,6 +68,28 @@ export class NornRunLease {
|
|
|
68
68
|
throw new Error("Run lease was lost");
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
async deleteRunDirectory(): Promise<void> {
|
|
72
|
+
await this.assertOwned();
|
|
73
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
74
|
+
await this.heartbeatChain;
|
|
75
|
+
await this.assertOwned();
|
|
76
|
+
const runRoot = dirname(this.lockRoot);
|
|
77
|
+
const retiredRoot = join(
|
|
78
|
+
dirname(dirname(runRoot)),
|
|
79
|
+
`.pruned-run-${randomUUID()}`,
|
|
80
|
+
);
|
|
81
|
+
await rename(runRoot, retiredRoot);
|
|
82
|
+
this.isReleased = true;
|
|
83
|
+
try {
|
|
84
|
+
await rm(retiredRoot, { recursive: true, force: true });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Run was removed from the run list but directory cleanup failed: ${retiredRoot}`,
|
|
88
|
+
{ cause: error },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
async release(): Promise<void> {
|
|
72
94
|
if (this.isReleased) return;
|
|
73
95
|
this.isReleased = true;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NornRunInfo,
|
|
3
|
+
NornRunPruneOptions,
|
|
4
|
+
NornRunPruneResult,
|
|
5
|
+
} from "@vimhead.dev/norn";
|
|
6
|
+
import { errorMessage } from "./errors.ts";
|
|
7
|
+
import { getRunLeaseOwner, NornRunLease } from "./run-lease.ts";
|
|
8
|
+
import { getRunInfo, listRuns } from "./run-state.ts";
|
|
9
|
+
import { NornRunStore } from "./run-store.ts";
|
|
10
|
+
|
|
11
|
+
const DURATION_UNITS: Readonly<Record<string, number>> = {
|
|
12
|
+
ms: 1,
|
|
13
|
+
s: 1000,
|
|
14
|
+
m: 60_000,
|
|
15
|
+
h: 3_600_000,
|
|
16
|
+
d: 86_400_000,
|
|
17
|
+
w: 604_800_000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function parseRunPruneOptions(
|
|
21
|
+
args: readonly string[],
|
|
22
|
+
): NornRunPruneOptions {
|
|
23
|
+
const flags = new Set<string>();
|
|
24
|
+
let olderThan: string | null = null;
|
|
25
|
+
for (let index = 0; index < args.length; index++) {
|
|
26
|
+
const flag = args[index];
|
|
27
|
+
if (!["--delete-runs", "--all", "--dry-run", "--older-than"].includes(flag))
|
|
28
|
+
throw new Error(`runs prune: unknown argument ${flag}`);
|
|
29
|
+
if (flags.has(flag))
|
|
30
|
+
throw new Error(`runs prune: duplicate option ${flag}`);
|
|
31
|
+
flags.add(flag);
|
|
32
|
+
if (flag === "--older-than") {
|
|
33
|
+
const value = args[++index];
|
|
34
|
+
if (value === undefined || value.startsWith("--"))
|
|
35
|
+
throw new Error(
|
|
36
|
+
"runs prune: --older-than requires a duration such as 24h or 7d",
|
|
37
|
+
);
|
|
38
|
+
olderThan = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
deleteRuns: flags.has("--delete-runs"),
|
|
43
|
+
all: flags.has("--all"),
|
|
44
|
+
olderThan,
|
|
45
|
+
dryRun: flags.has("--dry-run"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePruneDuration(value: string): number {
|
|
50
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/.exec(value);
|
|
51
|
+
const duration = match ? Number(match[1]) * DURATION_UNITS[match[2]] : NaN;
|
|
52
|
+
if (
|
|
53
|
+
!Number.isFinite(duration) ||
|
|
54
|
+
duration < 0 ||
|
|
55
|
+
duration > Number.MAX_SAFE_INTEGER
|
|
56
|
+
)
|
|
57
|
+
throw new Error(
|
|
58
|
+
`runs prune: invalid duration ${value}; use a non-negative number followed by ms, s, m, h, d, or w`,
|
|
59
|
+
);
|
|
60
|
+
return duration;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class NornRunPruner {
|
|
64
|
+
private readonly olderThan: string | null;
|
|
65
|
+
private readonly cutoff: number | null;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly input: {
|
|
69
|
+
readonly projectRoot: string;
|
|
70
|
+
readonly options: NornRunPruneOptions;
|
|
71
|
+
readonly now: Date;
|
|
72
|
+
},
|
|
73
|
+
) {
|
|
74
|
+
if (input.options.all && !input.options.deleteRuns)
|
|
75
|
+
throw new Error("runs prune: --all requires --delete-runs");
|
|
76
|
+
if (input.options.all && input.options.olderThan !== null)
|
|
77
|
+
throw new Error(
|
|
78
|
+
"runs prune: --all and --older-than are mutually exclusive",
|
|
79
|
+
);
|
|
80
|
+
if (!Number.isFinite(input.now.getTime()))
|
|
81
|
+
throw new Error("Invalid pruning time");
|
|
82
|
+
this.olderThan = input.options.all
|
|
83
|
+
? null
|
|
84
|
+
: (input.options.olderThan ?? "24h");
|
|
85
|
+
this.cutoff =
|
|
86
|
+
this.olderThan === null
|
|
87
|
+
? null
|
|
88
|
+
: input.now.getTime() - parsePruneDuration(this.olderThan);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async prune(): Promise<NornRunPruneResult> {
|
|
92
|
+
const results: NornRunPruneResult["runs"][number][] = [];
|
|
93
|
+
for (const run of await listRuns(this.input.projectRoot)) {
|
|
94
|
+
const identity = { id: run.id, name: run.name, path: run.path };
|
|
95
|
+
let lease: NornRunLease | undefined;
|
|
96
|
+
try {
|
|
97
|
+
let reason = this.findIneligibilityReason(run);
|
|
98
|
+
if (!reason && (await getRunLeaseOwner(run.path)))
|
|
99
|
+
reason = "Run is owned by an executor";
|
|
100
|
+
if (reason) {
|
|
101
|
+
results.push({ ...identity, status: "skipped", reason });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (this.input.options.dryRun) {
|
|
105
|
+
results.push({ ...identity, status: "planned" });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
lease = await NornRunLease.acquire(run.path);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (
|
|
112
|
+
error instanceof Error &&
|
|
113
|
+
error.message.startsWith("Run is already active:")
|
|
114
|
+
) {
|
|
115
|
+
results.push({
|
|
116
|
+
...identity,
|
|
117
|
+
status: "skipped",
|
|
118
|
+
reason: "Run is owned by an executor",
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
reason = this.findIneligibilityReason(await getRunInfo(run.path));
|
|
125
|
+
if (reason) {
|
|
126
|
+
results.push({ ...identity, status: "skipped", reason });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
await lease.assertOwned();
|
|
130
|
+
if (this.input.options.deleteRuns) {
|
|
131
|
+
await lease.deleteRunDirectory();
|
|
132
|
+
} else {
|
|
133
|
+
const store = await NornRunStore.open(run.path);
|
|
134
|
+
await store.pruneCheckpointHistory(this.input.now.toISOString());
|
|
135
|
+
}
|
|
136
|
+
results.push({ ...identity, status: "pruned" });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
results.push({
|
|
139
|
+
...identity,
|
|
140
|
+
status: "failed",
|
|
141
|
+
reason: errorMessage(error),
|
|
142
|
+
});
|
|
143
|
+
} finally {
|
|
144
|
+
await lease?.release();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
mode: this.input.options.deleteRuns ? "runs" : "checkpoints",
|
|
149
|
+
dryRun: this.input.options.dryRun,
|
|
150
|
+
olderThan: this.olderThan,
|
|
151
|
+
runs: results,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private findIneligibilityReason(run: NornRunInfo): string | undefined {
|
|
156
|
+
if (run.status !== "completed" && run.status !== "failed")
|
|
157
|
+
return `Run status is ${run.status}; only completed or failed runs are eligible`;
|
|
158
|
+
const finishedAt =
|
|
159
|
+
run.status === "failed"
|
|
160
|
+
? (run.failed?.failedAt ?? run.outcome?.completedAt ?? run.updatedAt)
|
|
161
|
+
: (run.outcome?.completedAt ?? run.updatedAt);
|
|
162
|
+
const finishedTime = Date.parse(finishedAt);
|
|
163
|
+
if (!Number.isFinite(finishedTime))
|
|
164
|
+
return "Run completion/failure time is invalid";
|
|
165
|
+
if (this.cutoff !== null && finishedTime >= this.cutoff)
|
|
166
|
+
return `Run finished less than or exactly ${this.olderThan} ago`;
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -21,6 +21,7 @@ import { gunzip, gzip } from "node:zlib";
|
|
|
21
21
|
import type { NornRunCheckpoint } from "@vimhead.dev/norn";
|
|
22
22
|
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
23
23
|
import {
|
|
24
|
+
writeBytesAtomically,
|
|
24
25
|
writeJsonAtomically,
|
|
25
26
|
writeTextAtomically,
|
|
26
27
|
} from "@vimhead.dev/norn-core/atomic-files";
|
|
@@ -31,6 +32,7 @@ const SNAPSHOT_VERSION = 1;
|
|
|
31
32
|
const CURRENT_DIR_NAME = "current";
|
|
32
33
|
const STORE_DIR_NAME = "store";
|
|
33
34
|
const CHECKPOINTS_FILE_NAME = "checkpoints.json";
|
|
35
|
+
const PRUNED_HISTORY_FILE_NAME = "checkpoint-history-pruned.json";
|
|
34
36
|
|
|
35
37
|
export function runCurrentRoot(runRoot: string): string {
|
|
36
38
|
return join(runRoot, CURRENT_DIR_NAME);
|
|
@@ -57,15 +59,18 @@ export class NornRunStore {
|
|
|
57
59
|
static async open(root: string): Promise<NornRunStore> {
|
|
58
60
|
const runStore = new NornRunStore(root);
|
|
59
61
|
await access(runStore.currentRoot, constants.R_OK | constants.W_OK);
|
|
60
|
-
await
|
|
62
|
+
if (!(await runStore.isCheckpointHistoryPruned()))
|
|
63
|
+
await access(runStore.storeRoot, constants.R_OK | constants.W_OK);
|
|
61
64
|
return runStore;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
async currentSnapshotRef(): Promise<string> {
|
|
68
|
+
await this.assertCheckpointHistoryAvailable();
|
|
65
69
|
return readFile(this.currentRefPath, "utf8").then((value) => value.trim());
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
async snapshotCurrent(message: string): Promise<NornRunCheckpoint> {
|
|
73
|
+
await this.assertCheckpointHistoryAvailable();
|
|
69
74
|
const previous = await this.readCheckpointHistory();
|
|
70
75
|
const checkpoint = this.createCheckpoint(
|
|
71
76
|
message,
|
|
@@ -89,6 +94,7 @@ export class NornRunStore {
|
|
|
89
94
|
ref: string,
|
|
90
95
|
prepare: ((stagedRunRoot: string) => Promise<void>) | undefined,
|
|
91
96
|
): Promise<void> {
|
|
97
|
+
await this.assertCheckpointHistoryAvailable();
|
|
92
98
|
const checkpoint = (await this.listCheckpoints()).find(
|
|
93
99
|
(entry) => entry.id === ref,
|
|
94
100
|
);
|
|
@@ -107,9 +113,55 @@ export class NornRunStore {
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
async listCheckpoints(): Promise<NornRunCheckpoint[]> {
|
|
116
|
+
if (await this.isCheckpointHistoryPruned()) return [];
|
|
110
117
|
return (await this.readCheckpointHistory()).checkpoints;
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
async pruneCheckpointHistory(prunedAt: string): Promise<void> {
|
|
121
|
+
if (!(await this.isCheckpointHistoryPruned()))
|
|
122
|
+
await writeJsonAtomically(this.prunedHistoryPath, {
|
|
123
|
+
version: 1,
|
|
124
|
+
prunedAt,
|
|
125
|
+
});
|
|
126
|
+
await writeJsonAtomically(this.checkpointsPath, []);
|
|
127
|
+
await rm(this.storeRoot, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private get prunedHistoryPath(): string {
|
|
131
|
+
return join(this.runRoot, PRUNED_HISTORY_FILE_NAME);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async isCheckpointHistoryPruned(): Promise<boolean> {
|
|
135
|
+
let content: string;
|
|
136
|
+
try {
|
|
137
|
+
content = await readFile(this.prunedHistoryPath, "utf8");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const marker: unknown = JSON.parse(content);
|
|
143
|
+
if (
|
|
144
|
+
!marker ||
|
|
145
|
+
typeof marker !== "object" ||
|
|
146
|
+
!("version" in marker) ||
|
|
147
|
+
marker.version !== 1 ||
|
|
148
|
+
!("prunedAt" in marker) ||
|
|
149
|
+
typeof marker.prunedAt !== "string" ||
|
|
150
|
+
!Number.isFinite(Date.parse(marker.prunedAt))
|
|
151
|
+
)
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Invalid pruned checkpoint history marker: ${this.prunedHistoryPath}`,
|
|
154
|
+
);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async assertCheckpointHistoryAvailable(): Promise<void> {
|
|
159
|
+
if (await this.isCheckpointHistoryPruned())
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Checkpoint history was pruned for this run: ${this.runRoot}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
private async readCheckpointHistory(): Promise<{
|
|
114
166
|
readonly checkpoints: NornRunCheckpoint[];
|
|
115
167
|
readonly content?: string;
|
|
@@ -191,7 +243,10 @@ export class NornRunStore {
|
|
|
191
243
|
createdAt: checkpoint.createdAt,
|
|
192
244
|
entries,
|
|
193
245
|
};
|
|
194
|
-
await
|
|
246
|
+
await writeBytesAtomically(
|
|
247
|
+
this.snapshotPath(snapshot.id),
|
|
248
|
+
await gzipBuffer(Buffer.from(JSON.stringify(snapshot))),
|
|
249
|
+
);
|
|
195
250
|
return snapshot;
|
|
196
251
|
}
|
|
197
252
|
|
|
@@ -363,12 +418,25 @@ export class NornRunStore {
|
|
|
363
418
|
}
|
|
364
419
|
|
|
365
420
|
private async readSnapshot(id: string): Promise<RunSnapshotManifest> {
|
|
421
|
+
let compressed: Buffer;
|
|
422
|
+
try {
|
|
423
|
+
compressed = await readFile(this.snapshotPath(id));
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
426
|
+
return parseRunSnapshotManifest(
|
|
427
|
+
JSON.parse(await readFile(this.getLegacySnapshotPath(id), "utf8")),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
366
430
|
return parseRunSnapshotManifest(
|
|
367
|
-
JSON.parse(await
|
|
431
|
+
JSON.parse((await gunzipBuffer(compressed)).toString("utf8")),
|
|
368
432
|
);
|
|
369
433
|
}
|
|
370
434
|
|
|
371
435
|
private snapshotPath(id: string): string {
|
|
436
|
+
return join(this.snapshotsRoot, `${id}.json.gz`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private getLegacySnapshotPath(id: string): string {
|
|
372
440
|
return join(this.snapshotsRoot, `${id}.json`);
|
|
373
441
|
}
|
|
374
442
|
|
|
@@ -378,7 +446,11 @@ export class NornRunStore {
|
|
|
378
446
|
|
|
379
447
|
private assertCheckpointPathMatchesId(checkpoint: NornRunCheckpoint): void {
|
|
380
448
|
const expectedPath = this.snapshotRelativePath(checkpoint.id);
|
|
381
|
-
|
|
449
|
+
const legacyPath = relative(
|
|
450
|
+
this.runRoot,
|
|
451
|
+
this.getLegacySnapshotPath(checkpoint.id),
|
|
452
|
+
);
|
|
453
|
+
if (checkpoint.path !== expectedPath && checkpoint.path !== legacyPath)
|
|
382
454
|
throw new Error(
|
|
383
455
|
`Run checkpoint path does not match id: ${checkpoint.id}`,
|
|
384
456
|
);
|
|
@@ -13,6 +13,13 @@ export async function writeJsonAtomically(
|
|
|
13
13
|
export async function writeTextAtomically(
|
|
14
14
|
path: string,
|
|
15
15
|
content: string,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function writeBytesAtomically(
|
|
21
|
+
path: string,
|
|
22
|
+
content: Uint8Array,
|
|
16
23
|
): Promise<void> {
|
|
17
24
|
await mkdir(dirname(path), { recursive: true });
|
|
18
25
|
let existingMode: number | undefined;
|
|
@@ -23,7 +30,7 @@ export async function writeTextAtomically(
|
|
|
23
30
|
}
|
|
24
31
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
25
32
|
try {
|
|
26
|
-
await writeFile(tmpPath, content, {
|
|
33
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
27
34
|
if (existingMode !== undefined) await chmod(tmpPath, existingMode);
|
|
28
35
|
await rename(tmpPath, path);
|
|
29
36
|
} catch (error) {
|
|
@@ -383,6 +383,26 @@ export type DeletedNornRunInfo = {
|
|
|
383
383
|
readonly path: string;
|
|
384
384
|
};
|
|
385
385
|
|
|
386
|
+
export type NornRunPruneOptions = {
|
|
387
|
+
readonly deleteRuns: boolean;
|
|
388
|
+
readonly all: boolean;
|
|
389
|
+
readonly olderThan: string | null;
|
|
390
|
+
readonly dryRun: boolean;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export type NornRunPruneResult = {
|
|
394
|
+
readonly mode: "checkpoints" | "runs";
|
|
395
|
+
readonly dryRun: boolean;
|
|
396
|
+
readonly olderThan: string | null;
|
|
397
|
+
readonly runs: readonly {
|
|
398
|
+
readonly id: string;
|
|
399
|
+
readonly name: string;
|
|
400
|
+
readonly path: string;
|
|
401
|
+
readonly status: "planned" | "pruned" | "skipped" | "failed";
|
|
402
|
+
readonly reason?: string;
|
|
403
|
+
}[];
|
|
404
|
+
};
|
|
405
|
+
|
|
386
406
|
export type NornRunCheckpoint = {
|
|
387
407
|
readonly id: string;
|
|
388
408
|
readonly path: string;
|
package/dist/cli.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { readRunMetrics } from "./internal/metrics.js";
|
|
49
49
|
import { getRunLeaseOwner, NornRunLease } from "./internal/run-lease.js";
|
|
50
50
|
import { generateRunName } from "./internal/run-names.js";
|
|
51
|
+
import { NornRunPruner, parseRunPruneOptions } from "./internal/run-pruning.js";
|
|
51
52
|
import {
|
|
52
53
|
assertRunVersion,
|
|
53
54
|
getRunInfo,
|
|
@@ -394,6 +395,37 @@ var COMMANDS = [
|
|
|
394
395
|
await signalRun(run, "SIGKILL");
|
|
395
396
|
}
|
|
396
397
|
},
|
|
398
|
+
{
|
|
399
|
+
id: "runs.prune",
|
|
400
|
+
path: ["runs", "prune"],
|
|
401
|
+
description: "Discard checkpoint history or entire failed/completed runs, preserving recent and nonterminal runs.",
|
|
402
|
+
usage: "norn runs prune [--delete-runs] [--older-than <duration>|--all] [--dry-run]",
|
|
403
|
+
options: [
|
|
404
|
+
"--delete-runs: delete entire eligible runs instead of only checkpoint storage",
|
|
405
|
+
"--older-than <duration>: time since completion/failure; default 24h; units ms, s, m, h, d, w",
|
|
406
|
+
"--all: ignore age; requires --delete-runs; incompatible with --older-than",
|
|
407
|
+
"--dry-run: report eligible runs without changing files"
|
|
408
|
+
],
|
|
409
|
+
output: "JSON object under prune with mode, dryRun, olderThan, and per-run planned/pruned/skipped/failed results. Any failed result sets a nonzero exit status.",
|
|
410
|
+
examples: [
|
|
411
|
+
"norn runs prune --dry-run",
|
|
412
|
+
"norn runs prune --older-than 7d",
|
|
413
|
+
"norn runs prune --delete-runs --all"
|
|
414
|
+
],
|
|
415
|
+
execute: async (args) => {
|
|
416
|
+
const options = parseRunPruneOptions(args);
|
|
417
|
+
const project = await findNornProject(process.cwd());
|
|
418
|
+
const pruner = new NornRunPruner({
|
|
419
|
+
projectRoot: project.projectRoot,
|
|
420
|
+
options,
|
|
421
|
+
now: /* @__PURE__ */ new Date()
|
|
422
|
+
});
|
|
423
|
+
const result = await pruner.prune();
|
|
424
|
+
writeJson({ prune: result });
|
|
425
|
+
if (result.runs.some((run) => run.status === "failed"))
|
|
426
|
+
process.exitCode = 1;
|
|
427
|
+
}
|
|
428
|
+
},
|
|
397
429
|
{
|
|
398
430
|
id: "runs.delete",
|
|
399
431
|
path: ["runs", "delete"],
|
|
@@ -468,6 +500,7 @@ var HELP_COMMAND_ORDER = [
|
|
|
468
500
|
"runs.stop",
|
|
469
501
|
"runs.kill",
|
|
470
502
|
"runs.delete",
|
|
503
|
+
"runs.prune",
|
|
471
504
|
"project.init",
|
|
472
505
|
"project.inspect",
|
|
473
506
|
"pi",
|
|
@@ -495,6 +528,7 @@ var HUMAN_COMMAND_SUMMARIES = {
|
|
|
495
528
|
"runs.stop": "Stop a running run.",
|
|
496
529
|
"runs.kill": "Force-stop a running run.",
|
|
497
530
|
"runs.delete": "Delete an inactive run.",
|
|
531
|
+
"runs.prune": "Prune checkpoint storage or old failed/completed runs.",
|
|
498
532
|
"project.init": "Create a Norn project in the current directory.",
|
|
499
533
|
"project.inspect": "Inspect the Norn project.",
|
|
500
534
|
pi: "Run bundled Pi for authentication, providers, and model setup.",
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeletedNornRunInfo, NornProjectInspection, NornWorkflowCatalogInfo, NornWorkflowInspection, NornRunCheckpoint, NornRunInfo, NornRunMetrics } from "@vimhead.dev/norn";
|
|
1
|
+
import type { DeletedNornRunInfo, NornProjectInspection, NornWorkflowCatalogInfo, NornWorkflowInspection, NornRunCheckpoint, NornRunInfo, NornRunPruneOptions, NornRunPruneResult, NornRunMetrics } from "@vimhead.dev/norn";
|
|
2
2
|
import type { NornRegisteredWorkflow } from "./internal/workflow-registry.ts";
|
|
3
3
|
export { NornProjectLoadError } from "./internal/errors.ts";
|
|
4
4
|
export type NornClientInput = {
|
|
@@ -35,6 +35,7 @@ export type NornClient = {
|
|
|
35
35
|
stop(run: string): Promise<NornRunInfo>;
|
|
36
36
|
kill(run: string): Promise<NornRunInfo>;
|
|
37
37
|
delete(run: string): Promise<DeletedNornRunInfo>;
|
|
38
|
+
prune(options: NornRunPruneOptions): Promise<NornRunPruneResult>;
|
|
38
39
|
logs(run: string, options?: {
|
|
39
40
|
readonly follow?: boolean;
|
|
40
41
|
}): AsyncIterable<Record<string, unknown>>;
|
package/dist/client.js
CHANGED
|
@@ -79,6 +79,14 @@ function createNornClient(input = {}) {
|
|
|
79
79
|
"delete",
|
|
80
80
|
run
|
|
81
81
|
])).deleted,
|
|
82
|
+
prune: async (options) => (await processRunner.readJson([
|
|
83
|
+
"runs",
|
|
84
|
+
"prune",
|
|
85
|
+
...options.deleteRuns ? ["--delete-runs"] : [],
|
|
86
|
+
...options.all ? ["--all"] : [],
|
|
87
|
+
...options.olderThan === null ? [] : ["--older-than", options.olderThan],
|
|
88
|
+
...options.dryRun ? ["--dry-run"] : []
|
|
89
|
+
])).prune,
|
|
82
90
|
logs: (run, options) => processRunner.readJsonLines([
|
|
83
91
|
"runs",
|
|
84
92
|
"logs",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const NORN_GENERATED_BUILD_INFO: {
|
|
2
2
|
readonly kind: "npm-registry";
|
|
3
|
-
readonly version: "0.1.0-tip.
|
|
4
|
-
readonly commit: "
|
|
3
|
+
readonly version: "0.1.0-tip.36824462948.1";
|
|
4
|
+
readonly commit: "c98b6febf7a42abc1201dfdceb1553fc9e91c08c";
|
|
5
5
|
readonly packageSpec: "@vimhead.dev/norn-cli@tip";
|
|
6
6
|
readonly upgrade: {
|
|
7
7
|
readonly supported: false;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/generated-build-info.ts
|
|
2
2
|
var NORN_GENERATED_BUILD_INFO = {
|
|
3
3
|
"kind": "npm-registry",
|
|
4
|
-
"version": "0.1.0-tip.
|
|
5
|
-
"commit": "
|
|
4
|
+
"version": "0.1.0-tip.36824462948.1",
|
|
5
|
+
"commit": "c98b6febf7a42abc1201dfdceb1553fc9e91c08c",
|
|
6
6
|
"packageSpec": "@vimhead.dev/norn-cli@tip",
|
|
7
7
|
"upgrade": {
|
|
8
8
|
"supported": false,
|
|
@@ -10,6 +10,9 @@ function isNodeError(error) {
|
|
|
10
10
|
|
|
11
11
|
// ../core/src/atomic-files.ts
|
|
12
12
|
async function writeTextAtomically(path, content) {
|
|
13
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
async function writeBytesAtomically(path, content) {
|
|
13
16
|
await mkdir(dirname(path), { recursive: true });
|
|
14
17
|
let existingMode;
|
|
15
18
|
try {
|
|
@@ -19,7 +22,7 @@ async function writeTextAtomically(path, content) {
|
|
|
19
22
|
}
|
|
20
23
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
21
24
|
try {
|
|
22
|
-
await writeFile(tmpPath, content, {
|
|
25
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
23
26
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
24
27
|
await rename(tmpPath, path);
|
|
25
28
|
} catch (error) {
|
|
@@ -19,6 +19,9 @@ async function writeJsonAtomically(path, value) {
|
|
|
19
19
|
`);
|
|
20
20
|
}
|
|
21
21
|
async function writeTextAtomically(path, content) {
|
|
22
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
async function writeBytesAtomically(path, content) {
|
|
22
25
|
await mkdir(dirname(path), { recursive: true });
|
|
23
26
|
let existingMode;
|
|
24
27
|
try {
|
|
@@ -28,7 +31,7 @@ async function writeTextAtomically(path, content) {
|
|
|
28
31
|
}
|
|
29
32
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
30
33
|
try {
|
|
31
|
-
await writeFile(tmpPath, content, {
|
|
34
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
32
35
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
33
36
|
await rename(tmpPath, path);
|
|
34
37
|
} catch (error) {
|
|
@@ -17,6 +17,7 @@ export declare class NornRunLease {
|
|
|
17
17
|
private constructor();
|
|
18
18
|
static acquire(runRoot: string, processOwner?: NornRunProcessOwner): Promise<NornRunLease>;
|
|
19
19
|
assertOwned(): Promise<void>;
|
|
20
|
+
deleteRunDirectory(): Promise<void>;
|
|
20
21
|
release(): Promise<void>;
|
|
21
22
|
private get ownerPath();
|
|
22
23
|
private startHeartbeat;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/internal/run-lease.ts
|
|
2
2
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
3
|
-
import { mkdir as mkdir2, readFile, rm as rm2, stat as stat2 } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
3
|
+
import { mkdir as mkdir2, readFile, rename as rename2, rm as rm2, stat as stat2 } from "node:fs/promises";
|
|
4
|
+
import { dirname as dirname2, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
// ../core/src/errors.ts
|
|
7
7
|
function isNodeError(error) {
|
|
@@ -17,6 +17,9 @@ async function writeJsonAtomically(path, value) {
|
|
|
17
17
|
`);
|
|
18
18
|
}
|
|
19
19
|
async function writeTextAtomically(path, content) {
|
|
20
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
async function writeBytesAtomically(path, content) {
|
|
20
23
|
await mkdir(dirname(path), { recursive: true });
|
|
21
24
|
let existingMode;
|
|
22
25
|
try {
|
|
@@ -26,7 +29,7 @@ async function writeTextAtomically(path, content) {
|
|
|
26
29
|
}
|
|
27
30
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
28
31
|
try {
|
|
29
|
-
await writeFile(tmpPath, content, {
|
|
32
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
30
33
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
31
34
|
await rename(tmpPath, path);
|
|
32
35
|
} catch (error) {
|
|
@@ -83,6 +86,27 @@ var NornRunLease = class _NornRunLease {
|
|
|
83
86
|
if (!owner || owner.token !== this.token)
|
|
84
87
|
throw new Error("Run lease was lost");
|
|
85
88
|
}
|
|
89
|
+
async deleteRunDirectory() {
|
|
90
|
+
await this.assertOwned();
|
|
91
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
92
|
+
await this.heartbeatChain;
|
|
93
|
+
await this.assertOwned();
|
|
94
|
+
const runRoot = dirname2(this.lockRoot);
|
|
95
|
+
const retiredRoot = join(
|
|
96
|
+
dirname2(dirname2(runRoot)),
|
|
97
|
+
`.pruned-run-${randomUUID2()}`
|
|
98
|
+
);
|
|
99
|
+
await rename2(runRoot, retiredRoot);
|
|
100
|
+
this.isReleased = true;
|
|
101
|
+
try {
|
|
102
|
+
await rm2(retiredRoot, { recursive: true, force: true });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Run was removed from the run list but directory cleanup failed: ${retiredRoot}`,
|
|
106
|
+
{ cause: error }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
86
110
|
async release() {
|
|
87
111
|
if (this.isReleased) return;
|
|
88
112
|
this.isReleased = true;
|
package/dist/internal/run-log.js
CHANGED
|
@@ -15,6 +15,9 @@ async function writeJsonAtomically(path, value) {
|
|
|
15
15
|
`);
|
|
16
16
|
}
|
|
17
17
|
async function writeTextAtomically(path, content) {
|
|
18
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
async function writeBytesAtomically(path, content) {
|
|
18
21
|
await mkdir(dirname(path), { recursive: true });
|
|
19
22
|
let existingMode;
|
|
20
23
|
try {
|
|
@@ -24,7 +27,7 @@ async function writeTextAtomically(path, content) {
|
|
|
24
27
|
}
|
|
25
28
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
26
29
|
try {
|
|
27
|
-
await writeFile(tmpPath, content, {
|
|
30
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
28
31
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
29
32
|
await rename(tmpPath, path);
|
|
30
33
|
} catch (error) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NornRunPruneOptions, NornRunPruneResult } from "@vimhead.dev/norn";
|
|
2
|
+
export declare function parseRunPruneOptions(args: readonly string[]): NornRunPruneOptions;
|
|
3
|
+
export declare class NornRunPruner {
|
|
4
|
+
private readonly input;
|
|
5
|
+
private readonly olderThan;
|
|
6
|
+
private readonly cutoff;
|
|
7
|
+
constructor(input: {
|
|
8
|
+
readonly projectRoot: string;
|
|
9
|
+
readonly options: NornRunPruneOptions;
|
|
10
|
+
readonly now: Date;
|
|
11
|
+
});
|
|
12
|
+
prune(): Promise<NornRunPruneResult>;
|
|
13
|
+
private findIneligibilityReason;
|
|
14
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/internal/run-pruning.ts
|
|
2
|
+
import { errorMessage } from "./errors.js";
|
|
3
|
+
import { getRunLeaseOwner, NornRunLease } from "./run-lease.js";
|
|
4
|
+
import { getRunInfo, listRuns } from "./run-state.js";
|
|
5
|
+
import { NornRunStore } from "./run-store.js";
|
|
6
|
+
var DURATION_UNITS = {
|
|
7
|
+
ms: 1,
|
|
8
|
+
s: 1e3,
|
|
9
|
+
m: 6e4,
|
|
10
|
+
h: 36e5,
|
|
11
|
+
d: 864e5,
|
|
12
|
+
w: 6048e5
|
|
13
|
+
};
|
|
14
|
+
function parseRunPruneOptions(args) {
|
|
15
|
+
const flags = /* @__PURE__ */ new Set();
|
|
16
|
+
let olderThan = null;
|
|
17
|
+
for (let index = 0; index < args.length; index++) {
|
|
18
|
+
const flag = args[index];
|
|
19
|
+
if (!["--delete-runs", "--all", "--dry-run", "--older-than"].includes(flag))
|
|
20
|
+
throw new Error(`runs prune: unknown argument ${flag}`);
|
|
21
|
+
if (flags.has(flag))
|
|
22
|
+
throw new Error(`runs prune: duplicate option ${flag}`);
|
|
23
|
+
flags.add(flag);
|
|
24
|
+
if (flag === "--older-than") {
|
|
25
|
+
const value = args[++index];
|
|
26
|
+
if (value === void 0 || value.startsWith("--"))
|
|
27
|
+
throw new Error(
|
|
28
|
+
"runs prune: --older-than requires a duration such as 24h or 7d"
|
|
29
|
+
);
|
|
30
|
+
olderThan = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
deleteRuns: flags.has("--delete-runs"),
|
|
35
|
+
all: flags.has("--all"),
|
|
36
|
+
olderThan,
|
|
37
|
+
dryRun: flags.has("--dry-run")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parsePruneDuration(value) {
|
|
41
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w)$/.exec(value);
|
|
42
|
+
const duration = match ? Number(match[1]) * DURATION_UNITS[match[2]] : NaN;
|
|
43
|
+
if (!Number.isFinite(duration) || duration < 0 || duration > Number.MAX_SAFE_INTEGER)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`runs prune: invalid duration ${value}; use a non-negative number followed by ms, s, m, h, d, or w`
|
|
46
|
+
);
|
|
47
|
+
return duration;
|
|
48
|
+
}
|
|
49
|
+
var NornRunPruner = class {
|
|
50
|
+
constructor(input) {
|
|
51
|
+
this.input = input;
|
|
52
|
+
if (input.options.all && !input.options.deleteRuns)
|
|
53
|
+
throw new Error("runs prune: --all requires --delete-runs");
|
|
54
|
+
if (input.options.all && input.options.olderThan !== null)
|
|
55
|
+
throw new Error(
|
|
56
|
+
"runs prune: --all and --older-than are mutually exclusive"
|
|
57
|
+
);
|
|
58
|
+
if (!Number.isFinite(input.now.getTime()))
|
|
59
|
+
throw new Error("Invalid pruning time");
|
|
60
|
+
this.olderThan = input.options.all ? null : input.options.olderThan ?? "24h";
|
|
61
|
+
this.cutoff = this.olderThan === null ? null : input.now.getTime() - parsePruneDuration(this.olderThan);
|
|
62
|
+
}
|
|
63
|
+
input;
|
|
64
|
+
olderThan;
|
|
65
|
+
cutoff;
|
|
66
|
+
async prune() {
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const run of await listRuns(this.input.projectRoot)) {
|
|
69
|
+
const identity = { id: run.id, name: run.name, path: run.path };
|
|
70
|
+
let lease;
|
|
71
|
+
try {
|
|
72
|
+
let reason = this.findIneligibilityReason(run);
|
|
73
|
+
if (!reason && await getRunLeaseOwner(run.path))
|
|
74
|
+
reason = "Run is owned by an executor";
|
|
75
|
+
if (reason) {
|
|
76
|
+
results.push({ ...identity, status: "skipped", reason });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (this.input.options.dryRun) {
|
|
80
|
+
results.push({ ...identity, status: "planned" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
lease = await NornRunLease.acquire(run.path);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof Error && error.message.startsWith("Run is already active:")) {
|
|
87
|
+
results.push({
|
|
88
|
+
...identity,
|
|
89
|
+
status: "skipped",
|
|
90
|
+
reason: "Run is owned by an executor"
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
reason = this.findIneligibilityReason(await getRunInfo(run.path));
|
|
97
|
+
if (reason) {
|
|
98
|
+
results.push({ ...identity, status: "skipped", reason });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
await lease.assertOwned();
|
|
102
|
+
if (this.input.options.deleteRuns) {
|
|
103
|
+
await lease.deleteRunDirectory();
|
|
104
|
+
} else {
|
|
105
|
+
const store = await NornRunStore.open(run.path);
|
|
106
|
+
await store.pruneCheckpointHistory(this.input.now.toISOString());
|
|
107
|
+
}
|
|
108
|
+
results.push({ ...identity, status: "pruned" });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
results.push({
|
|
111
|
+
...identity,
|
|
112
|
+
status: "failed",
|
|
113
|
+
reason: errorMessage(error)
|
|
114
|
+
});
|
|
115
|
+
} finally {
|
|
116
|
+
await lease?.release();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
mode: this.input.options.deleteRuns ? "runs" : "checkpoints",
|
|
121
|
+
dryRun: this.input.options.dryRun,
|
|
122
|
+
olderThan: this.olderThan,
|
|
123
|
+
runs: results
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
findIneligibilityReason(run) {
|
|
127
|
+
if (run.status !== "completed" && run.status !== "failed")
|
|
128
|
+
return `Run status is ${run.status}; only completed or failed runs are eligible`;
|
|
129
|
+
const finishedAt = run.status === "failed" ? run.failed?.failedAt ?? run.outcome?.completedAt ?? run.updatedAt : run.outcome?.completedAt ?? run.updatedAt;
|
|
130
|
+
const finishedTime = Date.parse(finishedAt);
|
|
131
|
+
if (!Number.isFinite(finishedTime))
|
|
132
|
+
return "Run completion/failure time is invalid";
|
|
133
|
+
if (this.cutoff !== null && finishedTime >= this.cutoff)
|
|
134
|
+
return `Run finished less than or exactly ${this.olderThan} ago`;
|
|
135
|
+
return void 0;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
export {
|
|
139
|
+
NornRunPruner,
|
|
140
|
+
parseRunPruneOptions
|
|
141
|
+
};
|
|
@@ -24,6 +24,9 @@ async function writeJsonAtomically(path, value) {
|
|
|
24
24
|
`);
|
|
25
25
|
}
|
|
26
26
|
async function writeTextAtomically(path, content) {
|
|
27
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
async function writeBytesAtomically(path, content) {
|
|
27
30
|
await mkdir(dirname(path), { recursive: true });
|
|
28
31
|
let existingMode;
|
|
29
32
|
try {
|
|
@@ -33,7 +36,7 @@ async function writeTextAtomically(path, content) {
|
|
|
33
36
|
}
|
|
34
37
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
35
38
|
try {
|
|
36
|
-
await writeFile(tmpPath, content, {
|
|
39
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
37
40
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
38
41
|
await rename(tmpPath, path);
|
|
39
42
|
} catch (error) {
|
|
@@ -12,6 +12,10 @@ export declare class NornRunStore {
|
|
|
12
12
|
restoreSnapshot(ref: string, prepare: ((stagedRunRoot: string) => Promise<void>) | undefined): Promise<void>;
|
|
13
13
|
restoreCurrentSnapshot(): Promise<void>;
|
|
14
14
|
listCheckpoints(): Promise<NornRunCheckpoint[]>;
|
|
15
|
+
pruneCheckpointHistory(prunedAt: string): Promise<void>;
|
|
16
|
+
private get prunedHistoryPath();
|
|
17
|
+
private isCheckpointHistoryPruned;
|
|
18
|
+
private assertCheckpointHistoryAvailable;
|
|
15
19
|
private readCheckpointHistory;
|
|
16
20
|
assertWorkspaceCanBeSnapshotted(_workspace: string): Promise<void>;
|
|
17
21
|
private get objectsRoot();
|
|
@@ -29,6 +33,7 @@ export declare class NornRunStore {
|
|
|
29
33
|
private materializedPath;
|
|
30
34
|
private readSnapshot;
|
|
31
35
|
private snapshotPath;
|
|
36
|
+
private getLegacySnapshotPath;
|
|
32
37
|
private snapshotRelativePath;
|
|
33
38
|
private assertCheckpointPathMatchesId;
|
|
34
39
|
private objectPath;
|
|
@@ -34,6 +34,9 @@ async function writeJsonAtomically(path, value) {
|
|
|
34
34
|
`);
|
|
35
35
|
}
|
|
36
36
|
async function writeTextAtomically(path, content) {
|
|
37
|
+
await writeBytesAtomically(path, Buffer.from(content, "utf8"));
|
|
38
|
+
}
|
|
39
|
+
async function writeBytesAtomically(path, content) {
|
|
37
40
|
await mkdir(dirname(path), { recursive: true });
|
|
38
41
|
let existingMode;
|
|
39
42
|
try {
|
|
@@ -43,7 +46,7 @@ async function writeTextAtomically(path, content) {
|
|
|
43
46
|
}
|
|
44
47
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
45
48
|
try {
|
|
46
|
-
await writeFile(tmpPath, content, {
|
|
49
|
+
await writeFile(tmpPath, content, { mode: existingMode });
|
|
47
50
|
if (existingMode !== void 0) await chmod(tmpPath, existingMode);
|
|
48
51
|
await rename(tmpPath, path);
|
|
49
52
|
} catch (error) {
|
|
@@ -59,6 +62,7 @@ var SNAPSHOT_VERSION = 1;
|
|
|
59
62
|
var CURRENT_DIR_NAME = "current";
|
|
60
63
|
var STORE_DIR_NAME = "store";
|
|
61
64
|
var CHECKPOINTS_FILE_NAME = "checkpoints.json";
|
|
65
|
+
var PRUNED_HISTORY_FILE_NAME = "checkpoint-history-pruned.json";
|
|
62
66
|
function runCurrentRoot(runRoot) {
|
|
63
67
|
return join(runRoot, CURRENT_DIR_NAME);
|
|
64
68
|
}
|
|
@@ -82,13 +86,16 @@ var NornRunStore = class _NornRunStore {
|
|
|
82
86
|
static async open(root) {
|
|
83
87
|
const runStore = new _NornRunStore(root);
|
|
84
88
|
await access(runStore.currentRoot, constants.R_OK | constants.W_OK);
|
|
85
|
-
await
|
|
89
|
+
if (!await runStore.isCheckpointHistoryPruned())
|
|
90
|
+
await access(runStore.storeRoot, constants.R_OK | constants.W_OK);
|
|
86
91
|
return runStore;
|
|
87
92
|
}
|
|
88
93
|
async currentSnapshotRef() {
|
|
94
|
+
await this.assertCheckpointHistoryAvailable();
|
|
89
95
|
return readFile(this.currentRefPath, "utf8").then((value) => value.trim());
|
|
90
96
|
}
|
|
91
97
|
async snapshotCurrent(message) {
|
|
98
|
+
await this.assertCheckpointHistoryAvailable();
|
|
92
99
|
const previous = await this.readCheckpointHistory();
|
|
93
100
|
const checkpoint = this.createCheckpoint(
|
|
94
101
|
message,
|
|
@@ -109,6 +116,7 @@ var NornRunStore = class _NornRunStore {
|
|
|
109
116
|
return checkpoint;
|
|
110
117
|
}
|
|
111
118
|
async restoreSnapshot(ref, prepare) {
|
|
119
|
+
await this.assertCheckpointHistoryAvailable();
|
|
112
120
|
const checkpoint = (await this.listCheckpoints()).find(
|
|
113
121
|
(entry) => entry.id === ref
|
|
114
122
|
);
|
|
@@ -125,8 +133,42 @@ var NornRunStore = class _NornRunStore {
|
|
|
125
133
|
);
|
|
126
134
|
}
|
|
127
135
|
async listCheckpoints() {
|
|
136
|
+
if (await this.isCheckpointHistoryPruned()) return [];
|
|
128
137
|
return (await this.readCheckpointHistory()).checkpoints;
|
|
129
138
|
}
|
|
139
|
+
async pruneCheckpointHistory(prunedAt) {
|
|
140
|
+
if (!await this.isCheckpointHistoryPruned())
|
|
141
|
+
await writeJsonAtomically(this.prunedHistoryPath, {
|
|
142
|
+
version: 1,
|
|
143
|
+
prunedAt
|
|
144
|
+
});
|
|
145
|
+
await writeJsonAtomically(this.checkpointsPath, []);
|
|
146
|
+
await rm2(this.storeRoot, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
get prunedHistoryPath() {
|
|
149
|
+
return join(this.runRoot, PRUNED_HISTORY_FILE_NAME);
|
|
150
|
+
}
|
|
151
|
+
async isCheckpointHistoryPruned() {
|
|
152
|
+
let content;
|
|
153
|
+
try {
|
|
154
|
+
content = await readFile(this.prunedHistoryPath, "utf8");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const marker = JSON.parse(content);
|
|
160
|
+
if (!marker || typeof marker !== "object" || !("version" in marker) || marker.version !== 1 || !("prunedAt" in marker) || typeof marker.prunedAt !== "string" || !Number.isFinite(Date.parse(marker.prunedAt)))
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Invalid pruned checkpoint history marker: ${this.prunedHistoryPath}`
|
|
163
|
+
);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
async assertCheckpointHistoryAvailable() {
|
|
167
|
+
if (await this.isCheckpointHistoryPruned())
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Checkpoint history was pruned for this run: ${this.runRoot}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
130
172
|
async readCheckpointHistory() {
|
|
131
173
|
try {
|
|
132
174
|
const content = await readFile(this.checkpointsPath, "utf8");
|
|
@@ -196,7 +238,10 @@ var NornRunStore = class _NornRunStore {
|
|
|
196
238
|
createdAt: checkpoint.createdAt,
|
|
197
239
|
entries
|
|
198
240
|
};
|
|
199
|
-
await
|
|
241
|
+
await writeBytesAtomically(
|
|
242
|
+
this.snapshotPath(snapshot.id),
|
|
243
|
+
await gzipBuffer(Buffer.from(JSON.stringify(snapshot)))
|
|
244
|
+
);
|
|
200
245
|
return snapshot;
|
|
201
246
|
}
|
|
202
247
|
async snapshotEntries(root) {
|
|
@@ -346,11 +391,23 @@ var NornRunStore = class _NornRunStore {
|
|
|
346
391
|
return path;
|
|
347
392
|
}
|
|
348
393
|
async readSnapshot(id) {
|
|
394
|
+
let compressed;
|
|
395
|
+
try {
|
|
396
|
+
compressed = await readFile(this.snapshotPath(id));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
399
|
+
return parseRunSnapshotManifest(
|
|
400
|
+
JSON.parse(await readFile(this.getLegacySnapshotPath(id), "utf8"))
|
|
401
|
+
);
|
|
402
|
+
}
|
|
349
403
|
return parseRunSnapshotManifest(
|
|
350
|
-
JSON.parse(await
|
|
404
|
+
JSON.parse((await gunzipBuffer(compressed)).toString("utf8"))
|
|
351
405
|
);
|
|
352
406
|
}
|
|
353
407
|
snapshotPath(id) {
|
|
408
|
+
return join(this.snapshotsRoot, `${id}.json.gz`);
|
|
409
|
+
}
|
|
410
|
+
getLegacySnapshotPath(id) {
|
|
354
411
|
return join(this.snapshotsRoot, `${id}.json`);
|
|
355
412
|
}
|
|
356
413
|
snapshotRelativePath(id) {
|
|
@@ -358,7 +415,11 @@ var NornRunStore = class _NornRunStore {
|
|
|
358
415
|
}
|
|
359
416
|
assertCheckpointPathMatchesId(checkpoint) {
|
|
360
417
|
const expectedPath = this.snapshotRelativePath(checkpoint.id);
|
|
361
|
-
|
|
418
|
+
const legacyPath = relative(
|
|
419
|
+
this.runRoot,
|
|
420
|
+
this.getLegacySnapshotPath(checkpoint.id)
|
|
421
|
+
);
|
|
422
|
+
if (checkpoint.path !== expectedPath && checkpoint.path !== legacyPath)
|
|
362
423
|
throw new Error(
|
|
363
424
|
`Run checkpoint path does not match id: ${checkpoint.id}`
|
|
364
425
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vimhead.dev/norn-cli",
|
|
3
|
-
"version": "0.1.0-tip.
|
|
3
|
+
"version": "0.1.0-tip.36824462948.1",
|
|
4
4
|
"description": "Harness-agnostic runtime for agent-driven and code-driven workflows, built primarily for agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@earendil-works/pi-coding-agent": "0.99.1",
|
|
44
44
|
"jiti": "^2.7.0",
|
|
45
45
|
"typebox": "^1.3.34",
|
|
46
|
-
"@vimhead.dev/norn": "0.1.0-tip.
|
|
46
|
+
"@vimhead.dev/norn": "0.1.0-tip.36824462948.1"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "node ../../scripts/build-package.ts"
|