@vimhead.dev/norn-cli 0.1.0-tip.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +13 -0
  2. package/assets/README.md +157 -0
  3. package/assets/docs/README.md +23 -0
  4. package/assets/docs/agents.md +64 -0
  5. package/assets/docs/cli.md +183 -0
  6. package/assets/docs/composition.md +76 -0
  7. package/assets/docs/persistence.md +56 -0
  8. package/assets/docs/projects.md +75 -0
  9. package/assets/docs/recovery.md +56 -0
  10. package/assets/docs/resources.md +61 -0
  11. package/assets/docs/workflows.md +57 -0
  12. package/assets/examples/agent-then-analysis/README.md +103 -0
  13. package/assets/examples/agent-then-analysis/input.json +5 -0
  14. package/assets/examples/agent-then-analysis/norn.project.json +4 -0
  15. package/assets/examples/agent-then-analysis/plugin.ts +89 -0
  16. package/assets/examples/coordinating-multiple-agents/README.md +56 -0
  17. package/assets/examples/coordinating-multiple-agents/input.json +10 -0
  18. package/assets/examples/coordinating-multiple-agents/norn.project.json +4 -0
  19. package/assets/examples/coordinating-multiple-agents/plugin.ts +102 -0
  20. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +52 -0
  21. package/assets/examples/coordinating-multiple-agents/work-queue.ts +153 -0
  22. package/assets/examples/minimal-workflow/README.md +71 -0
  23. package/assets/examples/minimal-workflow/norn.project.json +4 -0
  24. package/assets/examples/minimal-workflow/plugin.ts +29 -0
  25. package/assets/examples/shared-state/README.md +19 -0
  26. package/assets/examples/shared-state/input.json +1 -0
  27. package/assets/examples/shared-state/norn.project.json +4 -0
  28. package/assets/examples/shared-state/plugin.ts +47 -0
  29. package/assets/examples/worktree-development-loop/README.md +66 -0
  30. package/assets/examples/worktree-development-loop/index.ts +1 -0
  31. package/assets/examples/worktree-development-loop/manifest.ts +26 -0
  32. package/assets/examples/worktree-development-loop/norn.project.json +9 -0
  33. package/assets/examples/worktree-development-loop/plugin.ts +27 -0
  34. package/assets/examples/worktree-development-loop/shared/commands.ts +6 -0
  35. package/assets/examples/worktree-development-loop/state.ts +23 -0
  36. package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +8 -0
  37. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -0
  38. package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -0
  39. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +22 -0
  40. package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +14 -0
  41. package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +8 -0
  42. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +54 -0
  43. package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +3 -0
  44. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +12 -0
  45. package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +8 -0
  46. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +28 -0
  47. package/assets/examples/worktree-development-loop/workflows/planning/index.ts +3 -0
  48. package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +12 -0
  49. package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +8 -0
  50. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +53 -0
  51. package/assets/examples/worktree-development-loop/workflows/review/index.ts +10 -0
  52. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +23 -0
  53. package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +12 -0
  54. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -0
  55. package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +3 -0
  56. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +12 -0
  57. package/assets/package.json +1 -0
  58. package/assets/packages/cli/src/build-info.ts +36 -0
  59. package/assets/packages/cli/src/bun/cli.ts +16 -0
  60. package/assets/packages/cli/src/cli.ts +1135 -0
  61. package/assets/packages/cli/src/client.ts +167 -0
  62. package/assets/packages/cli/src/documentation-intro.ts +30 -0
  63. package/assets/packages/cli/src/documentation.ts +149 -0
  64. package/assets/packages/cli/src/generated-build-info.ts +12 -0
  65. package/assets/packages/cli/src/internal/agent-directory.ts +5 -0
  66. package/assets/packages/cli/src/internal/agent-response-tool.ts +96 -0
  67. package/assets/packages/cli/src/internal/agents.ts +365 -0
  68. package/assets/packages/cli/src/internal/artifacts.ts +26 -0
  69. package/assets/packages/cli/src/internal/commands.ts +180 -0
  70. package/assets/packages/cli/src/internal/documentation-bundle.ts +49 -0
  71. package/assets/packages/cli/src/internal/engine.ts +501 -0
  72. package/assets/packages/cli/src/internal/errors.ts +39 -0
  73. package/assets/packages/cli/src/internal/file-names.ts +3 -0
  74. package/assets/packages/cli/src/internal/launch-request.ts +94 -0
  75. package/assets/packages/cli/src/internal/logs.ts +41 -0
  76. package/assets/packages/cli/src/internal/metrics.ts +356 -0
  77. package/assets/packages/cli/src/internal/pi-assets.ts +95 -0
  78. package/assets/packages/cli/src/internal/resource-bindings.ts +35 -0
  79. package/assets/packages/cli/src/internal/run-lease.ts +158 -0
  80. package/assets/packages/cli/src/internal/run-log.ts +59 -0
  81. package/assets/packages/cli/src/internal/run-names.ts +36 -0
  82. package/assets/packages/cli/src/internal/run-resources.ts +23 -0
  83. package/assets/packages/cli/src/internal/run-state.ts +380 -0
  84. package/assets/packages/cli/src/internal/run-store.ts +323 -0
  85. package/assets/packages/cli/src/internal/run.ts +133 -0
  86. package/assets/packages/cli/src/internal/state-store.ts +75 -0
  87. package/assets/packages/cli/src/internal/usage.ts +70 -0
  88. package/assets/packages/cli/src/internal/workflow-registry.ts +176 -0
  89. package/assets/packages/cli/src/plugin-loader.ts +412 -0
  90. package/assets/packages/cli/src/resources.ts +67 -0
  91. package/assets/packages/core/src/agent-protocol.ts +1 -0
  92. package/assets/packages/core/src/atomic-files.ts +24 -0
  93. package/assets/packages/core/src/errors.ts +3 -0
  94. package/assets/packages/sdk/src/agent-resource-adapter.ts +11 -0
  95. package/assets/packages/sdk/src/api.ts +821 -0
  96. package/assets/packages/sdk/src/files.ts +136 -0
  97. package/assets/packages/sdk/src/index.ts +6 -0
  98. package/assets/packages/sdk/src/resources.ts +20 -0
  99. package/assets/packages/sdk/src/schema.ts +48 -0
  100. package/assets/packages/sdk/src/seer/config.ts +62 -0
  101. package/assets/packages/sdk/src/seer/index.ts +7 -0
  102. package/assets/packages/sdk/src/state-adapter.ts +75 -0
  103. package/assets/setup/providers.md +128 -0
  104. package/assets/setup/releases.md +76 -0
  105. package/assets/tests/workflow-ref.test.ts +113 -0
  106. package/bin/norn.mjs +10 -0
  107. package/dist/build-info.d.ts +30 -0
  108. package/dist/build-info.js +6 -0
  109. package/dist/cli.d.ts +2 -0
  110. package/dist/cli.js +1032 -0
  111. package/dist/client.d.ts +48 -0
  112. package/dist/client.js +118 -0
  113. package/dist/documentation-intro.d.ts +5 -0
  114. package/dist/documentation-intro.js +29 -0
  115. package/dist/documentation.d.ts +33 -0
  116. package/dist/documentation.js +132 -0
  117. package/dist/generated-build-info.d.ts +10 -0
  118. package/dist/generated-build-info.js +14 -0
  119. package/dist/internal/agent-directory.d.ts +4 -0
  120. package/dist/internal/agent-directory.js +8 -0
  121. package/dist/internal/agent-response-tool.d.ts +21 -0
  122. package/dist/internal/agent-response-tool.js +79 -0
  123. package/dist/internal/agents.d.ts +29 -0
  124. package/dist/internal/agents.js +336 -0
  125. package/dist/internal/artifacts.d.ts +10 -0
  126. package/dist/internal/artifacts.js +29 -0
  127. package/dist/internal/commands.d.ts +18 -0
  128. package/dist/internal/commands.js +147 -0
  129. package/dist/internal/documentation-bundle.d.ts +16 -0
  130. package/dist/internal/documentation-bundle.js +42 -0
  131. package/dist/internal/engine.d.ts +44 -0
  132. package/dist/internal/engine.js +399 -0
  133. package/dist/internal/errors.d.ts +14 -0
  134. package/dist/internal/errors.js +38 -0
  135. package/dist/internal/file-names.d.ts +1 -0
  136. package/dist/internal/file-names.js +7 -0
  137. package/dist/internal/launch-request.d.ts +33 -0
  138. package/dist/internal/launch-request.js +110 -0
  139. package/dist/internal/logs.d.ts +16 -0
  140. package/dist/internal/logs.js +38 -0
  141. package/dist/internal/metrics.d.ts +19 -0
  142. package/dist/internal/metrics.js +282 -0
  143. package/dist/internal/pi-assets.d.ts +13 -0
  144. package/dist/internal/pi-assets.js +94 -0
  145. package/dist/internal/resource-bindings.d.ts +13 -0
  146. package/dist/internal/resource-bindings.js +34 -0
  147. package/dist/internal/run-lease.d.ts +32 -0
  148. package/dist/internal/run-lease.js +166 -0
  149. package/dist/internal/run-log.d.ts +30 -0
  150. package/dist/internal/run-log.js +71 -0
  151. package/dist/internal/run-names.d.ts +1 -0
  152. package/dist/internal/run-names.js +144 -0
  153. package/dist/internal/run-resources.d.ts +6 -0
  154. package/dist/internal/run-resources.js +26 -0
  155. package/dist/internal/run-state.d.ts +95 -0
  156. package/dist/internal/run-state.js +323 -0
  157. package/dist/internal/run-store.d.ts +35 -0
  158. package/dist/internal/run-store.js +314 -0
  159. package/dist/internal/run.d.ts +51 -0
  160. package/dist/internal/run.js +101 -0
  161. package/dist/internal/state-store.d.ts +22 -0
  162. package/dist/internal/state-store.js +97 -0
  163. package/dist/internal/usage.d.ts +5 -0
  164. package/dist/internal/usage.js +70 -0
  165. package/dist/internal/workflow-registry.d.ts +35 -0
  166. package/dist/internal/workflow-registry.js +129 -0
  167. package/dist/plugin-loader.d.ts +55 -0
  168. package/dist/plugin-loader.js +353 -0
  169. package/dist/resources.d.ts +11 -0
  170. package/dist/resources.js +98 -0
  171. package/package.json +52 -0
@@ -0,0 +1,323 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, utimes, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { gunzip, gzip } from "node:zlib";
7
+ import type { NornRunCheckpoint } from "@vimhead.dev/norn";
8
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
9
+ import { writeJsonAtomically, writeTextAtomically } from "@vimhead.dev/norn-core/atomic-files";
10
+
11
+ const gzipBuffer = promisify(gzip);
12
+ const gunzipBuffer = promisify(gunzip);
13
+ const SNAPSHOT_VERSION = 1;
14
+ const CURRENT_DIR_NAME = "current";
15
+ const STORE_DIR_NAME = "store";
16
+ const CHECKPOINTS_FILE_NAME = "checkpoints.json";
17
+
18
+ export function runCurrentRoot(runRoot: string): string {
19
+ return join(runRoot, CURRENT_DIR_NAME);
20
+ }
21
+
22
+ export class NornRunStore {
23
+ private readonly currentRoot: string;
24
+ private readonly storeRoot: string;
25
+
26
+ private constructor(private readonly runRoot: string) {
27
+ this.currentRoot = runCurrentRoot(runRoot);
28
+ this.storeRoot = join(runRoot, STORE_DIR_NAME);
29
+ }
30
+
31
+ static async initialize(root: string): Promise<NornRunStore> {
32
+ const runStore = new NornRunStore(root);
33
+ await mkdir(runStore.currentRoot, { recursive: true });
34
+ await mkdir(runStore.objectsRoot, { recursive: true });
35
+ await mkdir(runStore.snapshotsRoot, { recursive: true });
36
+ await mkdir(runStore.refsRoot, { recursive: true });
37
+ return runStore;
38
+ }
39
+
40
+ static async open(root: string): Promise<NornRunStore> {
41
+ const runStore = new NornRunStore(root);
42
+ await access(runStore.currentRoot, constants.R_OK | constants.W_OK);
43
+ await access(runStore.storeRoot, constants.R_OK | constants.W_OK);
44
+ return runStore;
45
+ }
46
+
47
+ async currentSnapshotRef(): Promise<string> {
48
+ return readFile(this.currentRefPath, "utf8").then((value) => value.trim());
49
+ }
50
+
51
+ async snapshotCurrent(message: string): Promise<NornRunCheckpoint> {
52
+ const previous = await this.readCheckpointHistory();
53
+ const checkpoint = this.createCheckpoint(message, previous.checkpoints.length + 1);
54
+ const checkpoints = [...previous.checkpoints, checkpoint];
55
+ await this.createSnapshot(checkpoint, checkpoints);
56
+ await writeJsonAtomically(this.checkpointsPath, checkpoints);
57
+ try {
58
+ await writeTextAtomically(this.currentRefPath, `${checkpoint.id}\n`);
59
+ } catch (error) {
60
+ if (previous.content === undefined) await rm(this.checkpointsPath, { force: true });
61
+ else await writeTextAtomically(this.checkpointsPath, previous.content);
62
+ throw error;
63
+ }
64
+ return checkpoint;
65
+ }
66
+
67
+ async restoreSnapshot(ref: string, prepare: ((stagedRunRoot: string) => Promise<void>) | undefined): Promise<void> {
68
+ const checkpoint = (await this.listCheckpoints()).find((entry) => entry.id === ref);
69
+ if (!checkpoint) throw new Error(`Unknown active run checkpoint: ${ref}`);
70
+ await this.restoreSnapshotManifest(await this.readSnapshot(checkpoint.id), prepare);
71
+ }
72
+
73
+ async restoreCurrentSnapshot(): Promise<void> {
74
+ await this.restoreSnapshotManifest(await this.readSnapshot(await this.currentSnapshotRef()), undefined);
75
+ }
76
+
77
+ async listCheckpoints(): Promise<NornRunCheckpoint[]> {
78
+ return (await this.readCheckpointHistory()).checkpoints;
79
+ }
80
+
81
+ private async readCheckpointHistory(): Promise<{ readonly checkpoints: NornRunCheckpoint[]; readonly content?: string }> {
82
+ try {
83
+ const content = await readFile(this.checkpointsPath, "utf8");
84
+ const checkpoints = parseNornRunCheckpoints(JSON.parse(content));
85
+ for (const checkpoint of checkpoints) this.assertCheckpointPathMatchesId(checkpoint);
86
+ return { checkpoints, content };
87
+ } catch (error) {
88
+ if (isNodeError(error) && error.code === "ENOENT") return { checkpoints: [] };
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ async assertWorkspaceCanBeSnapshotted(_workspace: string): Promise<void> {}
94
+
95
+ private get objectsRoot(): string {
96
+ return join(this.storeRoot, "objects", "sha256");
97
+ }
98
+
99
+ private get snapshotsRoot(): string {
100
+ return join(this.storeRoot, "snapshots");
101
+ }
102
+
103
+ private get refsRoot(): string {
104
+ return join(this.storeRoot, "refs");
105
+ }
106
+
107
+ private get currentRefPath(): string {
108
+ return join(this.refsRoot, "current");
109
+ }
110
+
111
+ private get checkpointsPath(): string {
112
+ return join(this.currentRoot, CHECKPOINTS_FILE_NAME);
113
+ }
114
+
115
+ private createCheckpoint(message: string, index: number): NornRunCheckpoint {
116
+ const id = `cp_${randomUUID().replaceAll("-", "")}`;
117
+ return { id, path: this.snapshotRelativePath(id), index, message, createdAt: new Date().toISOString() };
118
+ }
119
+
120
+ private async createSnapshot(checkpoint: NornRunCheckpoint, checkpoints: readonly NornRunCheckpoint[]): Promise<RunSnapshotManifest> {
121
+ const entries = (await this.snapshotEntries(this.currentRoot)).filter((entry) => entry.path !== CHECKPOINTS_FILE_NAME);
122
+ const historyBytes = Buffer.from(`${JSON.stringify(checkpoints, null, 2)}\n`);
123
+ const historyHash = hashBuffer(historyBytes);
124
+ await this.storeObject(historyHash, historyBytes);
125
+ entries.push({ path: CHECKPOINTS_FILE_NAME, type: "file", sha256: historyHash, size: historyBytes.length, mode: 0o600, mtimeMs: Date.now(), compression: "gzip" });
126
+ entries.sort((left, right) => left.path.localeCompare(right.path));
127
+ const snapshot: RunSnapshotManifest = {
128
+ version: SNAPSHOT_VERSION,
129
+ id: checkpoint.id,
130
+ path: checkpoint.path,
131
+ index: checkpoint.index,
132
+ message: checkpoint.message,
133
+ createdAt: checkpoint.createdAt,
134
+ entries,
135
+ };
136
+ await writeJsonAtomically(this.snapshotPath(snapshot.id), snapshot);
137
+ return snapshot;
138
+ }
139
+
140
+ private async snapshotEntries(root: string): Promise<RunSnapshotEntry[]> {
141
+ const entries: RunSnapshotEntry[] = [];
142
+ await this.collectSnapshotEntries(root, "", entries);
143
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
144
+ }
145
+
146
+ private async collectSnapshotEntries(absolutePath: string, snapshotPath: string, entries: RunSnapshotEntry[]): Promise<void> {
147
+ const stat = await lstat(absolutePath);
148
+ if (stat.isDirectory()) {
149
+ if (snapshotPath.length > 0) entries.push({ path: snapshotPath, type: "directory", mode: stat.mode });
150
+ const children = await readdir(absolutePath, { withFileTypes: true });
151
+ for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
152
+ await this.collectSnapshotEntries(join(absolutePath, child.name), joinSnapshotPath(snapshotPath, child.name), entries);
153
+ }
154
+ return;
155
+ }
156
+ if (stat.isSymbolicLink()) {
157
+ entries.push({ path: snapshotPath, type: "symlink", target: await readlink(absolutePath) });
158
+ return;
159
+ }
160
+ if (!stat.isFile()) return;
161
+ const bytes = await readFile(absolutePath);
162
+ const sha256 = hashBuffer(bytes);
163
+ await this.storeObject(sha256, bytes);
164
+ entries.push({ path: snapshotPath, type: "file", sha256, size: stat.size, mode: stat.mode, mtimeMs: stat.mtimeMs, compression: "gzip" });
165
+ }
166
+
167
+ private async storeObject(sha256: string, bytes: Buffer): Promise<void> {
168
+ const path = this.objectPath(sha256);
169
+ try {
170
+ await access(path, constants.R_OK);
171
+ return;
172
+ } catch (error) {
173
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
174
+ }
175
+ await mkdir(dirname(path), { recursive: true });
176
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
177
+ await writeFile(tmpPath, await gzipBuffer(bytes));
178
+ await rename(tmpPath, path);
179
+ }
180
+
181
+ private async restoreSnapshotManifest(snapshot: RunSnapshotManifest, prepare: ((stagedRunRoot: string) => Promise<void>) | undefined): Promise<void> {
182
+ const stagedRunRoot = await mkdtemp(join(this.runRoot, ".restore-"));
183
+ const stagedCurrent = runCurrentRoot(stagedRunRoot);
184
+ const previousCurrent = join(this.runRoot, `.previous-${randomUUID()}`);
185
+ try {
186
+ await mkdir(stagedCurrent);
187
+ await this.materializeSnapshot(snapshot, stagedCurrent);
188
+ await prepare?.(stagedRunRoot);
189
+ await rename(this.currentRoot, previousCurrent);
190
+ try {
191
+ await rename(stagedCurrent, this.currentRoot);
192
+ } catch (error) {
193
+ await rename(previousCurrent, this.currentRoot);
194
+ throw error;
195
+ }
196
+ try {
197
+ await writeTextAtomically(this.currentRefPath, `${snapshot.id}\n`);
198
+ } catch (error) {
199
+ await rename(this.currentRoot, stagedCurrent);
200
+ await rename(previousCurrent, this.currentRoot);
201
+ throw error;
202
+ }
203
+ await rm(previousCurrent, { recursive: true, force: true }).catch(() => undefined);
204
+ } finally {
205
+ await rm(stagedRunRoot, { recursive: true, force: true }).catch(() => undefined);
206
+ }
207
+ }
208
+
209
+ private async materializeSnapshot(snapshot: RunSnapshotManifest, destination: string): Promise<void> {
210
+ const entriesByPath = new Map<string, RunSnapshotEntry>();
211
+ for (const entry of snapshot.entries) {
212
+ this.materializedPath(destination, entry.path);
213
+ if (entriesByPath.has(entry.path)) throw new Error(`Duplicate snapshot path: ${entry.path}`);
214
+ entriesByPath.set(entry.path, entry);
215
+ }
216
+ for (const entry of snapshot.entries) {
217
+ let parent = dirname(entry.path);
218
+ while (parent !== ".") {
219
+ if (entriesByPath.get(parent)?.type !== "directory") throw new Error(`Snapshot parent is not a directory: ${parent}`);
220
+ parent = dirname(parent);
221
+ }
222
+ if (entry.type === "directory") await mkdir(this.materializedPath(destination, entry.path), { recursive: true });
223
+ }
224
+ for (const entry of snapshot.entries) {
225
+ if (entry.type === "directory") continue;
226
+ const path = this.materializedPath(destination, entry.path);
227
+ if (entry.type === "symlink") {
228
+ await symlink(entry.target, path);
229
+ continue;
230
+ }
231
+ const bytes = await gunzipBuffer(await readFile(this.objectPath(entry.sha256)));
232
+ const actualSha256 = hashBuffer(bytes);
233
+ if (actualSha256 !== entry.sha256) throw new Error(`CAS object checksum mismatch: ${entry.sha256}`);
234
+ await writeFile(path, bytes);
235
+ await chmod(path, entry.mode);
236
+ await utimes(path, new Date(entry.mtimeMs), new Date(entry.mtimeMs));
237
+ }
238
+ for (const entry of [...snapshot.entries].reverse()) {
239
+ if (entry.type === "directory") await chmod(this.materializedPath(destination, entry.path), entry.mode);
240
+ }
241
+ }
242
+
243
+ private materializedPath(destination: string, snapshotPath: string): string {
244
+ if (snapshotPath.length === 0 || isAbsolute(snapshotPath) || snapshotPath.split(/[\\/]/).some((part) => part === ".." || part === "." || part === "")) throw new Error(`Invalid snapshot path: ${snapshotPath}`);
245
+ const path = resolve(destination, ...snapshotPath.split("/"));
246
+ const pathFromCurrent = relative(destination, path);
247
+ if (pathFromCurrent === ".." || pathFromCurrent.startsWith(`..${sep}`)) throw new Error(`Snapshot path escapes current checkout: ${snapshotPath}`);
248
+ return path;
249
+ }
250
+
251
+ private async readSnapshot(id: string): Promise<RunSnapshotManifest> {
252
+ return parseRunSnapshotManifest(JSON.parse(await readFile(this.snapshotPath(id), "utf8")));
253
+ }
254
+
255
+ private snapshotPath(id: string): string {
256
+ return join(this.snapshotsRoot, `${id}.json`);
257
+ }
258
+
259
+ private snapshotRelativePath(id: string): string {
260
+ return relative(this.runRoot, this.snapshotPath(id));
261
+ }
262
+
263
+ private assertCheckpointPathMatchesId(checkpoint: NornRunCheckpoint): void {
264
+ const expectedPath = this.snapshotRelativePath(checkpoint.id);
265
+ if (checkpoint.path !== expectedPath) throw new Error(`Run checkpoint path does not match id: ${checkpoint.id}`);
266
+ }
267
+
268
+ private objectPath(sha256: string): string {
269
+ return join(this.objectsRoot, sha256.slice(0, 2), sha256.slice(2, 4), `${sha256}.gz`);
270
+ }
271
+ }
272
+
273
+ type RunSnapshotManifest = {
274
+ readonly version: typeof SNAPSHOT_VERSION;
275
+ readonly id: string;
276
+ readonly path: string;
277
+ readonly index: number;
278
+ readonly message: string;
279
+ readonly createdAt: string;
280
+ readonly entries: readonly RunSnapshotEntry[];
281
+ };
282
+
283
+ type RunSnapshotEntry =
284
+ | { readonly path: string; readonly type: "directory"; readonly mode: number }
285
+ | { readonly path: string; readonly type: "file"; readonly sha256: string; readonly size: number; readonly mode: number; readonly mtimeMs: number; readonly compression: "gzip" }
286
+ | { readonly path: string; readonly type: "symlink"; readonly target: string };
287
+
288
+ function hashBuffer(bytes: Buffer): string {
289
+ return createHash("sha256").update(bytes).digest("hex");
290
+ }
291
+
292
+ function joinSnapshotPath(parent: string, child: string): string {
293
+ return parent.length === 0 ? child : `${parent}/${child}`;
294
+ }
295
+
296
+ function parseRunSnapshotManifest(value: unknown): RunSnapshotManifest {
297
+ if (!value || typeof value !== "object") throw new Error("Invalid run snapshot manifest");
298
+ const snapshot = value as Partial<RunSnapshotManifest>;
299
+ if (snapshot.version !== SNAPSHOT_VERSION) throw new Error(`Unsupported run snapshot version: ${String(snapshot.version)}`);
300
+ if (typeof snapshot.id !== "string" || snapshot.id.length === 0) throw new Error("Invalid run snapshot id");
301
+ if (typeof snapshot.index !== "number" || !Number.isInteger(snapshot.index) || snapshot.index < 1) throw new Error("Invalid run snapshot index");
302
+ if (typeof snapshot.message !== "string") throw new Error("Invalid run snapshot message");
303
+ if (typeof snapshot.createdAt !== "string") throw new Error("Invalid run snapshot timestamp");
304
+ if (!Array.isArray(snapshot.entries)) throw new Error("Invalid run snapshot entries");
305
+ return snapshot as RunSnapshotManifest;
306
+ }
307
+
308
+ function parseNornRunCheckpoints(value: unknown): NornRunCheckpoint[] {
309
+ if (!Array.isArray(value)) throw new Error("Invalid run checkpoints");
310
+ return value.map(parseNornRunCheckpoint);
311
+ }
312
+
313
+ function parseNornRunCheckpoint(value: unknown): NornRunCheckpoint {
314
+ if (!value || typeof value !== "object") throw new Error("Invalid run checkpoint");
315
+ const checkpoint = value as Partial<NornRunCheckpoint>;
316
+ if (typeof checkpoint.id !== "string" || checkpoint.id.length === 0) throw new Error("Invalid run checkpoint id");
317
+ if (typeof checkpoint.path !== "string" || checkpoint.path.length === 0) throw new Error("Invalid run checkpoint path");
318
+ if (checkpoint.path.split(/[\\/]/).includes("..") || resolve(checkpoint.path) === checkpoint.path) throw new Error(`Invalid run checkpoint path: ${checkpoint.path}`);
319
+ if (typeof checkpoint.index !== "number" || !Number.isInteger(checkpoint.index) || checkpoint.index < 1) throw new Error("Invalid run checkpoint index");
320
+ if (typeof checkpoint.message !== "string") throw new Error("Invalid run checkpoint message");
321
+ if (typeof checkpoint.createdAt !== "string") throw new Error("Invalid run checkpoint timestamp");
322
+ return checkpoint as NornRunCheckpoint;
323
+ }
@@ -0,0 +1,133 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
3
+ import type { NornAnyWorkflowDeclaration, NornProjectRun, NornRunOutcomeMetadata, NornWorkflowIsolationMode, NornWorkflowTarget, NornWorkflowTargetParamsInput, NornRun } from "@vimhead.dev/norn";
4
+ import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
5
+ import type { NornArtifacts } from "./artifacts.ts";
6
+ import { NornAgentRunner } from "./agents.ts";
7
+ import { NornCommandRunner } from "./commands.ts";
8
+ import type { NornRunLogs } from "./logs.ts";
9
+ import type { NornRunLogger } from "./run-log.ts";
10
+ import type { NornRunResources } from "../resources.ts";
11
+
12
+ type DefaultNornRunInput = {
13
+ readonly id: string;
14
+ readonly runRoot: string;
15
+ readonly projectRoot: string;
16
+ readonly workspace: string;
17
+ readonly cwd: string;
18
+ readonly isolationMode: NornWorkflowIsolationMode;
19
+ readonly signal?: AbortSignal;
20
+ readonly model?: CreateAgentSessionOptions["model"];
21
+ readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
22
+ readonly agentDir?: string;
23
+ readonly responseCollector: NornAgentResponseCollector;
24
+ readonly resources: NornRunResources;
25
+ readonly state: NornRun["state"];
26
+ readonly logger: NornRunLogger;
27
+ readonly artifacts: NornArtifacts;
28
+ readonly logs: NornRunLogs;
29
+ };
30
+
31
+ export class NornRunContext implements NornProjectRun {
32
+ readonly resources: NornRunResources;
33
+ readonly state: NornRun["state"];
34
+ readonly artifacts: NornRun["artifacts"];
35
+ readonly logs: NornRun["logs"];
36
+ readonly commands: NornRun["commands"];
37
+ readonly agents: NornRun["agents"];
38
+ readonly id: string;
39
+ readonly workspace: string;
40
+ readonly projectRoot: string;
41
+ readonly cwd: string;
42
+
43
+ constructor(private readonly input: DefaultNornRunInput) {
44
+ this.id = input.id;
45
+ this.workspace = input.workspace;
46
+ this.projectRoot = input.projectRoot;
47
+ this.cwd = input.cwd;
48
+ this.resources = input.resources;
49
+ this.state = input.state;
50
+ this.artifacts = input.artifacts;
51
+ this.logs = {
52
+ read: (log) => this.input.logs.read(log),
53
+ };
54
+ this.commands = {
55
+ run: (commandInput) =>
56
+ new NornCommandRunner({
57
+ boundaryRoot: this.boundaryRoot,
58
+ boundaryName: this.input.isolationMode,
59
+ cwd: this.cwd,
60
+ signal: this.input.signal,
61
+ logs: this.input.logs,
62
+ logger: this.input.logger,
63
+ }).run(commandInput),
64
+ };
65
+ this.agents = {
66
+ createSession: (agentInput) => this.createAgentRunner().createSession(agentInput),
67
+ prompt: (agentInput) => this.createAgentRunner().prompt(agentInput),
68
+ };
69
+ }
70
+
71
+ forWorkflow(workflow: NornAnyWorkflowDeclaration): NornRunContext {
72
+ const isolationMode = workflow.isolation.mode;
73
+ return new NornRunContext({
74
+ ...this.input,
75
+ isolationMode,
76
+ cwd: isolationMode === "project" ? this.projectRoot : this.workspace,
77
+ });
78
+ }
79
+
80
+ private get boundaryRoot(): string {
81
+ return this.input.isolationMode === "project" ? this.projectRoot : this.workspace;
82
+ }
83
+
84
+ private createAgentRunner(): NornAgentRunner {
85
+ return new NornAgentRunner({
86
+ id: this.id,
87
+ runRoot: this.input.runRoot,
88
+ boundaryRoot: this.boundaryRoot,
89
+ boundaryName: this.input.isolationMode,
90
+ cwd: this.cwd,
91
+ signal: this.input.signal,
92
+ model: this.input.model,
93
+ thinkingLevel: this.input.thinkingLevel,
94
+ agentDir: this.input.agentDir,
95
+ logs: this.input.logs,
96
+ logger: this.input.logger,
97
+ responseCollector: this.input.responseCollector,
98
+ });
99
+ }
100
+
101
+ path(relativePath: string): string {
102
+ return this.resolveFromRoot(this.cwd, this.boundaryRoot, relativePath);
103
+ }
104
+
105
+ projectPath(relativePath: string): string {
106
+ return this.resolveFromRoot(this.projectRoot, this.projectRoot, relativePath);
107
+ }
108
+
109
+ next<TWorkflow extends NornWorkflowTarget<any>>(workflow: TWorkflow, params: NornWorkflowTargetParamsInput<TWorkflow>): ReturnType<NornRun["next"]> {
110
+ return {
111
+ type: "next",
112
+ workflowId: typeof workflow === "string" ? workflow : workflow.id,
113
+ params,
114
+ };
115
+ }
116
+
117
+ complete(metadata?: NornRunOutcomeMetadata): ReturnType<NornRun["complete"]> {
118
+ return { type: "complete", metadata };
119
+ }
120
+
121
+ fail(metadata: NornRunOutcomeMetadata & { readonly summary: string }): ReturnType<NornRun["fail"]> {
122
+ return { type: "fail", metadata };
123
+ }
124
+
125
+ private resolveFromRoot(root: string, boundaryRoot: string, path: string): string {
126
+ const resolvedPath = isAbsolute(path) ? path : resolve(root, path);
127
+ const pathFromBoundary = relative(boundaryRoot, resolvedPath);
128
+ if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
129
+ throw new Error(`Workflow path escapes ${this.input.isolationMode} isolation: ${path}`);
130
+ }
131
+ return resolvedPath;
132
+ }
133
+ }
@@ -0,0 +1,75 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { NornWorkflowStateDefinition, NornWorkflowState } from "@vimhead.dev/norn";
3
+ import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
4
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
5
+ import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
6
+
7
+ export class NornMemoryWorkflowState implements NornWorkflowState {
8
+ private readonly data = new Map<string, unknown>();
9
+
10
+ async get<T>(state: NornWorkflowStateDefinition<T>): Promise<T> {
11
+ const value = await this.getOptional(state);
12
+ if (value === undefined) throw new Error(`Missing workflow state: ${state.id}`);
13
+ return value;
14
+ }
15
+
16
+ async getOptional<T>(state: NornWorkflowStateDefinition<T>): Promise<T | undefined> {
17
+ if (!this.data.has(state.id)) return undefined;
18
+ return state.schema.parse(this.data.get(state.id));
19
+ }
20
+
21
+ async set<T>(state: NornWorkflowStateDefinition<T>, value: T): Promise<void> {
22
+ this.data.set(state.id, state.schema.parse(value));
23
+ }
24
+ }
25
+
26
+ export class NornJsonWorkflowState implements NornWorkflowState {
27
+ readonly stateFile: string;
28
+
29
+ constructor(private readonly input: {
30
+ readonly stateFile: string;
31
+ readonly files: NornFileCoordinator;
32
+ readonly coordinateMutation: <T>(path: string, operation: () => Promise<T>) => Promise<T>;
33
+ }) {
34
+ this.stateFile = input.stateFile;
35
+ }
36
+
37
+ async initialize(mode: "create" | "open"): Promise<void> {
38
+ await this.input.files.withExclusiveLock(this.stateFile, async (path) => {
39
+ try {
40
+ await this.readStateFile(path);
41
+ } catch (error) {
42
+ if (mode !== "create" || !isNodeError(error) || error.code !== "ENOENT") throw error;
43
+ await writeJsonAtomically(path, {});
44
+ }
45
+ });
46
+ }
47
+
48
+ async get<T>(state: NornWorkflowStateDefinition<T>): Promise<T> {
49
+ const value = await this.getOptional(state);
50
+ if (value === undefined) throw new Error(`Missing workflow state: ${state.id}`);
51
+ return value;
52
+ }
53
+
54
+ async getOptional<T>(state: NornWorkflowStateDefinition<T>): Promise<T | undefined> {
55
+ const data = await this.input.files.withExclusiveLock(this.stateFile, (path) => this.readStateFile(path));
56
+ if (!Object.prototype.hasOwnProperty.call(data, state.id)) return undefined;
57
+ return state.schema.parse(data[state.id]);
58
+ }
59
+
60
+ async set<T>(state: NornWorkflowStateDefinition<T>, value: T): Promise<void> {
61
+ const parsedValue = state.schema.parse(value);
62
+ await this.input.coordinateMutation(this.stateFile, () => this.input.files.withExclusiveLock(this.stateFile, async (path) => {
63
+ const data = await this.readStateFile(path);
64
+ Object.defineProperty(data, state.id, { value: parsedValue, enumerable: true, configurable: true, writable: true });
65
+ await writeJsonAtomically(path, data);
66
+ }));
67
+ }
68
+
69
+ private async readStateFile(path: string): Promise<Record<string, unknown>> {
70
+ const content = await readFile(path, "utf8");
71
+ const parsed = JSON.parse(content) as unknown;
72
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
73
+ throw new Error(`Invalid workflow state document: ${path}`);
74
+ }
75
+ }
@@ -0,0 +1,70 @@
1
+ import type { NornAgentUsage } from "@vimhead.dev/norn";
2
+
3
+ export function agentUsageFromValue(value: unknown): NornAgentUsage | undefined {
4
+ if (!value || typeof value !== "object") return undefined;
5
+ const usage = value as Record<string, unknown>;
6
+ const input = numberField(usage.input);
7
+ const output = numberField(usage.output);
8
+ const cacheRead = numberField(usage.cacheRead);
9
+ const cacheWrite = numberField(usage.cacheWrite);
10
+ const reasoning = optionalNumberField(usage.reasoning);
11
+ const totalTokens = numberField(usage.totalTokens) || input + output + cacheRead + cacheWrite;
12
+ const costValue = usage.cost && typeof usage.cost === "object" ? usage.cost as Record<string, unknown> : {};
13
+ return {
14
+ input,
15
+ output,
16
+ cacheRead,
17
+ cacheWrite,
18
+ ...(reasoning === undefined ? {} : { reasoning }),
19
+ totalTokens,
20
+ cost: {
21
+ input: numberField(costValue.input),
22
+ output: numberField(costValue.output),
23
+ cacheRead: numberField(costValue.cacheRead),
24
+ cacheWrite: numberField(costValue.cacheWrite),
25
+ total: numberField(costValue.total),
26
+ },
27
+ };
28
+ }
29
+
30
+ export function totalAgentUsage(usages: readonly NornAgentUsage[]): NornAgentUsage {
31
+ return usages.reduce(addAgentUsage, emptyAgentUsage());
32
+ }
33
+
34
+ export function addAgentUsage(left: NornAgentUsage, right: NornAgentUsage): NornAgentUsage {
35
+ const reasoning = left.reasoning === undefined && right.reasoning === undefined ? undefined : (left.reasoning ?? 0) + (right.reasoning ?? 0);
36
+ return {
37
+ input: left.input + right.input,
38
+ output: left.output + right.output,
39
+ cacheRead: left.cacheRead + right.cacheRead,
40
+ cacheWrite: left.cacheWrite + right.cacheWrite,
41
+ ...(reasoning === undefined ? {} : { reasoning }),
42
+ totalTokens: left.totalTokens + right.totalTokens,
43
+ cost: {
44
+ input: left.cost.input + right.cost.input,
45
+ output: left.cost.output + right.cost.output,
46
+ cacheRead: left.cost.cacheRead + right.cost.cacheRead,
47
+ cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
48
+ total: left.cost.total + right.cost.total,
49
+ },
50
+ };
51
+ }
52
+
53
+ export function emptyAgentUsage(): NornAgentUsage {
54
+ return {
55
+ input: 0,
56
+ output: 0,
57
+ cacheRead: 0,
58
+ cacheWrite: 0,
59
+ totalTokens: 0,
60
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
61
+ };
62
+ }
63
+
64
+ function numberField(value: unknown): number {
65
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
66
+ }
67
+
68
+ function optionalNumberField(value: unknown): number | undefined {
69
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
70
+ }