@vimhead.dev/norn-cli 0.1.0-tip.35614826665.1 → 0.1.0-tip.35724621678.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 (119) hide show
  1. package/assets/README.md +41 -13
  2. package/assets/docs/README.md +16 -16
  3. package/assets/docs/agents.md +23 -15
  4. package/assets/docs/cli.md +10 -8
  5. package/assets/docs/composition.md +7 -7
  6. package/assets/docs/persistence.md +22 -22
  7. package/assets/docs/projects.md +6 -6
  8. package/assets/docs/recovery.md +10 -10
  9. package/assets/docs/schemas.md +42 -36
  10. package/assets/docs/workflows.md +39 -32
  11. package/assets/examples/agent-then-analysis/input.json +3 -3
  12. package/assets/examples/agent-then-analysis/norn.project.json +2 -2
  13. package/assets/examples/agent-then-analysis/plugin.ts +37 -9
  14. package/assets/examples/caller-owned-routing/README.md +6 -6
  15. package/assets/examples/caller-owned-routing/assessment.ts +18 -4
  16. package/assets/examples/caller-owned-routing/contracts.ts +7 -2
  17. package/assets/examples/caller-owned-routing/input.json +12 -12
  18. package/assets/examples/caller-owned-routing/norn.project.json +2 -2
  19. package/assets/examples/caller-owned-routing/revision.ts +7 -2
  20. package/assets/examples/caller-owned-routing/router.ts +14 -3
  21. package/assets/examples/caller-selected-continuation/caller.ts +38 -12
  22. package/assets/examples/caller-selected-continuation/input.json +7 -7
  23. package/assets/examples/caller-selected-continuation/norn.project.json +2 -2
  24. package/assets/examples/caller-selected-continuation/producer.ts +10 -3
  25. package/assets/examples/coordinating-multiple-agents/README.md +6 -6
  26. package/assets/examples/coordinating-multiple-agents/input.json +20 -8
  27. package/assets/examples/coordinating-multiple-agents/norn.project.json +2 -2
  28. package/assets/examples/coordinating-multiple-agents/plugin.ts +165 -46
  29. package/assets/examples/coordinating-multiple-agents/queue-tools.ts +30 -7
  30. package/assets/examples/coordinating-multiple-agents/work-queue.ts +211 -63
  31. package/assets/examples/getting-started/norn.project.json +2 -2
  32. package/assets/examples/getting-started/plugin.ts +33 -13
  33. package/assets/examples/minimal-workflow/norn.project.json +2 -2
  34. package/assets/examples/minimal-workflow/plugin.ts +12 -3
  35. package/assets/examples/shared-state/README.md +6 -6
  36. package/assets/examples/shared-state/input.json +1 -1
  37. package/assets/examples/shared-state/norn.project.json +2 -2
  38. package/assets/examples/shared-state/plugin.ts +44 -15
  39. package/assets/examples/shared-state/shared-state.ts +46 -13
  40. package/assets/examples/shared-state/state-tools.ts +68 -16
  41. package/assets/examples/worktree-development-loop/README.md +12 -12
  42. package/assets/examples/worktree-development-loop/norn.project.json +7 -7
  43. package/assets/examples/worktree-development-loop/plugin.ts +7 -1
  44. package/assets/examples/worktree-development-loop/scope.ts +4 -1
  45. package/assets/examples/worktree-development-loop/shared/commands.ts +6 -2
  46. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -4
  47. package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -1
  48. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +11 -1
  49. package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +6 -2
  50. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +40 -10
  51. package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +5 -1
  52. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -1
  53. package/assets/examples/worktree-development-loop/workflows/planning/index.ts +5 -1
  54. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +33 -9
  55. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -8
  56. package/assets/package.json +1 -1
  57. package/assets/packages/cli/src/build-info.ts +4 -1
  58. package/assets/packages/cli/src/bun/cli.ts +21 -5
  59. package/assets/packages/cli/src/cli.ts +585 -170
  60. package/assets/packages/cli/src/client.ts +194 -36
  61. package/assets/packages/cli/src/documentation.ts +135 -30
  62. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  63. package/assets/packages/cli/src/internal/agent-directory.ts +7 -2
  64. package/assets/packages/cli/src/internal/agent-response-tool.ts +57 -15
  65. package/assets/packages/cli/src/internal/agents.ts +248 -60
  66. package/assets/packages/cli/src/internal/commands.ts +82 -20
  67. package/assets/packages/cli/src/internal/documentation-bundle.ts +44 -13
  68. package/assets/packages/cli/src/internal/engine.ts +396 -87
  69. package/assets/packages/cli/src/internal/errors.ts +13 -5
  70. package/assets/packages/cli/src/internal/execution-context.ts +20 -12
  71. package/assets/packages/cli/src/internal/file-coordinator.ts +95 -26
  72. package/assets/packages/cli/src/internal/file-names.ts +4 -1
  73. package/assets/packages/cli/src/internal/launch-request.ts +74 -22
  74. package/assets/packages/cli/src/internal/logs.ts +10 -2
  75. package/assets/packages/cli/src/internal/metrics.ts +247 -61
  76. package/assets/packages/cli/src/internal/pi-assets.ts +105 -23
  77. package/assets/packages/cli/src/internal/run-lease.ts +72 -22
  78. package/assets/packages/cli/src/internal/run-log.ts +49 -20
  79. package/assets/packages/cli/src/internal/run-names.ts +120 -12
  80. package/assets/packages/cli/src/internal/run-state.ts +306 -71
  81. package/assets/packages/cli/src/internal/run-store.ts +216 -53
  82. package/assets/packages/cli/src/internal/usage.ts +23 -7
  83. package/assets/packages/cli/src/internal/worker-directory.ts +9 -3
  84. package/assets/packages/cli/src/internal/workflow-registry.ts +281 -70
  85. package/assets/packages/cli/src/internal/working-directory.ts +2 -1
  86. package/assets/packages/cli/src/workflow-loader.ts +341 -74
  87. package/assets/packages/core/src/atomic-files.ts +13 -4
  88. package/assets/packages/core/src/workflow-transition.ts +13 -2
  89. package/assets/packages/sdk/src/api.ts +248 -80
  90. package/assets/packages/sdk/src/index.ts +6 -1
  91. package/assets/packages/sdk/src/schema.ts +75 -21
  92. package/assets/tests/workflow-ref.test.ts +242 -57
  93. package/bin/norn.mjs +13 -4
  94. package/dist/cli.js +344 -90
  95. package/dist/client.js +100 -22
  96. package/dist/documentation.js +102 -25
  97. package/dist/generated-build-info.d.ts +2 -2
  98. package/dist/generated-build-info.js +2 -2
  99. package/dist/internal/agent-directory.js +3 -1
  100. package/dist/internal/agent-response-tool.js +42 -11
  101. package/dist/internal/agents.js +149 -37
  102. package/dist/internal/commands.js +65 -16
  103. package/dist/internal/documentation-bundle.js +23 -11
  104. package/dist/internal/engine.js +304 -66
  105. package/dist/internal/errors.js +4 -2
  106. package/dist/internal/execution-context.js +2 -1
  107. package/dist/internal/file-coordinator.js +59 -19
  108. package/dist/internal/launch-request.js +32 -14
  109. package/dist/internal/metrics.js +138 -38
  110. package/dist/internal/pi-assets.js +70 -19
  111. package/dist/internal/run-lease.js +29 -9
  112. package/dist/internal/run-log.js +24 -12
  113. package/dist/internal/run-state.js +172 -48
  114. package/dist/internal/run-store.js +157 -45
  115. package/dist/internal/worker-directory.js +6 -2
  116. package/dist/internal/workflow-registry.js +123 -32
  117. package/dist/internal/working-directory.js +2 -1
  118. package/dist/workflow-loader.js +198 -41
  119. package/package.json +2 -2
@@ -1,10 +1,21 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type { NornAgentMetrics, NornAgentUsage, NornCommandMetrics, NornRunMetrics, NornRunStatus, NornWorkflowMetrics } from "@vimhead.dev/norn";
3
+ import type {
4
+ NornAgentMetrics,
5
+ NornAgentUsage,
6
+ NornCommandMetrics,
7
+ NornRunMetrics,
8
+ NornRunStatus,
9
+ NornWorkflowMetrics,
10
+ } from "@vimhead.dev/norn";
4
11
  import { runCurrentRoot } from "./run-store.ts";
5
12
  import { RUN_STATE_FILE_NAME } from "./run-state.ts";
6
13
  import { isNodeError } from "@vimhead.dev/norn-core/errors";
7
- import { addAgentUsage, agentUsageFromValue, emptyAgentUsage } from "./usage.ts";
14
+ import {
15
+ addAgentUsage,
16
+ agentUsageFromValue,
17
+ emptyAgentUsage,
18
+ } from "./usage.ts";
8
19
 
9
20
  const LEGACY_RUN_STATE_FILE_NAME = "runtime-state.json";
10
21
 
@@ -61,7 +72,8 @@ export function calculateRunMetrics(
61
72
  state: NornRunStateSnapshot,
62
73
  now: Date,
63
74
  ): NornRunMetrics {
64
- const startedAt = state.startedAt ?? firstEventTime(events) ?? now.toISOString();
75
+ const startedAt =
76
+ state.startedAt ?? firstEventTime(events) ?? now.toISOString();
65
77
  const endedAt = runEndedAt(state);
66
78
  const startedAtMs = timestampMs(startedAt) ?? now.getTime();
67
79
  const endedAtMs = timestampMs(endedAt) ?? now.getTime();
@@ -73,8 +85,13 @@ export function calculateRunMetrics(
73
85
 
74
86
  for (const event of events) {
75
87
  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) {
88
+ if (event.type === "run.interrupted" && eventAtMs !== undefined)
89
+ gateStartedAtMs = eventAtMs;
90
+ else if (
91
+ event.type === "run.resumed" &&
92
+ eventAtMs !== undefined &&
93
+ gateStartedAtMs !== undefined
94
+ ) {
78
95
  gateWaitMs += Math.max(0, eventAtMs - gateStartedAtMs);
79
96
  gateStartedAtMs = undefined;
80
97
  }
@@ -93,45 +110,101 @@ export function calculateRunMetrics(
93
110
  }
94
111
 
95
112
  if (event.type === "agent.started") {
96
- if (openWorkflow) openWorkflow = startAgentMetrics(openWorkflow, event, now);
113
+ if (openWorkflow)
114
+ openWorkflow = startAgentMetrics(openWorkflow, event, now);
97
115
  continue;
98
116
  }
99
117
 
100
118
  if (event.type === "agent.completed" || event.type === "agent.failed") {
101
119
  const usage = agentUsageFromEvent(event);
102
120
  agentUsage = addAgentUsage(agentUsage, usage);
103
- if (openWorkflow) openWorkflow = finishAgentMetrics(openWorkflow, event, usage, now);
121
+ if (openWorkflow)
122
+ openWorkflow = finishAgentMetrics(openWorkflow, event, usage, now);
104
123
  continue;
105
124
  }
106
125
 
107
126
  if (event.type === "command.started") {
108
- if (openWorkflow) openWorkflow = startCommandMetrics(openWorkflow, event, now);
127
+ if (openWorkflow)
128
+ openWorkflow = startCommandMetrics(openWorkflow, event, now);
109
129
  continue;
110
130
  }
111
131
 
112
132
  if (event.type === "command.completed" || event.type === "command.failed") {
113
- if (openWorkflow) openWorkflow = finishCommandMetrics(openWorkflow, event, now);
133
+ if (openWorkflow)
134
+ openWorkflow = finishCommandMetrics(openWorkflow, event, now);
114
135
  continue;
115
136
  }
116
137
 
117
138
  const status = workflowTerminalStatus(event.type);
118
139
  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);
140
+ const durationMs =
141
+ numberField(event.durationMs) ||
142
+ (openWorkflow && eventAtMs !== undefined
143
+ ? Math.max(0, eventAtMs - openWorkflow.startedAtMs)
144
+ : 0);
145
+ const workflowId =
146
+ status === "transitioned"
147
+ ? stringField(
148
+ event.fromWorkflowId,
149
+ openWorkflow?.workflowId ?? "unknown",
150
+ )
151
+ : stringField(
152
+ event.workflowId,
153
+ openWorkflow?.workflowId ?? "unknown",
154
+ );
155
+ const workflow =
156
+ openWorkflow ??
157
+ createSyntheticWorkflowMetrics(
158
+ workflowId,
159
+ startedAtMs,
160
+ endedAtMs,
161
+ eventAtMs,
162
+ durationMs,
163
+ );
122
164
  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));
165
+ workflows.push(
166
+ closeWorkflowMetrics(
167
+ closeOpenChildMetrics(
168
+ workflow,
169
+ eventAtMs ?? now.getTime(),
170
+ event.at,
171
+ childStatus,
172
+ ),
173
+ workflows.length + 1,
174
+ status,
175
+ durationMs,
176
+ event.at,
177
+ ),
178
+ );
124
179
  openWorkflow = undefined;
125
180
  }
126
181
  }
127
182
 
128
- if (gateStartedAtMs !== undefined) gateWaitMs += Math.max(0, endedAtMs - gateStartedAtMs);
183
+ if (gateStartedAtMs !== undefined)
184
+ gateWaitMs += Math.max(0, endedAtMs - gateStartedAtMs);
129
185
  if (openWorkflow) {
130
- workflows.push(closeWorkflowMetrics(closeOpenChildMetrics(openWorkflow, endedAtMs, endedAt, "running"), workflows.length + 1, "running", Math.max(0, endedAtMs - openWorkflow.startedAtMs), endedAt));
186
+ workflows.push(
187
+ closeWorkflowMetrics(
188
+ closeOpenChildMetrics(openWorkflow, endedAtMs, endedAt, "running"),
189
+ workflows.length + 1,
190
+ "running",
191
+ Math.max(0, endedAtMs - openWorkflow.startedAtMs),
192
+ endedAt,
193
+ ),
194
+ );
131
195
  }
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);
196
+ const workflowsMs = workflows.reduce(
197
+ (sum, workflow) => sum + workflow.wallMs,
198
+ 0,
199
+ );
200
+ const agentsMs = workflows.reduce(
201
+ (sum, workflow) => sum + workflow.agentsMs,
202
+ 0,
203
+ );
204
+ const commandsMs = workflows.reduce(
205
+ (sum, workflow) => sum + workflow.commandsMs,
206
+ 0,
207
+ );
135
208
  const wallMs = Math.max(0, endedAtMs - startedAtMs);
136
209
  return {
137
210
  status: state.status ?? "running",
@@ -149,22 +222,39 @@ export function calculateRunMetrics(
149
222
  };
150
223
  }
151
224
 
152
- async function readRunStateSnapshot(currentRoot: string): Promise<NornRunStateSnapshot> {
225
+ async function readRunStateSnapshot(
226
+ currentRoot: string,
227
+ ): Promise<NornRunStateSnapshot> {
153
228
  try {
154
- return JSON.parse(await readFile(join(currentRoot, RUN_STATE_FILE_NAME), "utf8")) as NornRunStateSnapshot;
229
+ return JSON.parse(
230
+ await readFile(join(currentRoot, RUN_STATE_FILE_NAME), "utf8"),
231
+ ) as NornRunStateSnapshot;
155
232
  } catch (error) {
156
233
  if (!isNodeError(error) || error.code !== "ENOENT") throw error;
157
- return JSON.parse(await readFile(join(currentRoot, LEGACY_RUN_STATE_FILE_NAME), "utf8")) as NornRunStateSnapshot;
234
+ return JSON.parse(
235
+ await readFile(join(currentRoot, LEGACY_RUN_STATE_FILE_NAME), "utf8"),
236
+ ) as NornRunStateSnapshot;
158
237
  }
159
238
  }
160
239
 
161
- async function readManifestEvents(path: string): Promise<readonly NornRunManifestEvent[]> {
240
+ async function readManifestEvents(
241
+ path: string,
242
+ ): Promise<readonly NornRunManifestEvent[]> {
162
243
  const manifest = JSON.parse(await readFile(path, "utf8")) as NornRunManifest;
163
244
  return manifest.events ?? [];
164
245
  }
165
246
 
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);
247
+ function createSyntheticWorkflowMetrics(
248
+ workflowId: string,
249
+ runStartedAtMs: number,
250
+ runEndedAtMs: number,
251
+ eventAtMs: number | undefined,
252
+ durationMs: number,
253
+ ): OpenWorkflowMetrics {
254
+ const startedAtMs =
255
+ eventAtMs === undefined
256
+ ? runEndedAtMs
257
+ : Math.max(runStartedAtMs, eventAtMs - durationMs);
168
258
  return {
169
259
  workflowId,
170
260
  startedAt: isoTimeFromMs(startedAtMs),
@@ -176,41 +266,90 @@ function createSyntheticWorkflowMetrics(workflowId: string, runStartedAtMs: numb
176
266
  };
177
267
  }
178
268
 
179
- function startAgentMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, now: Date): OpenWorkflowMetrics {
269
+ function startAgentMetrics(
270
+ workflow: OpenWorkflowMetrics,
271
+ event: NornRunManifestEvent,
272
+ now: Date,
273
+ ): OpenWorkflowMetrics {
180
274
  return {
181
275
  ...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
- }],
276
+ openAgents: [
277
+ ...workflow.openAgents,
278
+ {
279
+ label: stringField(event.label, "unknown"),
280
+ startedAt: isoTime(event.at, now),
281
+ startedAtMs: timestampMs(event.at) ?? now.getTime(),
282
+ },
283
+ ],
187
284
  };
188
285
  }
189
286
 
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);
287
+ function finishAgentMetrics(
288
+ workflow: OpenWorkflowMetrics,
289
+ event: NornRunManifestEvent,
290
+ usage: NornAgentUsage,
291
+ now: Date,
292
+ ): OpenWorkflowMetrics {
293
+ const [agent, openAgents] = takeOpenChild(
294
+ workflow.openAgents,
295
+ stringField(event.label, "unknown"),
296
+ );
297
+ const closedAgent = closeAgentMetrics(
298
+ event,
299
+ workflow.agents.length + 1,
300
+ usage,
301
+ now,
302
+ agent,
303
+ );
193
304
  return { ...workflow, agents: [...workflow.agents, closedAgent], openAgents };
194
305
  }
195
306
 
196
- function startCommandMetrics(workflow: OpenWorkflowMetrics, event: NornRunManifestEvent, now: Date): OpenWorkflowMetrics {
307
+ function startCommandMetrics(
308
+ workflow: OpenWorkflowMetrics,
309
+ event: NornRunManifestEvent,
310
+ now: Date,
311
+ ): OpenWorkflowMetrics {
197
312
  return {
198
313
  ...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
- }],
314
+ openCommands: [
315
+ ...workflow.openCommands,
316
+ {
317
+ label: stringField(event.label, "unknown"),
318
+ startedAt: isoTime(event.at, now),
319
+ startedAtMs: timestampMs(event.at) ?? now.getTime(),
320
+ },
321
+ ],
204
322
  };
205
323
  }
206
324
 
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 };
325
+ function finishCommandMetrics(
326
+ workflow: OpenWorkflowMetrics,
327
+ event: NornRunManifestEvent,
328
+ now: Date,
329
+ ): OpenWorkflowMetrics {
330
+ const [command, openCommands] = takeOpenChild(
331
+ workflow.openCommands,
332
+ stringField(event.label, "unknown"),
333
+ );
334
+ const closedCommand = closeCommandMetrics(
335
+ event,
336
+ workflow.commands.length + 1,
337
+ now,
338
+ command,
339
+ );
340
+ return {
341
+ ...workflow,
342
+ commands: [...workflow.commands, closedCommand],
343
+ openCommands,
344
+ };
211
345
  }
212
346
 
213
- function closeOpenChildMetrics(workflow: OpenWorkflowMetrics, endedAtMs: number, endedAt: string | undefined, status: NornAgentMetrics["status"]): OpenWorkflowMetrics {
347
+ function closeOpenChildMetrics(
348
+ workflow: OpenWorkflowMetrics,
349
+ endedAtMs: number,
350
+ endedAt: string | undefined,
351
+ status: NornAgentMetrics["status"],
352
+ ): OpenWorkflowMetrics {
214
353
  return {
215
354
  ...workflow,
216
355
  agents: [
@@ -248,8 +387,14 @@ function closeWorkflowMetrics(
248
387
  wallMs: number,
249
388
  endedAt: string | undefined,
250
389
  ): 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);
390
+ const agentsMs = workflow.agents.reduce(
391
+ (sum, agent) => sum + agent.wallMs,
392
+ 0,
393
+ );
394
+ const commandsMs = workflow.commands.reduce(
395
+ (sum, command) => sum + command.wallMs,
396
+ 0,
397
+ );
253
398
  return {
254
399
  index,
255
400
  workflowId: workflow.workflowId,
@@ -260,20 +405,32 @@ function closeWorkflowMetrics(
260
405
  ownMs: Math.max(0, wallMs - agentsMs - commandsMs),
261
406
  agentsMs,
262
407
  commandsMs,
263
- agentUsage: workflow.agents.reduce((sum, agent) => addAgentUsage(sum, agent.usage), emptyAgentUsage()),
408
+ agentUsage: workflow.agents.reduce(
409
+ (sum, agent) => addAgentUsage(sum, agent.usage),
410
+ emptyAgentUsage(),
411
+ ),
264
412
  agents: workflow.agents,
265
413
  commands: workflow.commands,
266
414
  };
267
415
  }
268
416
 
269
- function closeAgentMetrics(event: NornRunManifestEvent, index: number, usage: NornAgentUsage, now: Date, openAgent: OpenAgentMetrics | undefined): NornAgentMetrics {
417
+ function closeAgentMetrics(
418
+ event: NornRunManifestEvent,
419
+ index: number,
420
+ usage: NornAgentUsage,
421
+ now: Date,
422
+ openAgent: OpenAgentMetrics | undefined,
423
+ ): NornAgentMetrics {
270
424
  const endedAtMs = timestampMs(event.at) ?? now.getTime();
271
- const wallMs = numberField(event.durationMs) || (openAgent ? Math.max(0, endedAtMs - openAgent.startedAtMs) : 0);
425
+ const wallMs =
426
+ numberField(event.durationMs) ||
427
+ (openAgent ? Math.max(0, endedAtMs - openAgent.startedAtMs) : 0);
272
428
  return {
273
429
  index,
274
430
  label: stringField(event.label, openAgent?.label ?? "unknown"),
275
431
  status: event.type === "agent.failed" ? "failed" : "completed",
276
- startedAt: openAgent?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
432
+ startedAt:
433
+ openAgent?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
277
434
  endedAt: isoTime(event.at, now),
278
435
  wallMs,
279
436
  attempts: optionalNumberField(event.attempts),
@@ -281,27 +438,48 @@ function closeAgentMetrics(event: NornRunManifestEvent, index: number, usage: No
281
438
  };
282
439
  }
283
440
 
284
- function closeCommandMetrics(event: NornRunManifestEvent, index: number, now: Date, openCommand: OpenCommandMetrics | undefined): NornCommandMetrics {
441
+ function closeCommandMetrics(
442
+ event: NornRunManifestEvent,
443
+ index: number,
444
+ now: Date,
445
+ openCommand: OpenCommandMetrics | undefined,
446
+ ): NornCommandMetrics {
285
447
  const endedAtMs = timestampMs(event.at) ?? now.getTime();
286
- const wallMs = numberField(event.durationMs) || (openCommand ? Math.max(0, endedAtMs - openCommand.startedAtMs) : 0);
448
+ const wallMs =
449
+ numberField(event.durationMs) ||
450
+ (openCommand ? Math.max(0, endedAtMs - openCommand.startedAtMs) : 0);
287
451
  return {
288
452
  index,
289
453
  label: stringField(event.label, openCommand?.label ?? "unknown"),
290
454
  status: event.type === "command.failed" ? "failed" : "completed",
291
- startedAt: openCommand?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
455
+ startedAt:
456
+ openCommand?.startedAt ?? isoTimeFromMs(Math.max(0, endedAtMs - wallMs)),
292
457
  endedAt: isoTime(event.at, now),
293
458
  wallMs,
294
- ...(event.type === "command.completed" ? { exitCode: nullableNumberField(event.exitCode), killed: booleanField(event.killed) } : {}),
459
+ ...(event.type === "command.completed"
460
+ ? {
461
+ exitCode: nullableNumberField(event.exitCode),
462
+ killed: booleanField(event.killed),
463
+ }
464
+ : {}),
295
465
  };
296
466
  }
297
467
 
298
- function takeOpenChild<T extends { readonly label: string }>(children: readonly T[], label: string): readonly [T | undefined, readonly T[]] {
468
+ function takeOpenChild<T extends { readonly label: string }>(
469
+ children: readonly T[],
470
+ label: string,
471
+ ): readonly [T | undefined, readonly T[]] {
299
472
  const index = children.findIndex((child) => child.label === label);
300
473
  if (index === -1) return [undefined, children];
301
- return [children[index], [...children.slice(0, index), ...children.slice(index + 1)]];
474
+ return [
475
+ children[index],
476
+ [...children.slice(0, index), ...children.slice(index + 1)],
477
+ ];
302
478
  }
303
479
 
304
- function workflowTerminalStatus(type: unknown): NornWorkflowMetrics["status"] | undefined {
480
+ function workflowTerminalStatus(
481
+ type: unknown,
482
+ ): NornWorkflowMetrics["status"] | undefined {
305
483
  if (type === "workflow.completed") return "completed";
306
484
  if (type === "workflow.failed") return "failed";
307
485
  if (type === "workflow.transitioned") return "transitioned";
@@ -309,8 +487,12 @@ function workflowTerminalStatus(type: unknown): NornWorkflowMetrics["status"] |
309
487
  }
310
488
 
311
489
  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;
490
+ if (state.status === "completed")
491
+ return state.outcome?.completedAt ?? state.updatedAt;
492
+ if (state.status === "failed")
493
+ return (
494
+ state.failed?.failedAt ?? state.outcome?.completedAt ?? state.updatedAt
495
+ );
314
496
  return undefined;
315
497
  }
316
498
 
@@ -318,7 +500,9 @@ function agentUsageFromEvent(event: NornRunManifestEvent): NornAgentUsage {
318
500
  return agentUsageFromValue(event.usage) ?? emptyAgentUsage();
319
501
  }
320
502
 
321
- function firstEventTime(events: readonly NornRunManifestEvent[]): string | undefined {
503
+ function firstEventTime(
504
+ events: readonly NornRunManifestEvent[],
505
+ ): string | undefined {
322
506
  return events.find((event) => timestampMs(event.at) !== undefined)?.at;
323
507
  }
324
508
 
@@ -340,7 +524,9 @@ function numberField(value: unknown): number {
340
524
  }
341
525
 
342
526
  function optionalNumberField(value: unknown): number | undefined {
343
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
527
+ return typeof value === "number" && Number.isFinite(value)
528
+ ? value
529
+ : undefined;
344
530
  }
345
531
 
346
532
  function nullableNumberField(value: unknown): number | null {
@@ -1,5 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import {
3
+ lstat,
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ rm,
10
+ writeFile,
11
+ } from "node:fs/promises";
3
12
  import { join, resolve } from "node:path";
4
13
  import { gunzipSync } from "node:zlib";
5
14
  import { isNodeError } from "@vimhead.dev/norn-core/errors";
@@ -11,50 +20,99 @@ export function resolvePiAssetsCacheRoot(input: {
11
20
  readonly home: string;
12
21
  readonly environment: Readonly<Record<string, string | undefined>>;
13
22
  }): 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");
23
+ if (input.environment.NORN_PI_CACHE_DIR)
24
+ return resolve(input.environment.NORN_PI_CACHE_DIR);
25
+ if (input.platform === "darwin")
26
+ return join(input.home, "Library", "Caches", "norn", "pi");
27
+ if (input.platform === "win32")
28
+ return join(
29
+ input.environment.LOCALAPPDATA || join(input.home, "AppData", "Local"),
30
+ "norn",
31
+ "pi",
32
+ );
17
33
  const xdgCache = input.environment.XDG_CACHE_HOME;
18
- return join(xdgCache?.startsWith("/") ? xdgCache : join(input.home, ".cache"), "norn", "pi");
34
+ return join(
35
+ xdgCache?.startsWith("/") ? xdgCache : join(input.home, ".cache"),
36
+ "norn",
37
+ "pi",
38
+ );
19
39
  }
20
40
 
21
- export async function materializePiAssets(input: { readonly archive: string; readonly cacheRoot: string }): Promise<string> {
41
+ export async function materializePiAssets(input: {
42
+ readonly archive: string;
43
+ readonly cacheRoot: string;
44
+ }): Promise<string> {
22
45
  const digest = createHash("sha256").update(input.archive).digest("hex");
23
- const files: PiAssetFile[] = JSON.parse(gunzipSync(Buffer.from(input.archive, "base64")).toString("utf8"));
46
+ const files: PiAssetFile[] = JSON.parse(
47
+ gunzipSync(Buffer.from(input.archive, "base64")).toString("utf8"),
48
+ );
24
49
  const expectedFiles = new Map<string, Buffer>();
25
50
  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)) {
51
+ if (
52
+ !file.path ||
53
+ file.path.includes("\\") ||
54
+ file.path.includes(":") ||
55
+ file.path
56
+ .split("/")
57
+ .some((part) => !part || part === "." || part === "..") ||
58
+ expectedFiles.has(file.path)
59
+ ) {
27
60
  throw new Error("Invalid bundled Pi asset path");
28
61
  }
29
62
  expectedFiles.set(file.path, Buffer.from(file.content, "base64"));
30
63
  }
31
- return new PiAssetCache({ cacheRoot: input.cacheRoot, digest, expectedFiles }).materialize();
64
+ return new PiAssetCache({
65
+ cacheRoot: input.cacheRoot,
66
+ digest,
67
+ expectedFiles,
68
+ }).materialize();
32
69
  }
33
70
 
34
71
  class PiAssetCache {
35
72
  private readonly root: string;
36
73
 
37
- constructor(private readonly input: { readonly cacheRoot: string; readonly digest: string; readonly expectedFiles: ReadonlyMap<string, Buffer> }) {
74
+ constructor(
75
+ private readonly input: {
76
+ readonly cacheRoot: string;
77
+ readonly digest: string;
78
+ readonly expectedFiles: ReadonlyMap<string, Buffer>;
79
+ },
80
+ ) {
38
81
  this.root = join(input.cacheRoot, `v1-${input.digest}`);
39
82
  }
40
83
 
41
84
  async materialize(): Promise<string> {
42
85
  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");
86
+ if (!(await lstat(this.input.cacheRoot)).isDirectory())
87
+ throw new Error("Pi asset cache root must be a real directory");
44
88
  if (await this.validateExistingCache()) return this.root;
45
89
  const staging = await mkdtemp(join(this.input.cacheRoot, ".extract-"));
46
90
  try {
47
91
  for (const [path, content] of this.input.expectedFiles) {
48
92
  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 });
93
+ await mkdir(join(staging, ...parts.slice(0, -1)), {
94
+ recursive: true,
95
+ mode: 0o700,
96
+ });
97
+ await writeFile(join(staging, ...parts), content, {
98
+ flag: "wx",
99
+ mode: 0o600,
100
+ });
51
101
  }
52
102
  try {
53
103
  await rename(staging, this.root);
54
104
  } catch (error) {
55
- if (!isNodeError(error) || !["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(error.code ?? "") || !await this.validateExistingCache()) throw error;
105
+ if (
106
+ !isNodeError(error) ||
107
+ !["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(
108
+ error.code ?? "",
109
+ ) ||
110
+ !(await this.validateExistingCache())
111
+ )
112
+ throw error;
56
113
  }
57
- if (!await this.validateExistingCache()) throw new Error("Pi assets disappeared after extraction");
114
+ if (!(await this.validateExistingCache()))
115
+ throw new Error("Pi assets disappeared after extraction");
58
116
  return this.root;
59
117
  } finally {
60
118
  await rm(staging, { recursive: true, force: true });
@@ -63,33 +121,57 @@ class PiAssetCache {
63
121
 
64
122
  private async validateExistingCache(): Promise<boolean> {
65
123
  try {
66
- if (!(await lstat(this.root)).isDirectory()) throw this.invalidCacheError();
124
+ if (!(await lstat(this.root)).isDirectory())
125
+ throw this.invalidCacheError();
67
126
  } catch (error) {
68
127
  if (isNodeError(error) && error.code === "ENOENT") return false;
69
128
  throw error;
70
129
  }
71
130
  const seen = new Set<string>();
72
131
  await this.validateDirectory({ directory: this.root, prefix: "", seen });
73
- if (seen.size !== this.input.expectedFiles.size) throw this.invalidCacheError();
132
+ if (seen.size !== this.input.expectedFiles.size)
133
+ throw this.invalidCacheError();
74
134
  return true;
75
135
  }
76
136
 
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 })) {
137
+ private async validateDirectory(input: {
138
+ readonly directory: string;
139
+ readonly prefix: string;
140
+ readonly seen: Set<string>;
141
+ }): Promise<void> {
142
+ for (const entry of await readdir(input.directory, {
143
+ withFileTypes: true,
144
+ })) {
79
145
  const path = input.prefix ? `${input.prefix}/${entry.name}` : entry.name;
80
146
  const absolutePath = join(input.directory, entry.name);
81
147
  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 });
148
+ if (
149
+ ![...this.input.expectedFiles.keys()].some((file) =>
150
+ file.startsWith(`${path}/`),
151
+ )
152
+ )
153
+ throw this.invalidCacheError();
154
+ await this.validateDirectory({
155
+ directory: absolutePath,
156
+ prefix: path,
157
+ seen: input.seen,
158
+ });
84
159
  } else {
85
160
  const expected = this.input.expectedFiles.get(path);
86
- if (!entry.isFile() || !expected || !(await readFile(absolutePath)).equals(expected)) throw this.invalidCacheError();
161
+ if (
162
+ !entry.isFile() ||
163
+ !expected ||
164
+ !(await readFile(absolutePath)).equals(expected)
165
+ )
166
+ throw this.invalidCacheError();
87
167
  input.seen.add(path);
88
168
  }
89
169
  }
90
170
  }
91
171
 
92
172
  private invalidCacheError(): Error {
93
- return new Error(`Bundled Pi assets are incomplete or modified: ${this.root}. Remove this cache entry and retry.`);
173
+ return new Error(
174
+ `Bundled Pi assets are incomplete or modified: ${this.root}. Remove this cache entry and retry.`,
175
+ );
94
176
  }
95
177
  }