@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,356 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { NornAgentMetrics, NornAgentUsage, NornCommandMetrics, NornRunMetrics, NornRunStatus, NornWorkflowMetrics } from "@vimhead.dev/norn";
4
+ import { runCurrentRoot } from "./run-store.ts";
5
+ import { RUN_STATE_FILE_NAME } from "./run-state.ts";
6
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
7
+ import { addAgentUsage, agentUsageFromValue, emptyAgentUsage } from "./usage.ts";
8
+
9
+ const LEGACY_RUN_STATE_FILE_NAME = "runtime-state.json";
10
+
11
+ type NornRunManifestEvent = Record<string, unknown> & {
12
+ readonly at?: string;
13
+ readonly type?: string;
14
+ };
15
+
16
+ type NornRunManifest = {
17
+ readonly events?: readonly NornRunManifestEvent[];
18
+ };
19
+
20
+ type NornRunStateSnapshot = {
21
+ readonly status?: NornRunStatus;
22
+ readonly startedAt?: string;
23
+ readonly updatedAt?: string;
24
+ readonly outcome?: { readonly completedAt?: string } | null;
25
+ readonly failed?: { readonly failedAt?: string } | null;
26
+ };
27
+
28
+ type OpenWorkflowMetrics = {
29
+ readonly workflowId: string;
30
+ readonly startedAt: string;
31
+ readonly startedAtMs: number;
32
+ readonly agents: readonly NornAgentMetrics[];
33
+ readonly commands: readonly NornCommandMetrics[];
34
+ readonly openAgents: readonly OpenAgentMetrics[];
35
+ readonly openCommands: readonly OpenCommandMetrics[];
36
+ };
37
+
38
+ type OpenAgentMetrics = {
39
+ readonly label: string;
40
+ readonly startedAt: string;
41
+ readonly startedAtMs: number;
42
+ };
43
+
44
+ type OpenCommandMetrics = {
45
+ readonly label: string;
46
+ readonly startedAt: string;
47
+ readonly startedAtMs: number;
48
+ };
49
+
50
+ export async function readRunMetrics(runRoot: string): Promise<NornRunMetrics> {
51
+ const currentRoot = runCurrentRoot(runRoot);
52
+ const [state, events] = await Promise.all([
53
+ readRunStateSnapshot(currentRoot),
54
+ readManifestEvents(join(currentRoot, "manifest.json")),
55
+ ]);
56
+ return calculateRunMetrics(events, state, new Date());
57
+ }
58
+
59
+ export function calculateRunMetrics(
60
+ events: readonly NornRunManifestEvent[],
61
+ state: NornRunStateSnapshot,
62
+ now: Date,
63
+ ): NornRunMetrics {
64
+ const startedAt = state.startedAt ?? firstEventTime(events) ?? now.toISOString();
65
+ const endedAt = runEndedAt(state);
66
+ const startedAtMs = timestampMs(startedAt) ?? now.getTime();
67
+ const endedAtMs = timestampMs(endedAt) ?? now.getTime();
68
+ const workflows: NornWorkflowMetrics[] = [];
69
+ let openWorkflow: OpenWorkflowMetrics | undefined;
70
+ let gateStartedAtMs: number | undefined;
71
+ let gateWaitMs = 0;
72
+ let agentUsage = emptyAgentUsage();
73
+
74
+ for (const event of events) {
75
+ const eventAtMs = timestampMs(event.at);
76
+ if (event.type === "run.interrupted" && eventAtMs !== undefined) gateStartedAtMs = eventAtMs;
77
+ else if (event.type === "run.resumed" && eventAtMs !== undefined && gateStartedAtMs !== undefined) {
78
+ gateWaitMs += Math.max(0, eventAtMs - gateStartedAtMs);
79
+ gateStartedAtMs = undefined;
80
+ }
81
+
82
+ if (event.type === "workflow.started") {
83
+ openWorkflow = {
84
+ workflowId: stringField(event.workflowId, "unknown"),
85
+ startedAt: isoTime(event.at, now),
86
+ startedAtMs: eventAtMs ?? now.getTime(),
87
+ agents: [],
88
+ commands: [],
89
+ openAgents: [],
90
+ openCommands: [],
91
+ };
92
+ continue;
93
+ }
94
+
95
+ if (event.type === "agent.started") {
96
+ if (openWorkflow) openWorkflow = startAgentMetrics(openWorkflow, event, now);
97
+ continue;
98
+ }
99
+
100
+ if (event.type === "agent.completed" || event.type === "agent.failed") {
101
+ const usage = agentUsageFromEvent(event);
102
+ agentUsage = addAgentUsage(agentUsage, usage);
103
+ if (openWorkflow) openWorkflow = finishAgentMetrics(openWorkflow, event, usage, now);
104
+ continue;
105
+ }
106
+
107
+ if (event.type === "command.started") {
108
+ if (openWorkflow) openWorkflow = startCommandMetrics(openWorkflow, event, now);
109
+ continue;
110
+ }
111
+
112
+ if (event.type === "command.completed" || event.type === "command.failed") {
113
+ if (openWorkflow) openWorkflow = finishCommandMetrics(openWorkflow, event, now);
114
+ continue;
115
+ }
116
+
117
+ const status = workflowTerminalStatus(event.type);
118
+ if (status) {
119
+ const durationMs = numberField(event.durationMs) || (openWorkflow && eventAtMs !== undefined ? Math.max(0, eventAtMs - openWorkflow.startedAtMs) : 0);
120
+ const workflowId = status === "transitioned" ? stringField(event.fromWorkflowId, openWorkflow?.workflowId ?? "unknown") : stringField(event.workflowId, openWorkflow?.workflowId ?? "unknown");
121
+ const workflow = openWorkflow ?? createSyntheticWorkflowMetrics(workflowId, startedAtMs, endedAtMs, eventAtMs, durationMs);
122
+ const childStatus = status === "failed" ? "failed" : "completed";
123
+ workflows.push(closeWorkflowMetrics(closeOpenChildMetrics(workflow, eventAtMs ?? now.getTime(), event.at, childStatus), workflows.length + 1, status, durationMs, event.at));
124
+ openWorkflow = undefined;
125
+ }
126
+ }
127
+
128
+ if (gateStartedAtMs !== undefined) gateWaitMs += Math.max(0, endedAtMs - gateStartedAtMs);
129
+ if (openWorkflow) {
130
+ workflows.push(closeWorkflowMetrics(closeOpenChildMetrics(openWorkflow, endedAtMs, endedAt, "running"), workflows.length + 1, "running", Math.max(0, endedAtMs - openWorkflow.startedAtMs), endedAt));
131
+ }
132
+ const workflowsMs = workflows.reduce((sum, workflow) => sum + workflow.wallMs, 0);
133
+ const agentsMs = workflows.reduce((sum, workflow) => sum + workflow.agentsMs, 0);
134
+ const commandsMs = workflows.reduce((sum, workflow) => sum + workflow.commandsMs, 0);
135
+ const wallMs = Math.max(0, endedAtMs - startedAtMs);
136
+ return {
137
+ status: state.status ?? "running",
138
+ startedAt,
139
+ ...(endedAt === undefined ? {} : { endedAt }),
140
+ wallMs,
141
+ activeMs: Math.max(0, wallMs - gateWaitMs),
142
+ gateWaitMs,
143
+ workflowsMs,
144
+ workflowOwnMs: Math.max(0, workflowsMs - agentsMs - commandsMs),
145
+ agentsMs,
146
+ commandsMs,
147
+ agentUsage,
148
+ workflows,
149
+ };
150
+ }
151
+
152
+ async function readRunStateSnapshot(currentRoot: string): Promise<NornRunStateSnapshot> {
153
+ try {
154
+ return JSON.parse(await readFile(join(currentRoot, RUN_STATE_FILE_NAME), "utf8")) as NornRunStateSnapshot;
155
+ } catch (error) {
156
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
157
+ return JSON.parse(await readFile(join(currentRoot, LEGACY_RUN_STATE_FILE_NAME), "utf8")) as NornRunStateSnapshot;
158
+ }
159
+ }
160
+
161
+ async function readManifestEvents(path: string): Promise<readonly NornRunManifestEvent[]> {
162
+ const manifest = JSON.parse(await readFile(path, "utf8")) as NornRunManifest;
163
+ return manifest.events ?? [];
164
+ }
165
+
166
+ function createSyntheticWorkflowMetrics(workflowId: string, runStartedAtMs: number, runEndedAtMs: number, eventAtMs: number | undefined, durationMs: number): OpenWorkflowMetrics {
167
+ const startedAtMs = eventAtMs === undefined ? runEndedAtMs : Math.max(runStartedAtMs, eventAtMs - durationMs);
168
+ return {
169
+ workflowId,
170
+ startedAt: isoTimeFromMs(startedAtMs),
171
+ startedAtMs,
172
+ agents: [],
173
+ commands: [],
174
+ openAgents: [],
175
+ openCommands: [],
176
+ };
177
+ }
178
+
179
+ function startAgentMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, now: Date): OpenWorkflowMetrics {
180
+ return {
181
+ ...workflow,
182
+ openAgents: [...workflow.openAgents, {
183
+ label: stringField(event.label, "unknown"),
184
+ startedAt: isoTime(event.at, now),
185
+ startedAtMs: timestampMs(event.at) ?? now.getTime(),
186
+ }],
187
+ };
188
+ }
189
+
190
+ function finishAgentMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, usage: NornAgentUsage, now: Date): OpenWorkflowMetrics {
191
+ const [agent, openAgents] = takeOpenChild(workflow.openAgents, stringField(event.label, "unknown"));
192
+ const closedAgent = closeAgentMetrics(event, workflow.agents.length + 1, usage, now, agent);
193
+ return { ...workflow, agents: [...workflow.agents, closedAgent], openAgents };
194
+ }
195
+
196
+ function startCommandMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, now: Date): OpenWorkflowMetrics {
197
+ return {
198
+ ...workflow,
199
+ openCommands: [...workflow.openCommands, {
200
+ label: stringField(event.label, "unknown"),
201
+ startedAt: isoTime(event.at, now),
202
+ startedAtMs: timestampMs(event.at) ?? now.getTime(),
203
+ }],
204
+ };
205
+ }
206
+
207
+ function finishCommandMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, now: Date): OpenWorkflowMetrics {
208
+ const [command, openCommands] = takeOpenChild(workflow.openCommands, stringField(event.label, "unknown"));
209
+ const closedCommand = closeCommandMetrics(event, workflow.commands.length + 1, now, command);
210
+ return { ...workflow, commands: [...workflow.commands, closedCommand], openCommands };
211
+ }
212
+
213
+ function closeOpenChildMetrics(workflow: OpenWorkflowMetrics, endedAtMs: number, endedAt: string | undefined, status: NornAgentMetrics["status"]): OpenWorkflowMetrics {
214
+ return {
215
+ ...workflow,
216
+ agents: [
217
+ ...workflow.agents,
218
+ ...workflow.openAgents.map((agent, offset): NornAgentMetrics => ({
219
+ index: workflow.agents.length + offset + 1,
220
+ label: agent.label,
221
+ status,
222
+ startedAt: agent.startedAt,
223
+ ...(endedAt === undefined ? {} : { endedAt }),
224
+ wallMs: Math.max(0, endedAtMs - agent.startedAtMs),
225
+ usage: emptyAgentUsage(),
226
+ })),
227
+ ],
228
+ commands: [
229
+ ...workflow.commands,
230
+ ...workflow.openCommands.map((command, offset): NornCommandMetrics => ({
231
+ index: workflow.commands.length + offset + 1,
232
+ label: command.label,
233
+ status,
234
+ startedAt: command.startedAt,
235
+ ...(endedAt === undefined ? {} : { endedAt }),
236
+ wallMs: Math.max(0, endedAtMs - command.startedAtMs),
237
+ })),
238
+ ],
239
+ openAgents: [],
240
+ openCommands: [],
241
+ };
242
+ }
243
+
244
+ function closeWorkflowMetrics(
245
+ workflow: OpenWorkflowMetrics,
246
+ index: number,
247
+ status: NornWorkflowMetrics["status"],
248
+ wallMs: number,
249
+ endedAt: string | undefined,
250
+ ): NornWorkflowMetrics {
251
+ const agentsMs = workflow.agents.reduce((sum, agent) => sum + agent.wallMs, 0);
252
+ const commandsMs = workflow.commands.reduce((sum, command) => sum + command.wallMs, 0);
253
+ return {
254
+ index,
255
+ workflowId: workflow.workflowId,
256
+ status,
257
+ startedAt: workflow.startedAt,
258
+ ...(endedAt === undefined ? {} : { endedAt }),
259
+ wallMs,
260
+ ownMs: Math.max(0, wallMs - agentsMs - commandsMs),
261
+ agentsMs,
262
+ commandsMs,
263
+ agentUsage: workflow.agents.reduce((sum, agent) => addAgentUsage(sum, agent.usage), emptyAgentUsage()),
264
+ agents: workflow.agents,
265
+ commands: workflow.commands,
266
+ };
267
+ }
268
+
269
+ function closeAgentMetrics(event: NornRunManifestEvent, index: number, usage: NornAgentUsage, now: Date, openAgent: OpenAgentMetrics | undefined): NornAgentMetrics {
270
+ const endedAtMs = timestampMs(event.at) ?? now.getTime();
271
+ const wallMs = numberField(event.durationMs) || (openAgent ? Math.max(0, endedAtMs - openAgent.startedAtMs) : 0);
272
+ return {
273
+ index,
274
+ label: stringField(event.label, openAgent?.label ?? "unknown"),
275
+ status: event.type === "agent.failed" ? "failed" : "completed",
276
+ startedAt: openAgent?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
277
+ endedAt: isoTime(event.at, now),
278
+ wallMs,
279
+ attempts: optionalNumberField(event.attempts),
280
+ usage,
281
+ };
282
+ }
283
+
284
+ function closeCommandMetrics(event: NornRunManifestEvent, index: number, now: Date, openCommand: OpenCommandMetrics | undefined): NornCommandMetrics {
285
+ const endedAtMs = timestampMs(event.at) ?? now.getTime();
286
+ const wallMs = numberField(event.durationMs) || (openCommand ? Math.max(0, endedAtMs - openCommand.startedAtMs) : 0);
287
+ return {
288
+ index,
289
+ label: stringField(event.label, openCommand?.label ?? "unknown"),
290
+ status: event.type === "command.failed" ? "failed" : "completed",
291
+ startedAt: openCommand?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
292
+ endedAt: isoTime(event.at, now),
293
+ wallMs,
294
+ ...(event.type === "command.completed" ? { exitCode: nullableNumberField(event.exitCode), killed: booleanField(event.killed) } : {}),
295
+ };
296
+ }
297
+
298
+ function takeOpenChild<T extends { readonly label: string }>(children: readonly T[], label: string): readonly [T | undefined, readonly T[]] {
299
+ const index = children.findIndex((child) => child.label === label);
300
+ if (index === -1) return [undefined, children];
301
+ return [children[index], [...children.slice(0, index), ...children.slice(index + 1)]];
302
+ }
303
+
304
+ function workflowTerminalStatus(type: unknown): NornWorkflowMetrics["status"] | undefined {
305
+ if (type === "workflow.completed") return "completed";
306
+ if (type === "workflow.failed") return "failed";
307
+ if (type === "workflow.transitioned") return "transitioned";
308
+ return undefined;
309
+ }
310
+
311
+ function runEndedAt(state: NornRunStateSnapshot): string | undefined {
312
+ if (state.status === "completed") return state.outcome?.completedAt ?? state.updatedAt;
313
+ if (state.status === "failed") return state.failed?.failedAt ?? state.outcome?.completedAt ?? state.updatedAt;
314
+ return undefined;
315
+ }
316
+
317
+ function agentUsageFromEvent(event: NornRunManifestEvent): NornAgentUsage {
318
+ return agentUsageFromValue(event.usage) ?? emptyAgentUsage();
319
+ }
320
+
321
+ function firstEventTime(events: readonly NornRunManifestEvent[]): string | undefined {
322
+ return events.find((event) => timestampMs(event.at) !== undefined)?.at;
323
+ }
324
+
325
+ function timestampMs(value: string | undefined): number | undefined {
326
+ const parsed = value ? Date.parse(value) : NaN;
327
+ return Number.isFinite(parsed) ? parsed : undefined;
328
+ }
329
+
330
+ function isoTime(value: string | undefined, now: Date): string {
331
+ return value && timestampMs(value) !== undefined ? value : now.toISOString();
332
+ }
333
+
334
+ function isoTimeFromMs(value: number): string {
335
+ return new Date(value).toISOString();
336
+ }
337
+
338
+ function numberField(value: unknown): number {
339
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
340
+ }
341
+
342
+ function optionalNumberField(value: unknown): number | undefined {
343
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
344
+ }
345
+
346
+ function nullableNumberField(value: unknown): number | null {
347
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
348
+ }
349
+
350
+ function booleanField(value: unknown): boolean {
351
+ return typeof value === "boolean" ? value : false;
352
+ }
353
+
354
+ function stringField(value: unknown, fallback: string): string {
355
+ return typeof value === "string" && value.length > 0 ? value : fallback;
356
+ }
@@ -0,0 +1,95 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { gunzipSync } from "node:zlib";
5
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
6
+
7
+ export type PiAssetFile = { readonly path: string; readonly content: string };
8
+
9
+ export function resolvePiAssetsCacheRoot(input: {
10
+ readonly platform: string;
11
+ readonly home: string;
12
+ readonly environment: Readonly<Record<string, string | undefined>>;
13
+ }): string {
14
+ if (input.environment.NORN_PI_CACHE_DIR) return resolve(input.environment.NORN_PI_CACHE_DIR);
15
+ if (input.platform === "darwin") return join(input.home, "Library", "Caches", "norn", "pi");
16
+ if (input.platform === "win32") return join(input.environment.LOCALAPPDATA || join(input.home, "AppData", "Local"), "norn", "pi");
17
+ const xdgCache = input.environment.XDG_CACHE_HOME;
18
+ return join(xdgCache?.startsWith("/") ? xdgCache : join(input.home, ".cache"), "norn", "pi");
19
+ }
20
+
21
+ export async function materializePiAssets(input: { readonly archive: string; readonly cacheRoot: string }): Promise<string> {
22
+ const digest = createHash("sha256").update(input.archive).digest("hex");
23
+ const files: PiAssetFile[] = JSON.parse(gunzipSync(Buffer.from(input.archive, "base64")).toString("utf8"));
24
+ const expectedFiles = new Map<string, Buffer>();
25
+ for (const file of files) {
26
+ if (!file.path || file.path.includes("\\") || file.path.includes(":") || file.path.split("/").some(part => !part || part === "." || part === "..") || expectedFiles.has(file.path)) {
27
+ throw new Error("Invalid bundled Pi asset path");
28
+ }
29
+ expectedFiles.set(file.path, Buffer.from(file.content, "base64"));
30
+ }
31
+ return new PiAssetCache({ cacheRoot: input.cacheRoot, digest, expectedFiles }).materialize();
32
+ }
33
+
34
+ class PiAssetCache {
35
+ private readonly root: string;
36
+
37
+ constructor(private readonly input: { readonly cacheRoot: string; readonly digest: string; readonly expectedFiles: ReadonlyMap<string, Buffer> }) {
38
+ this.root = join(input.cacheRoot, `v1-${input.digest}`);
39
+ }
40
+
41
+ async materialize(): Promise<string> {
42
+ await mkdir(this.input.cacheRoot, { recursive: true, mode: 0o700 });
43
+ if (!(await lstat(this.input.cacheRoot)).isDirectory()) throw new Error("Pi asset cache root must be a real directory");
44
+ if (await this.validateExistingCache()) return this.root;
45
+ const staging = await mkdtemp(join(this.input.cacheRoot, ".extract-"));
46
+ try {
47
+ for (const [path, content] of this.input.expectedFiles) {
48
+ const parts = path.split("/");
49
+ await mkdir(join(staging, ...parts.slice(0, -1)), { recursive: true, mode: 0o700 });
50
+ await writeFile(join(staging, ...parts), content, { flag: "wx", mode: 0o600 });
51
+ }
52
+ try {
53
+ await rename(staging, this.root);
54
+ } catch (error) {
55
+ if (!isNodeError(error) || !["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(error.code ?? "") || !await this.validateExistingCache()) throw error;
56
+ }
57
+ if (!await this.validateExistingCache()) throw new Error("Pi assets disappeared after extraction");
58
+ return this.root;
59
+ } finally {
60
+ await rm(staging, { recursive: true, force: true });
61
+ }
62
+ }
63
+
64
+ private async validateExistingCache(): Promise<boolean> {
65
+ try {
66
+ if (!(await lstat(this.root)).isDirectory()) throw this.invalidCacheError();
67
+ } catch (error) {
68
+ if (isNodeError(error) && error.code === "ENOENT") return false;
69
+ throw error;
70
+ }
71
+ const seen = new Set<string>();
72
+ await this.validateDirectory({ directory: this.root, prefix: "", seen });
73
+ if (seen.size !== this.input.expectedFiles.size) throw this.invalidCacheError();
74
+ return true;
75
+ }
76
+
77
+ private async validateDirectory(input: { readonly directory: string; readonly prefix: string; readonly seen: Set<string> }): Promise<void> {
78
+ for (const entry of await readdir(input.directory, { withFileTypes: true })) {
79
+ const path = input.prefix ? `${input.prefix}/${entry.name}` : entry.name;
80
+ const absolutePath = join(input.directory, entry.name);
81
+ if (entry.isDirectory()) {
82
+ if (![...this.input.expectedFiles.keys()].some(file => file.startsWith(`${path}/`))) throw this.invalidCacheError();
83
+ await this.validateDirectory({ directory: absolutePath, prefix: path, seen: input.seen });
84
+ } else {
85
+ const expected = this.input.expectedFiles.get(path);
86
+ if (!entry.isFile() || !expected || !(await readFile(absolutePath)).equals(expected)) throw this.invalidCacheError();
87
+ input.seen.add(path);
88
+ }
89
+ }
90
+ }
91
+
92
+ private invalidCacheError(): Error {
93
+ return new Error(`Bundled Pi assets are incomplete or modified: ${this.root}. Remove this cache entry and retry.`);
94
+ }
95
+ }
@@ -0,0 +1,35 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import type { NornAgentResourceBinding, NornAgentResourceAdapter } from "@vimhead.dev/norn";
3
+
4
+ export class NornSessionResourceBindings {
5
+ readonly tools: ToolDefinition[] = [];
6
+ private readonly bindings: NornAgentResourceBinding[] = [];
7
+
8
+ async bind(input: { readonly adapters: readonly NornAgentResourceAdapter[]; readonly runId: string; readonly label: string; readonly reservedTools: readonly string[] }): Promise<void> {
9
+ const adapterNames = new Set<string>();
10
+ const tools = new Set(input.reservedTools);
11
+ for (const adapter of input.adapters) {
12
+ if (adapterNames.has(adapter.name)) throw new Error(`Duplicate resource adapter: ${adapter.name}`);
13
+ adapterNames.add(adapter.name);
14
+ const binding = await adapter.bind({ runId: input.runId, label: input.label });
15
+ this.bindings.push(binding);
16
+ for (const tool of binding.tools) {
17
+ if (tools.has(tool.name)) throw new Error(`Resource tool name collision: ${tool.name}`);
18
+ tools.add(tool.name);
19
+ this.tools.push(tool);
20
+ }
21
+ }
22
+ }
23
+
24
+ async dispose(): Promise<void> {
25
+ const errors: unknown[] = [];
26
+ for (const binding of this.bindings.splice(0).reverse()) {
27
+ try {
28
+ await binding.dispose();
29
+ } catch (error) {
30
+ errors.push(error);
31
+ }
32
+ }
33
+ if (errors.length) throw new AggregateError(errors, "Resource binding cleanup failed");
34
+ }
35
+ }
@@ -0,0 +1,158 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { NornRunHealth } from "@vimhead.dev/norn";
5
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
6
+ import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
7
+
8
+ const LOCK_DIR_NAME = "active.lock";
9
+ const OWNER_FILE_NAME = "owner.json";
10
+ const HEARTBEAT_INTERVAL_MS = 15_000;
11
+ const HEARTBEAT_TTL_MS = 60_000;
12
+ const ACQUIRE_ATTEMPTS = 5;
13
+
14
+ export type NornRunLeaseOwner = {
15
+ readonly token: string;
16
+ readonly acquiredAt: string;
17
+ readonly heartbeatAt: string;
18
+ readonly pid: number;
19
+ readonly processGroupId: number;
20
+ readonly command: readonly string[];
21
+ };
22
+
23
+ export class NornRunLease {
24
+ private heartbeat: ReturnType<typeof setInterval> | undefined;
25
+ private heartbeatChain: Promise<void> = Promise.resolve();
26
+ private isReleased = false;
27
+
28
+ private constructor(
29
+ private readonly lockRoot: string,
30
+ private readonly token: string,
31
+ private readonly processOwner: NornRunProcessOwner,
32
+ ) {}
33
+
34
+ static async acquire(runRoot: string, processOwner: NornRunProcessOwner = currentRunProcessOwner()): Promise<NornRunLease> {
35
+ const lockRoot = join(runRoot, LOCK_DIR_NAME);
36
+ for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
37
+ try {
38
+ await mkdir(lockRoot);
39
+ const lease = new NornRunLease(lockRoot, randomUUID(), processOwner);
40
+ try {
41
+ await lease.writeOwner({ acquiredAt: new Date().toISOString() });
42
+ } catch (error) {
43
+ await rm(lockRoot, { recursive: true, force: true });
44
+ throw error;
45
+ }
46
+ lease.startHeartbeat();
47
+ return lease;
48
+ } catch (error) {
49
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
50
+ const owner = await readRunLeaseOwner(join(lockRoot, OWNER_FILE_NAME));
51
+ const isStale = owner ? isRunLeaseStale(owner) : await isRunLeaseDirectoryStale(lockRoot);
52
+ if (!isStale) throw new Error(`Run is already active: ${runRoot}`);
53
+ await rm(lockRoot, { recursive: true, force: true });
54
+ }
55
+ }
56
+ throw new Error(`Could not acquire run lease: ${runRoot}`);
57
+ }
58
+
59
+ async assertOwned(): Promise<void> {
60
+ if (this.isReleased) throw new Error("Run lease has been released");
61
+ const owner = await readRunLeaseOwner(this.ownerPath);
62
+ if (!owner || owner.token !== this.token) throw new Error("Run lease was lost");
63
+ }
64
+
65
+ async release(): Promise<void> {
66
+ if (this.isReleased) return;
67
+ this.isReleased = true;
68
+ if (this.heartbeat) clearInterval(this.heartbeat);
69
+ await this.heartbeatChain.catch(() => undefined);
70
+ const owner = await readRunLeaseOwner(this.ownerPath);
71
+ if (owner?.token === this.token) await rm(this.lockRoot, { recursive: true, force: true });
72
+ }
73
+
74
+ private get ownerPath(): string {
75
+ return join(this.lockRoot, OWNER_FILE_NAME);
76
+ }
77
+
78
+ private startHeartbeat(): void {
79
+ this.heartbeat = setInterval(() => {
80
+ this.heartbeatChain = this.heartbeatChain.then(() => this.refreshHeartbeat()).catch(() => {
81
+ this.isReleased = true;
82
+ if (this.heartbeat) clearInterval(this.heartbeat);
83
+ });
84
+ }, HEARTBEAT_INTERVAL_MS);
85
+ this.heartbeat.unref?.();
86
+ }
87
+
88
+ private async refreshHeartbeat(): Promise<void> {
89
+ if (this.isReleased) return;
90
+ const owner = await readRunLeaseOwner(this.ownerPath);
91
+ if (!owner || owner.token !== this.token) throw new Error("Run lease was lost");
92
+ await this.writeOwner({ acquiredAt: owner.acquiredAt });
93
+ }
94
+
95
+ private async writeOwner(input: { readonly acquiredAt: string }): Promise<void> {
96
+ const now = new Date().toISOString();
97
+ await writeJsonAtomically(this.ownerPath, { token: this.token, acquiredAt: input.acquiredAt, heartbeatAt: now, ...this.processOwner });
98
+ }
99
+ }
100
+
101
+ export type NornRunProcessOwner = {
102
+ readonly pid: number;
103
+ readonly processGroupId: number;
104
+ readonly command: readonly string[];
105
+ };
106
+
107
+ export async function getRunLeaseHealth(runRoot: string): Promise<NornRunHealth> {
108
+ const lockRoot = join(runRoot, LOCK_DIR_NAME);
109
+ const owner = await readRunLeaseOwner(join(lockRoot, OWNER_FILE_NAME));
110
+ if (owner) return isRunLeaseStale(owner) ? "unhealthy" : "healthy";
111
+ return await isRunLeaseDirectoryStale(lockRoot) ? "unhealthy" : "healthy";
112
+ }
113
+
114
+ export async function getRunLeaseOwner(runRoot: string): Promise<NornRunLeaseOwner | undefined> {
115
+ return readRunLeaseOwner(join(runRoot, LOCK_DIR_NAME, OWNER_FILE_NAME));
116
+ }
117
+
118
+ function currentRunProcessOwner(): NornRunProcessOwner {
119
+ return {
120
+ pid: process.pid,
121
+ processGroupId: process.pid,
122
+ command: process.argv,
123
+ };
124
+ }
125
+
126
+ async function readRunLeaseOwner(path: string): Promise<NornRunLeaseOwner | undefined> {
127
+ try {
128
+ return parseRunLeaseOwner(JSON.parse(await readFile(path, "utf8")));
129
+ } catch (error) {
130
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ function parseRunLeaseOwner(value: unknown): NornRunLeaseOwner | undefined {
136
+ if (!value || typeof value !== "object") return undefined;
137
+ const owner = value as Partial<NornRunLeaseOwner>;
138
+ if (typeof owner.token !== "string" || owner.token.length === 0) return undefined;
139
+ if (typeof owner.acquiredAt !== "string" || Number.isNaN(Date.parse(owner.acquiredAt))) return undefined;
140
+ if (typeof owner.heartbeatAt !== "string" || Number.isNaN(Date.parse(owner.heartbeatAt))) return undefined;
141
+ const pid = typeof owner.pid === "number" && Number.isInteger(owner.pid) ? owner.pid : 0;
142
+ const processGroupId = typeof owner.processGroupId === "number" && Number.isInteger(owner.processGroupId) ? owner.processGroupId : pid;
143
+ const command = Array.isArray(owner.command) ? owner.command.filter((value): value is string => typeof value === "string") : [];
144
+ return { token: owner.token, acquiredAt: owner.acquiredAt, heartbeatAt: owner.heartbeatAt, pid, processGroupId, command };
145
+ }
146
+
147
+ function isRunLeaseStale(owner: NornRunLeaseOwner): boolean {
148
+ return Date.now() - Date.parse(owner.heartbeatAt) > HEARTBEAT_TTL_MS;
149
+ }
150
+
151
+ async function isRunLeaseDirectoryStale(lockRoot: string): Promise<boolean> {
152
+ try {
153
+ return Date.now() - (await stat(lockRoot)).mtimeMs > HEARTBEAT_TTL_MS;
154
+ } catch (error) {
155
+ if (isNodeError(error) && error.code === "ENOENT") return true;
156
+ throw error;
157
+ }
158
+ }