@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.
@@ -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
@@ -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
@@ -1 +1 @@
1
- {"version":"0.1.0-tip.36679386408.1"}
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.36679386408.1",
6
- "commit": "3cb3189ec70f3b4407456061207294d685297b69",
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 access(runStore.storeRoot, constants.R_OK | constants.W_OK);
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 writeJsonAtomically(this.snapshotPath(snapshot.id), snapshot);
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 readFile(this.snapshotPath(id), "utf8")),
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
- if (checkpoint.path !== expectedPath)
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, { encoding: "utf8", mode: existingMode });
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.36679386408.1";
4
- readonly commit: "3cb3189ec70f3b4407456061207294d685297b69";
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.36679386408.1",
5
- "commit": "3cb3189ec70f3b4407456061207294d685297b69",
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, { encoding: "utf8", mode: existingMode });
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, { encoding: "utf8", mode: existingMode });
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, { encoding: "utf8", mode: existingMode });
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;
@@ -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, { encoding: "utf8", mode: existingMode });
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, { encoding: "utf8", mode: existingMode });
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, { encoding: "utf8", mode: existingMode });
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 access(runStore.storeRoot, constants.R_OK | constants.W_OK);
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 writeJsonAtomically(this.snapshotPath(snapshot.id), snapshot);
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 readFile(this.snapshotPath(id), "utf8"))
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
- if (checkpoint.path !== expectedPath)
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.36679386408.1",
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.36679386408.1"
46
+ "@vimhead.dev/norn": "0.1.0-tip.36824462948.1"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node ../../scripts/build-package.ts"