@vimhead.dev/norn-cli 0.1.0-tip.35359392805.1 → 0.1.0-tip.35436871363.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/README.md +22 -27
- package/assets/docs/README.md +5 -5
- package/assets/docs/agents.md +48 -9
- package/assets/docs/cli.md +7 -7
- package/assets/docs/composition.md +25 -25
- package/assets/docs/persistence.md +34 -23
- package/assets/docs/projects.md +30 -18
- package/assets/docs/providers.md +3 -3
- package/assets/docs/recovery.md +13 -9
- package/assets/docs/schemas.md +1 -1
- package/assets/docs/workflows.md +105 -12
- package/assets/examples/agent-then-analysis/README.md +10 -10
- package/assets/examples/agent-then-analysis/input.json +1 -1
- package/assets/examples/agent-then-analysis/norn.project.json +1 -1
- package/assets/examples/agent-then-analysis/plugin.ts +57 -68
- package/assets/examples/caller-selected-continuation/README.md +15 -14
- package/assets/examples/caller-selected-continuation/caller.ts +37 -38
- package/assets/examples/caller-selected-continuation/input.json +2 -2
- package/assets/examples/caller-selected-continuation/norn.project.json +1 -1
- package/assets/examples/caller-selected-continuation/producer.ts +19 -24
- package/assets/examples/coordinating-multiple-agents/README.md +27 -17
- package/assets/examples/coordinating-multiple-agents/input.json +1 -1
- package/assets/examples/coordinating-multiple-agents/norn.project.json +1 -1
- package/assets/examples/coordinating-multiple-agents/plugin.ts +79 -60
- package/assets/examples/coordinating-multiple-agents/queue-tools.ts +43 -0
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +60 -65
- package/assets/examples/getting-started/README.md +2 -2
- package/assets/examples/getting-started/norn.project.json +1 -1
- package/assets/examples/getting-started/plugin.ts +20 -26
- package/assets/examples/minimal-workflow/README.md +13 -12
- package/assets/examples/minimal-workflow/norn.project.json +1 -1
- package/assets/examples/minimal-workflow/plugin.ts +14 -25
- package/assets/examples/shared-state/README.md +12 -4
- package/assets/examples/shared-state/input.json +1 -1
- package/assets/examples/shared-state/norn.project.json +1 -1
- package/assets/examples/shared-state/plugin.ts +49 -41
- package/assets/examples/shared-state/shared-state.ts +56 -0
- package/assets/examples/shared-state/state-tools.ts +68 -0
- package/assets/examples/worktree-development-loop/README.md +18 -17
- package/assets/examples/worktree-development-loop/norn.project.json +1 -1
- package/assets/examples/worktree-development-loop/plugin.ts +6 -26
- package/assets/examples/worktree-development-loop/scope.ts +4 -0
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +14 -15
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +3 -4
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +5 -3
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +2 -2
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +35 -33
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +2 -3
- package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +6 -3
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +27 -16
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +2 -3
- package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +4 -2
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +43 -31
- package/assets/examples/worktree-development-loop/workflows/review/index.ts +3 -4
- package/assets/examples/worktree-development-loop/workflows/review/schema.ts +6 -6
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +50 -44
- package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +2 -3
- package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +5 -5
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/cli.ts +44 -37
- package/assets/packages/cli/src/client.ts +9 -16
- package/assets/packages/cli/src/documentation-intro.ts +1 -1
- package/assets/packages/cli/src/generated-build-info.ts +2 -2
- package/assets/packages/cli/src/internal/agent-response-tool.ts +3 -3
- package/assets/packages/cli/src/internal/agents.ts +24 -32
- package/assets/packages/cli/src/internal/commands.ts +2 -14
- package/assets/packages/cli/src/internal/engine.ts +63 -101
- package/assets/packages/cli/src/internal/errors.ts +4 -4
- package/assets/packages/cli/src/internal/launch-request.ts +6 -6
- package/assets/packages/cli/src/internal/logs.ts +1 -1
- package/assets/packages/cli/src/internal/run-log.ts +1 -1
- package/assets/packages/cli/src/internal/run-state.ts +38 -23
- package/assets/packages/cli/src/internal/run.ts +4 -62
- package/assets/packages/cli/src/internal/worker-directory.ts +17 -0
- package/assets/packages/cli/src/internal/workflow-registry.ts +126 -111
- package/assets/packages/cli/src/internal/working-directory.ts +6 -0
- package/assets/packages/cli/src/workflow-loader.ts +213 -0
- package/assets/packages/core/src/workflow-transition.ts +2 -2
- package/assets/packages/sdk/src/api.ts +141 -329
- package/assets/packages/sdk/src/index.ts +1 -4
- package/assets/packages/sdk/src/schema.ts +3 -3
- package/assets/tests/workflow-ref.test.ts +53 -55
- package/dist/cli.js +41 -35
- package/dist/client.d.ts +3 -4
- package/dist/client.js +3 -8
- package/dist/documentation-intro.js +1 -1
- package/dist/generated-build-info.d.ts +2 -2
- package/dist/generated-build-info.js +2 -2
- package/dist/internal/agent-response-tool.js +3 -3
- package/dist/internal/agents.d.ts +0 -4
- package/dist/internal/agents.js +30 -46
- package/dist/internal/commands.d.ts +0 -4
- package/dist/internal/commands.js +2 -10
- package/dist/internal/engine.d.ts +4 -8
- package/dist/internal/engine.js +57 -84
- package/dist/internal/errors.d.ts +3 -3
- package/dist/internal/errors.js +1 -1
- package/dist/internal/file-coordinator.d.ts +17 -0
- package/dist/internal/file-coordinator.js +162 -0
- package/dist/internal/launch-request.d.ts +4 -4
- package/dist/internal/launch-request.js +2 -2
- package/dist/internal/logs.d.ts +1 -1
- package/dist/internal/run-log.d.ts +1 -1
- package/dist/internal/run-state.d.ts +11 -8
- package/dist/internal/run-state.js +34 -22
- package/dist/internal/run.d.ts +3 -23
- package/dist/internal/run.js +4 -50
- package/dist/internal/worker-directory.d.ts +1 -0
- package/dist/internal/worker-directory.js +26 -0
- package/dist/internal/workflow-registry.d.ts +35 -17
- package/dist/internal/workflow-registry.js +110 -73
- package/dist/internal/working-directory.d.ts +1 -0
- package/dist/internal/working-directory.js +9 -0
- package/dist/{plugin-loader.d.ts → workflow-loader.d.ts} +7 -8
- package/dist/workflow-loader.js +234 -0
- package/package.json +2 -2
- package/assets/docs/resources.md +0 -61
- package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +0 -51
- package/assets/examples/worktree-development-loop/manifest.ts +0 -26
- package/assets/examples/worktree-development-loop/state.ts +0 -23
- package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +0 -8
- package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +0 -8
- package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +0 -8
- package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +0 -8
- package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +0 -12
- package/assets/packages/cli/src/internal/artifacts.ts +0 -26
- package/assets/packages/cli/src/internal/resource-bindings.ts +0 -35
- package/assets/packages/cli/src/internal/run-resources.ts +0 -23
- package/assets/packages/cli/src/internal/state-store.ts +0 -83
- package/assets/packages/cli/src/plugin-loader.ts +0 -400
- package/assets/packages/cli/src/resources.ts +0 -69
- package/assets/packages/sdk/src/agent-resource-adapter.ts +0 -11
- package/assets/packages/sdk/src/resources.ts +0 -20
- package/assets/packages/sdk/src/state-adapter.ts +0 -75
- package/dist/internal/artifacts.d.ts +0 -10
- package/dist/internal/artifacts.js +0 -29
- package/dist/internal/resource-bindings.d.ts +0 -13
- package/dist/internal/resource-bindings.js +0 -34
- package/dist/internal/run-resources.d.ts +0 -6
- package/dist/internal/run-resources.js +0 -26
- package/dist/internal/state-store.d.ts +0 -23
- package/dist/internal/state-store.js +0 -103
- package/dist/plugin-loader.js +0 -341
- package/dist/resources.d.ts +0 -11
- package/dist/resources.js +0 -100
- /package/assets/packages/{sdk/src/files.ts → cli/src/internal/file-coordinator.ts} +0 -0
|
@@ -5,7 +5,7 @@ import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
|
5
5
|
import { readRunLaunchRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS } from "./launch-request.ts";
|
|
6
6
|
import { getRunLeaseHealth } from "./run-lease.ts";
|
|
7
7
|
import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
|
|
8
|
-
import { createRunFileCoordinator, type NornFileCoordinator } from "
|
|
8
|
+
import { createRunFileCoordinator, type NornFileCoordinator } from "./file-coordinator.ts";
|
|
9
9
|
import { runCurrentRoot } from "./run-store.ts";
|
|
10
10
|
import { jsonValueSchema } from "@vimhead.dev/norn/schema";
|
|
11
11
|
import { Value } from "typebox/value";
|
|
@@ -13,19 +13,19 @@ import { Value } from "typebox/value";
|
|
|
13
13
|
export const RUN_STATE_FILE_NAME = "run-state.json";
|
|
14
14
|
const LEGACY_RUN_STATE_FILE_NAME = "runtime-state.json";
|
|
15
15
|
|
|
16
|
-
export function
|
|
17
|
-
if (!isRecord(
|
|
16
|
+
export function mergeInterruptedWorkflowArgs(currentArgs: unknown, args: unknown, fields: readonly string[] | undefined): unknown {
|
|
17
|
+
if (!isRecord(currentArgs) || !isRecord(args)) return args;
|
|
18
18
|
if (fields) {
|
|
19
|
-
const unsupportedFields = Object.keys(
|
|
20
|
-
if (unsupportedFields.length > 0) throw new Error(`Interrupted workflow
|
|
19
|
+
const unsupportedFields = Object.keys(args).filter((field) => !fields.includes(field));
|
|
20
|
+
if (unsupportedFields.length > 0) throw new Error(`Interrupted workflow args cannot update non-gate fields: ${unsupportedFields.join(", ")}`);
|
|
21
21
|
}
|
|
22
|
-
return { ...
|
|
22
|
+
return { ...currentArgs, ...args };
|
|
23
23
|
}
|
|
24
|
-
const RUN_STATE_VERSION =
|
|
24
|
+
const RUN_STATE_VERSION = 2;
|
|
25
25
|
|
|
26
26
|
type NornRunWorkflowStep = {
|
|
27
27
|
readonly workflowId: string;
|
|
28
|
-
readonly
|
|
28
|
+
readonly args: unknown;
|
|
29
29
|
readonly cwd: string;
|
|
30
30
|
readonly env: Record<string, string>;
|
|
31
31
|
readonly interruption?: RuntimeInterruption;
|
|
@@ -58,7 +58,8 @@ type NornRunWorkflowOutcome = {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
export type NornRunState = {
|
|
61
|
-
readonly version: typeof RUN_STATE_VERSION;
|
|
61
|
+
readonly version: 1 | typeof RUN_STATE_VERSION;
|
|
62
|
+
readonly projectRoot?: string;
|
|
62
63
|
readonly id: string;
|
|
63
64
|
readonly name: string;
|
|
64
65
|
readonly entrypointWorkflowId: string;
|
|
@@ -74,6 +75,7 @@ export type NornRunState = {
|
|
|
74
75
|
};
|
|
75
76
|
|
|
76
77
|
type CreateNornRunStateInput = {
|
|
78
|
+
readonly projectRoot: string;
|
|
77
79
|
readonly id: string;
|
|
78
80
|
readonly name: string;
|
|
79
81
|
readonly entrypointWorkflowId: string;
|
|
@@ -101,7 +103,8 @@ export class NornRunStateStore {
|
|
|
101
103
|
id: input.id,
|
|
102
104
|
name: input.name,
|
|
103
105
|
entrypointWorkflowId: input.entrypointWorkflowId,
|
|
104
|
-
|
|
106
|
+
projectRoot: resolve(input.projectRoot),
|
|
107
|
+
workspace: resolve(input.workspace),
|
|
105
108
|
configOverride: input.configOverride,
|
|
106
109
|
status: "running",
|
|
107
110
|
current: input.current,
|
|
@@ -119,7 +122,7 @@ export class NornRunStateStore {
|
|
|
119
122
|
static async load(runRoot: string): Promise<NornRunStateStore> {
|
|
120
123
|
const currentRoot = runCurrentRoot(runRoot);
|
|
121
124
|
const files = createRunFileCoordinator(runRoot);
|
|
122
|
-
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) })));
|
|
125
|
+
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) }), "execute"));
|
|
123
126
|
return new NornRunStateStore({ path: join(currentRoot, RUN_STATE_FILE_NAME), state, files });
|
|
124
127
|
}
|
|
125
128
|
|
|
@@ -163,11 +166,11 @@ export class NornRunStateStore {
|
|
|
163
166
|
});
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
async interruptCurrent(
|
|
169
|
+
async interruptCurrent(args: unknown, interruption: { readonly description: string; readonly fields?: readonly string[] }): Promise<void> {
|
|
167
170
|
if (!this.state.current) throw new Error("Cannot interrupt run without current step");
|
|
168
171
|
await this.update({
|
|
169
172
|
status: "interrupted",
|
|
170
|
-
current: { ...this.state.current,
|
|
173
|
+
current: { ...this.state.current, args, interruption: { status: "pending", ...interruption } },
|
|
171
174
|
outcome: null,
|
|
172
175
|
failed: null,
|
|
173
176
|
});
|
|
@@ -185,11 +188,11 @@ export class NornRunStateStore {
|
|
|
185
188
|
await this.update({ status: "stopped", outcome: null, failed: null });
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
async
|
|
191
|
+
async replaceCurrentArgs(args: unknown): Promise<void> {
|
|
189
192
|
if (!this.state.current) throw new Error("Cannot resume run without current step");
|
|
190
193
|
await this.update({
|
|
191
194
|
status: "running",
|
|
192
|
-
current: { ...this.state.current,
|
|
195
|
+
current: { ...this.state.current, args, interruption: { ...this.state.current.interruption, status: "satisfied" } },
|
|
193
196
|
outcome: null,
|
|
194
197
|
failed: null,
|
|
195
198
|
});
|
|
@@ -208,16 +211,16 @@ export class NornRunStateStore {
|
|
|
208
211
|
|
|
209
212
|
private async update(patch: Partial<NornRunState>): Promise<void> {
|
|
210
213
|
await this.files.withExclusiveLock(this.path, async (path) => {
|
|
211
|
-
const current = parseNornRunState(await readRunStateFile({ path, legacyPath: join(dirname(this.path), LEGACY_RUN_STATE_FILE_NAME) }));
|
|
214
|
+
const current = parseNornRunState(await readRunStateFile({ path, legacyPath: join(dirname(this.path), LEGACY_RUN_STATE_FILE_NAME) }), "execute");
|
|
212
215
|
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
213
|
-
if (next.current?.
|
|
216
|
+
if (next.current?.args !== undefined) Value.Assert(jsonValueSchema, next.current.args);
|
|
214
217
|
await writeJsonAtomically(path, next);
|
|
215
218
|
this.state = next;
|
|
216
219
|
});
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
private async write(): Promise<void> {
|
|
220
|
-
if (this.state.current?.
|
|
223
|
+
if (this.state.current?.args !== undefined) Value.Assert(jsonValueSchema, this.state.current.args);
|
|
221
224
|
await mkdir(dirname(this.path), { recursive: true });
|
|
222
225
|
await this.files.withExclusiveLock(this.path, (path) => writeJsonAtomically(path, this.state));
|
|
223
226
|
}
|
|
@@ -239,13 +242,20 @@ export async function listRuns(sessionCwd: string): Promise<NornRunInfo[]> {
|
|
|
239
242
|
|
|
240
243
|
export async function getRunInfo(runRoot: string): Promise<NornRunInfo> {
|
|
241
244
|
try {
|
|
242
|
-
|
|
243
|
-
const
|
|
245
|
+
// Read the request first so a completed handoff cannot revive an old interruption.
|
|
246
|
+
const pendingResume = await readOptionalRunResumeRequest(runRoot).then(
|
|
247
|
+
request => ({ status: "fulfilled" as const, request }),
|
|
248
|
+
(error: unknown) => ({ status: "rejected" as const, error }),
|
|
249
|
+
);
|
|
250
|
+
const state = parseNornRunState(await readRunStateJson(runCurrentRoot(runRoot)), "inspect");
|
|
251
|
+
if (state.version === RUN_STATE_VERSION && pendingResume.status === "rejected") throw pendingResume.error;
|
|
252
|
+
const resumeRequest = state.version === RUN_STATE_VERSION && pendingResume.status === "fulfilled" ? pendingResume.request : undefined;
|
|
244
253
|
return {
|
|
245
254
|
version: state.version,
|
|
246
255
|
id: state.id,
|
|
247
256
|
name: state.name,
|
|
248
257
|
path: runRoot,
|
|
258
|
+
paths: { project: state.projectRoot ?? resolve(runRoot, "../../.."), workspace: state.workspace },
|
|
249
259
|
entrypointWorkflowId: state.entrypointWorkflowId,
|
|
250
260
|
currentWorkflowId: state.current?.workflowId,
|
|
251
261
|
status: resumeRequest ? "running" : state.status,
|
|
@@ -311,6 +321,7 @@ async function getLaunchedRunInfo(runRoot: string): Promise<NornRunInfo> {
|
|
|
311
321
|
id: launchRequest.id,
|
|
312
322
|
name: launchRequest.name,
|
|
313
323
|
path: runRoot,
|
|
324
|
+
paths: { project: resolve(runRoot, "../../.."), workspace: join(runCurrentRoot(resolve(runRoot)), "workspace") },
|
|
314
325
|
entrypointWorkflowId: launchRequest.workflowId,
|
|
315
326
|
currentWorkflowId: launchRequest.workflowId,
|
|
316
327
|
status: "running",
|
|
@@ -337,10 +348,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
337
348
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
338
349
|
}
|
|
339
350
|
|
|
340
|
-
function
|
|
351
|
+
export function assertRunVersion(version: unknown): void {
|
|
352
|
+
if (version !== RUN_STATE_VERSION) throw new Error(`Incompatible saved run state version ${String(version)}; this alpha requires version ${RUN_STATE_VERSION}. Use the original Norn version to resume this run, or start a new run. Saved files have not been migrated.`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseNornRunState(value: unknown, mode: "execute" | "inspect"): NornRunState {
|
|
341
356
|
if (!value || typeof value !== "object") throw new Error("Invalid run state");
|
|
342
357
|
const state = value as Partial<NornRunState> & { rootWorkflowId?: unknown };
|
|
343
|
-
if (state.version !==
|
|
358
|
+
if (mode === "execute" || state.version !== 1) assertRunVersion(state.version);
|
|
344
359
|
if (typeof state.id !== "string" || state.id.length === 0) throw new Error("Invalid run state id");
|
|
345
360
|
const normalizedState = state as { name?: string; entrypointWorkflowId?: unknown; rootWorkflowId?: unknown };
|
|
346
361
|
if (typeof normalizedState.name !== "string" || normalizedState.name.length === 0) normalizedState.name = state.id;
|
|
@@ -369,7 +384,7 @@ function runInterruption(state: NornRunState): NornRunInterruption | undefined {
|
|
|
369
384
|
if (state.status !== "interrupted" || !state.current?.interruption) return undefined;
|
|
370
385
|
return {
|
|
371
386
|
workflowId: state.current.workflowId,
|
|
372
|
-
params: state.current.
|
|
387
|
+
args: state.version === 1 ? (state.current as { readonly params?: unknown }).params : state.current.args,
|
|
373
388
|
description: state.current.interruption.description ?? "",
|
|
374
389
|
fields: state.current.interruption.fields,
|
|
375
390
|
};
|
|
@@ -1,63 +1,38 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
1
|
import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type {
|
|
2
|
+
import type { NornRunOutcomeMetadata, NornRun } from "@vimhead.dev/norn";
|
|
4
3
|
import { createWorkflowTransition } from "@vimhead.dev/norn-core/workflow-transition";
|
|
5
4
|
import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
|
|
6
|
-
import type { NornArtifacts } from "./artifacts.ts";
|
|
7
5
|
import { NornAgentRunner } from "./agents.ts";
|
|
8
6
|
import { NornCommandRunner } from "./commands.ts";
|
|
9
7
|
import type { NornRunLogs } from "./logs.ts";
|
|
10
8
|
import type { NornRunLogger } from "./run-log.ts";
|
|
11
|
-
import type { NornRunResources } from "../resources.ts";
|
|
12
9
|
|
|
13
10
|
type DefaultNornRunInput = {
|
|
14
11
|
readonly id: string;
|
|
15
12
|
readonly runRoot: string;
|
|
16
|
-
readonly projectRoot: string;
|
|
17
|
-
readonly workspace: string;
|
|
18
|
-
readonly cwd: string;
|
|
19
|
-
readonly isolationMode: NornWorkflowIsolationMode;
|
|
20
13
|
readonly signal?: AbortSignal;
|
|
21
14
|
readonly model?: CreateAgentSessionOptions["model"];
|
|
22
15
|
readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
|
|
23
16
|
readonly agentDir?: string;
|
|
24
17
|
readonly responseCollector: NornAgentResponseCollector;
|
|
25
|
-
readonly resources: NornRunResources;
|
|
26
|
-
readonly state: NornRun["state"];
|
|
27
18
|
readonly logger: NornRunLogger;
|
|
28
|
-
readonly artifacts: NornArtifacts;
|
|
29
19
|
readonly logs: NornRunLogs;
|
|
30
20
|
};
|
|
31
21
|
|
|
32
|
-
export class NornRunContext implements
|
|
33
|
-
readonly resources: NornRunResources;
|
|
34
|
-
readonly state: NornRun["state"];
|
|
35
|
-
readonly artifacts: NornRun["artifacts"];
|
|
22
|
+
export class NornRunContext implements NornRun {
|
|
36
23
|
readonly logs: NornRun["logs"];
|
|
37
24
|
readonly commands: NornRun["commands"];
|
|
38
25
|
readonly agents: NornRun["agents"];
|
|
39
26
|
readonly id: string;
|
|
40
|
-
readonly workspace: string;
|
|
41
|
-
readonly projectRoot: string;
|
|
42
|
-
readonly cwd: string;
|
|
43
27
|
|
|
44
28
|
constructor(private readonly input: DefaultNornRunInput) {
|
|
45
29
|
this.id = input.id;
|
|
46
|
-
this.workspace = input.workspace;
|
|
47
|
-
this.projectRoot = input.projectRoot;
|
|
48
|
-
this.cwd = input.cwd;
|
|
49
|
-
this.resources = input.resources;
|
|
50
|
-
this.state = input.state;
|
|
51
|
-
this.artifacts = input.artifacts;
|
|
52
30
|
this.logs = {
|
|
53
31
|
read: (log) => this.input.logs.read(log),
|
|
54
32
|
};
|
|
55
33
|
this.commands = {
|
|
56
34
|
run: (commandInput) =>
|
|
57
35
|
new NornCommandRunner({
|
|
58
|
-
boundaryRoot: this.boundaryRoot,
|
|
59
|
-
boundaryName: this.input.isolationMode,
|
|
60
|
-
cwd: this.cwd,
|
|
61
36
|
signal: this.input.signal,
|
|
62
37
|
logs: this.input.logs,
|
|
63
38
|
logger: this.input.logger,
|
|
@@ -69,26 +44,10 @@ export class NornRunContext implements NornProjectRun {
|
|
|
69
44
|
};
|
|
70
45
|
}
|
|
71
46
|
|
|
72
|
-
forWorkflow(workflow: NornAnyWorkflowDeclaration): NornRunContext {
|
|
73
|
-
const isolationMode = workflow.isolation.mode;
|
|
74
|
-
return new NornRunContext({
|
|
75
|
-
...this.input,
|
|
76
|
-
isolationMode,
|
|
77
|
-
cwd: isolationMode === "project" ? this.projectRoot : this.workspace,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
private get boundaryRoot(): string {
|
|
82
|
-
return this.input.isolationMode === "project" ? this.projectRoot : this.workspace;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
47
|
private createAgentRunner(): NornAgentRunner {
|
|
86
48
|
return new NornAgentRunner({
|
|
87
49
|
id: this.id,
|
|
88
50
|
runRoot: this.input.runRoot,
|
|
89
|
-
boundaryRoot: this.boundaryRoot,
|
|
90
|
-
boundaryName: this.input.isolationMode,
|
|
91
|
-
cwd: this.cwd,
|
|
92
51
|
signal: this.input.signal,
|
|
93
52
|
model: this.input.model,
|
|
94
53
|
thinkingLevel: this.input.thinkingLevel,
|
|
@@ -99,16 +58,8 @@ export class NornRunContext implements NornProjectRun {
|
|
|
99
58
|
});
|
|
100
59
|
}
|
|
101
60
|
|
|
102
|
-
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
projectPath(relativePath: string): string {
|
|
107
|
-
return this.resolveFromRoot(this.projectRoot, this.projectRoot, relativePath);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
next(workflowId: string, params: unknown): ReturnType<NornRun["next"]> {
|
|
111
|
-
return createWorkflowTransition({ workflowId, params });
|
|
61
|
+
next(workflowId: string, args: unknown): ReturnType<NornRun["next"]> {
|
|
62
|
+
return createWorkflowTransition({ workflowId, args });
|
|
112
63
|
}
|
|
113
64
|
|
|
114
65
|
complete(metadata?: NornRunOutcomeMetadata): ReturnType<NornRun["complete"]> {
|
|
@@ -118,13 +69,4 @@ export class NornRunContext implements NornProjectRun {
|
|
|
118
69
|
fail(metadata: NornRunOutcomeMetadata & { readonly summary: string }): ReturnType<NornRun["fail"]> {
|
|
119
70
|
return { type: "fail", metadata };
|
|
120
71
|
}
|
|
121
|
-
|
|
122
|
-
private resolveFromRoot(root: string, boundaryRoot: string, path: string): string {
|
|
123
|
-
const resolvedPath = isAbsolute(path) ? path : resolve(root, path);
|
|
124
|
-
const pathFromBoundary = relative(boundaryRoot, resolvedPath);
|
|
125
|
-
if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
|
|
126
|
-
throw new Error(`Workflow path escapes ${this.input.isolationMode} isolation: ${path}`);
|
|
127
|
-
}
|
|
128
|
-
return resolvedPath;
|
|
129
|
-
}
|
|
130
72
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
4
|
+
|
|
5
|
+
export async function prepareRunWorkerDirectory(runRoot: string): Promise<string> {
|
|
6
|
+
const directory = join(runRoot, "worker");
|
|
7
|
+
try {
|
|
8
|
+
await mkdir(directory, { mode: 0o555 });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (!isNodeError(error) || error.code !== "EEXIST") throw error;
|
|
11
|
+
}
|
|
12
|
+
const metadata = await lstat(directory);
|
|
13
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Run worker cwd must be a directory, not a symlink: ${directory}`);
|
|
14
|
+
if ((await readdir(directory)).length !== 0) throw new Error(`Run worker cwd must be empty: ${directory}`);
|
|
15
|
+
await chmod(directory, 0o555);
|
|
16
|
+
return directory;
|
|
17
|
+
}
|
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
import { isWorkflowComplete, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type
|
|
1
|
+
import { isWorkflowComplete, isWorkflowDeclaration, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRun, type NornRunNext, type NornWorkflowPaths, type NornWorkflowContext, type NornWorkflowGateInfo, type NornWorkflowSource, type NornWorkflowScopeInfo, type NornProjectConfigurationInfo } from "@vimhead.dev/norn";
|
|
2
2
|
import { assertWorkflowMetadata, inspectSchema, isPlainObject, schemaShape, schemaType, unwrapSchema } from "@vimhead.dev/norn/schema";
|
|
3
|
-
import {
|
|
3
|
+
import type { TSchema } from "typebox";
|
|
4
4
|
import { Value } from "typebox/value";
|
|
5
5
|
|
|
6
|
+
export type NornRegisteredConfiguration = { readonly key: string; readonly schema: TSchema | undefined; readonly input: unknown; readonly value: unknown };
|
|
6
7
|
export type NornRegisteredWorkflow = {
|
|
7
|
-
workflow: NornAnyWorkflowDeclaration;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
readonly workflow: NornAnyWorkflowDeclaration;
|
|
9
|
+
readonly configuration: NornRegisteredConfiguration;
|
|
10
|
+
readonly scopeId: string | undefined;
|
|
11
|
+
readonly source: NornWorkflowSource | undefined;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
type NornRegisteredScope = { readonly definition: NornWorkflowScopeInfo; readonly configuration: NornRegisteredConfiguration };
|
|
14
15
|
|
|
15
|
-
export function
|
|
16
|
+
export function decodeConfiguration(input: { readonly key: string; readonly schema: TSchema | undefined; readonly value: unknown }): NornRegisteredConfiguration {
|
|
16
17
|
if (!input.schema) {
|
|
17
|
-
if (input.value !== undefined) throw new Error(`Norn config provided
|
|
18
|
-
return undefined;
|
|
18
|
+
if (input.value !== undefined) throw new Error(`Norn config provided without config schema: ${input.key}`);
|
|
19
|
+
return { key: input.key, schema: undefined, input: undefined, value: undefined };
|
|
20
|
+
}
|
|
21
|
+
const value = input.value === undefined && schemaType(unwrapSchema(input.schema)) === "object" ? {} : input.value;
|
|
22
|
+
return { key: input.key, schema: input.schema, input: value, value: Value.Decode(input.schema, value) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertCompatibleConfigurationOwners(workflows: readonly NornAnyWorkflowDeclaration[]): void {
|
|
26
|
+
const owners = new Map<string, { readonly kind: "workflow" | "scope"; readonly definition: object }>();
|
|
27
|
+
for (const workflow of workflows) {
|
|
28
|
+
for (const owner of [
|
|
29
|
+
{ key: workflow.id, kind: "workflow" as const, definition: workflow },
|
|
30
|
+
...(workflow.scope ? [{ key: workflow.scope.id, kind: "scope" as const, definition: workflow.scope }] : []),
|
|
31
|
+
]) {
|
|
32
|
+
const previous = owners.get(owner.key);
|
|
33
|
+
if (previous && previous.kind !== owner.kind) throw new Error(`Ambiguous configuration key: ${owner.key}`);
|
|
34
|
+
if (previous && owner.kind === "scope" && previous.definition !== owner.definition) throw new Error(`Duplicate workflow scope id: ${owner.key}; import one shared scope definition`);
|
|
35
|
+
owners.set(owner.key, owner);
|
|
36
|
+
}
|
|
19
37
|
}
|
|
20
|
-
const value = defaultConfigInput(input.schema, input.value);
|
|
21
|
-
return { schema: input.schema, input: value, value: Value.Decode(input.schema, value) };
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
export type NornWorkflowStepResult =
|
|
@@ -26,157 +42,156 @@ export type NornWorkflowStepResult =
|
|
|
26
42
|
| { readonly type: "complete"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata?: NornRunComplete["metadata"] }
|
|
27
43
|
| { readonly type: "fail"; readonly workflow: NornAnyWorkflowDeclaration; readonly metadata: NornRunFail["metadata"] };
|
|
28
44
|
|
|
45
|
+
type NornExecutionContextInput = {
|
|
46
|
+
readonly run: NornRun;
|
|
47
|
+
readonly paths: NornWorkflowPaths;
|
|
48
|
+
readonly args: unknown;
|
|
49
|
+
readonly configOverride: unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type NornWorkflowExecutionInput = NornExecutionContextInput & { readonly workflow: NornAnyWorkflowDeclaration };
|
|
53
|
+
|
|
29
54
|
export class NornWorkflowRegistry {
|
|
30
55
|
private readonly entries = new Map<string, NornRegisteredWorkflow>();
|
|
56
|
+
private readonly scopes = new Map<string, NornRegisteredScope>();
|
|
31
57
|
|
|
32
|
-
register<
|
|
33
|
-
workflow
|
|
34
|
-
|
|
35
|
-
metadata: { readonly plugin?: NornWorkflowPluginInfo; readonly configuration?: NornRegisteredConfiguration } = {},
|
|
36
|
-
): NornDispose {
|
|
58
|
+
register(input: { readonly workflow: NornAnyWorkflowDeclaration; readonly config: Readonly<Record<string, unknown>>; readonly source: NornWorkflowSource | undefined }): NornDispose {
|
|
59
|
+
const { workflow, config, source } = input;
|
|
60
|
+
assertWorkflowDefinition(workflow);
|
|
37
61
|
if (this.entries.has(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
|
|
38
|
-
|
|
62
|
+
assertCompatibleConfigurationOwners([...this.sortedEntries().map(entry => entry.workflow), workflow]);
|
|
39
63
|
const entry: NornRegisteredWorkflow = {
|
|
40
|
-
workflow,
|
|
41
|
-
|
|
42
|
-
configuration: metadata.configuration,
|
|
43
|
-
plugin: metadata.plugin,
|
|
64
|
+
workflow, source, scopeId: workflow.scope?.id,
|
|
65
|
+
configuration: decodeConfiguration({ key: workflow.id, schema: workflow.config, value: configurationInput(config, workflow.id) }),
|
|
44
66
|
};
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
if (workflow.scope && !this.scopes.has(workflow.scope.id)) {
|
|
68
|
+
this.scopes.set(workflow.scope.id, {
|
|
69
|
+
definition: workflow.scope,
|
|
70
|
+
configuration: decodeConfiguration({ key: workflow.scope.id, schema: workflow.scope.config, value: configurationInput(config, workflow.scope.id) }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
47
73
|
this.entries.set(workflow.id, entry);
|
|
48
|
-
|
|
49
74
|
return () => {
|
|
50
|
-
if (this.entries.get(workflow.id)
|
|
75
|
+
if (this.entries.get(workflow.id) !== entry) return;
|
|
76
|
+
this.entries.delete(workflow.id);
|
|
77
|
+
if (entry.scopeId && ![...this.entries.values()].some(other => other.scopeId === entry.scopeId)) this.scopes.delete(entry.scopeId);
|
|
51
78
|
};
|
|
52
79
|
}
|
|
53
80
|
|
|
54
81
|
list(options: { readonly entrypointsOnly?: boolean } = {}): NornRegisteredWorkflowInfo[] {
|
|
55
|
-
|
|
56
|
-
return entries.map((entry) => workflowInfo(entry));
|
|
82
|
+
return (options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries()).map(workflowInfo);
|
|
57
83
|
}
|
|
58
84
|
|
|
59
85
|
inspect(workflowId: string): NornInspectedWorkflowInfo | undefined {
|
|
60
86
|
const entry = this.entries.get(workflowId);
|
|
61
|
-
|
|
87
|
+
const scope = entry?.scopeId ? this.scopes.get(entry.scopeId) : undefined;
|
|
88
|
+
return entry ? {
|
|
89
|
+
...workflowInfo(entry),
|
|
90
|
+
argsSchema: inspectSchema(entry.workflow.args),
|
|
91
|
+
configSchema: entry.configuration.schema ? inspectSchema(entry.configuration.schema) : null,
|
|
92
|
+
scope: scope ? {
|
|
93
|
+
id: scope.definition.id,
|
|
94
|
+
configKey: scope.configuration.key,
|
|
95
|
+
configSchema: scope.configuration.schema ? inspectSchema(scope.configuration.schema) : null,
|
|
96
|
+
} : undefined,
|
|
97
|
+
gate: gateInfo(entry.workflow),
|
|
98
|
+
} : undefined;
|
|
62
99
|
}
|
|
63
100
|
|
|
64
|
-
|
|
65
|
-
return
|
|
101
|
+
configurationInfos(): NornProjectConfigurationInfo[] {
|
|
102
|
+
return [
|
|
103
|
+
...this.sortedEntries().map(entry => ({ configuration: entry.configuration, scopeId: null })),
|
|
104
|
+
...[...this.scopes.values()].map(scope => ({ configuration: scope.configuration, scopeId: scope.definition.id })),
|
|
105
|
+
].map(({ configuration, scopeId }) => ({ key: configuration.key, scopeId, configSchema: configuration.schema ? inspectSchema(configuration.schema) : null, config: configuration.value }));
|
|
66
106
|
}
|
|
67
107
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
108
|
+
launchableEntries(): NornRegisteredWorkflow[] { return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint); }
|
|
109
|
+
workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined { return this.entries.get(workflowId)?.workflow; }
|
|
71
110
|
|
|
72
|
-
async describeGate
|
|
73
|
-
workflow
|
|
74
|
-
|
|
75
|
-
params: unknown,
|
|
76
|
-
configOverride?: unknown,
|
|
77
|
-
): Promise<string> {
|
|
78
|
-
const entry = this.entries.get(workflow.id);
|
|
79
|
-
if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
|
|
111
|
+
async describeGate(input: NornWorkflowExecutionInput): Promise<string> {
|
|
112
|
+
const { workflow } = input;
|
|
113
|
+
const entry = this.requireEntry(workflow.id);
|
|
80
114
|
if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
return
|
|
115
|
+
const context = this.createExecutionContext({ ...input, entry });
|
|
116
|
+
const description = await entry.workflow.gate?.describe?.(context) ?? workflow.id;
|
|
117
|
+
if (typeof description !== "string" || description.trim().length === 0) throw new Error(`Workflow gate description must not be empty: ${workflow.id}`);
|
|
118
|
+
return description.trim();
|
|
85
119
|
}
|
|
86
120
|
|
|
87
|
-
async execute
|
|
88
|
-
workflow
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
configOverride?: unknown,
|
|
92
|
-
): Promise<NornWorkflowStepResult> {
|
|
93
|
-
const entry = this.entries.get(workflow.id);
|
|
94
|
-
if (!entry) throw new Error(`Unknown workflow: ${workflow.id}`);
|
|
95
|
-
|
|
96
|
-
const parsedParams = Value.Decode(workflow.params, params) as NornWorkflowParams<TWorkflow>;
|
|
97
|
-
const parsedConfig = parseExecutionConfig(entry, configOverride);
|
|
98
|
-
const result = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).execute(run, parsedParams, parsedConfig);
|
|
121
|
+
async execute(input: NornWorkflowExecutionInput): Promise<NornWorkflowStepResult> {
|
|
122
|
+
const { workflow } = input;
|
|
123
|
+
const entry = this.requireEntry(workflow.id);
|
|
124
|
+
const result = await entry.workflow.execute(this.createExecutionContext({ ...input, entry }));
|
|
99
125
|
if (isWorkflowNext(result)) return result;
|
|
100
126
|
if (isWorkflowComplete(result)) return { type: "complete", workflow, metadata: result.metadata };
|
|
101
127
|
if (isWorkflowFail(result)) return { type: "fail", workflow, metadata: result.metadata };
|
|
102
128
|
throw new Error(`Workflow returned invalid control result: ${workflow.id}`);
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
private
|
|
106
|
-
|
|
131
|
+
private createExecutionContext(input: NornExecutionContextInput & { readonly entry: NornRegisteredWorkflow }): NornWorkflowContext {
|
|
132
|
+
const { entry, run, paths, configOverride } = input;
|
|
133
|
+
const scope = entry.scopeId ? this.scopes.get(entry.scopeId) : undefined;
|
|
134
|
+
return {
|
|
135
|
+
args: Value.Decode(entry.workflow.args, input.args),
|
|
136
|
+
config: parseExecutionConfig(entry.configuration, configOverride),
|
|
137
|
+
...(scope ? { scope: { id: scope.definition.id, config: parseExecutionConfig(scope.configuration, configOverride) } } : {}),
|
|
138
|
+
paths,
|
|
139
|
+
run,
|
|
140
|
+
};
|
|
107
141
|
}
|
|
108
|
-
}
|
|
109
142
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
private requireEntry(id: string): NornRegisteredWorkflow {
|
|
144
|
+
const entry = this.entries.get(id);
|
|
145
|
+
if (!entry) throw new Error(`Unknown workflow: ${id}`);
|
|
146
|
+
return entry;
|
|
147
|
+
}
|
|
148
|
+
private sortedEntries(): NornRegisteredWorkflow[] { return [...this.entries.values()].sort((left, right) => left.workflow.id.localeCompare(right.workflow.id)); }
|
|
113
149
|
}
|
|
114
150
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
if (!entry.configuration) {
|
|
118
|
-
if (pluginConfigOverride !== undefined) throw new Error(`Run config override provided for plugin without config schema: ${entry.plugin?.id ?? entry.workflow.id}`);
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
if (pluginConfigOverride === undefined) return entry.configuration.value;
|
|
122
|
-
const rawConfig = isPlainObject(entry.configuration.input) && isPlainObject(pluginConfigOverride)
|
|
123
|
-
? deepMerge(entry.configuration.input, pluginConfigOverride)
|
|
124
|
-
: pluginConfigOverride;
|
|
125
|
-
return Value.Decode(entry.configuration.schema, rawConfig);
|
|
151
|
+
function configurationInput(config: Readonly<Record<string, unknown>>, key: string): unknown {
|
|
152
|
+
return Object.hasOwn(config, key) ? config[key] : undefined;
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
function
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
155
|
+
function parseExecutionConfig(configuration: NornRegisteredConfiguration, overrides: unknown): unknown {
|
|
156
|
+
if (overrides !== undefined && !isPlainObject(overrides)) throw new Error("Run config override must be an object keyed by workflow or scope id");
|
|
157
|
+
const override = overrides ? configurationInput(overrides, configuration.key) : undefined;
|
|
158
|
+
if (override === undefined) return configuration.value;
|
|
159
|
+
if (!configuration.schema) throw new Error(`Run config override provided without config schema: ${configuration.key}`);
|
|
160
|
+
const input = isPlainObject(configuration.input) && isPlainObject(override) ? deepMerge(configuration.input, override) : override;
|
|
161
|
+
return Value.Decode(configuration.schema, input);
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
|
136
|
-
const result
|
|
137
|
-
for (const [key, value] of Object.entries(override))
|
|
138
|
-
const baseValue = result[key];
|
|
139
|
-
result[key] = isPlainObject(baseValue) && isPlainObject(value) ? deepMerge(baseValue, value) : value;
|
|
140
|
-
}
|
|
165
|
+
const result = { ...base };
|
|
166
|
+
for (const [key, value] of Object.entries(override)) result[key] = isPlainObject(result[key]) && isPlainObject(value) ? deepMerge(result[key], value) : value;
|
|
141
167
|
return result;
|
|
142
168
|
}
|
|
143
169
|
|
|
170
|
+
export function assertWorkflowDefinition(workflow: NornAnyWorkflowDeclaration): void {
|
|
171
|
+
assertWorkflowMetadata(workflow);
|
|
172
|
+
if (!isWorkflowDeclaration(workflow)) throw new Error("Invalid workflow definition");
|
|
173
|
+
assertGateWorkflow(workflow);
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
function assertGateWorkflow(workflow: NornAnyWorkflowDeclaration): void {
|
|
145
177
|
if (!workflow.gate) return;
|
|
146
178
|
if (workflow.gate.enabled !== true) throw new Error(`Workflow gate must be enabled with true: ${workflow.id}`);
|
|
179
|
+
if (workflow.gate.describe !== undefined && typeof workflow.gate.describe !== "function") throw new Error(`Invalid gate description callback: ${workflow.id}`);
|
|
147
180
|
if (!workflow.gate.fields) return;
|
|
148
|
-
const
|
|
149
|
-
if (schemaType(
|
|
150
|
-
const
|
|
151
|
-
for (const field of workflow.gate.fields) {
|
|
152
|
-
if (!Object.prototype.hasOwnProperty.call(paramsShape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function validateGateDescription(description: string, workflowId: string): string {
|
|
157
|
-
const trimmed = description.trim();
|
|
158
|
-
if (trimmed.length === 0) throw new Error(`Workflow gate description must not be empty: ${workflowId}`);
|
|
159
|
-
return trimmed;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function inspectedWorkflowInfo(entry: NornRegisteredWorkflow): NornInspectedWorkflowInfo {
|
|
163
|
-
return {
|
|
164
|
-
...workflowInfo(entry),
|
|
165
|
-
paramsSchema: inspectSchema(entry.workflow.params),
|
|
166
|
-
gate: gateInfo(entry.workflow),
|
|
167
|
-
};
|
|
181
|
+
const argsSchema = unwrapSchema(workflow.args);
|
|
182
|
+
if (schemaType(argsSchema) !== "object") throw new Error(`Workflow gate fields require object args: ${workflow.id}`);
|
|
183
|
+
const shape = schemaShape(argsSchema);
|
|
184
|
+
for (const field of workflow.gate.fields) if (!Object.hasOwn(shape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
|
|
168
185
|
}
|
|
169
186
|
|
|
170
187
|
function workflowInfo(entry: NornRegisteredWorkflow): NornRegisteredWorkflowInfo {
|
|
171
188
|
return {
|
|
172
|
-
id: entry.workflow.id,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
plugin: entry.plugin,
|
|
189
|
+
id: entry.workflow.id, instructions: entry.workflow.instructions, isEntrypoint: entry.workflow.isEntrypoint,
|
|
190
|
+
source: entry.source,
|
|
191
|
+
configKey: entry.workflow.id,
|
|
192
|
+
scope: entry.scopeId ? { id: entry.scopeId } : undefined,
|
|
177
193
|
};
|
|
178
194
|
}
|
|
179
|
-
|
|
180
195
|
function gateInfo(workflow: NornAnyWorkflowDeclaration): NornWorkflowGateInfo | null {
|
|
181
196
|
return workflow.gate ? { enabled: true, fields: workflow.gate.fields } : null;
|
|
182
197
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function requireAbsoluteWorkingDirectory(cwd: string): string {
|
|
4
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) throw new Error("Working directory (cwd) must be an absolute path");
|
|
5
|
+
return resolve(cwd);
|
|
6
|
+
}
|