@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.
- package/assets/README.md +41 -13
- package/assets/docs/README.md +16 -16
- package/assets/docs/agents.md +23 -15
- package/assets/docs/cli.md +10 -8
- package/assets/docs/composition.md +7 -7
- package/assets/docs/persistence.md +22 -22
- package/assets/docs/projects.md +6 -6
- package/assets/docs/recovery.md +10 -10
- package/assets/docs/schemas.md +42 -36
- package/assets/docs/workflows.md +39 -32
- package/assets/examples/agent-then-analysis/input.json +3 -3
- package/assets/examples/agent-then-analysis/norn.project.json +2 -2
- package/assets/examples/agent-then-analysis/plugin.ts +37 -9
- package/assets/examples/caller-owned-routing/README.md +6 -6
- package/assets/examples/caller-owned-routing/assessment.ts +18 -4
- package/assets/examples/caller-owned-routing/contracts.ts +7 -2
- package/assets/examples/caller-owned-routing/input.json +12 -12
- package/assets/examples/caller-owned-routing/norn.project.json +2 -2
- package/assets/examples/caller-owned-routing/revision.ts +7 -2
- package/assets/examples/caller-owned-routing/router.ts +14 -3
- package/assets/examples/caller-selected-continuation/caller.ts +38 -12
- package/assets/examples/caller-selected-continuation/input.json +7 -7
- package/assets/examples/caller-selected-continuation/norn.project.json +2 -2
- package/assets/examples/caller-selected-continuation/producer.ts +10 -3
- package/assets/examples/coordinating-multiple-agents/README.md +6 -6
- package/assets/examples/coordinating-multiple-agents/input.json +20 -8
- package/assets/examples/coordinating-multiple-agents/norn.project.json +2 -2
- package/assets/examples/coordinating-multiple-agents/plugin.ts +165 -46
- package/assets/examples/coordinating-multiple-agents/queue-tools.ts +30 -7
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +211 -63
- package/assets/examples/getting-started/norn.project.json +2 -2
- package/assets/examples/getting-started/plugin.ts +33 -13
- package/assets/examples/minimal-workflow/norn.project.json +2 -2
- package/assets/examples/minimal-workflow/plugin.ts +12 -3
- package/assets/examples/shared-state/README.md +6 -6
- package/assets/examples/shared-state/input.json +1 -1
- package/assets/examples/shared-state/norn.project.json +2 -2
- package/assets/examples/shared-state/plugin.ts +44 -15
- package/assets/examples/shared-state/shared-state.ts +46 -13
- package/assets/examples/shared-state/state-tools.ts +68 -16
- package/assets/examples/worktree-development-loop/README.md +12 -12
- package/assets/examples/worktree-development-loop/norn.project.json +7 -7
- package/assets/examples/worktree-development-loop/plugin.ts +7 -1
- package/assets/examples/worktree-development-loop/scope.ts +4 -1
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -4
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +11 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +40 -10
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -1
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +33 -9
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -8
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/build-info.ts +4 -1
- package/assets/packages/cli/src/bun/cli.ts +21 -5
- package/assets/packages/cli/src/cli.ts +585 -170
- package/assets/packages/cli/src/client.ts +194 -36
- package/assets/packages/cli/src/documentation.ts +135 -30
- package/assets/packages/cli/src/generated-build-info.ts +2 -2
- package/assets/packages/cli/src/internal/agent-directory.ts +7 -2
- package/assets/packages/cli/src/internal/agent-response-tool.ts +57 -15
- package/assets/packages/cli/src/internal/agents.ts +248 -60
- package/assets/packages/cli/src/internal/commands.ts +82 -20
- package/assets/packages/cli/src/internal/documentation-bundle.ts +44 -13
- package/assets/packages/cli/src/internal/engine.ts +396 -87
- package/assets/packages/cli/src/internal/errors.ts +13 -5
- package/assets/packages/cli/src/internal/execution-context.ts +20 -12
- package/assets/packages/cli/src/internal/file-coordinator.ts +95 -26
- package/assets/packages/cli/src/internal/file-names.ts +4 -1
- package/assets/packages/cli/src/internal/launch-request.ts +74 -22
- package/assets/packages/cli/src/internal/logs.ts +10 -2
- package/assets/packages/cli/src/internal/metrics.ts +247 -61
- package/assets/packages/cli/src/internal/pi-assets.ts +105 -23
- package/assets/packages/cli/src/internal/run-lease.ts +72 -22
- package/assets/packages/cli/src/internal/run-log.ts +49 -20
- package/assets/packages/cli/src/internal/run-names.ts +120 -12
- package/assets/packages/cli/src/internal/run-state.ts +306 -71
- package/assets/packages/cli/src/internal/run-store.ts +216 -53
- package/assets/packages/cli/src/internal/usage.ts +23 -7
- package/assets/packages/cli/src/internal/worker-directory.ts +9 -3
- package/assets/packages/cli/src/internal/workflow-registry.ts +281 -70
- package/assets/packages/cli/src/internal/working-directory.ts +2 -1
- package/assets/packages/cli/src/workflow-loader.ts +341 -74
- package/assets/packages/core/src/atomic-files.ts +13 -4
- package/assets/packages/core/src/workflow-transition.ts +13 -2
- package/assets/packages/sdk/src/api.ts +248 -80
- package/assets/packages/sdk/src/index.ts +6 -1
- package/assets/packages/sdk/src/schema.ts +75 -21
- package/assets/tests/workflow-ref.test.ts +242 -57
- package/bin/norn.mjs +13 -4
- package/dist/cli.js +344 -90
- package/dist/client.js +100 -22
- package/dist/documentation.js +102 -25
- package/dist/generated-build-info.d.ts +2 -2
- package/dist/generated-build-info.js +2 -2
- package/dist/internal/agent-directory.js +3 -1
- package/dist/internal/agent-response-tool.js +42 -11
- package/dist/internal/agents.js +149 -37
- package/dist/internal/commands.js +65 -16
- package/dist/internal/documentation-bundle.js +23 -11
- package/dist/internal/engine.js +304 -66
- package/dist/internal/errors.js +4 -2
- package/dist/internal/execution-context.js +2 -1
- package/dist/internal/file-coordinator.js +59 -19
- package/dist/internal/launch-request.js +32 -14
- package/dist/internal/metrics.js +138 -38
- package/dist/internal/pi-assets.js +70 -19
- package/dist/internal/run-lease.js +29 -9
- package/dist/internal/run-log.js +24 -12
- package/dist/internal/run-state.js +172 -48
- package/dist/internal/run-store.js +157 -45
- package/dist/internal/worker-directory.js +6 -2
- package/dist/internal/workflow-registry.js +123 -32
- package/dist/internal/working-directory.js +2 -1
- package/dist/workflow-loader.js +198 -41
- 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 {
|
|
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 {
|
|
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 =
|
|
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)
|
|
77
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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 =
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
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)
|
|
183
|
+
if (gateStartedAtMs !== undefined)
|
|
184
|
+
gateWaitMs += Math.max(0, endedAtMs - gateStartedAtMs);
|
|
129
185
|
if (openWorkflow) {
|
|
130
|
-
workflows.push(
|
|
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(
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
225
|
+
async function readRunStateSnapshot(
|
|
226
|
+
currentRoot: string,
|
|
227
|
+
): Promise<NornRunStateSnapshot> {
|
|
153
228
|
try {
|
|
154
|
-
return JSON.parse(
|
|
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(
|
|
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(
|
|
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(
|
|
167
|
-
|
|
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(
|
|
269
|
+
function startAgentMetrics(
|
|
270
|
+
workflow: OpenWorkflowMetrics,
|
|
271
|
+
event: NornRunManifestEvent,
|
|
272
|
+
now: Date,
|
|
273
|
+
): OpenWorkflowMetrics {
|
|
180
274
|
return {
|
|
181
275
|
...workflow,
|
|
182
|
-
openAgents: [
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
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(
|
|
307
|
+
function startCommandMetrics(
|
|
308
|
+
workflow: OpenWorkflowMetrics,
|
|
309
|
+
event: NornRunManifestEvent,
|
|
310
|
+
now: Date,
|
|
311
|
+
): OpenWorkflowMetrics {
|
|
197
312
|
return {
|
|
198
313
|
...workflow,
|
|
199
|
-
openCommands: [
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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(
|
|
252
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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:
|
|
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(
|
|
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 =
|
|
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:
|
|
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"
|
|
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 }>(
|
|
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 [
|
|
474
|
+
return [
|
|
475
|
+
children[index],
|
|
476
|
+
[...children.slice(0, index), ...children.slice(index + 1)],
|
|
477
|
+
];
|
|
302
478
|
}
|
|
303
479
|
|
|
304
|
-
function workflowTerminalStatus(
|
|
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")
|
|
313
|
-
|
|
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(
|
|
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)
|
|
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 {
|
|
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)
|
|
15
|
-
|
|
16
|
-
if (input.platform === "
|
|
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(
|
|
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: {
|
|
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(
|
|
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 (
|
|
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({
|
|
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(
|
|
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())
|
|
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)), {
|
|
50
|
-
|
|
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 (
|
|
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())
|
|
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())
|
|
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)
|
|
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: {
|
|
78
|
-
|
|
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 (
|
|
83
|
-
|
|
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 (
|
|
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(
|
|
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
|
}
|