@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.
- package/README.md +13 -0
- package/assets/README.md +157 -0
- package/assets/docs/README.md +23 -0
- package/assets/docs/agents.md +64 -0
- package/assets/docs/cli.md +183 -0
- package/assets/docs/composition.md +76 -0
- package/assets/docs/persistence.md +56 -0
- package/assets/docs/projects.md +75 -0
- package/assets/docs/recovery.md +56 -0
- package/assets/docs/resources.md +61 -0
- package/assets/docs/workflows.md +57 -0
- package/assets/examples/agent-then-analysis/README.md +103 -0
- package/assets/examples/agent-then-analysis/input.json +5 -0
- package/assets/examples/agent-then-analysis/norn.project.json +4 -0
- package/assets/examples/agent-then-analysis/plugin.ts +89 -0
- package/assets/examples/coordinating-multiple-agents/README.md +56 -0
- package/assets/examples/coordinating-multiple-agents/input.json +10 -0
- package/assets/examples/coordinating-multiple-agents/norn.project.json +4 -0
- package/assets/examples/coordinating-multiple-agents/plugin.ts +102 -0
- package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +52 -0
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +153 -0
- package/assets/examples/minimal-workflow/README.md +71 -0
- package/assets/examples/minimal-workflow/norn.project.json +4 -0
- package/assets/examples/minimal-workflow/plugin.ts +29 -0
- package/assets/examples/shared-state/README.md +19 -0
- package/assets/examples/shared-state/input.json +1 -0
- package/assets/examples/shared-state/norn.project.json +4 -0
- package/assets/examples/shared-state/plugin.ts +47 -0
- package/assets/examples/worktree-development-loop/README.md +66 -0
- package/assets/examples/worktree-development-loop/index.ts +1 -0
- package/assets/examples/worktree-development-loop/manifest.ts +26 -0
- package/assets/examples/worktree-development-loop/norn.project.json +9 -0
- package/assets/examples/worktree-development-loop/plugin.ts +27 -0
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -0
- package/assets/examples/worktree-development-loop/state.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +22 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +14 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +54 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +28 -0
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +8 -0
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +53 -0
- package/assets/examples/worktree-development-loop/workflows/review/index.ts +10 -0
- package/assets/examples/worktree-development-loop/workflows/review/schema.ts +23 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +12 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +3 -0
- package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +12 -0
- package/assets/package.json +1 -0
- package/assets/packages/cli/src/build-info.ts +36 -0
- package/assets/packages/cli/src/bun/cli.ts +16 -0
- package/assets/packages/cli/src/cli.ts +1135 -0
- package/assets/packages/cli/src/client.ts +167 -0
- package/assets/packages/cli/src/documentation-intro.ts +30 -0
- package/assets/packages/cli/src/documentation.ts +149 -0
- package/assets/packages/cli/src/generated-build-info.ts +12 -0
- package/assets/packages/cli/src/internal/agent-directory.ts +5 -0
- package/assets/packages/cli/src/internal/agent-response-tool.ts +96 -0
- package/assets/packages/cli/src/internal/agents.ts +365 -0
- package/assets/packages/cli/src/internal/artifacts.ts +26 -0
- package/assets/packages/cli/src/internal/commands.ts +180 -0
- package/assets/packages/cli/src/internal/documentation-bundle.ts +49 -0
- package/assets/packages/cli/src/internal/engine.ts +501 -0
- package/assets/packages/cli/src/internal/errors.ts +39 -0
- package/assets/packages/cli/src/internal/file-names.ts +3 -0
- package/assets/packages/cli/src/internal/launch-request.ts +94 -0
- package/assets/packages/cli/src/internal/logs.ts +41 -0
- package/assets/packages/cli/src/internal/metrics.ts +356 -0
- package/assets/packages/cli/src/internal/pi-assets.ts +95 -0
- package/assets/packages/cli/src/internal/resource-bindings.ts +35 -0
- package/assets/packages/cli/src/internal/run-lease.ts +158 -0
- package/assets/packages/cli/src/internal/run-log.ts +59 -0
- package/assets/packages/cli/src/internal/run-names.ts +36 -0
- package/assets/packages/cli/src/internal/run-resources.ts +23 -0
- package/assets/packages/cli/src/internal/run-state.ts +380 -0
- package/assets/packages/cli/src/internal/run-store.ts +323 -0
- package/assets/packages/cli/src/internal/run.ts +133 -0
- package/assets/packages/cli/src/internal/state-store.ts +75 -0
- package/assets/packages/cli/src/internal/usage.ts +70 -0
- package/assets/packages/cli/src/internal/workflow-registry.ts +176 -0
- package/assets/packages/cli/src/plugin-loader.ts +412 -0
- package/assets/packages/cli/src/resources.ts +67 -0
- package/assets/packages/core/src/agent-protocol.ts +1 -0
- package/assets/packages/core/src/atomic-files.ts +24 -0
- package/assets/packages/core/src/errors.ts +3 -0
- package/assets/packages/sdk/src/agent-resource-adapter.ts +11 -0
- package/assets/packages/sdk/src/api.ts +821 -0
- package/assets/packages/sdk/src/files.ts +136 -0
- package/assets/packages/sdk/src/index.ts +6 -0
- package/assets/packages/sdk/src/resources.ts +20 -0
- package/assets/packages/sdk/src/schema.ts +48 -0
- package/assets/packages/sdk/src/seer/config.ts +62 -0
- package/assets/packages/sdk/src/seer/index.ts +7 -0
- package/assets/packages/sdk/src/state-adapter.ts +75 -0
- package/assets/setup/providers.md +128 -0
- package/assets/setup/releases.md +76 -0
- package/assets/tests/workflow-ref.test.ts +113 -0
- package/bin/norn.mjs +10 -0
- package/dist/build-info.d.ts +30 -0
- package/dist/build-info.js +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1032 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +118 -0
- package/dist/documentation-intro.d.ts +5 -0
- package/dist/documentation-intro.js +29 -0
- package/dist/documentation.d.ts +33 -0
- package/dist/documentation.js +132 -0
- package/dist/generated-build-info.d.ts +10 -0
- package/dist/generated-build-info.js +14 -0
- package/dist/internal/agent-directory.d.ts +4 -0
- package/dist/internal/agent-directory.js +8 -0
- package/dist/internal/agent-response-tool.d.ts +21 -0
- package/dist/internal/agent-response-tool.js +79 -0
- package/dist/internal/agents.d.ts +29 -0
- package/dist/internal/agents.js +336 -0
- package/dist/internal/artifacts.d.ts +10 -0
- package/dist/internal/artifacts.js +29 -0
- package/dist/internal/commands.d.ts +18 -0
- package/dist/internal/commands.js +147 -0
- package/dist/internal/documentation-bundle.d.ts +16 -0
- package/dist/internal/documentation-bundle.js +42 -0
- package/dist/internal/engine.d.ts +44 -0
- package/dist/internal/engine.js +399 -0
- package/dist/internal/errors.d.ts +14 -0
- package/dist/internal/errors.js +38 -0
- package/dist/internal/file-names.d.ts +1 -0
- package/dist/internal/file-names.js +7 -0
- package/dist/internal/launch-request.d.ts +33 -0
- package/dist/internal/launch-request.js +110 -0
- package/dist/internal/logs.d.ts +16 -0
- package/dist/internal/logs.js +38 -0
- package/dist/internal/metrics.d.ts +19 -0
- package/dist/internal/metrics.js +282 -0
- package/dist/internal/pi-assets.d.ts +13 -0
- package/dist/internal/pi-assets.js +94 -0
- package/dist/internal/resource-bindings.d.ts +13 -0
- package/dist/internal/resource-bindings.js +34 -0
- package/dist/internal/run-lease.d.ts +32 -0
- package/dist/internal/run-lease.js +166 -0
- package/dist/internal/run-log.d.ts +30 -0
- package/dist/internal/run-log.js +71 -0
- package/dist/internal/run-names.d.ts +1 -0
- package/dist/internal/run-names.js +144 -0
- package/dist/internal/run-resources.d.ts +6 -0
- package/dist/internal/run-resources.js +26 -0
- package/dist/internal/run-state.d.ts +95 -0
- package/dist/internal/run-state.js +323 -0
- package/dist/internal/run-store.d.ts +35 -0
- package/dist/internal/run-store.js +314 -0
- package/dist/internal/run.d.ts +51 -0
- package/dist/internal/run.js +101 -0
- package/dist/internal/state-store.d.ts +22 -0
- package/dist/internal/state-store.js +97 -0
- package/dist/internal/usage.d.ts +5 -0
- package/dist/internal/usage.js +70 -0
- package/dist/internal/workflow-registry.d.ts +35 -0
- package/dist/internal/workflow-registry.js +129 -0
- package/dist/plugin-loader.d.ts +55 -0
- package/dist/plugin-loader.js +353 -0
- package/dist/resources.d.ts +11 -0
- package/dist/resources.js +98 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { main as runPi } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { renderNornDocumentationIntro, resolveDocumentationCacheRoot, resolveNornDocumentation, type NornDocumentationSource } from "./documentation.ts";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { discoverNornProject, findNornProject, inspectNornWorkflow, loadNornProject, NORN_PROJECT_FILE_NAME } from "./plugin-loader.ts";
|
|
11
|
+
import { type NornAnyWorkflowDeclaration, type DeletedNornRunInfo, type NornRunInfo } from "@vimhead.dev/norn";
|
|
12
|
+
import { NornEngine } from "./internal/engine.ts";
|
|
13
|
+
import { errorMessage, NornProjectLoadError, NornRunStoppedError } from "./internal/errors.ts";
|
|
14
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
15
|
+
import { clearRunResumeRequest, readRunLaunchRequest, readRunResumeRequest, writeRunLaunchRequest, writeRunResumeRequest, type NornRunResumeRequest } from "./internal/launch-request.ts";
|
|
16
|
+
import { generateRunName } from "./internal/run-names.ts";
|
|
17
|
+
import { getRunLeaseOwner, NornRunLease } from "./internal/run-lease.ts";
|
|
18
|
+
import { NornRunStore } from "./internal/run-store.ts";
|
|
19
|
+
import { readRunMetrics } from "./internal/metrics.ts";
|
|
20
|
+
import { getRunInfo, listRuns, mergeInterruptedWorkflowParams, resolveRunRoot } from "./internal/run-state.ts";
|
|
21
|
+
import { NORN_BUILD_INFO, type NornBuildInfo, type NornGithubReleaseBinaryBuildInfo } from "./build-info.ts";
|
|
22
|
+
|
|
23
|
+
const RUNS_ROOT = join(".norn", "runs");
|
|
24
|
+
const RUN_WAIT_INTERVAL_MS = 1000;
|
|
25
|
+
const CLI_DESCRIPTION = "Norn is a harness-agnostic runtime for agent-driven and code-driven workflows, built primarily for agents. Build workflows with the Norn SDK; use this JSON-native CLI to discover workflows, start or resume runs, inspect evidence, and manage the installed runtime.";
|
|
26
|
+
|
|
27
|
+
const COMMANDS: readonly CliCommand[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "pi",
|
|
30
|
+
path: ["pi"],
|
|
31
|
+
description: "Run Norn's bundled Pi CLI for interactive authentication, provider packages, model configuration, or direct Pi use. No Norn project is required.",
|
|
32
|
+
usage: "norn pi [pi arguments...]",
|
|
33
|
+
arguments: ["pi arguments: forwarded unchanged; use norn pi --help for Pi's CLI contract"],
|
|
34
|
+
output: "Pi's native terminal, text, JSON, or RPC output and exit status; not wrapped in Norn JSON.",
|
|
35
|
+
examples: ["norn pi", "norn pi install npm:pi-cursor-sdk", "norn pi --list-models cursor", "norn pi --help"],
|
|
36
|
+
execute: args => runPi([...args]),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "commands.list",
|
|
40
|
+
path: ["commands", "list"],
|
|
41
|
+
description: "Use when an agent or human needs machine-readable Norn CLI command metadata.",
|
|
42
|
+
usage: "norn commands list [--all]",
|
|
43
|
+
options: ["--all: include hidden internal commands"],
|
|
44
|
+
output: "JSON object with command metadata under commands.",
|
|
45
|
+
examples: ["norn commands list", "norn commands list --all"],
|
|
46
|
+
execute: listCliCommands,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "commands.inspect",
|
|
50
|
+
path: ["commands", "inspect"],
|
|
51
|
+
description: "Use when an agent or human needs the usage contract for one Norn CLI command.",
|
|
52
|
+
usage: "norn commands inspect <command-id>",
|
|
53
|
+
arguments: ["command-id: command metadata id such as runs.start"],
|
|
54
|
+
output: "JSON object with one command metadata object under command.",
|
|
55
|
+
examples: ["norn commands inspect runs.start"],
|
|
56
|
+
execute: inspectCliCommand,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "help",
|
|
60
|
+
path: ["help"],
|
|
61
|
+
description: "Use when reading JSON help for all Norn commands, one command group, or one command.",
|
|
62
|
+
usage: "norn help [command-or-group]",
|
|
63
|
+
arguments: ["command-or-group: optional command path such as runs or runs start"],
|
|
64
|
+
output: "JSON object with help metadata under help.",
|
|
65
|
+
examples: ["norn help", "norn help runs", "norn help runs start", "norn --help"],
|
|
66
|
+
execute: (args) => writeCliHelp(args),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "docs.inspect",
|
|
70
|
+
path: ["docs", "inspect"],
|
|
71
|
+
description: "Resolve version-matched local documentation and examples. Standalone binaries extract bundled assets into a verified build-specific cache; no project or network is required.",
|
|
72
|
+
usage: "norn docs inspect",
|
|
73
|
+
output: "JSON object under documentation with storage, version, commit, assetDigest, and absolute paths. NORN_DOCS_CACHE_DIR overrides the binary cache directory.",
|
|
74
|
+
examples: ["norn docs inspect"],
|
|
75
|
+
execute: async (args, documentationSource) => {
|
|
76
|
+
assertNoExtraArgs("docs inspect", args);
|
|
77
|
+
writeJson({ documentation: await resolveCliDocumentation(documentationSource) });
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "docs.intro",
|
|
82
|
+
path: ["docs", "intro"],
|
|
83
|
+
description: "Produce a compact introduction to Norn authoring with this runtime's invocation and matching local documentation pointers. Does not load a project, list workflows, or deliver context to agents.",
|
|
84
|
+
usage: "norn docs intro",
|
|
85
|
+
output: "JSON object with introduction text under intro. Uses the same asset resolution and NORN_DOCS_CACHE_DIR override as docs inspect.",
|
|
86
|
+
examples: ["norn docs intro"],
|
|
87
|
+
execute: async (args, documentationSource) => {
|
|
88
|
+
assertNoExtraArgs("docs intro", args);
|
|
89
|
+
writeJson({ intro: renderNornDocumentationIntro({
|
|
90
|
+
documentation: await resolveCliDocumentation(documentationSource),
|
|
91
|
+
invocation: documentationSource.kind === "embedded"
|
|
92
|
+
? [process.execPath]
|
|
93
|
+
: [process.execPath, fileURLToPath(new URL("../bin/norn.mjs", import.meta.url))],
|
|
94
|
+
}) });
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "project.init",
|
|
99
|
+
path: ["project", "init"],
|
|
100
|
+
description: "Use when creating a self-contained Norn project config and local run state directory in the current directory.",
|
|
101
|
+
usage: "norn project init",
|
|
102
|
+
output: "JSON object with initialized project metadata under project.",
|
|
103
|
+
examples: ["norn project init"],
|
|
104
|
+
execute: async (args) => {
|
|
105
|
+
assertNoExtraArgs("project init", args);
|
|
106
|
+
await initProject();
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "project.inspect",
|
|
111
|
+
path: ["project", "inspect"],
|
|
112
|
+
description: "Use when discovering the active Norn project, plugins, workflow sources, and Seer mode.",
|
|
113
|
+
usage: "norn project inspect",
|
|
114
|
+
output: "JSON object with project metadata under project, isComplete, and plugin diagnostics. Incomplete discovery does not permit execution.",
|
|
115
|
+
examples: ["norn project inspect"],
|
|
116
|
+
execute: async (args) => {
|
|
117
|
+
assertNoExtraArgs("project inspect", args);
|
|
118
|
+
await inspectProject();
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "seer.inspect",
|
|
123
|
+
path: ["seer", "inspect"],
|
|
124
|
+
description: "Use when checking the current project's resolved Seer mode before agent execution.",
|
|
125
|
+
usage: "norn seer inspect",
|
|
126
|
+
output: "JSON object with resolved Seer mode under seerMode.",
|
|
127
|
+
examples: ["norn seer inspect"],
|
|
128
|
+
execute: async (args) => {
|
|
129
|
+
assertNoExtraArgs("seer inspect", args);
|
|
130
|
+
await inspectSeerMode();
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "workflows.list",
|
|
135
|
+
path: ["workflows", "list"],
|
|
136
|
+
description: "Use when selecting a Norn workflow for a user task; defaults to entrypoint workflows.",
|
|
137
|
+
usage: "norn workflows list [--entrypoints|--all]",
|
|
138
|
+
options: ["--entrypoints: list entrypoint workflows", "--all: include internal workflow steps"],
|
|
139
|
+
output: "JSON object with workflow summaries under workflows, isComplete, and plugin diagnostics. Successful discovery can be incomplete; start/resume remain strict.",
|
|
140
|
+
examples: ["norn workflows list", "norn workflows list --all"],
|
|
141
|
+
execute: listWorkflows,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "workflows.inspect",
|
|
145
|
+
path: ["workflows", "inspect"],
|
|
146
|
+
description: "Use when reading a workflow's instructions, params schema, gate contract, and source plugin before starting or editing it.",
|
|
147
|
+
usage: "norn workflows inspect <workflow-id>",
|
|
148
|
+
arguments: ["workflow-id: fully qualified workflow id"],
|
|
149
|
+
output: "JSON object with workflow details, isComplete, and plugin diagnostics. workflow is null if unavailable in an incomplete catalog or its schema cannot be inspected; an unknown id in a complete catalog is an error.",
|
|
150
|
+
examples: ["norn workflows inspect example.plan"],
|
|
151
|
+
execute: async (args) => {
|
|
152
|
+
const workflowId = requiredArg("workflows inspect", args, 0, "workflow id");
|
|
153
|
+
assertNoExtraArgs("workflows inspect", args.slice(1));
|
|
154
|
+
await inspectWorkflow(workflowId);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "runs.start",
|
|
159
|
+
path: ["runs", "start"],
|
|
160
|
+
description: "Use when starting a Norn workflow run after the workflow id and params are known.",
|
|
161
|
+
usage: "norn runs start <workflow-id>",
|
|
162
|
+
arguments: ["workflow-id: fully qualified workflow id to start"],
|
|
163
|
+
stdin: "Optional JSON object: {\"params\":{...},\"config\":{\"pluginId\":{...}}}.",
|
|
164
|
+
output: "JSON object with started run info under run.",
|
|
165
|
+
examples: ["printf '{\"params\":{\"task\":\"Add tests\"}}' | norn runs start example.plan"],
|
|
166
|
+
execute: async (args) => {
|
|
167
|
+
const workflowId = requiredArg("runs start", args, 0, "workflow id");
|
|
168
|
+
await startRun(workflowId, args.slice(1));
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "runs.resume",
|
|
173
|
+
path: ["runs", "resume"],
|
|
174
|
+
description: "Use when resuming an interrupted gate or a checkpoint restored for retry.",
|
|
175
|
+
usage: "norn runs resume <run>",
|
|
176
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
177
|
+
stdin: "Optional JSON object: {\"params\":{...}}. Interrupted runs require a patch containing only editable gate fields; pending-resume runs do not accept params.",
|
|
178
|
+
output: "JSON object with resumed run info under run.",
|
|
179
|
+
examples: ["printf '{\"params\":{\"decision\":\"accept\"}}' | norn runs resume quiet-river-lantern"],
|
|
180
|
+
execute: async (args) => {
|
|
181
|
+
const run = requiredArg("runs resume", args, 0, "run");
|
|
182
|
+
await resumeRun(run, args.slice(1));
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "runs.wait",
|
|
187
|
+
path: ["runs", "wait"],
|
|
188
|
+
description: "Use when waiting for a Norn run to finish, fail, interrupt, or become unhealthy.",
|
|
189
|
+
usage: "norn runs wait <run>",
|
|
190
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
191
|
+
output: "JSON object with final or current run info under run.",
|
|
192
|
+
examples: ["norn runs wait quiet-river-lantern"],
|
|
193
|
+
execute: async (args) => {
|
|
194
|
+
const run = requiredArg("runs wait", args, 0, "run");
|
|
195
|
+
assertNoExtraArgs("runs wait", args.slice(1));
|
|
196
|
+
await waitRun(run);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: "runs.list",
|
|
201
|
+
path: ["runs", "list"],
|
|
202
|
+
description: "Use when listing known Norn runs in the current project.",
|
|
203
|
+
usage: "norn runs list",
|
|
204
|
+
output: "JSON object with run summaries under runs.",
|
|
205
|
+
examples: ["norn runs list"],
|
|
206
|
+
execute: async (args) => {
|
|
207
|
+
assertNoExtraArgs("runs list", args);
|
|
208
|
+
writeJson({ runs: await listCurrentProjectRuns() });
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "runs.inspect",
|
|
213
|
+
path: ["runs", "inspect"],
|
|
214
|
+
description: "Use when reading status, health, current workflow, interruption, and outcome details for one Norn run.",
|
|
215
|
+
usage: "norn runs inspect <run>",
|
|
216
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
217
|
+
output: "JSON object with run details under run.",
|
|
218
|
+
examples: ["norn runs inspect quiet-river-lantern"],
|
|
219
|
+
execute: async (args) => {
|
|
220
|
+
const run = requiredArg("runs inspect", args, 0, "run");
|
|
221
|
+
assertNoExtraArgs("runs inspect", args.slice(1));
|
|
222
|
+
writeJson({ run: await inspectRun(run) });
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: "runs.checkpoints",
|
|
227
|
+
path: ["runs", "checkpoints"],
|
|
228
|
+
description: "Use when finding rollback points before retrying or repairing a Norn run.",
|
|
229
|
+
usage: "norn runs checkpoints <run>",
|
|
230
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
231
|
+
output: "JSON object with checkpoints under checkpoints.",
|
|
232
|
+
examples: ["norn runs checkpoints quiet-river-lantern"],
|
|
233
|
+
execute: async (args) => {
|
|
234
|
+
const run = requiredArg("runs checkpoints", args, 0, "run");
|
|
235
|
+
assertNoExtraArgs("runs checkpoints", args.slice(1));
|
|
236
|
+
const project = await findNornProject(process.cwd());
|
|
237
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
238
|
+
writeJson({ checkpoints: await (await NornRunStore.open(runRoot)).listCheckpoints() });
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: "runs.metrics",
|
|
243
|
+
path: ["runs", "metrics"],
|
|
244
|
+
description: "Use when measuring workflow, agent, command, token, and cost totals for a Norn run.",
|
|
245
|
+
usage: "norn runs metrics <run>",
|
|
246
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
247
|
+
output: "JSON object with metrics under metrics.",
|
|
248
|
+
examples: ["norn runs metrics quiet-river-lantern"],
|
|
249
|
+
execute: async (args) => {
|
|
250
|
+
const run = requiredArg("runs metrics", args, 0, "run");
|
|
251
|
+
assertNoExtraArgs("runs metrics", args.slice(1));
|
|
252
|
+
const project = await findNornProject(process.cwd());
|
|
253
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
254
|
+
writeJson({ metrics: await readRunMetrics(runRoot) });
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "runs.logs",
|
|
259
|
+
path: ["runs", "logs"],
|
|
260
|
+
description: "Use when streaming or reading chronological JSON events for a Norn run.",
|
|
261
|
+
usage: "norn runs logs <run> [--follow]",
|
|
262
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
263
|
+
options: ["--follow: continue streaming until the run stops"],
|
|
264
|
+
output: "JSON Lines stream of run events.",
|
|
265
|
+
examples: ["norn runs logs quiet-river-lantern", "norn runs logs quiet-river-lantern --follow"],
|
|
266
|
+
execute: async (args) => {
|
|
267
|
+
const run = requiredArg("runs logs", args, 0, "run");
|
|
268
|
+
const logArgs = args.slice(1);
|
|
269
|
+
assertKnownFlags("runs logs", logArgs, ["--follow"]);
|
|
270
|
+
await writeRunLogs(run, { follow: logArgs.includes("--follow") });
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: "runs.rollback",
|
|
275
|
+
path: ["runs", "rollback"],
|
|
276
|
+
description: "Use when restoring a failed or stopped run to a checkpoint and marking it pending resume.",
|
|
277
|
+
usage: "norn runs rollback <run> <checkpoint-id>",
|
|
278
|
+
arguments: ["run: run id, generated name, or run path", "checkpoint-id: checkpoint id from runs.checkpoints"],
|
|
279
|
+
output: "JSON object with rolled-back run info under run.",
|
|
280
|
+
examples: ["norn runs rollback quiet-river-lantern checkpoint-1"],
|
|
281
|
+
execute: async (args) => {
|
|
282
|
+
const run = requiredArg("runs rollback", args, 0, "run");
|
|
283
|
+
await rollbackRun(run, args.slice(1));
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "runs.stop",
|
|
288
|
+
path: ["runs", "stop"],
|
|
289
|
+
description: "Use when stopping a running Norn execution while preserving its dirty state for inspection or rollback.",
|
|
290
|
+
usage: "norn runs stop <run>",
|
|
291
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
292
|
+
output: "JSON object with updated run info under run.",
|
|
293
|
+
examples: ["norn runs stop quiet-river-lantern"],
|
|
294
|
+
execute: async (args) => {
|
|
295
|
+
const run = requiredArg("runs stop", args, 0, "run");
|
|
296
|
+
assertNoExtraArgs("runs stop", args.slice(1));
|
|
297
|
+
await signalRun(run, "SIGTERM");
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "runs.kill",
|
|
302
|
+
path: ["runs", "kill"],
|
|
303
|
+
description: "Use when force-stopping a Norn execution process that did not stop politely.",
|
|
304
|
+
usage: "norn runs kill <run>",
|
|
305
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
306
|
+
output: "JSON object with updated run info under run.",
|
|
307
|
+
examples: ["norn runs kill quiet-river-lantern"],
|
|
308
|
+
execute: async (args) => {
|
|
309
|
+
const run = requiredArg("runs kill", args, 0, "run");
|
|
310
|
+
assertNoExtraArgs("runs kill", args.slice(1));
|
|
311
|
+
await signalRun(run, "SIGKILL");
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "runs.delete",
|
|
316
|
+
path: ["runs", "delete"],
|
|
317
|
+
description: "Use when deleting an inactive Norn run directory after evidence is no longer needed.",
|
|
318
|
+
usage: "norn runs delete <run>",
|
|
319
|
+
arguments: ["run: run id, generated name, or run path"],
|
|
320
|
+
output: "JSON object with deleted run identity under deleted.",
|
|
321
|
+
examples: ["norn runs delete quiet-river-lantern"],
|
|
322
|
+
execute: async (args) => {
|
|
323
|
+
const run = requiredArg("runs delete", args, 0, "run");
|
|
324
|
+
assertNoExtraArgs("runs delete", args.slice(1));
|
|
325
|
+
writeJson({ deleted: await deleteRun(run) });
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
id: "execute-run",
|
|
330
|
+
path: ["execute-run"],
|
|
331
|
+
description: "Use internally when executing a previously launched detached run request.",
|
|
332
|
+
usage: "norn execute-run <run-id>",
|
|
333
|
+
arguments: ["run-id: internal run id"],
|
|
334
|
+
output: "No stable stdout contract; execution state is persisted in the run directory.",
|
|
335
|
+
hidden: true,
|
|
336
|
+
examples: ["norn execute-run 00000000-0000-0000-0000-000000000000"],
|
|
337
|
+
execute: async (args) => {
|
|
338
|
+
const runId = requiredArg("execute-run", args, 0, "run id");
|
|
339
|
+
assertNoExtraArgs("execute-run", args.slice(1));
|
|
340
|
+
await executeRun(runId);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "upgrade",
|
|
345
|
+
path: ["upgrade"],
|
|
346
|
+
description: "Use when upgrading this Norn CLI installation according to explicit build metadata.",
|
|
347
|
+
usage: "norn upgrade [--dry-run]",
|
|
348
|
+
options: ["--dry-run: report the upgrade plan without changing files or running installers"],
|
|
349
|
+
output: "JSON object with upgrade status, plan, or unsupported reason under upgrade.",
|
|
350
|
+
examples: ["norn upgrade --dry-run", "norn upgrade"],
|
|
351
|
+
execute: upgradeNorn,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: "version",
|
|
355
|
+
path: ["version"],
|
|
356
|
+
description: "Use when checking the installed Norn CLI version and explicit build metadata.",
|
|
357
|
+
usage: "norn version",
|
|
358
|
+
output: "JSON object with package version under version and build metadata under build.",
|
|
359
|
+
examples: ["norn version", "norn --version"],
|
|
360
|
+
execute: async (args) => {
|
|
361
|
+
assertNoExtraArgs("version", args);
|
|
362
|
+
writeJson(await versionInfo());
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const HELP_COMMAND_ORDER = [
|
|
368
|
+
"commands.list",
|
|
369
|
+
"commands.inspect",
|
|
370
|
+
"docs.inspect",
|
|
371
|
+
"docs.intro",
|
|
372
|
+
"workflows.list",
|
|
373
|
+
"workflows.inspect",
|
|
374
|
+
"runs.start",
|
|
375
|
+
"runs.resume",
|
|
376
|
+
"runs.wait",
|
|
377
|
+
"runs.list",
|
|
378
|
+
"runs.inspect",
|
|
379
|
+
"runs.logs",
|
|
380
|
+
"runs.checkpoints",
|
|
381
|
+
"runs.rollback",
|
|
382
|
+
"runs.metrics",
|
|
383
|
+
"runs.stop",
|
|
384
|
+
"runs.kill",
|
|
385
|
+
"runs.delete",
|
|
386
|
+
"project.init",
|
|
387
|
+
"project.inspect",
|
|
388
|
+
"seer.inspect",
|
|
389
|
+
"pi",
|
|
390
|
+
"upgrade",
|
|
391
|
+
"version",
|
|
392
|
+
"help",
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
const HUMAN_COMMAND_SUMMARIES: Readonly<Record<string, string>> = {
|
|
396
|
+
"commands.list": "List machine-readable command metadata.",
|
|
397
|
+
"commands.inspect": "Inspect one command's machine-readable contract.",
|
|
398
|
+
"docs.inspect": "Locate matching documentation and examples offline.",
|
|
399
|
+
"docs.intro": "Produce a compact authoring introduction with local documentation pointers.",
|
|
400
|
+
"workflows.list": "List Norn workflows.",
|
|
401
|
+
"workflows.inspect": "Inspect a workflow schema and source.",
|
|
402
|
+
"runs.start": "Start a workflow run.",
|
|
403
|
+
"runs.resume": "Resume an interrupted gate or a restored checkpoint.",
|
|
404
|
+
"runs.wait": "Wait for a run to stop running.",
|
|
405
|
+
"runs.list": "List known runs.",
|
|
406
|
+
"runs.inspect": "Inspect a run.",
|
|
407
|
+
"runs.logs": "Read run event logs.",
|
|
408
|
+
"runs.checkpoints": "List run rollback checkpoints.",
|
|
409
|
+
"runs.rollback": "Restore a run checkpoint for a manual resume.",
|
|
410
|
+
"runs.metrics": "Inspect run metrics.",
|
|
411
|
+
"runs.stop": "Stop a running run.",
|
|
412
|
+
"runs.kill": "Force-stop a running run.",
|
|
413
|
+
"runs.delete": "Delete an inactive run.",
|
|
414
|
+
"project.init": "Create a Norn project in the current directory.",
|
|
415
|
+
"project.inspect": "Inspect the Norn project.",
|
|
416
|
+
"seer.inspect": "Inspect resolved Seer mode.",
|
|
417
|
+
pi: "Run bundled Pi for authentication, providers, and model setup.",
|
|
418
|
+
upgrade: "Upgrade the installed Norn CLI.",
|
|
419
|
+
version: "Print version/build info.",
|
|
420
|
+
help: "Show concise command help.",
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
export async function main(args: readonly string[], documentationSource: NornDocumentationSource): Promise<void> {
|
|
424
|
+
if (args[0] === "pi") {
|
|
425
|
+
await runPi([...args.slice(1)]);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await runCommand(args, documentationSource);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
writeJson({ error: {
|
|
432
|
+
code: errorCode(error), message: errorMessage(error),
|
|
433
|
+
...(error instanceof NornProjectLoadError ? { isComplete: error.isComplete, diagnostics: error.diagnostics } : {}),
|
|
434
|
+
} });
|
|
435
|
+
process.exitCode = 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function runCommand(args: readonly string[], documentationSource: NornDocumentationSource): Promise<void> {
|
|
440
|
+
if (args.length === 0) {
|
|
441
|
+
writeCliHelp([]);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (isVersionRequest(args)) {
|
|
445
|
+
writeJson(await versionInfo());
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const helpPath = cliHelpPath(args);
|
|
449
|
+
if (helpPath) {
|
|
450
|
+
writeCliHelp(helpPath);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const command = findCliCommand(args);
|
|
454
|
+
if (!command) throw new Error(`Unknown norn command: ${args.join(" ")}`);
|
|
455
|
+
await command.execute(args.slice(command.path.length), documentationSource);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
type CliCommand = {
|
|
459
|
+
readonly id: string;
|
|
460
|
+
readonly path: readonly string[];
|
|
461
|
+
readonly description: string;
|
|
462
|
+
readonly usage: string;
|
|
463
|
+
readonly arguments?: readonly string[];
|
|
464
|
+
readonly options?: readonly string[];
|
|
465
|
+
readonly stdin?: string;
|
|
466
|
+
readonly output: string;
|
|
467
|
+
readonly examples: readonly string[];
|
|
468
|
+
readonly hidden?: true;
|
|
469
|
+
readonly execute: (args: readonly string[], documentationSource: NornDocumentationSource) => Promise<void> | void;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
type CliCommandInfo = Omit<CliCommand, "execute">;
|
|
473
|
+
|
|
474
|
+
async function listCliCommands(args: readonly string[]): Promise<void> {
|
|
475
|
+
assertKnownFlags("commands list", args, ["--all"]);
|
|
476
|
+
const shouldIncludeHidden = args.includes("--all");
|
|
477
|
+
writeJson({ commands: COMMANDS.filter((command) => shouldIncludeHidden || !command.hidden).map(cliCommandInfo) });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function inspectCliCommand(args: readonly string[]): Promise<void> {
|
|
481
|
+
const commandId = requiredArg("commands inspect", args, 0, "command id");
|
|
482
|
+
assertNoExtraArgs("commands inspect", args.slice(1));
|
|
483
|
+
const command = COMMANDS.find((candidate) => candidate.id === commandId);
|
|
484
|
+
if (!command) throw new Error(`Unknown norn command id: ${commandId}`);
|
|
485
|
+
writeJson({ command: cliCommandInfo(command) });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function cliCommandInfo(command: CliCommand): CliCommandInfo {
|
|
489
|
+
return {
|
|
490
|
+
id: command.id,
|
|
491
|
+
path: command.path,
|
|
492
|
+
description: command.description,
|
|
493
|
+
usage: command.usage,
|
|
494
|
+
arguments: command.arguments,
|
|
495
|
+
options: command.options,
|
|
496
|
+
stdin: command.stdin,
|
|
497
|
+
output: command.output,
|
|
498
|
+
examples: command.examples,
|
|
499
|
+
hidden: command.hidden,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isVersionRequest(args: readonly string[]): boolean {
|
|
504
|
+
return args.length === 1 && (args[0] === "--version" || args[0] === "-v");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function cliHelpPath(args: readonly string[]): readonly string[] | undefined {
|
|
508
|
+
if (args[0] === "help") return args.slice(1);
|
|
509
|
+
if (!args.includes("--help") && !args.includes("-h")) return undefined;
|
|
510
|
+
return args.filter((arg) => arg !== "--help" && arg !== "-h");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function writeCliHelp(path: readonly string[]): void {
|
|
514
|
+
const command = findExactCliCommand(path);
|
|
515
|
+
if (command) {
|
|
516
|
+
process.stdout.write(renderCommandHelp(command));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const groupCommands = sortedCommandsForHelp(visibleCommands().filter((candidate) => path.length === 0 || startsWithPath(candidate.path, path)));
|
|
520
|
+
if (groupCommands.length === 0) throw new Error(`Unknown norn help topic: ${path.join(" ")}`);
|
|
521
|
+
process.stdout.write(renderGroupHelp(path, groupCommands));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderGroupHelp(path: readonly string[], commands: readonly CliCommand[]): string {
|
|
525
|
+
return [
|
|
526
|
+
"Norn",
|
|
527
|
+
"",
|
|
528
|
+
CLI_DESCRIPTION,
|
|
529
|
+
"",
|
|
530
|
+
"Usage:",
|
|
531
|
+
...groupHelpUsage(path).map((line) => ` ${line}`),
|
|
532
|
+
"",
|
|
533
|
+
"Commands:",
|
|
534
|
+
renderCommandSummary(commands),
|
|
535
|
+
"",
|
|
536
|
+
"Machine-readable metadata:",
|
|
537
|
+
" norn commands list",
|
|
538
|
+
" norn commands inspect <command-id>",
|
|
539
|
+
"",
|
|
540
|
+
].join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function renderCommandHelp(command: CliCommand): string {
|
|
544
|
+
return [
|
|
545
|
+
commandHumanSummary(command),
|
|
546
|
+
"",
|
|
547
|
+
"Usage:",
|
|
548
|
+
` ${command.usage}`,
|
|
549
|
+
"",
|
|
550
|
+
"Description:",
|
|
551
|
+
` ${command.description}`,
|
|
552
|
+
...(command.arguments ? helpSection("Arguments", command.arguments) : []),
|
|
553
|
+
...(command.options ? helpSection("Options", command.options) : []),
|
|
554
|
+
...(command.stdin ? ["", "Stdin:", ` ${command.stdin}`] : []),
|
|
555
|
+
"",
|
|
556
|
+
"Output:",
|
|
557
|
+
` ${command.output}`,
|
|
558
|
+
...helpSection("Examples", command.examples),
|
|
559
|
+
"",
|
|
560
|
+
].join("\n");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function renderCommandSummary(commands: readonly CliCommand[]): string {
|
|
564
|
+
const commandNames = commands.map((command) => command.path.join(" "));
|
|
565
|
+
const width = Math.max(...commandNames.map((name) => name.length));
|
|
566
|
+
return commands.map((command, index) => ` ${commandNames[index].padEnd(width)} ${commandHumanSummary(command)}`).join("\n");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function helpSection(title: string, lines: readonly string[]): readonly string[] {
|
|
570
|
+
return ["", `${title}:`, ...lines.map((line) => ` ${line}`)];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function groupHelpUsage(path: readonly string[]): readonly string[] {
|
|
574
|
+
if (path.length === 0) return ["norn <command> [args]", "norn help <command>", "norn --version"];
|
|
575
|
+
const prefix = `norn ${path.join(" ")}`;
|
|
576
|
+
return [`${prefix} <command> [args]`, `norn help ${path.join(" ")} <command>`, "norn --version"];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function findCliCommand(args: readonly string[]): CliCommand | undefined {
|
|
580
|
+
return sortedCommandsByPathLength(COMMANDS).find((command) => startsWithPath(args, command.path));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function findExactCliCommand(path: readonly string[]): CliCommand | undefined {
|
|
584
|
+
return COMMANDS.find((command) => command.path.length === path.length && startsWithPath(command.path, path));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function sortedCommandsByPathLength(commands: readonly CliCommand[]): readonly CliCommand[] {
|
|
588
|
+
return [...commands].sort((left, right) => right.path.length - left.path.length);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function sortedCommandsForHelp(commands: readonly CliCommand[]): readonly CliCommand[] {
|
|
592
|
+
return [...commands].sort((left, right) => commandHelpOrder(left) - commandHelpOrder(right));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function commandHelpOrder(command: CliCommand): number {
|
|
596
|
+
const index = HELP_COMMAND_ORDER.indexOf(command.id);
|
|
597
|
+
return index === -1 ? HELP_COMMAND_ORDER.length : index;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function commandHumanSummary(command: CliCommand): string {
|
|
601
|
+
return HUMAN_COMMAND_SUMMARIES[command.id] ?? command.description;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function visibleCommands(): readonly CliCommand[] {
|
|
605
|
+
return COMMANDS.filter((command) => !command.hidden);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function startsWithPath(value: readonly string[], path: readonly string[]): boolean {
|
|
609
|
+
return path.every((segment, index) => value[index] === segment);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function resolveCliDocumentation(source: NornDocumentationSource) {
|
|
613
|
+
return resolveNornDocumentation({
|
|
614
|
+
source,
|
|
615
|
+
build: { ...NORN_BUILD_INFO, version: await readPackageVersion() },
|
|
616
|
+
cacheRoot: resolveDocumentationCacheRoot({ platform: process.platform, home: homedir(), environment: process.env }),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function versionInfo(): Promise<{ readonly version: string; readonly build: NornBuildInfo }> {
|
|
621
|
+
return { version: await readPackageVersion(), build: NORN_BUILD_INFO };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function readPackageVersion(): Promise<string> {
|
|
625
|
+
try {
|
|
626
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
|
|
627
|
+
if (typeof packageJson.version !== "string") throw new Error("Invalid Norn package version");
|
|
628
|
+
return packageJson.version;
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (isNodeError(error) && error.code === "ENOENT") return NORN_BUILD_INFO.version;
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function upgradeNorn(args: readonly string[]): Promise<void> {
|
|
636
|
+
assertKnownFlags("upgrade", args, ["--dry-run"]);
|
|
637
|
+
const dryRun = args.includes("--dry-run");
|
|
638
|
+
const currentVersion = await readPackageVersion();
|
|
639
|
+
const build = NORN_BUILD_INFO;
|
|
640
|
+
if (build.kind !== "github-release-binary") {
|
|
641
|
+
writeJson({ upgrade: unsupportedUpgrade(build, currentVersion, build.upgrade.reason) });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
writeJson({ upgrade: await upgradeGithubReleaseBinary(build, currentVersion, dryRun) });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function unsupportedUpgrade(build: NornBuildInfo, currentVersion: string, reason: string): Record<string, unknown> {
|
|
648
|
+
return {
|
|
649
|
+
supported: false,
|
|
650
|
+
kind: build.kind,
|
|
651
|
+
currentVersion,
|
|
652
|
+
buildVersion: build.version,
|
|
653
|
+
reason,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function upgradeGithubReleaseBinary(build: NornGithubReleaseBinaryBuildInfo, currentVersion: string, dryRun: boolean): Promise<Record<string, unknown>> {
|
|
658
|
+
const plan = githubReleaseBinaryUpgradePlan(build, currentVersion, dryRun);
|
|
659
|
+
if (dryRun) return { ...plan, status: "planned" };
|
|
660
|
+
if (process.platform === "win32") return unsupportedUpgrade(build, currentVersion, "Replacing a running Windows executable is not supported yet.");
|
|
661
|
+
const binary = await downloadReleaseAsset(plan.downloadUrl);
|
|
662
|
+
const checksumText = await downloadReleaseText(plan.checksumUrl);
|
|
663
|
+
const expectedChecksum = parseSha256Checksum(checksumText, build.checksumAssetName);
|
|
664
|
+
const actualChecksum = createHash("sha256").update(Buffer.from(binary)).digest("hex");
|
|
665
|
+
if (actualChecksum !== expectedChecksum) throw new Error(`Downloaded Norn binary checksum mismatch: expected ${expectedChecksum}, got ${actualChecksum}`);
|
|
666
|
+
const tempPath = join(dirname(plan.targetPath), `.norn-upgrade-${process.pid}-${build.assetName}`);
|
|
667
|
+
try {
|
|
668
|
+
await writeFile(tempPath, binary);
|
|
669
|
+
await chmod(tempPath, 0o755);
|
|
670
|
+
await rename(tempPath, plan.targetPath);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
await rm(tempPath, { force: true });
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
return { ...plan, status: "completed", checksum: actualChecksum };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function githubReleaseBinaryUpgradePlan(build: NornGithubReleaseBinaryBuildInfo, currentVersion: string, dryRun: boolean): {
|
|
679
|
+
readonly supported: true;
|
|
680
|
+
readonly kind: "github-release-binary";
|
|
681
|
+
readonly dryRun: boolean;
|
|
682
|
+
readonly currentVersion: string;
|
|
683
|
+
readonly buildVersion: string;
|
|
684
|
+
readonly repository: string;
|
|
685
|
+
readonly releaseTag: string;
|
|
686
|
+
readonly assetName: string;
|
|
687
|
+
readonly checksumAssetName: string;
|
|
688
|
+
readonly downloadUrl: string;
|
|
689
|
+
readonly checksumUrl: string;
|
|
690
|
+
readonly targetPath: string;
|
|
691
|
+
} {
|
|
692
|
+
return {
|
|
693
|
+
supported: true,
|
|
694
|
+
kind: build.kind,
|
|
695
|
+
dryRun,
|
|
696
|
+
currentVersion,
|
|
697
|
+
buildVersion: build.version,
|
|
698
|
+
repository: build.repository,
|
|
699
|
+
releaseTag: build.releaseTag,
|
|
700
|
+
assetName: build.assetName,
|
|
701
|
+
checksumAssetName: build.checksumAssetName,
|
|
702
|
+
downloadUrl: githubReleaseDownloadUrl(build, build.assetName),
|
|
703
|
+
checksumUrl: githubReleaseDownloadUrl(build, build.checksumAssetName),
|
|
704
|
+
targetPath: process.execPath,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function githubReleaseDownloadUrl(build: NornGithubReleaseBinaryBuildInfo, assetName: string): string {
|
|
709
|
+
return `https://github.com/${build.repository}/releases/download/${build.releaseTag}/${assetName}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function downloadReleaseAsset(url: string): Promise<Uint8Array> {
|
|
713
|
+
const response = await fetch(url);
|
|
714
|
+
if (!response.ok) throw new Error(`Failed to download Norn release asset: ${url} returned ${response.status}`);
|
|
715
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function downloadReleaseText(url: string): Promise<string> {
|
|
719
|
+
const response = await fetch(url);
|
|
720
|
+
if (!response.ok) throw new Error(`Failed to download Norn release metadata: ${url} returned ${response.status}`);
|
|
721
|
+
return response.text();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function parseSha256Checksum(text: string, checksumAssetName: string): string {
|
|
725
|
+
const checksum = text.trim().split(/\s+/)[0] ?? "";
|
|
726
|
+
if (!/^[a-f0-9]{64}$/i.test(checksum)) throw new Error(`Invalid Norn checksum asset: ${checksumAssetName}`);
|
|
727
|
+
return checksum.toLowerCase();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function requiredArg(command: string, args: readonly string[], index: number, label: string): string {
|
|
731
|
+
const value = args[index];
|
|
732
|
+
if (!value) throw new Error(`Missing ${label} for ${command}`);
|
|
733
|
+
return value;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function assertNoExtraArgs(command: string, args: readonly string[]): void {
|
|
737
|
+
if (args.length > 0) throw new Error(`${command} does not accept CLI arguments: ${args.join(" ")}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function assertKnownFlags(command: string, args: readonly string[], flags: readonly string[]): void {
|
|
741
|
+
const allowedFlags = new Set(flags);
|
|
742
|
+
const unsupportedFlags = args.filter((arg) => !allowedFlags.has(arg));
|
|
743
|
+
if (unsupportedFlags.length > 0) throw new Error(`${command} has unsupported flags or arguments: ${unsupportedFlags.join(" ")}`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function listWorkflows(args: readonly string[]): Promise<void> {
|
|
747
|
+
assertKnownFlags("workflows list", args, ["--entrypoints", "--all"]);
|
|
748
|
+
const entrypointsOnly = workflowListEntrypointsOnly(args);
|
|
749
|
+
const { workflows, isComplete, diagnostics } = await discoverNornProject(process.cwd());
|
|
750
|
+
writeJson({ workflows: entrypointsOnly ? workflows.filter(workflow => workflow.isEntrypoint) : workflows, isComplete, diagnostics });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function inspectWorkflow(workflowId: string): Promise<void> {
|
|
754
|
+
writeJson(await inspectNornWorkflow({ cwd: process.cwd(), workflowId }));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function workflowListEntrypointsOnly(args: readonly string[]): boolean {
|
|
758
|
+
const entrypoints = args.includes("--entrypoints");
|
|
759
|
+
const all = args.includes("--all");
|
|
760
|
+
if (entrypoints && all) throw new Error("Use either --entrypoints or --all, not both");
|
|
761
|
+
return !all;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function initProject(): Promise<void> {
|
|
765
|
+
const projectRoot = process.cwd();
|
|
766
|
+
const projectPath = resolve(projectRoot, NORN_PROJECT_FILE_NAME);
|
|
767
|
+
if (await isFile(projectPath)) throw new Error(`Norn project already exists: ${projectPath}`);
|
|
768
|
+
await mkdir(resolve(projectRoot, RUNS_ROOT), { recursive: true });
|
|
769
|
+
await writeFile(projectPath, `${JSON.stringify({ version: 1, plugins: [], includes: [], config: {} }, null, 2)}\n`, "utf8");
|
|
770
|
+
await ensureGitignoreExcludesRunState(projectRoot);
|
|
771
|
+
writeJson({ project: { path: projectPath, root: projectRoot, runsRoot: resolve(projectRoot, RUNS_ROOT) } });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function listCurrentProjectRuns(): Promise<NornRunInfo[]> {
|
|
775
|
+
const project = await findNornProject(process.cwd());
|
|
776
|
+
return listRuns(project.projectRoot);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function inspectProject(): Promise<void> {
|
|
780
|
+
const { project, isComplete, diagnostics } = await discoverNornProject(process.cwd());
|
|
781
|
+
writeJson({ project, isComplete, diagnostics });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function inspectSeerMode(): Promise<void> {
|
|
785
|
+
const project = await findNornProject(process.cwd());
|
|
786
|
+
writeJson({ seerMode: project.seerMode ?? null });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function startRun(workflowId: string, args: readonly string[]): Promise<void> {
|
|
790
|
+
assertNoStructuredInputArgs("runs start", args);
|
|
791
|
+
const project = await loadNornProject(process.cwd());
|
|
792
|
+
const workflow = project.registry.workflowById(workflowId);
|
|
793
|
+
if (!workflow) throw new Error(`Unknown workflow: ${workflowId}`);
|
|
794
|
+
const input = parseStartRunInput(await readStdinJson());
|
|
795
|
+
const params = workflow.params.parse(input.params ?? {});
|
|
796
|
+
const configOverride = input.config;
|
|
797
|
+
const id = randomUUID();
|
|
798
|
+
const name = generateRunName(new Set((await listRuns(project.projectRoot)).map((run) => run.name)));
|
|
799
|
+
const runRoot = resolve(project.projectRoot, RUNS_ROOT, id);
|
|
800
|
+
await mkdir(runRoot, { recursive: true });
|
|
801
|
+
const createdAt = new Date().toISOString();
|
|
802
|
+
await writeRunLaunchRequest(runRoot, { version: 1, type: "run", id, name, workflowId, params, configOverride, createdAt });
|
|
803
|
+
await startDetachedExecuteRun(id, project.projectRoot);
|
|
804
|
+
writeJson({ run: startedRunInfo({ id, name, workflow, runRoot, createdAt }) });
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async function resumeRun(run: string, args: readonly string[]): Promise<void> {
|
|
808
|
+
assertNoStructuredInputArgs("runs resume", args);
|
|
809
|
+
const project = await loadNornProject(process.cwd());
|
|
810
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
811
|
+
const input = parseResumeRunInput(await readStdinJson());
|
|
812
|
+
const lease = await NornRunLease.acquire(runRoot);
|
|
813
|
+
let request: NornRunResumeRequest;
|
|
814
|
+
try {
|
|
815
|
+
const runInfo = await getRunInfo(runRoot);
|
|
816
|
+
const params = await parseResumeParams(runInfo, input.params);
|
|
817
|
+
request = { version: 1, type: "resume", id: runInfo.id, requestId: randomUUID(), params, createdAt: new Date().toISOString() };
|
|
818
|
+
await writeRunResumeRequest(runRoot, request);
|
|
819
|
+
} finally {
|
|
820
|
+
await lease.release();
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
await startDetachedExecuteRun(request.id, project.projectRoot);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
await removeResumeRequest({ runRoot, request });
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
writeJson({ run: await getRunInfo(runRoot) });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function parseResumeParams(runInfo: NornRunInfo, params: unknown): Promise<unknown> {
|
|
832
|
+
if (runInfo.status === "interrupted") {
|
|
833
|
+
if (params === undefined) throw new Error(`Interrupted workflow resume requires params: ${runInfo.name}`);
|
|
834
|
+
if (!runInfo.currentWorkflowId) throw new Error(`Run has no current workflow: ${runInfo.name}`);
|
|
835
|
+
const project = await loadNornProject(process.cwd());
|
|
836
|
+
const workflow = project.registry.workflowById(runInfo.currentWorkflowId);
|
|
837
|
+
if (!workflow) throw new Error(`Unknown workflow for resumed run: ${runInfo.currentWorkflowId}`);
|
|
838
|
+
workflow.params.parse(mergeInterruptedWorkflowParams(runInfo.interruption?.params, params, runInfo.interruption?.fields));
|
|
839
|
+
return params;
|
|
840
|
+
}
|
|
841
|
+
if (runInfo.status === "pendingResume") {
|
|
842
|
+
if (params !== undefined) throw new Error(`Pending-resume workflows do not accept params: ${runInfo.name}`);
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
throw new Error(`Run must be rolled back before resuming: ${runInfo.name}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function waitRun(run: string): Promise<void> {
|
|
849
|
+
const project = await findNornProject(process.cwd());
|
|
850
|
+
writeJson({ run: await waitForInactiveRun(project.projectRoot, run) });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function waitForInactiveRun(projectRoot: string, run: string): Promise<NornRunInfo> {
|
|
854
|
+
let runRoot: string | undefined;
|
|
855
|
+
while (true) {
|
|
856
|
+
runRoot ??= await resolveWaitableRunRoot(projectRoot, run);
|
|
857
|
+
try {
|
|
858
|
+
const runInfo = await readRunInspection(runRoot);
|
|
859
|
+
if (runInfo.status !== "running" || runInfo.health === "unhealthy") return runInfo;
|
|
860
|
+
} catch (error) {
|
|
861
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
862
|
+
}
|
|
863
|
+
await delay(RUN_WAIT_INTERVAL_MS);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function resolveWaitableRunRoot(sessionCwd: string, run: string): Promise<string> {
|
|
868
|
+
try {
|
|
869
|
+
return await resolveRunRoot(sessionCwd, run);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
for (const candidate of [resolve(sessionCwd, run), resolve(sessionCwd, RUNS_ROOT, run)]) {
|
|
872
|
+
if (await isDirectory(candidate)) return candidate;
|
|
873
|
+
}
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
879
|
+
try {
|
|
880
|
+
return (await stat(path)).isDirectory();
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function isFile(path: string): Promise<boolean> {
|
|
888
|
+
try {
|
|
889
|
+
return (await stat(path)).isFile();
|
|
890
|
+
} catch (error) {
|
|
891
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function ensureGitignoreExcludesRunState(projectRoot: string): Promise<void> {
|
|
897
|
+
const gitignorePath = resolve(projectRoot, ".gitignore");
|
|
898
|
+
const runStatePattern = ".norn/runs/";
|
|
899
|
+
let currentText = "";
|
|
900
|
+
try {
|
|
901
|
+
currentText = await readFile(gitignorePath, "utf8");
|
|
902
|
+
} catch (error) {
|
|
903
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
904
|
+
}
|
|
905
|
+
if (currentText.split(/\r?\n/).includes(runStatePattern)) return;
|
|
906
|
+
const separator = currentText.length === 0 || currentText.endsWith("\n") ? "" : "\n";
|
|
907
|
+
await writeFile(gitignorePath, `${currentText}${separator}${runStatePattern}\n`, "utf8");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function executeRun(runId: string): Promise<void> {
|
|
911
|
+
const abortController = new AbortController();
|
|
912
|
+
process.once("SIGTERM", () => abortController.abort(new NornRunStoppedError()));
|
|
913
|
+
process.once("SIGINT", () => abortController.abort(new NornRunStoppedError()));
|
|
914
|
+
const location = await findNornProject(process.cwd());
|
|
915
|
+
const runRoot = resolve(location.projectRoot, RUNS_ROOT, runId);
|
|
916
|
+
const request = await readOptionalRunLaunchRequest(runRoot) ?? await readRunResumeRequest(runRoot);
|
|
917
|
+
try {
|
|
918
|
+
const project = await loadNornProject(process.cwd());
|
|
919
|
+
const engine = new NornEngine({ cwd: project.projectRoot, signal: abortController.signal, gateMode: "pause", config: project.projectConfig });
|
|
920
|
+
for (const plugin of project.plugins) engine.registerPlugin(plugin);
|
|
921
|
+
if (request.type === "run") {
|
|
922
|
+
const workflow = project.registry.workflowById(request.workflowId);
|
|
923
|
+
if (!workflow) throw new Error(`Unknown workflow: ${request.workflowId}`);
|
|
924
|
+
await engine.runWorkflow(workflow, request.params, { id: request.id, name: request.name, configOverride: request.configOverride });
|
|
925
|
+
} else {
|
|
926
|
+
await engine.resumeRequestedWorkflow({ runRoot, request });
|
|
927
|
+
}
|
|
928
|
+
} finally {
|
|
929
|
+
if (request.type === "run") await rm(join(runRoot, "launch-request.json"), { force: true });
|
|
930
|
+
else await removeResumeRequest({ runRoot, request });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function removeResumeRequest(input: { readonly runRoot: string; readonly request: NornRunResumeRequest }): Promise<void> {
|
|
935
|
+
const lease = await NornRunLease.acquire(input.runRoot);
|
|
936
|
+
try {
|
|
937
|
+
await clearRunResumeRequest({ ...input, lease });
|
|
938
|
+
} finally {
|
|
939
|
+
await lease.release();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function rollbackRun(run: string, args: readonly string[]): Promise<void> {
|
|
944
|
+
const checkpointId = requiredArg("runs rollback", args, 0, "checkpoint id");
|
|
945
|
+
assertNoExtraArgs("runs rollback", args.slice(1));
|
|
946
|
+
const project = await findNornProject(process.cwd());
|
|
947
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
948
|
+
const engine = new NornEngine({ cwd: project.projectRoot, gateMode: "pause" });
|
|
949
|
+
writeJson({ run: await engine.rollbackRun(runRoot, checkpointId) });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function inspectRun(run: string): Promise<NornRunInfo> {
|
|
953
|
+
const project = await findNornProject(process.cwd());
|
|
954
|
+
return readRunInspection(await resolveRunRoot(project.projectRoot, run));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function readRunInspection(runRoot: string): Promise<NornRunInfo> {
|
|
958
|
+
return getRunInfo(runRoot);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function signalRun(run: string, signal: NodeJS.Signals): Promise<void> {
|
|
962
|
+
const project = await findNornProject(process.cwd());
|
|
963
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
964
|
+
await terminateRun(runRoot, signal);
|
|
965
|
+
writeJson({ run: await getRunInfo(runRoot) });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function deleteRun(run: string): Promise<DeletedNornRunInfo> {
|
|
969
|
+
const project = await findNornProject(process.cwd());
|
|
970
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
971
|
+
const runInfo = await getRunInfo(runRoot);
|
|
972
|
+
if (runInfo.status === "running") throw new Error(`Stop run before deleting it: ${runInfo.name}`);
|
|
973
|
+
await rm(runRoot, { recursive: true, force: true });
|
|
974
|
+
return { id: runInfo.id, name: runInfo.name, path: runInfo.path };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function terminateRun(runRoot: string, signal: NodeJS.Signals): Promise<void> {
|
|
978
|
+
const owner = await getRunLeaseOwner(runRoot);
|
|
979
|
+
if (!owner) return;
|
|
980
|
+
sendSignal(owner.processGroupId, owner.pid, signal);
|
|
981
|
+
await delay(signal === "SIGKILL" ? 250 : 1000);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function writeRunLogs(run: string, options: { readonly follow: boolean }): Promise<void> {
|
|
985
|
+
const project = await findNornProject(process.cwd());
|
|
986
|
+
const runRoot = await resolveRunRoot(project.projectRoot, run);
|
|
987
|
+
let written = 0;
|
|
988
|
+
while (true) {
|
|
989
|
+
const events = await readRunEvents(runRoot);
|
|
990
|
+
for (const event of events.slice(written)) process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
991
|
+
written = events.length;
|
|
992
|
+
if (!options.follow) return;
|
|
993
|
+
const info = await getRunInfo(runRoot);
|
|
994
|
+
if (info.status !== "running" && written >= events.length) return;
|
|
995
|
+
await delay(1000);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function startDetachedExecuteRun(runId: string, projectRoot: string): Promise<void> {
|
|
1000
|
+
const child = spawn(process.execPath, detachedExecuteRunArgs(runId), {
|
|
1001
|
+
cwd: projectRoot,
|
|
1002
|
+
detached: true,
|
|
1003
|
+
stdio: "ignore",
|
|
1004
|
+
});
|
|
1005
|
+
await new Promise<void>((resolveSpawn, reject) => {
|
|
1006
|
+
child.once("spawn", resolveSpawn);
|
|
1007
|
+
child.once("error", reject);
|
|
1008
|
+
});
|
|
1009
|
+
child.unref();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function detachedExecuteRunArgs(runId: string): readonly string[] {
|
|
1013
|
+
if (NORN_BUILD_INFO.kind === "github-release-binary") return ["execute-run", runId];
|
|
1014
|
+
const scriptPath = process.argv[1];
|
|
1015
|
+
return scriptPath && isAbsolute(scriptPath) && /\.(?:c?m?js|ts)$/.test(scriptPath) ? [scriptPath, "execute-run", runId] : ["execute-run", runId];
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function startedRunInfo(input: {
|
|
1019
|
+
readonly id: string;
|
|
1020
|
+
readonly name: string;
|
|
1021
|
+
readonly workflow: NornAnyWorkflowDeclaration;
|
|
1022
|
+
readonly runRoot: string;
|
|
1023
|
+
readonly createdAt: string;
|
|
1024
|
+
}): NornRunInfo {
|
|
1025
|
+
return {
|
|
1026
|
+
version: 1,
|
|
1027
|
+
id: input.id,
|
|
1028
|
+
name: input.name,
|
|
1029
|
+
path: input.runRoot,
|
|
1030
|
+
entrypointWorkflowId: input.workflow.id,
|
|
1031
|
+
currentWorkflowId: input.workflow.id,
|
|
1032
|
+
status: "running",
|
|
1033
|
+
health: "healthy",
|
|
1034
|
+
startedAt: input.createdAt,
|
|
1035
|
+
updatedAt: input.createdAt,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function readOptionalRunLaunchRequest(runRoot: string) {
|
|
1040
|
+
try {
|
|
1041
|
+
return await readRunLaunchRequest(runRoot);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function readRunEvents(runRoot: string): Promise<readonly Record<string, unknown>[]> {
|
|
1049
|
+
try {
|
|
1050
|
+
const manifest = JSON.parse(await readFile(join(runRoot, "current", "manifest.json"), "utf8")) as { events?: readonly Record<string, unknown>[] };
|
|
1051
|
+
return manifest.events ?? [];
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (isNodeError(error) && error.code === "ENOENT") return [];
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function readStdinJson(): Promise<unknown | undefined> {
|
|
1059
|
+
const text = await readStdin();
|
|
1060
|
+
const trimmed = text.trim();
|
|
1061
|
+
return trimmed.length === 0 ? undefined : JSON.parse(trimmed);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function readStdin(): Promise<string> {
|
|
1065
|
+
if (process.stdin.isTTY) return "";
|
|
1066
|
+
return new Promise((resolvePromise, reject) => {
|
|
1067
|
+
let text = "";
|
|
1068
|
+
process.stdin.setEncoding("utf8");
|
|
1069
|
+
process.stdin.on("data", (chunk) => {
|
|
1070
|
+
text += chunk;
|
|
1071
|
+
});
|
|
1072
|
+
process.stdin.on("error", reject);
|
|
1073
|
+
process.stdin.on("end", () => resolvePromise(text));
|
|
1074
|
+
process.stdin.resume();
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function parseStartRunInput(value: unknown): { readonly params?: unknown; readonly config?: unknown } {
|
|
1079
|
+
if (value === undefined) return {};
|
|
1080
|
+
const input = parseStructuredInputObject("runs start", value);
|
|
1081
|
+
assertStructuredInputKeys("runs start", input, ["params", "config"]);
|
|
1082
|
+
return { params: input.params, config: input.config };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function parseResumeRunInput(value: unknown): { readonly params?: unknown } {
|
|
1086
|
+
if (value === undefined) return {};
|
|
1087
|
+
const input = parseStructuredInputObject("runs resume", value);
|
|
1088
|
+
assertStructuredInputKeys("runs resume", input, ["params"]);
|
|
1089
|
+
return { params: input.params };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function parseStructuredInputObject(command: string, value: unknown): Record<string, unknown> {
|
|
1093
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${command} reads a JSON object from stdin`);
|
|
1094
|
+
return value as Record<string, unknown>;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function assertStructuredInputKeys(command: string, input: Record<string, unknown>, allowedKeys: readonly string[]): void {
|
|
1098
|
+
const allowed = new Set(allowedKeys);
|
|
1099
|
+
const unexpected = Object.keys(input).filter((key) => !allowed.has(key));
|
|
1100
|
+
if (unexpected.length > 0) throw new Error(`${command} stdin JSON has unsupported keys: ${unexpected.join(", ")}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function assertNoStructuredInputArgs(command: string, args: readonly string[]): void {
|
|
1104
|
+
if (args.length > 0) throw new Error(`${command} reads structured input from stdin, not CLI arguments: ${args.join(" ")}`);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function sendSignal(processGroupId: number, pid: number, signal: NodeJS.Signals): void {
|
|
1108
|
+
try {
|
|
1109
|
+
if (processGroupId > 0 && process.platform !== "win32") {
|
|
1110
|
+
process.kill(-processGroupId, signal);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
if (!isIgnorableSignalError(error)) throw error;
|
|
1115
|
+
}
|
|
1116
|
+
try {
|
|
1117
|
+
process.kill(pid, signal);
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
if (!isIgnorableSignalError(error)) throw error;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function isIgnorableSignalError(error: unknown): boolean {
|
|
1124
|
+
return isNodeError(error) && (error.code === "ESRCH" || error.code === "EPERM");
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function writeJson(value: unknown): void {
|
|
1128
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function errorCode(error: unknown): string {
|
|
1132
|
+
if (error instanceof NornProjectLoadError) return error.code;
|
|
1133
|
+
if (error instanceof SyntaxError) return "INVALID_JSON";
|
|
1134
|
+
return "NORN_ERROR";
|
|
1135
|
+
}
|