@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,501 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { initializeRunResources } from "./run-resources.ts";
|
|
5
|
+
import { createRunFileCoordinator } from "@vimhead.dev/norn/files";
|
|
6
|
+
import {
|
|
7
|
+
type NornAnyWorkflowDeclaration,
|
|
8
|
+
type NornAnyWorkflowPluginManifest,
|
|
9
|
+
type NornDispose,
|
|
10
|
+
type NornRunStartOptions,
|
|
11
|
+
type NornInterruptedRunResult,
|
|
12
|
+
type NornRunResult,
|
|
13
|
+
type NornRunCheckpoint,
|
|
14
|
+
type NornRunInfo,
|
|
15
|
+
type NornWorkflowPlugin,
|
|
16
|
+
type NornWorkflowPluginImplementation,
|
|
17
|
+
type NornWorkflowPluginImplementationInput,
|
|
18
|
+
type NornRunNext,
|
|
19
|
+
} from "@vimhead.dev/norn";
|
|
20
|
+
import { NornAgentResponseCollector } from "./agent-response-tool.ts";
|
|
21
|
+
import { NornArtifacts } from "./artifacts.ts";
|
|
22
|
+
import { errorMessage, NornRunStoppedError } from "./errors.ts";
|
|
23
|
+
import { NornRunLogs } from "./logs.ts";
|
|
24
|
+
import { clearRunResumeRequest, matchesRunResumeRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS, type NornRunResumeRequest } from "./launch-request.ts";
|
|
25
|
+
import { NornRunLease } from "./run-lease.ts";
|
|
26
|
+
import { NornRunLogger } from "./run-log.ts";
|
|
27
|
+
import { NornRunStore, runCurrentRoot } from "./run-store.ts";
|
|
28
|
+
import { NornRunStateStore, getRunInfo, mergeInterruptedWorkflowParams, resolveRunRoot, type NornRunState } from "./run-state.ts";
|
|
29
|
+
import { NornRunContext } from "./run.ts";
|
|
30
|
+
import { NornMemoryWorkflowState } from "./state-store.ts";
|
|
31
|
+
import { NornWorkflowRegistry, type NornRegisteredWorkflow, type NornWorkflowStepResult } from "./workflow-registry.ts";
|
|
32
|
+
|
|
33
|
+
const RUNS_DIR_NAME = ".norn";
|
|
34
|
+
const MANIFEST_FILE_NAME = "manifest.json";
|
|
35
|
+
|
|
36
|
+
export type NornEngineInput = {
|
|
37
|
+
readonly cwd: string;
|
|
38
|
+
readonly agentDir?: string;
|
|
39
|
+
readonly signal?: AbortSignal;
|
|
40
|
+
readonly responseCollector?: NornAgentResponseCollector;
|
|
41
|
+
readonly gateMode?: "auto" | "pause";
|
|
42
|
+
readonly config?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type RunSession = {
|
|
46
|
+
readonly runRoot: string;
|
|
47
|
+
readonly run: NornRunContext;
|
|
48
|
+
readonly state: NornRunStateStore;
|
|
49
|
+
readonly lease: NornRunLease;
|
|
50
|
+
readonly runStore: NornRunStore;
|
|
51
|
+
readonly logger: NornRunLogger;
|
|
52
|
+
readonly activeRun: ActiveRun;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type ActiveRun = {
|
|
56
|
+
readonly controller: AbortController;
|
|
57
|
+
readonly finished: Promise<void>;
|
|
58
|
+
readonly finish: () => void;
|
|
59
|
+
readonly dispose: () => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type WorkflowStep = {
|
|
63
|
+
readonly workflow: NornAnyWorkflowDeclaration;
|
|
64
|
+
readonly params: unknown;
|
|
65
|
+
readonly interruption?: NonNullable<NornRunState["current"]>["interruption"];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export class NornEngine {
|
|
69
|
+
private readonly registry = new NornWorkflowRegistry();
|
|
70
|
+
private readonly registrarState = new NornMemoryWorkflowState();
|
|
71
|
+
private readonly disposersByPlugin = new Map<string, NornDispose[]>();
|
|
72
|
+
private readonly responseCollector: NornAgentResponseCollector;
|
|
73
|
+
private readonly activeRuns = new Map<string, ActiveRun>();
|
|
74
|
+
|
|
75
|
+
constructor(private readonly input: NornEngineInput) {
|
|
76
|
+
this.responseCollector = input.responseCollector ?? new NornAgentResponseCollector();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
registerPlugin<TManifest extends NornAnyWorkflowPluginManifest>(plugin: NornWorkflowPlugin<TManifest>): NornDispose {
|
|
80
|
+
this.disposePlugin(plugin.manifest.id);
|
|
81
|
+
const implementation = this.resolvePluginImplementation(plugin.implementation);
|
|
82
|
+
const disposers = Object.entries(plugin.manifest.workflows).map(([key, workflow]) => {
|
|
83
|
+
const workflowImplementation = implementation.workflows[key];
|
|
84
|
+
if (!workflowImplementation) throw new Error(`Missing implementation for workflow ${plugin.manifest.id}.${key}`);
|
|
85
|
+
return this.registry.register(workflow, workflowImplementation, { plugin: { id: plugin.manifest.id }, configSchema: plugin.manifest.config, config: this.input.config?.[plugin.manifest.id] });
|
|
86
|
+
});
|
|
87
|
+
this.disposersByPlugin.set(plugin.manifest.id, disposers);
|
|
88
|
+
return () => {
|
|
89
|
+
if (this.disposersByPlugin.get(plugin.manifest.id) === disposers) this.disposePlugin(plugin.manifest.id);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listWorkflows() {
|
|
94
|
+
return this.registry.list();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
visibleWorkflowEntries(): NornRegisteredWorkflow[] {
|
|
98
|
+
return this.registry.launchableEntries();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private resolvePluginImplementation<TManifest extends NornAnyWorkflowPluginManifest>(
|
|
102
|
+
implementationInput: NornWorkflowPluginImplementationInput<TManifest>,
|
|
103
|
+
): NornWorkflowPluginImplementation<TManifest> {
|
|
104
|
+
return typeof implementationInput === "function"
|
|
105
|
+
? implementationInput({ cwd: this.input.cwd, state: this.registrarState })
|
|
106
|
+
: implementationInput;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async runWorkflow<TWorkflow extends NornAnyWorkflowDeclaration>(
|
|
110
|
+
workflow: TWorkflow,
|
|
111
|
+
params: unknown,
|
|
112
|
+
options: NornRunStartOptions | undefined,
|
|
113
|
+
): Promise<NornRunResult> {
|
|
114
|
+
const session = await this.createRunSession(workflow, params, options);
|
|
115
|
+
const current = session.state.currentState().current;
|
|
116
|
+
if (!current) throw new Error("New run is missing its initial workflow step");
|
|
117
|
+
return this.runScheduler(session, toWorkflowStep(workflow, current));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async listRunCheckpoints(path: string): Promise<NornRunCheckpoint[]> {
|
|
121
|
+
const runRoot = await resolveRunRoot(this.input.cwd, path);
|
|
122
|
+
return (await NornRunStore.open(runRoot)).listCheckpoints();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async rollbackRun(path: string, checkpointId: string): Promise<NornRunInfo> {
|
|
126
|
+
const runRoot = await resolveRunRoot(this.input.cwd, path);
|
|
127
|
+
const lease = await NornRunLease.acquire(runRoot);
|
|
128
|
+
try {
|
|
129
|
+
const pendingRequest = await readOptionalRunResumeRequest(runRoot);
|
|
130
|
+
if (pendingRequest && Date.now() - Date.parse(pendingRequest.createdAt) < RESUME_START_GRACE_MS) throw new Error(`Resume request is still pending: ${runRoot}`);
|
|
131
|
+
const runStore = await NornRunStore.open(runRoot);
|
|
132
|
+
await runStore.restoreSnapshot(checkpointId, async (stagedRunRoot) => {
|
|
133
|
+
const state = await NornRunStateStore.load(stagedRunRoot);
|
|
134
|
+
await state.prepareForResumeAfterRollback();
|
|
135
|
+
});
|
|
136
|
+
if (pendingRequest) await clearRunResumeRequest({ runRoot, request: pendingRequest, lease });
|
|
137
|
+
return getRunInfo(runRoot);
|
|
138
|
+
} finally {
|
|
139
|
+
await lease.release();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async resumeWorkflow(path: string, params?: unknown): Promise<NornRunResult> {
|
|
144
|
+
return this.resumeWorkflowExecution({ path, params, expectedRequest: undefined });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async resumeRequestedWorkflow(input: { readonly runRoot: string; readonly request: NornRunResumeRequest }): Promise<NornRunResult> {
|
|
148
|
+
return this.resumeWorkflowExecution({ path: input.runRoot, params: input.request.params, expectedRequest: input.request });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async resumeWorkflowExecution(input: { readonly path: string; readonly params: unknown; readonly expectedRequest: NornRunResumeRequest | undefined }): Promise<NornRunResult> {
|
|
152
|
+
const { params, expectedRequest } = input;
|
|
153
|
+
const runRoot = await resolveRunRoot(this.input.cwd, input.path);
|
|
154
|
+
const initialStateStore = await NornRunStateStore.load(runRoot);
|
|
155
|
+
const initialState = initialStateStore.currentState();
|
|
156
|
+
if (initialState.status === "interrupted" && params === undefined) throw new Error(`Interrupted workflow resume requires params: ${runRoot}`);
|
|
157
|
+
if (initialState.status === "pendingResume" && params !== undefined) throw new Error(`Pending-resume workflows do not accept params: ${runRoot}`);
|
|
158
|
+
if (initialState.status !== "interrupted" && initialState.status !== "pendingResume") throw new Error(`Run must be rolled back before resuming: ${runRoot}`);
|
|
159
|
+
|
|
160
|
+
const lease = await NornRunLease.acquire(runRoot);
|
|
161
|
+
let isLeaseOwnedByScheduler = false;
|
|
162
|
+
let session: RunSession | undefined;
|
|
163
|
+
try {
|
|
164
|
+
const pendingRequest = await readOptionalRunResumeRequest(runRoot);
|
|
165
|
+
if (expectedRequest ? !matchesRunResumeRequest(pendingRequest, expectedRequest) : pendingRequest !== undefined) throw new Error(`Resume request changed or belongs to another executor: ${runRoot}`);
|
|
166
|
+
session = await this.openRunSession(runRoot, lease);
|
|
167
|
+
const state = session.state.currentState();
|
|
168
|
+
const current = state.current;
|
|
169
|
+
if (!current) throw new Error(`Run is not resumable: ${runRoot}`);
|
|
170
|
+
const workflow = this.registry.workflowById(current.workflowId);
|
|
171
|
+
if (!workflow) throw new Error(`Unknown workflow for resumed run: ${current.workflowId}`);
|
|
172
|
+
if (state.status === "interrupted") {
|
|
173
|
+
const parsedParams = workflow.params.parse(mergeInterruptedWorkflowParams(current.params, params, current.interruption?.fields));
|
|
174
|
+
await session.state.replaceCurrentParams(parsedParams);
|
|
175
|
+
await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
|
|
176
|
+
const resumedCurrent = session.state.currentState().current;
|
|
177
|
+
if (!resumedCurrent) throw new Error(`Run is not resumable: ${runRoot}`);
|
|
178
|
+
isLeaseOwnedByScheduler = true;
|
|
179
|
+
return this.runScheduler(session, toWorkflowStep(workflow, resumedCurrent));
|
|
180
|
+
}
|
|
181
|
+
if (state.status !== "pendingResume") throw new Error(`Run must be rolled back before resuming: ${runRoot}`);
|
|
182
|
+
await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
|
|
183
|
+
isLeaseOwnedByScheduler = true;
|
|
184
|
+
return this.runScheduler(session, toWorkflowStep(workflow, current));
|
|
185
|
+
} finally {
|
|
186
|
+
if (!isLeaseOwnedByScheduler) {
|
|
187
|
+
try {
|
|
188
|
+
await lease.release();
|
|
189
|
+
} finally {
|
|
190
|
+
if (session) this.failActiveRun(runRoot, session.activeRun);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async runScheduler(session: RunSession, initialStep: WorkflowStep): Promise<NornRunResult> {
|
|
197
|
+
let currentStep = initialStep;
|
|
198
|
+
try {
|
|
199
|
+
for (let step = 1; step <= 1_000; step++) {
|
|
200
|
+
throwIfRunAborted(session.activeRun);
|
|
201
|
+
const stepRuntime = session.run.forWorkflow(currentStep.workflow);
|
|
202
|
+
if (shouldPauseForGate(currentStep)) {
|
|
203
|
+
const parsedParams = currentStep.workflow.params.parse(currentStep.params);
|
|
204
|
+
const description = await this.registry.describeGate(currentStep.workflow, stepRuntime, parsedParams, session.state.currentState().configOverride);
|
|
205
|
+
const interruption = { description, fields: currentStep.workflow.gate?.fields };
|
|
206
|
+
await session.state.interruptCurrent(parsedParams, interruption);
|
|
207
|
+
await recordRunEvent(session, { type: "run.interrupted", workflowId: currentStep.workflow.id });
|
|
208
|
+
await commitRunBoundary(session, `run interrupted: ${currentStep.workflow.id}`);
|
|
209
|
+
return interruptedLaunchResult({ id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd }, currentStep.workflow, parsedParams, interruption);
|
|
210
|
+
}
|
|
211
|
+
await session.state.startStep(toRunStateStep(currentStep));
|
|
212
|
+
const stepResult = await this.executeWorkflowStep(session, currentStep, stepRuntime);
|
|
213
|
+
throwIfRunAborted(session.activeRun);
|
|
214
|
+
if (stepResult.type === "complete") {
|
|
215
|
+
await session.state.completeRun(stepResult.workflow.id, stepResult.metadata);
|
|
216
|
+
await recordRunEvent(session, { type: "run.completed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
|
|
217
|
+
await commitRunBoundary(session, `run completed: ${stepResult.workflow.id}`);
|
|
218
|
+
return { status: "completed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
|
|
219
|
+
}
|
|
220
|
+
if (stepResult.type === "fail") {
|
|
221
|
+
await session.state.failRun(stepResult.workflow.id, stepResult.metadata);
|
|
222
|
+
await recordRunEvent(session, { type: "run.failed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
|
|
223
|
+
return { status: "failed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
|
|
224
|
+
}
|
|
225
|
+
const nextStep = this.nextWorkflowStep(stepResult);
|
|
226
|
+
await session.state.completeWithNext(currentStep.workflow.id, toRunStateStep(nextStep));
|
|
227
|
+
await recordRunEvent(session, { type: "run.transitioned", fromWorkflowId: currentStep.workflow.id, toWorkflowId: nextStep.workflow.id });
|
|
228
|
+
await commitRunBoundary(session, `transition: ${currentStep.workflow.id} -> ${nextStep.workflow.id}`);
|
|
229
|
+
currentStep = nextStep;
|
|
230
|
+
}
|
|
231
|
+
throw new Error("Run exceeded 1000 scheduler steps");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (isRunStopped(session, error)) {
|
|
234
|
+
await session.state.stopCurrent();
|
|
235
|
+
await recordRunEvent(session, { type: "run.stopped", workflowId: currentStep.workflow.id });
|
|
236
|
+
return { status: "stopped", id: session.run.id, name: session.state.currentState().name, workspace: session.run.workspace, cwd: session.run.cwd, workflowId: currentStep.workflow.id };
|
|
237
|
+
}
|
|
238
|
+
await session.state.failCurrent(errorMessage(error));
|
|
239
|
+
await recordRunEvent(session, { type: "run.failed", workflowId: currentStep.workflow.id, error: errorMessage(error) });
|
|
240
|
+
throw error;
|
|
241
|
+
} finally {
|
|
242
|
+
await session.lease.release();
|
|
243
|
+
this.finishActiveRun(session.runRoot, session.activeRun);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async executeWorkflowStep(session: RunSession, step: WorkflowStep, run: NornRunContext): Promise<NornWorkflowStepResult> {
|
|
248
|
+
const startedAtMs = Date.now();
|
|
249
|
+
try {
|
|
250
|
+
await assertWorkspaceBoundary(session, run.workspace);
|
|
251
|
+
await recordRunEvent(session, { type: "workflow.started", workflowId: step.workflow.id });
|
|
252
|
+
const result = await this.registry.execute(step.workflow, run, step.params, session.state.currentState().configOverride);
|
|
253
|
+
await assertWorkspaceBoundary(session, run.workspace);
|
|
254
|
+
const durationMs = Date.now() - startedAtMs;
|
|
255
|
+
if (result.type === "complete") await recordRunEvent(session, { type: "workflow.completed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
|
|
256
|
+
else if (result.type === "fail") await recordRunEvent(session, { type: "workflow.failed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
|
|
257
|
+
else await recordRunEvent(session, { type: "workflow.transitioned", fromWorkflowId: step.workflow.id, toWorkflowId: result.workflowId, durationMs });
|
|
258
|
+
return result;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await recordRunEvent(session, {
|
|
261
|
+
type: isRunStopped(session, error) ? "workflow.stopped" : "workflow.failed",
|
|
262
|
+
workflowId: step.workflow.id,
|
|
263
|
+
durationMs: Date.now() - startedAtMs,
|
|
264
|
+
...(isRunStopped(session, error) ? {} : { error: errorMessage(error) }),
|
|
265
|
+
});
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private nextWorkflowStep(next: NornRunNext): WorkflowStep {
|
|
271
|
+
const workflow = this.registry.workflowById(next.workflowId);
|
|
272
|
+
if (!workflow) throw new Error(`Unknown next workflow: ${next.workflowId}`);
|
|
273
|
+
return {
|
|
274
|
+
workflow,
|
|
275
|
+
params: next.params,
|
|
276
|
+
interruption: this.input.gateMode === "pause" && workflow.gate ? { status: "pending" } : undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async createRunSession<TWorkflow extends NornAnyWorkflowDeclaration>(
|
|
281
|
+
workflow: TWorkflow,
|
|
282
|
+
params: unknown,
|
|
283
|
+
options: NornRunStartOptions | undefined,
|
|
284
|
+
): Promise<RunSession> {
|
|
285
|
+
const id = options?.id ?? randomUUID();
|
|
286
|
+
const name = options?.name ?? id;
|
|
287
|
+
const runRoot = defaultRunRoot(this.input.cwd, id);
|
|
288
|
+
const currentRoot = runCurrentRoot(runRoot);
|
|
289
|
+
const workspace = join(currentRoot, "workspace");
|
|
290
|
+
const cwd = workflowDefaultCwd(this.input.cwd, workspace, workflow);
|
|
291
|
+
const startedAt = new Date().toISOString();
|
|
292
|
+
await mkdir(runRoot, { recursive: true });
|
|
293
|
+
const lease = await NornRunLease.acquire(runRoot);
|
|
294
|
+
const activeRun = this.startActiveRun(runRoot);
|
|
295
|
+
try {
|
|
296
|
+
const runStore = await NornRunStore.initialize(runRoot);
|
|
297
|
+
await mkdir(workspace, { recursive: true });
|
|
298
|
+
const logger = new NornRunLogger({ manifestPath: join(currentRoot, MANIFEST_FILE_NAME), files: createRunFileCoordinator(runRoot), manifest: {
|
|
299
|
+
id,
|
|
300
|
+
name,
|
|
301
|
+
workflowId: workflow.id,
|
|
302
|
+
runRoot,
|
|
303
|
+
workspace,
|
|
304
|
+
initialCwd: cwd,
|
|
305
|
+
startedAt,
|
|
306
|
+
} });
|
|
307
|
+
const run = await this.buildRun({ id, currentRoot, workspace, cwd, isolationMode: workflow.isolation.mode, signal: activeRun.controller.signal, logger });
|
|
308
|
+
const state = await NornRunStateStore.create(runRoot, {
|
|
309
|
+
id,
|
|
310
|
+
name,
|
|
311
|
+
entrypointWorkflowId: workflow.id,
|
|
312
|
+
workspace,
|
|
313
|
+
configOverride: options?.configOverride,
|
|
314
|
+
current: {
|
|
315
|
+
workflowId: workflow.id, params, cwd, env: {},
|
|
316
|
+
interruption: this.input.gateMode === "pause" && workflow.gate ? { status: "pending" } : undefined,
|
|
317
|
+
},
|
|
318
|
+
startedAt,
|
|
319
|
+
});
|
|
320
|
+
const session = { runRoot, run, state, lease, runStore, logger, activeRun };
|
|
321
|
+
await logger.record({ type: "run.started", workflowId: workflow.id, cwd, workspace });
|
|
322
|
+
await commitRunBoundary(session, `run started: ${workflow.id}`);
|
|
323
|
+
return session;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
await lease.release();
|
|
326
|
+
this.failActiveRun(runRoot, activeRun);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async openRunSession(runRoot: string, lease: NornRunLease): Promise<RunSession> {
|
|
332
|
+
const activeRun = this.startActiveRun(runRoot);
|
|
333
|
+
try {
|
|
334
|
+
const state = await NornRunStateStore.load(runRoot);
|
|
335
|
+
const currentState = state.currentState();
|
|
336
|
+
if (currentState.status === "completed") throw new Error(`Run is already completed: ${runRoot}`);
|
|
337
|
+
const runStore = await NornRunStore.open(runRoot);
|
|
338
|
+
const currentRoot = runCurrentRoot(runRoot);
|
|
339
|
+
const logger = await NornRunLogger.load(join(currentRoot, MANIFEST_FILE_NAME), createRunFileCoordinator(runRoot));
|
|
340
|
+
const workflow = currentState.current ? this.registry.workflowById(currentState.current.workflowId) : undefined;
|
|
341
|
+
const isolationMode = workflow?.isolation.mode ?? "runWorkspace";
|
|
342
|
+
const cwd = workflowDefaultCwd(this.input.cwd, currentState.workspace, workflow);
|
|
343
|
+
const run = await this.buildRun({ id: currentState.id, currentRoot, workspace: currentState.workspace, cwd, isolationMode, signal: activeRun.controller.signal, logger });
|
|
344
|
+
return { runRoot, run, state, lease, runStore, logger, activeRun };
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this.failActiveRun(runRoot, activeRun);
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async buildRun(input: {
|
|
352
|
+
readonly id: string;
|
|
353
|
+
readonly currentRoot: string;
|
|
354
|
+
readonly workspace: string;
|
|
355
|
+
readonly cwd: string;
|
|
356
|
+
readonly isolationMode: "runWorkspace" | "project";
|
|
357
|
+
readonly signal: AbortSignal;
|
|
358
|
+
readonly logger: NornRunLogger;
|
|
359
|
+
}): Promise<NornRunContext> {
|
|
360
|
+
const artifactsRoot = join(input.currentRoot, "artifacts");
|
|
361
|
+
const logsRoot = join(input.currentRoot, "logs");
|
|
362
|
+
await mkdir(input.workspace, { recursive: true });
|
|
363
|
+
await mkdir(artifactsRoot, { recursive: true });
|
|
364
|
+
await mkdir(logsRoot, { recursive: true });
|
|
365
|
+
const { resources, state } = await initializeRunResources(dirname(input.currentRoot));
|
|
366
|
+
return new NornRunContext({
|
|
367
|
+
id: input.id,
|
|
368
|
+
runRoot: input.currentRoot,
|
|
369
|
+
workspace: input.workspace,
|
|
370
|
+
projectRoot: this.input.cwd,
|
|
371
|
+
cwd: input.cwd,
|
|
372
|
+
isolationMode: input.isolationMode,
|
|
373
|
+
signal: input.signal,
|
|
374
|
+
agentDir: this.input.agentDir,
|
|
375
|
+
responseCollector: this.responseCollector,
|
|
376
|
+
resources,
|
|
377
|
+
state,
|
|
378
|
+
logger: input.logger,
|
|
379
|
+
artifacts: new NornArtifacts(artifactsRoot, resources.files),
|
|
380
|
+
logs: new NornRunLogs(logsRoot, resources.files),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private startActiveRun(runRoot: string): ActiveRun {
|
|
385
|
+
if (this.activeRuns.has(runRoot)) throw new Error(`Run is already active in this engine: ${runRoot}`);
|
|
386
|
+
const controller = new AbortController();
|
|
387
|
+
let finish: () => void;
|
|
388
|
+
const finished = new Promise<void>((resolvePromise) => {
|
|
389
|
+
finish = resolvePromise;
|
|
390
|
+
});
|
|
391
|
+
const abortFromParent = () => controller.abort(this.input.signal?.reason);
|
|
392
|
+
if (this.input.signal?.aborted) abortFromParent();
|
|
393
|
+
else this.input.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
394
|
+
const activeRun: ActiveRun = {
|
|
395
|
+
controller,
|
|
396
|
+
finished,
|
|
397
|
+
finish: () => finish(),
|
|
398
|
+
dispose: () => this.input.signal?.removeEventListener("abort", abortFromParent),
|
|
399
|
+
};
|
|
400
|
+
this.activeRuns.set(runRoot, activeRun);
|
|
401
|
+
return activeRun;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private finishActiveRun(runRoot: string, activeRun: ActiveRun): void {
|
|
405
|
+
if (this.activeRuns.get(runRoot) !== activeRun) return;
|
|
406
|
+
this.activeRuns.delete(runRoot);
|
|
407
|
+
activeRun.dispose();
|
|
408
|
+
activeRun.finish();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private failActiveRun(runRoot: string, activeRun: ActiveRun): void {
|
|
412
|
+
if (this.activeRuns.get(runRoot) === activeRun) this.activeRuns.delete(runRoot);
|
|
413
|
+
activeRun.dispose();
|
|
414
|
+
activeRun.finish();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private disposePlugin(pluginId: string): void {
|
|
418
|
+
const disposers = this.disposersByPlugin.get(pluginId) ?? [];
|
|
419
|
+
for (const dispose of [...disposers].reverse()) dispose();
|
|
420
|
+
this.disposersByPlugin.delete(pluginId);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function defaultRunRoot(cwd: string, id: string): string {
|
|
425
|
+
return join(cwd, RUNS_DIR_NAME, "runs", id);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function workflowDefaultCwd(projectRoot: string, workspace: string, workflow: NornAnyWorkflowDeclaration | undefined): string {
|
|
429
|
+
return workflow?.isolation.mode === "project" ? projectRoot : workspace;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function toWorkflowStep(workflow: NornAnyWorkflowDeclaration, step: NonNullable<NornRunState["current"]>): WorkflowStep {
|
|
433
|
+
return {
|
|
434
|
+
workflow,
|
|
435
|
+
params: step.params,
|
|
436
|
+
interruption: step.interruption,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function toRunStateStep(step: WorkflowStep): NonNullable<NornRunState["current"]> {
|
|
441
|
+
return {
|
|
442
|
+
workflowId: step.workflow.id,
|
|
443
|
+
params: step.params,
|
|
444
|
+
cwd: "",
|
|
445
|
+
env: {},
|
|
446
|
+
interruption: step.interruption,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function shouldPauseForGate(step: WorkflowStep): boolean {
|
|
451
|
+
return step.interruption?.status === "pending" && step.workflow.gate !== undefined;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function throwIfRunAborted(activeRun: ActiveRun): void {
|
|
455
|
+
if (!activeRun.controller.signal.aborted) return;
|
|
456
|
+
const reason: unknown = activeRun.controller.signal.reason;
|
|
457
|
+
if (reason instanceof Error) throw reason;
|
|
458
|
+
throw new Error(typeof reason === "string" && reason.length > 0 ? reason : "Run aborted");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isRunStopped(session: RunSession, error: unknown): boolean {
|
|
462
|
+
return error instanceof NornRunStoppedError || session.activeRun.controller.signal.reason instanceof NornRunStoppedError;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function interruptedLaunchResult(run: RunIdentity, workflow: NornAnyWorkflowDeclaration, params: unknown, interruption: { readonly description?: string; readonly fields?: readonly string[] }): NornInterruptedRunResult {
|
|
466
|
+
if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
|
|
467
|
+
if (!interruption.description) throw new Error(`Interrupted workflow is missing description: ${workflow.id}`);
|
|
468
|
+
return {
|
|
469
|
+
status: "interrupted",
|
|
470
|
+
id: run.id,
|
|
471
|
+
name: run.name,
|
|
472
|
+
workspace: run.workspace,
|
|
473
|
+
cwd: run.cwd,
|
|
474
|
+
workflowId: workflow.id,
|
|
475
|
+
interruption: { workflowId: workflow.id, params, description: interruption.description, fields: interruption.fields },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
type RunIdentity = {
|
|
480
|
+
readonly id: string;
|
|
481
|
+
readonly name: string;
|
|
482
|
+
readonly workspace: string;
|
|
483
|
+
readonly cwd: string;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
async function recordRunEvent(
|
|
487
|
+
session: RunSession,
|
|
488
|
+
event: { readonly type: string; readonly [key: string]: unknown },
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
await session.logger.record(event);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function commitRunBoundary(session: RunSession, message: string): Promise<void> {
|
|
494
|
+
await session.lease.assertOwned();
|
|
495
|
+
await session.runStore.snapshotCurrent(message);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function assertWorkspaceBoundary(session: RunSession, workspace: string): Promise<void> {
|
|
499
|
+
await session.lease.assertOwned();
|
|
500
|
+
await session.runStore.assertWorkspaceCanBeSnapshotted(workspace);
|
|
501
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { NornPluginDiagnostic } from "@vimhead.dev/norn";
|
|
2
|
+
|
|
3
|
+
export class NornProjectLoadError extends Error {
|
|
4
|
+
readonly code = "NORN_PROJECT_INVALID";
|
|
5
|
+
readonly isComplete = false;
|
|
6
|
+
readonly diagnostics: readonly NornPluginDiagnostic[];
|
|
7
|
+
|
|
8
|
+
constructor(input: { readonly diagnostics: readonly NornPluginDiagnostic[] }) {
|
|
9
|
+
const details = input.diagnostics.map(diagnostic =>
|
|
10
|
+
`${diagnostic.stage}: ${diagnostic.pluginPath} (declared in ${diagnostic.configPath}${diagnostic.workflowId ? `; workflow ${diagnostic.workflowId}` : ""}): ${diagnostic.message}`,
|
|
11
|
+
);
|
|
12
|
+
super(`Norn project is incomplete (${input.diagnostics.length} diagnostics); execution is blocked.\n${details.join("\n")}`);
|
|
13
|
+
this.name = "NornProjectLoadError";
|
|
14
|
+
this.diagnostics = input.diagnostics;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class NornRunStoppedError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super("Stopped by user");
|
|
21
|
+
this.name = "NornRunStoppedError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function zodErrorMessage(error: unknown): string {
|
|
26
|
+
const issues = (error as { issues?: Array<{ path?: unknown[]; message?: string }> }).issues;
|
|
27
|
+
if (!Array.isArray(issues) || issues.length === 0) return errorMessage(error);
|
|
28
|
+
return issues
|
|
29
|
+
.slice(0, 3)
|
|
30
|
+
.map((issue) => {
|
|
31
|
+
const path = Array.isArray(issue.path) && issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
|
32
|
+
return `${path}${issue.message ?? "Invalid workflow response"}`;
|
|
33
|
+
})
|
|
34
|
+
.join("; ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function errorMessage(error: unknown): string {
|
|
38
|
+
return error instanceof Error ? error.message : String(error);
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
5
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
6
|
+
import type { NornRunLease } from "./run-lease.ts";
|
|
7
|
+
|
|
8
|
+
export const LAUNCH_REQUEST_FILE_NAME = "launch-request.json";
|
|
9
|
+
export const RESUME_REQUEST_FILE_NAME = "resume-request.json";
|
|
10
|
+
export const RESUME_START_GRACE_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export type NornRunLaunchRequest = {
|
|
13
|
+
readonly version: 1;
|
|
14
|
+
readonly type: "run";
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly workflowId: string;
|
|
18
|
+
readonly params: unknown;
|
|
19
|
+
readonly configOverride?: unknown;
|
|
20
|
+
readonly createdAt: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NornRunResumeRequest = {
|
|
24
|
+
readonly version: 1;
|
|
25
|
+
readonly type: "resume";
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly requestId?: string;
|
|
28
|
+
readonly params?: unknown;
|
|
29
|
+
readonly createdAt: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function writeRunLaunchRequest(runRoot: string, request: NornRunLaunchRequest): Promise<void> {
|
|
33
|
+
await writeJsonAtomically(join(runRoot, LAUNCH_REQUEST_FILE_NAME), request);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readRunLaunchRequest(runRoot: string): Promise<NornRunLaunchRequest> {
|
|
37
|
+
return parseRunLaunchRequest(JSON.parse(await readFile(join(runRoot, LAUNCH_REQUEST_FILE_NAME), "utf8")));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeRunResumeRequest(runRoot: string, request: NornRunResumeRequest): Promise<void> {
|
|
41
|
+
const path = join(runRoot, RESUME_REQUEST_FILE_NAME);
|
|
42
|
+
const stagedPath = `${path}.${randomUUID()}.tmp`;
|
|
43
|
+
try {
|
|
44
|
+
await writeJsonAtomically(stagedPath, request);
|
|
45
|
+
await link(stagedPath, path);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(stagedPath, { force: true }).catch(() => undefined);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readRunResumeRequest(runRoot: string): Promise<NornRunResumeRequest> {
|
|
52
|
+
return parseRunResumeRequest(JSON.parse(await readFile(join(runRoot, RESUME_REQUEST_FILE_NAME), "utf8")));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readOptionalRunResumeRequest(runRoot: string): Promise<NornRunResumeRequest | undefined> {
|
|
56
|
+
try {
|
|
57
|
+
return await readRunResumeRequest(runRoot);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function matchesRunResumeRequest(actual: NornRunResumeRequest | undefined, expected: NornRunResumeRequest): boolean {
|
|
65
|
+
return actual?.id === expected.id && (actual.requestId ?? actual.createdAt) === (expected.requestId ?? expected.createdAt);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function clearRunResumeRequest(input: { readonly runRoot: string; readonly request: NornRunResumeRequest; readonly lease: NornRunLease }): Promise<void> {
|
|
69
|
+
await input.lease.assertOwned();
|
|
70
|
+
if (matchesRunResumeRequest(await readOptionalRunResumeRequest(input.runRoot), input.request)) {
|
|
71
|
+
await rm(join(input.runRoot, RESUME_REQUEST_FILE_NAME), { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseRunLaunchRequest(value: unknown): NornRunLaunchRequest {
|
|
76
|
+
if (!value || typeof value !== "object") throw new Error("Invalid workflow launch request");
|
|
77
|
+
const request = value as Partial<NornRunLaunchRequest>;
|
|
78
|
+
if (request.version !== 1 || request.type !== "run") throw new Error("Unsupported workflow launch request");
|
|
79
|
+
if (typeof request.id !== "string" || request.id.length === 0) throw new Error("Invalid workflow launch request id");
|
|
80
|
+
if (typeof request.name !== "string" || request.name.length === 0) throw new Error("Invalid workflow launch request name");
|
|
81
|
+
if (typeof request.workflowId !== "string" || request.workflowId.length === 0) throw new Error("Invalid workflow launch request workflow id");
|
|
82
|
+
if (typeof request.createdAt !== "string" || Number.isNaN(Date.parse(request.createdAt))) throw new Error("Invalid workflow launch request timestamp");
|
|
83
|
+
return request as NornRunLaunchRequest;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseRunResumeRequest(value: unknown): NornRunResumeRequest {
|
|
87
|
+
if (!value || typeof value !== "object") throw new Error("Invalid workflow resume request");
|
|
88
|
+
const request = value as Partial<NornRunResumeRequest>;
|
|
89
|
+
if (request.version !== 1 || request.type !== "resume") throw new Error("Unsupported workflow resume request");
|
|
90
|
+
if (typeof request.id !== "string" || request.id.length === 0) throw new Error("Invalid workflow resume request id");
|
|
91
|
+
if (request.requestId !== undefined && (typeof request.requestId !== "string" || request.requestId.length === 0)) throw new Error("Invalid workflow resume request token");
|
|
92
|
+
if (typeof request.createdAt !== "string" || Number.isNaN(Date.parse(request.createdAt))) throw new Error("Invalid workflow resume request timestamp");
|
|
93
|
+
return request as NornRunResumeRequest;
|
|
94
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createWriteStream, type WriteStream } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import type { NornLogRef } from "@vimhead.dev/norn";
|
|
6
|
+
|
|
7
|
+
export type NornLogWriteStream = {
|
|
8
|
+
readonly log: NornLogRef;
|
|
9
|
+
readonly stream: WriteStream;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class NornRunLogs {
|
|
13
|
+
constructor(private readonly logsRoot: string, private readonly files: NornFileCoordinator) {}
|
|
14
|
+
|
|
15
|
+
async write(path: string, content: string): Promise<void> {
|
|
16
|
+
await this.files.writeText(this.resolveLogPath(path), content);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async read(log: NornLogRef): Promise<string> {
|
|
20
|
+
return this.files.readText(this.resolveLogPath(`${log.id}.log`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async createWriteStream(log: NornLogRef): Promise<NornLogWriteStream> {
|
|
24
|
+
const absolutePath = this.resolveLogPath(`${log.id}.log`);
|
|
25
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
26
|
+
return {
|
|
27
|
+
log,
|
|
28
|
+
stream: createWriteStream(absolutePath, { encoding: "utf8" }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private resolveLogPath(path: string): string {
|
|
33
|
+
if (isAbsolute(path)) throw new Error(`Log path must be relative: ${path}`);
|
|
34
|
+
const resolvedPath = resolve(this.logsRoot, path);
|
|
35
|
+
const relativePath = relative(this.logsRoot, resolvedPath);
|
|
36
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
37
|
+
throw new Error(`Log path escapes logs directory: ${path}`);
|
|
38
|
+
}
|
|
39
|
+
return resolvedPath;
|
|
40
|
+
}
|
|
41
|
+
}
|