@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,59 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
|
|
3
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
4
|
+
import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
5
|
+
|
|
6
|
+
export type NornRunManifestEvent = {
|
|
7
|
+
readonly at: string;
|
|
8
|
+
readonly type: string;
|
|
9
|
+
readonly [key: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type NornRunManifest = {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly workflowId: string;
|
|
16
|
+
readonly runRoot: string;
|
|
17
|
+
readonly workspace: string;
|
|
18
|
+
readonly initialCwd: string;
|
|
19
|
+
readonly startedAt: string;
|
|
20
|
+
readonly events: readonly NornRunManifestEvent[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class NornRunLogger {
|
|
24
|
+
private isInitialized = false;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly input: {
|
|
27
|
+
readonly manifestPath: string;
|
|
28
|
+
readonly manifest: Omit<NornRunManifest, "events">;
|
|
29
|
+
readonly files: NornFileCoordinator;
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
static async load(manifestPath: string, files: NornFileCoordinator): Promise<NornRunLogger> {
|
|
33
|
+
const manifest = parseManifest(await files.readText(manifestPath));
|
|
34
|
+
const logger = new NornRunLogger({ manifestPath, manifest, files });
|
|
35
|
+
logger.isInitialized = true;
|
|
36
|
+
return logger;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async record(event: { readonly type: string; readonly [key: string]: unknown }): Promise<void> {
|
|
40
|
+
await this.input.files.withExclusiveLock(this.input.manifestPath, async (path) => {
|
|
41
|
+
let manifest: NornRunManifest;
|
|
42
|
+
try {
|
|
43
|
+
manifest = parseManifest(await readFile(path, "utf8"));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (this.isInitialized || !isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
46
|
+
manifest = { ...this.input.manifest, events: [] };
|
|
47
|
+
}
|
|
48
|
+
if (manifest.id !== this.input.manifest.id) throw new Error("Run manifest identity changed");
|
|
49
|
+
await writeJsonAtomically(path, { ...manifest, events: [...manifest.events, { ...event, at: new Date().toISOString() }] });
|
|
50
|
+
this.isInitialized = true;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseManifest(content: string): NornRunManifest {
|
|
56
|
+
const manifest = JSON.parse(content) as NornRunManifest;
|
|
57
|
+
if (!manifest || typeof manifest !== "object" || typeof manifest.id !== "string" || !Array.isArray(manifest.events)) throw new Error("Invalid run manifest");
|
|
58
|
+
return manifest;
|
|
59
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const ADJECTIVES = [
|
|
2
|
+
"able", "amber", "brave", "bright", "calm", "clever", "crisp", "daring", "eager", "fair",
|
|
3
|
+
"fast", "fresh", "gentle", "golden", "grand", "happy", "honest", "jolly", "kind", "lively",
|
|
4
|
+
"lucky", "merry", "nimble", "noble", "patient", "peaceful", "proud", "quiet", "rapid", "ready",
|
|
5
|
+
"royal", "sharp", "silent", "silver", "simple", "steady", "sunny", "swift", "tidy", "vivid",
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
const MODIFIERS = [
|
|
9
|
+
"anchor", "autumn", "brook", "cedar", "cloud", "copper", "dawn", "ember", "field", "forest",
|
|
10
|
+
"harbor", "hazel", "hill", "island", "ivory", "jade", "lake", "maple", "meadow", "moon",
|
|
11
|
+
"north", "ocean", "olive", "pearl", "pine", "rain", "river", "rose", "shadow", "snow",
|
|
12
|
+
"solar", "spring", "stone", "summer", "thunder", "timber", "valley", "violet", "willow", "winter",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const NOUNS = [
|
|
16
|
+
"badger", "beacon", "bison", "canvas", "comet", "falcon", "fjord", "fox", "garden", "glade",
|
|
17
|
+
"grove", "heron", "lantern", "lynx", "marker", "otter", "panda", "planet", "prairie", "raven",
|
|
18
|
+
"rocket", "sail", "signal", "sparrow", "summit", "tiger", "trail", "voyage", "whale", "wolf",
|
|
19
|
+
"zephyr", "orchid", "bridge", "castle", "harvest", "mirror", "needle", "quartz", "ripple", "signal",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function generateRunName(existingNames: ReadonlySet<string>): string {
|
|
23
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
24
|
+
const name = `${pick(ADJECTIVES)}-${pick(MODIFIERS)}-${pick(NOUNS)}`;
|
|
25
|
+
if (!existingNames.has(name)) return name;
|
|
26
|
+
}
|
|
27
|
+
while (true) {
|
|
28
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
29
|
+
const name = `${pick(ADJECTIVES)}-${pick(MODIFIERS)}-${pick(NOUNS)}-${suffix}`;
|
|
30
|
+
if (!existingNames.has(name)) return name;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pick(values: readonly string[]): string {
|
|
35
|
+
return values[Math.floor(Math.random() * values.length)] ?? values[0];
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { NornRunResources } from "../resources.ts";
|
|
4
|
+
import { NornJsonWorkflowState } from "./state-store.ts";
|
|
5
|
+
|
|
6
|
+
export async function initializeRunResources(runRoot: string): Promise<{ readonly resources: NornRunResources; readonly state: NornJsonWorkflowState }> {
|
|
7
|
+
const resources = await NornRunResources.initialize(runRoot);
|
|
8
|
+
const state = await resources.ensure({
|
|
9
|
+
name: "workflow-state",
|
|
10
|
+
kind: "norn.state",
|
|
11
|
+
configuration: { format: 1, path: "state.json" },
|
|
12
|
+
async initialize({ files, mode }) {
|
|
13
|
+
const state = new NornJsonWorkflowState({
|
|
14
|
+
stateFile: join(resolve(runRoot), "current", "state.json"),
|
|
15
|
+
files,
|
|
16
|
+
coordinateMutation: withFileMutationQueue,
|
|
17
|
+
});
|
|
18
|
+
await state.initialize(mode);
|
|
19
|
+
return state;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return { resources, state };
|
|
23
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import type { NornRunHealth, NornRunStatus, NornRunOutcomeMetadata, NornRunInfo, NornRunInterruption, NornRunOutcomeInfo, NornRunFailureInfo } from "@vimhead.dev/norn";
|
|
4
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
5
|
+
import { readRunLaunchRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS } from "./launch-request.ts";
|
|
6
|
+
import { getRunLeaseHealth } from "./run-lease.ts";
|
|
7
|
+
import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
8
|
+
import { createRunFileCoordinator, type NornFileCoordinator } from "@vimhead.dev/norn/files";
|
|
9
|
+
import { runCurrentRoot } from "./run-store.ts";
|
|
10
|
+
|
|
11
|
+
export const RUN_STATE_FILE_NAME = "run-state.json";
|
|
12
|
+
const LEGACY_RUN_STATE_FILE_NAME = "runtime-state.json";
|
|
13
|
+
|
|
14
|
+
export function mergeInterruptedWorkflowParams(currentParams: unknown, params: unknown, fields: readonly string[] | undefined): unknown {
|
|
15
|
+
if (!isRecord(currentParams) || !isRecord(params)) return params;
|
|
16
|
+
if (fields) {
|
|
17
|
+
const unsupportedFields = Object.keys(params).filter((field) => !fields.includes(field));
|
|
18
|
+
if (unsupportedFields.length > 0) throw new Error(`Interrupted workflow params cannot update non-gate fields: ${unsupportedFields.join(", ")}`);
|
|
19
|
+
}
|
|
20
|
+
return { ...currentParams, ...params };
|
|
21
|
+
}
|
|
22
|
+
const RUN_STATE_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
type NornRunWorkflowStep = {
|
|
25
|
+
readonly workflowId: string;
|
|
26
|
+
readonly params: unknown;
|
|
27
|
+
readonly cwd: string;
|
|
28
|
+
readonly env: Record<string, string>;
|
|
29
|
+
readonly interruption?: RuntimeInterruption;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type RuntimeInterruption = {
|
|
33
|
+
readonly status: "pending" | "satisfied";
|
|
34
|
+
readonly description?: string;
|
|
35
|
+
readonly fields?: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type NornRunWorkflowCompletion = {
|
|
39
|
+
readonly workflowId: string;
|
|
40
|
+
readonly completedAt: string;
|
|
41
|
+
readonly outcome: { readonly type: "next"; readonly workflowId: string } | { readonly type: "complete" } | { readonly type: "fail" };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type NornRunWorkflowFailure = {
|
|
45
|
+
readonly workflowId: string;
|
|
46
|
+
readonly error: string;
|
|
47
|
+
readonly metadata?: NornRunOutcomeMetadata;
|
|
48
|
+
readonly failedAt: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type NornRunWorkflowOutcome = {
|
|
52
|
+
readonly workflowId: string;
|
|
53
|
+
readonly completedAt: string;
|
|
54
|
+
readonly status: "completed" | "failed";
|
|
55
|
+
readonly metadata?: NornRunOutcomeMetadata;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type NornRunState = {
|
|
59
|
+
readonly version: typeof RUN_STATE_VERSION;
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly entrypointWorkflowId: string;
|
|
63
|
+
readonly workspace: string;
|
|
64
|
+
readonly configOverride?: unknown;
|
|
65
|
+
readonly status: NornRunStatus;
|
|
66
|
+
readonly current: NornRunWorkflowStep | null;
|
|
67
|
+
readonly lastCompleted: NornRunWorkflowCompletion | null;
|
|
68
|
+
readonly outcome: NornRunWorkflowOutcome | null;
|
|
69
|
+
readonly failed: NornRunWorkflowFailure | null;
|
|
70
|
+
readonly startedAt: string;
|
|
71
|
+
readonly updatedAt: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type CreateNornRunStateInput = {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly entrypointWorkflowId: string;
|
|
78
|
+
readonly workspace: string;
|
|
79
|
+
readonly configOverride?: unknown;
|
|
80
|
+
readonly current: NornRunWorkflowStep;
|
|
81
|
+
readonly startedAt: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export class NornRunStateStore {
|
|
85
|
+
private readonly path: string;
|
|
86
|
+
private state: NornRunState;
|
|
87
|
+
private readonly files: NornFileCoordinator;
|
|
88
|
+
|
|
89
|
+
private constructor(input: { readonly path: string; readonly state: NornRunState; readonly files: NornFileCoordinator }) {
|
|
90
|
+
this.path = input.path;
|
|
91
|
+
this.state = input.state;
|
|
92
|
+
this.files = input.files;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static async create(runRoot: string, input: CreateNornRunStateInput): Promise<NornRunStateStore> {
|
|
96
|
+
const now = input.startedAt;
|
|
97
|
+
const state: NornRunState = {
|
|
98
|
+
version: RUN_STATE_VERSION,
|
|
99
|
+
id: input.id,
|
|
100
|
+
name: input.name,
|
|
101
|
+
entrypointWorkflowId: input.entrypointWorkflowId,
|
|
102
|
+
workspace: input.workspace,
|
|
103
|
+
configOverride: input.configOverride,
|
|
104
|
+
status: "running",
|
|
105
|
+
current: input.current,
|
|
106
|
+
lastCompleted: null,
|
|
107
|
+
outcome: null,
|
|
108
|
+
failed: null,
|
|
109
|
+
startedAt: now,
|
|
110
|
+
updatedAt: now,
|
|
111
|
+
};
|
|
112
|
+
const store = new NornRunStateStore({ path: join(runCurrentRoot(runRoot), RUN_STATE_FILE_NAME), state, files: createRunFileCoordinator(runRoot) });
|
|
113
|
+
await store.write();
|
|
114
|
+
return store;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static async load(runRoot: string): Promise<NornRunStateStore> {
|
|
118
|
+
const currentRoot = runCurrentRoot(runRoot);
|
|
119
|
+
const files = createRunFileCoordinator(runRoot);
|
|
120
|
+
const state = await files.withExclusiveLock(join(currentRoot, RUN_STATE_FILE_NAME), async (path) => parseNornRunState(await readRunStateFile({ path, legacyPath: join(currentRoot, LEGACY_RUN_STATE_FILE_NAME) })));
|
|
121
|
+
return new NornRunStateStore({ path: join(currentRoot, RUN_STATE_FILE_NAME), state, files });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
currentState(): NornRunState {
|
|
125
|
+
return this.state;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async startStep(step: NornRunWorkflowStep): Promise<void> {
|
|
129
|
+
await this.update({ status: "running", current: step, outcome: null, failed: null });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async completeWithNext(completedWorkflowId: string, next: NornRunWorkflowStep): Promise<void> {
|
|
133
|
+
await this.update({
|
|
134
|
+
status: "running",
|
|
135
|
+
current: next,
|
|
136
|
+
lastCompleted: { workflowId: completedWorkflowId, completedAt: new Date().toISOString(), outcome: { type: "next", workflowId: next.workflowId } },
|
|
137
|
+
outcome: null,
|
|
138
|
+
failed: null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async completeRun(workflowId: string, metadata: NornRunOutcomeMetadata | undefined): Promise<void> {
|
|
143
|
+
const completedAt = new Date().toISOString();
|
|
144
|
+
await this.update({
|
|
145
|
+
status: "completed",
|
|
146
|
+
current: null,
|
|
147
|
+
lastCompleted: { workflowId, completedAt, outcome: { type: "complete" } },
|
|
148
|
+
outcome: { workflowId, completedAt, status: "completed", metadata },
|
|
149
|
+
failed: null,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async failRun(workflowId: string, metadata: NornRunOutcomeMetadata & { readonly summary: string }): Promise<void> {
|
|
154
|
+
const failedAt = new Date().toISOString();
|
|
155
|
+
await this.update({
|
|
156
|
+
status: "failed",
|
|
157
|
+
current: null,
|
|
158
|
+
lastCompleted: { workflowId, completedAt: failedAt, outcome: { type: "fail" } },
|
|
159
|
+
outcome: { workflowId, completedAt: failedAt, status: "failed", metadata },
|
|
160
|
+
failed: { workflowId, error: metadata.summary, metadata, failedAt },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async interruptCurrent(params: unknown, interruption: { readonly description: string; readonly fields?: readonly string[] }): Promise<void> {
|
|
165
|
+
if (!this.state.current) throw new Error("Cannot interrupt run without current step");
|
|
166
|
+
await this.update({
|
|
167
|
+
status: "interrupted",
|
|
168
|
+
current: { ...this.state.current, params, interruption: { status: "pending", ...interruption } },
|
|
169
|
+
outcome: null,
|
|
170
|
+
failed: null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async prepareForResumeAfterRollback(): Promise<void> {
|
|
175
|
+
if (!this.state.current) throw new Error("Cannot resume a checkpoint without a current step");
|
|
176
|
+
if (this.state.status === "interrupted") return;
|
|
177
|
+
if (this.state.status !== "running") throw new Error(`Cannot resume checkpoint with ${this.state.status} status`);
|
|
178
|
+
await this.update({ status: "pendingResume", outcome: null, failed: null });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async stopCurrent(): Promise<void> {
|
|
182
|
+
if (!this.state.current) throw new Error("Cannot stop run without current step");
|
|
183
|
+
await this.update({ status: "stopped", outcome: null, failed: null });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async replaceCurrentParams(params: unknown): Promise<void> {
|
|
187
|
+
if (!this.state.current) throw new Error("Cannot resume run without current step");
|
|
188
|
+
await this.update({
|
|
189
|
+
status: "running",
|
|
190
|
+
current: { ...this.state.current, params, interruption: { ...this.state.current.interruption, status: "satisfied" } },
|
|
191
|
+
outcome: null,
|
|
192
|
+
failed: null,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async failCurrent(errorOrMetadata: string | NornRunOutcomeMetadata & { readonly summary: string }): Promise<void> {
|
|
197
|
+
if (!this.state.current) throw new Error("Cannot fail run without current step");
|
|
198
|
+
const failedAt = new Date().toISOString();
|
|
199
|
+
const metadata = typeof errorOrMetadata === "string" ? { summary: errorOrMetadata } : errorOrMetadata;
|
|
200
|
+
await this.update({
|
|
201
|
+
status: "failed",
|
|
202
|
+
outcome: { workflowId: this.state.current.workflowId, completedAt: failedAt, status: "failed", metadata },
|
|
203
|
+
failed: { workflowId: this.state.current.workflowId, error: metadata.summary, metadata, failedAt },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async update(patch: Partial<NornRunState>): Promise<void> {
|
|
208
|
+
await this.files.withExclusiveLock(this.path, async (path) => {
|
|
209
|
+
const current = parseNornRunState(await readRunStateFile({ path, legacyPath: join(dirname(this.path), LEGACY_RUN_STATE_FILE_NAME) }));
|
|
210
|
+
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
211
|
+
await writeJsonAtomically(path, next);
|
|
212
|
+
this.state = next;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async write(): Promise<void> {
|
|
217
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
218
|
+
await this.files.withExclusiveLock(this.path, (path) => writeJsonAtomically(path, this.state));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function listRuns(sessionCwd: string): Promise<NornRunInfo[]> {
|
|
223
|
+
const runsRoot = join(sessionCwd, ".norn", "runs");
|
|
224
|
+
let entries;
|
|
225
|
+
try {
|
|
226
|
+
entries = await readdir(runsRoot, { withFileTypes: true });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (isNodeError(error) && error.code === "ENOENT") return [];
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const runs = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => readNornRunInfo(resolve(runsRoot, entry.name))));
|
|
233
|
+
return runs.filter((run): run is NornRunInfo => run !== undefined).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function getRunInfo(runRoot: string): Promise<NornRunInfo> {
|
|
237
|
+
try {
|
|
238
|
+
const resumeRequest = await readOptionalRunResumeRequest(runRoot);
|
|
239
|
+
const state = parseNornRunState(await readRunStateJson(runCurrentRoot(runRoot)));
|
|
240
|
+
return {
|
|
241
|
+
version: state.version,
|
|
242
|
+
id: state.id,
|
|
243
|
+
name: state.name,
|
|
244
|
+
path: runRoot,
|
|
245
|
+
entrypointWorkflowId: state.entrypointWorkflowId,
|
|
246
|
+
currentWorkflowId: state.current?.workflowId,
|
|
247
|
+
status: resumeRequest ? "running" : state.status,
|
|
248
|
+
health: resumeRequest
|
|
249
|
+
? await getPendingResumeHealth(runRoot, resumeRequest.createdAt)
|
|
250
|
+
: await runHealth(runRoot, state.status),
|
|
251
|
+
interruption: resumeRequest ? undefined : runInterruption(state),
|
|
252
|
+
outcome: runOutcome(state),
|
|
253
|
+
failed: runFailure(state),
|
|
254
|
+
startedAt: state.startedAt,
|
|
255
|
+
updatedAt: state.updatedAt,
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
259
|
+
return getLaunchedRunInfo(runRoot);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function resolveRunRoot(sessionCwd: string, run: string): Promise<string> {
|
|
264
|
+
for (const candidate of runRootCandidates(sessionCwd, run)) {
|
|
265
|
+
try {
|
|
266
|
+
if (await readNornRunInfo(candidate)) return candidate;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (!isNodeError(error) && (!(error instanceof Error) || !error.message.includes("run state"))) throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const runs = await listRuns(sessionCwd);
|
|
273
|
+
const match = runs.find((entry) => entry.id === run || entry.name === run || entry.path === run || entry.path.endsWith(`/${run}`));
|
|
274
|
+
if (!match) throw new Error(`Unknown run: ${run}`);
|
|
275
|
+
return match.path;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function runRootCandidates(sessionCwd: string, run: string): string[] {
|
|
279
|
+
const candidates = [isAbsolute(run) ? run : resolve(sessionCwd, run)];
|
|
280
|
+
if (!isAbsolute(run)) candidates.push(resolve(sessionCwd, ".norn", "runs", run));
|
|
281
|
+
return Array.from(new Set(candidates));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function getPendingResumeHealth(runRoot: string, requestedAt: string): Promise<NornRunHealth> {
|
|
285
|
+
const health = await getRunLeaseHealth(runRoot);
|
|
286
|
+
return health === "healthy" || Date.now() - Date.parse(requestedAt) < RESUME_START_GRACE_MS ? "healthy" : "unhealthy";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function runHealth(runRoot: string, status: NornRunStatus): Promise<NornRunHealth> {
|
|
290
|
+
if (status !== "running") return "healthy";
|
|
291
|
+
return getRunLeaseHealth(runRoot);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function readNornRunInfo(runRoot: string): Promise<NornRunInfo | undefined> {
|
|
295
|
+
try {
|
|
296
|
+
return await getRunInfo(runRoot);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function getLaunchedRunInfo(runRoot: string): Promise<NornRunInfo> {
|
|
304
|
+
const launchRequest = await readRunLaunchRequest(runRoot);
|
|
305
|
+
return {
|
|
306
|
+
version: launchRequest.version,
|
|
307
|
+
id: launchRequest.id,
|
|
308
|
+
name: launchRequest.name,
|
|
309
|
+
path: runRoot,
|
|
310
|
+
entrypointWorkflowId: launchRequest.workflowId,
|
|
311
|
+
currentWorkflowId: launchRequest.workflowId,
|
|
312
|
+
status: "running",
|
|
313
|
+
health: "healthy",
|
|
314
|
+
startedAt: launchRequest.createdAt,
|
|
315
|
+
updatedAt: launchRequest.createdAt,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function readRunStateJson(currentRoot: string): Promise<unknown> {
|
|
320
|
+
return readRunStateFile({ path: join(currentRoot, RUN_STATE_FILE_NAME), legacyPath: join(currentRoot, LEGACY_RUN_STATE_FILE_NAME) });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function readRunStateFile(input: { readonly path: string; readonly legacyPath: string }): Promise<unknown> {
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(await readFile(input.path, "utf8"));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
328
|
+
return JSON.parse(await readFile(input.legacyPath, "utf8"));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
333
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseNornRunState(value: unknown): NornRunState {
|
|
337
|
+
if (!value || typeof value !== "object") throw new Error("Invalid run state");
|
|
338
|
+
const state = value as Partial<NornRunState> & { rootWorkflowId?: unknown };
|
|
339
|
+
if (state.version !== RUN_STATE_VERSION) throw new Error(`Unsupported run state version: ${String(state.version)}`);
|
|
340
|
+
if (typeof state.id !== "string" || state.id.length === 0) throw new Error("Invalid run state id");
|
|
341
|
+
const normalizedState = state as { name?: string; entrypointWorkflowId?: unknown; rootWorkflowId?: unknown };
|
|
342
|
+
if (typeof normalizedState.name !== "string" || normalizedState.name.length === 0) normalizedState.name = state.id;
|
|
343
|
+
if (typeof normalizedState.entrypointWorkflowId !== "string" && typeof normalizedState.rootWorkflowId === "string") normalizedState.entrypointWorkflowId = normalizedState.rootWorkflowId;
|
|
344
|
+
if (typeof state.entrypointWorkflowId !== "string" || state.entrypointWorkflowId.length === 0) throw new Error("Invalid run state entrypoint workflow id");
|
|
345
|
+
if (typeof state.workspace !== "string" || state.workspace.length === 0) throw new Error("Invalid run state workspace");
|
|
346
|
+
if (state.status !== "running" && state.status !== "interrupted" && state.status !== "stopped" && state.status !== "pendingResume" && state.status !== "completed" && state.status !== "failed") throw new Error("Invalid run state status");
|
|
347
|
+
if (state.status === "interrupted") assertInterruptedNornRunState(state);
|
|
348
|
+
if (state.status === "stopped" || state.status === "pendingResume") assertResumableNornRunState(state);
|
|
349
|
+
if (typeof state.startedAt !== "string" || typeof state.updatedAt !== "string") throw new Error("Invalid run state timestamps");
|
|
350
|
+
return state as NornRunState;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function assertInterruptedNornRunState(state: Partial<NornRunState>): void {
|
|
354
|
+
if (!state.current) throw new Error("Invalid interrupted run current step");
|
|
355
|
+
const interruption = state.current.interruption;
|
|
356
|
+
if (!interruption || interruption.status !== "pending") throw new Error("Invalid run interruption");
|
|
357
|
+
if (typeof interruption.description !== "string" || interruption.description.length === 0) throw new Error("Invalid run interruption description");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function assertResumableNornRunState(state: Partial<NornRunState>): void {
|
|
361
|
+
if (!state.current) throw new Error("Invalid resumable run current step");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function runInterruption(state: NornRunState): NornRunInterruption | undefined {
|
|
365
|
+
if (state.status !== "interrupted" || !state.current?.interruption) return undefined;
|
|
366
|
+
return {
|
|
367
|
+
workflowId: state.current.workflowId,
|
|
368
|
+
params: state.current.params,
|
|
369
|
+
description: state.current.interruption.description ?? "",
|
|
370
|
+
fields: state.current.interruption.fields,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function runOutcome(state: NornRunState): NornRunOutcomeInfo | undefined {
|
|
375
|
+
return state.outcome ?? undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function runFailure(state: NornRunState): NornRunFailureInfo | undefined {
|
|
379
|
+
return state.failed ?? undefined;
|
|
380
|
+
}
|