@vimhead.dev/norn-cli 0.1.0-tip.36679386408.1 → 0.1.0-tip.37307659560.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.
Files changed (48) hide show
  1. package/assets/README.md +3 -2
  2. package/assets/docs/agents.md +7 -2
  3. package/assets/docs/cli.md +25 -0
  4. package/assets/docs/providers.md +3 -3
  5. package/assets/docs/recovery.md +4 -0
  6. package/assets/examples/agent-then-analysis/README.md +1 -1
  7. package/assets/examples/agent-then-analysis/plugin.ts +2 -0
  8. package/assets/examples/coordinating-multiple-agents/README.md +2 -1
  9. package/assets/examples/coordinating-multiple-agents/plugin.ts +1 -0
  10. package/assets/examples/getting-started/README.md +3 -3
  11. package/assets/examples/getting-started/plugin.ts +1 -0
  12. package/assets/examples/shared-state/README.md +1 -1
  13. package/assets/examples/shared-state/plugin.ts +1 -0
  14. package/assets/examples/worktree-development-loop/README.md +1 -1
  15. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +1 -0
  16. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -0
  17. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +1 -0
  18. package/assets/package.json +1 -1
  19. package/assets/packages/cli/src/cli.ts +37 -0
  20. package/assets/packages/cli/src/client.ts +16 -0
  21. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  22. package/assets/packages/cli/src/internal/agents.ts +42 -2
  23. package/assets/packages/cli/src/internal/execution-context.ts +0 -2
  24. package/assets/packages/cli/src/internal/run-lease.ts +24 -2
  25. package/assets/packages/cli/src/internal/run-pruning.ts +169 -0
  26. package/assets/packages/cli/src/internal/run-store.ts +76 -4
  27. package/assets/packages/core/src/atomic-files.ts +8 -1
  28. package/assets/packages/sdk/src/api.ts +29 -1
  29. package/dist/cli.js +34 -0
  30. package/dist/client.d.ts +2 -1
  31. package/dist/client.js +8 -0
  32. package/dist/generated-build-info.d.ts +2 -2
  33. package/dist/generated-build-info.js +2 -2
  34. package/dist/internal/agents.d.ts +1 -1
  35. package/dist/internal/agents.js +33 -2
  36. package/dist/internal/execution-context.d.ts +0 -1
  37. package/dist/internal/execution-context.js +0 -1
  38. package/dist/internal/file-coordinator.js +4 -1
  39. package/dist/internal/launch-request.js +4 -1
  40. package/dist/internal/run-lease.d.ts +1 -0
  41. package/dist/internal/run-lease.js +27 -3
  42. package/dist/internal/run-log.js +4 -1
  43. package/dist/internal/run-pruning.d.ts +14 -0
  44. package/dist/internal/run-pruning.js +141 -0
  45. package/dist/internal/run-state.js +4 -1
  46. package/dist/internal/run-store.d.ts +5 -0
  47. package/dist/internal/run-store.js +66 -5
  48. package/package.json +2 -2
@@ -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.37307659560.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.37307659560.1"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node ../../scripts/build-package.ts"