@vimhead.dev/norn-cli 0.1.0-tip.35359392805.1 → 0.1.0-tip.35390859630.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 +17 -26
- package/assets/docs/README.md +3 -3
- package/assets/docs/agents.md +12 -1
- package/assets/docs/cli.md +5 -5
- package/assets/docs/composition.md +21 -21
- package/assets/docs/persistence.md +7 -10
- package/assets/docs/projects.md +30 -18
- package/assets/docs/providers.md +1 -1
- package/assets/docs/recovery.md +13 -9
- package/assets/docs/resources.md +4 -19
- package/assets/docs/schemas.md +1 -1
- package/assets/docs/workflows.md +95 -7
- package/assets/examples/agent-then-analysis/README.md +4 -4
- 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 +55 -68
- package/assets/examples/caller-selected-continuation/README.md +5 -5
- package/assets/examples/caller-selected-continuation/caller.ts +35 -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 +15 -23
- package/assets/examples/coordinating-multiple-agents/README.md +2 -2
- 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 +52 -56
- package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +4 -4
- package/assets/examples/getting-started/norn.project.json +1 -1
- package/assets/examples/getting-started/plugin.ts +16 -26
- package/assets/examples/minimal-workflow/README.md +8 -8
- package/assets/examples/minimal-workflow/norn.project.json +1 -1
- package/assets/examples/minimal-workflow/plugin.ts +11 -25
- package/assets/examples/shared-state/README.md +10 -2
- 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 +36 -41
- package/assets/examples/shared-state/shared-state.ts +75 -0
- package/assets/{packages/sdk/src → examples/shared-state}/state-adapter.ts +17 -16
- package/assets/examples/worktree-development-loop/README.md +6 -7
- 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/schema.ts +2 -2
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +34 -33
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +2 -3
- package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +7 -3
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +23 -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 +40 -31
- package/assets/examples/worktree-development-loop/workflows/review/index.ts +3 -4
- package/assets/examples/worktree-development-loop/workflows/review/schema.ts +5 -4
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +49 -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 +4 -3
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/cli.ts +35 -34
- 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/engine.ts +38 -62
- 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/run-state.ts +31 -21
- package/assets/packages/cli/src/internal/run.ts +2 -5
- package/assets/packages/cli/src/internal/workflow-registry.ts +114 -111
- package/assets/packages/cli/src/workflow-loader.ts +214 -0
- package/assets/packages/core/src/workflow-transition.ts +2 -2
- package/assets/packages/sdk/src/api.ts +133 -289
- package/assets/packages/sdk/src/index.ts +1 -1
- package/assets/packages/sdk/src/schema.ts +3 -3
- package/assets/tests/workflow-ref.test.ts +50 -51
- package/dist/cli.js +33 -32
- 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/engine.d.ts +4 -8
- package/dist/internal/engine.js +36 -49
- package/dist/internal/errors.d.ts +3 -3
- package/dist/internal/errors.js +1 -1
- package/dist/internal/launch-request.d.ts +4 -4
- package/dist/internal/launch-request.js +2 -2
- package/dist/internal/run-state.d.ts +9 -8
- package/dist/internal/run-state.js +29 -20
- package/dist/internal/run.d.ts +1 -3
- package/dist/internal/run.js +3 -5
- package/dist/internal/workflow-registry.d.ts +25 -17
- package/dist/internal/workflow-registry.js +107 -72
- package/dist/{plugin-loader.d.ts → workflow-loader.d.ts} +7 -8
- package/dist/workflow-loader.js +235 -0
- package/package.json +2 -2
- 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/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/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
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
import { isWorkflowComplete, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRunFor, type NornRunNext, type NornWorkflowGateInfo, type
|
|
1
|
+
import { isWorkflowComplete, isWorkflowDeclaration, isWorkflowFail, isWorkflowNext, type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRunFor, type NornRunNext, 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 =
|
|
@@ -28,155 +44,142 @@ export type NornWorkflowStepResult =
|
|
|
28
44
|
|
|
29
45
|
export class NornWorkflowRegistry {
|
|
30
46
|
private readonly entries = new Map<string, NornRegisteredWorkflow>();
|
|
47
|
+
private readonly scopes = new Map<string, NornRegisteredScope>();
|
|
31
48
|
|
|
32
|
-
register<
|
|
33
|
-
workflow
|
|
34
|
-
|
|
35
|
-
metadata: { readonly plugin?: NornWorkflowPluginInfo; readonly configuration?: NornRegisteredConfiguration } = {},
|
|
36
|
-
): NornDispose {
|
|
49
|
+
register(input: { readonly workflow: NornAnyWorkflowDeclaration; readonly config: Readonly<Record<string, unknown>>; readonly source: NornWorkflowSource | undefined }): NornDispose {
|
|
50
|
+
const { workflow, config, source } = input;
|
|
51
|
+
assertWorkflowDefinition(workflow);
|
|
37
52
|
if (this.entries.has(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
|
|
38
|
-
|
|
53
|
+
assertCompatibleConfigurationOwners([...this.sortedEntries().map(entry => entry.workflow), workflow]);
|
|
39
54
|
const entry: NornRegisteredWorkflow = {
|
|
40
|
-
workflow,
|
|
41
|
-
|
|
42
|
-
configuration: metadata.configuration,
|
|
43
|
-
plugin: metadata.plugin,
|
|
55
|
+
workflow, source, scopeId: workflow.scope?.id,
|
|
56
|
+
configuration: decodeConfiguration({ key: workflow.id, schema: workflow.config, value: configurationInput(config, workflow.id) }),
|
|
44
57
|
};
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
if (workflow.scope && !this.scopes.has(workflow.scope.id)) {
|
|
59
|
+
this.scopes.set(workflow.scope.id, {
|
|
60
|
+
definition: workflow.scope,
|
|
61
|
+
configuration: decodeConfiguration({ key: workflow.scope.id, schema: workflow.scope.config, value: configurationInput(config, workflow.scope.id) }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
47
64
|
this.entries.set(workflow.id, entry);
|
|
48
|
-
|
|
49
65
|
return () => {
|
|
50
|
-
if (this.entries.get(workflow.id)
|
|
66
|
+
if (this.entries.get(workflow.id) !== entry) return;
|
|
67
|
+
this.entries.delete(workflow.id);
|
|
68
|
+
if (entry.scopeId && ![...this.entries.values()].some(other => other.scopeId === entry.scopeId)) this.scopes.delete(entry.scopeId);
|
|
51
69
|
};
|
|
52
70
|
}
|
|
53
71
|
|
|
54
72
|
list(options: { readonly entrypointsOnly?: boolean } = {}): NornRegisteredWorkflowInfo[] {
|
|
55
|
-
|
|
56
|
-
return entries.map((entry) => workflowInfo(entry));
|
|
73
|
+
return (options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries()).map(workflowInfo);
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
inspect(workflowId: string): NornInspectedWorkflowInfo | undefined {
|
|
60
77
|
const entry = this.entries.get(workflowId);
|
|
61
|
-
|
|
78
|
+
const scope = entry?.scopeId ? this.scopes.get(entry.scopeId) : undefined;
|
|
79
|
+
return entry ? {
|
|
80
|
+
...workflowInfo(entry),
|
|
81
|
+
argsSchema: inspectSchema(entry.workflow.args),
|
|
82
|
+
configSchema: entry.configuration.schema ? inspectSchema(entry.configuration.schema) : null,
|
|
83
|
+
scope: scope ? {
|
|
84
|
+
id: scope.definition.id,
|
|
85
|
+
configKey: scope.configuration.key,
|
|
86
|
+
configSchema: scope.configuration.schema ? inspectSchema(scope.configuration.schema) : null,
|
|
87
|
+
} : undefined,
|
|
88
|
+
gate: gateInfo(entry.workflow),
|
|
89
|
+
} : undefined;
|
|
62
90
|
}
|
|
63
91
|
|
|
64
|
-
|
|
65
|
-
return
|
|
92
|
+
configurationInfos(): NornProjectConfigurationInfo[] {
|
|
93
|
+
return [
|
|
94
|
+
...this.sortedEntries().map(entry => ({ configuration: entry.configuration, scopeId: null })),
|
|
95
|
+
...[...this.scopes.values()].map(scope => ({ configuration: scope.configuration, scopeId: scope.definition.id })),
|
|
96
|
+
].map(({ configuration, scopeId }) => ({ key: configuration.key, scopeId, configSchema: configuration.schema ? inspectSchema(configuration.schema) : null, config: configuration.value }));
|
|
66
97
|
}
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
99
|
+
launchableEntries(): NornRegisteredWorkflow[] { return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint); }
|
|
100
|
+
workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined { return this.entries.get(workflowId)?.workflow; }
|
|
71
101
|
|
|
72
|
-
async describeGate<TWorkflow extends NornAnyWorkflowDeclaration>(
|
|
73
|
-
workflow
|
|
74
|
-
run: NornRunFor<TWorkflow>,
|
|
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}`);
|
|
102
|
+
async describeGate<TWorkflow extends NornAnyWorkflowDeclaration>(workflow: TWorkflow, run: NornRunFor<TWorkflow>, args: unknown, configOverride?: unknown): Promise<string> {
|
|
103
|
+
const entry = this.requireEntry(workflow.id);
|
|
80
104
|
if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
return
|
|
105
|
+
const context = this.createExecutionContext({ entry, run, args, configOverride });
|
|
106
|
+
const description = await entry.workflow.gate?.describe?.(context) ?? workflow.id;
|
|
107
|
+
if (typeof description !== "string" || description.trim().length === 0) throw new Error(`Workflow gate description must not be empty: ${workflow.id}`);
|
|
108
|
+
return description.trim();
|
|
85
109
|
}
|
|
86
110
|
|
|
87
|
-
async execute<TWorkflow extends NornAnyWorkflowDeclaration>(
|
|
88
|
-
workflow
|
|
89
|
-
run
|
|
90
|
-
params: unknown,
|
|
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);
|
|
111
|
+
async execute<TWorkflow extends NornAnyWorkflowDeclaration>(workflow: TWorkflow, run: NornRunFor<TWorkflow>, args: unknown, configOverride?: unknown): Promise<NornWorkflowStepResult> {
|
|
112
|
+
const entry = this.requireEntry(workflow.id);
|
|
113
|
+
const result = await entry.workflow.execute(this.createExecutionContext({ entry, run, args, configOverride }));
|
|
99
114
|
if (isWorkflowNext(result)) return result;
|
|
100
115
|
if (isWorkflowComplete(result)) return { type: "complete", workflow, metadata: result.metadata };
|
|
101
116
|
if (isWorkflowFail(result)) return { type: "fail", workflow, metadata: result.metadata };
|
|
102
117
|
throw new Error(`Workflow returned invalid control result: ${workflow.id}`);
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
private
|
|
106
|
-
|
|
120
|
+
private createExecutionContext(input: { readonly entry: NornRegisteredWorkflow; readonly run: NornRunFor<NornAnyWorkflowDeclaration>; readonly args: unknown; readonly configOverride: unknown }): NornWorkflowContext {
|
|
121
|
+
const { entry, run, configOverride } = input;
|
|
122
|
+
const scope = entry.scopeId ? this.scopes.get(entry.scopeId) : undefined;
|
|
123
|
+
return {
|
|
124
|
+
args: Value.Decode(entry.workflow.args, input.args),
|
|
125
|
+
config: parseExecutionConfig(entry.configuration, configOverride),
|
|
126
|
+
...(scope ? { scope: { id: scope.definition.id, config: parseExecutionConfig(scope.configuration, configOverride) } } : {}),
|
|
127
|
+
run,
|
|
128
|
+
};
|
|
107
129
|
}
|
|
108
|
-
}
|
|
109
130
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
131
|
+
private requireEntry(id: string): NornRegisteredWorkflow {
|
|
132
|
+
const entry = this.entries.get(id);
|
|
133
|
+
if (!entry) throw new Error(`Unknown workflow: ${id}`);
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
private sortedEntries(): NornRegisteredWorkflow[] { return [...this.entries.values()].sort((left, right) => left.workflow.id.localeCompare(right.workflow.id)); }
|
|
113
137
|
}
|
|
114
138
|
|
|
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);
|
|
139
|
+
function configurationInput(config: Readonly<Record<string, unknown>>, key: string): unknown {
|
|
140
|
+
return Object.hasOwn(config, key) ? config[key] : undefined;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
function
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
function parseExecutionConfig(configuration: NornRegisteredConfiguration, overrides: unknown): unknown {
|
|
144
|
+
if (overrides !== undefined && !isPlainObject(overrides)) throw new Error("Run config override must be an object keyed by workflow or scope id");
|
|
145
|
+
const override = overrides ? configurationInput(overrides, configuration.key) : undefined;
|
|
146
|
+
if (override === undefined) return configuration.value;
|
|
147
|
+
if (!configuration.schema) throw new Error(`Run config override provided without config schema: ${configuration.key}`);
|
|
148
|
+
const input = isPlainObject(configuration.input) && isPlainObject(override) ? deepMerge(configuration.input, override) : override;
|
|
149
|
+
return Value.Decode(configuration.schema, input);
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
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
|
-
}
|
|
153
|
+
const result = { ...base };
|
|
154
|
+
for (const [key, value] of Object.entries(override)) result[key] = isPlainObject(result[key]) && isPlainObject(value) ? deepMerge(result[key], value) : value;
|
|
141
155
|
return result;
|
|
142
156
|
}
|
|
143
157
|
|
|
158
|
+
export function assertWorkflowDefinition(workflow: NornAnyWorkflowDeclaration): void {
|
|
159
|
+
assertWorkflowMetadata(workflow);
|
|
160
|
+
if (!isWorkflowDeclaration(workflow)) throw new Error("Invalid workflow definition");
|
|
161
|
+
assertGateWorkflow(workflow);
|
|
162
|
+
}
|
|
163
|
+
|
|
144
164
|
function assertGateWorkflow(workflow: NornAnyWorkflowDeclaration): void {
|
|
145
165
|
if (!workflow.gate) return;
|
|
146
166
|
if (workflow.gate.enabled !== true) throw new Error(`Workflow gate must be enabled with true: ${workflow.id}`);
|
|
167
|
+
if (workflow.gate.describe !== undefined && typeof workflow.gate.describe !== "function") throw new Error(`Invalid gate description callback: ${workflow.id}`);
|
|
147
168
|
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
|
-
};
|
|
169
|
+
const argsSchema = unwrapSchema(workflow.args);
|
|
170
|
+
if (schemaType(argsSchema) !== "object") throw new Error(`Workflow gate fields require object args: ${workflow.id}`);
|
|
171
|
+
const shape = schemaShape(argsSchema);
|
|
172
|
+
for (const field of workflow.gate.fields) if (!Object.hasOwn(shape, field)) throw new Error(`Unknown workflow gate field ${field}: ${workflow.id}`);
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
function workflowInfo(entry: NornRegisteredWorkflow): NornRegisteredWorkflowInfo {
|
|
171
176
|
return {
|
|
172
|
-
id: entry.workflow.id,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
plugin: entry.plugin,
|
|
177
|
+
id: entry.workflow.id, instructions: entry.workflow.instructions, isEntrypoint: entry.workflow.isEntrypoint,
|
|
178
|
+
isolation: entry.workflow.isolation, source: entry.source,
|
|
179
|
+
configKey: entry.workflow.id,
|
|
180
|
+
scope: entry.scopeId ? { id: entry.scopeId } : undefined,
|
|
177
181
|
};
|
|
178
182
|
}
|
|
179
|
-
|
|
180
183
|
function gateInfo(workflow: NornAnyWorkflowDeclaration): NornWorkflowGateInfo | null {
|
|
181
184
|
return workflow.gate ? { enabled: true, fields: workflow.gate.fields } : null;
|
|
182
185
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import * as nornModule from "@vimhead.dev/norn";
|
|
2
|
+
import { isWorkflowDeclaration, type NornAnyWorkflowDeclaration, type NornDispose, type NornWorkflowDiagnostic, type NornProjectInfo, type NornProjectInspection, type NornProjectConfigurationInfo, type NornWorkflowCatalogInfo, type NornWorkflowInspection, type NornWorkflowSource } from "@vimhead.dev/norn";
|
|
3
|
+
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
4
|
+
import * as nornFilesModule from "@vimhead.dev/norn/files";
|
|
5
|
+
import * as nornSchemaModule from "@vimhead.dev/norn/schema";
|
|
6
|
+
import { inspectSchema, isPlainObject } from "@vimhead.dev/norn/schema";
|
|
7
|
+
import { createJiti, type ModuleCache } from "jiti/static";
|
|
8
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
import * as typeboxModule from "typebox";
|
|
12
|
+
import { Type, type StaticDecode } from "typebox";
|
|
13
|
+
import * as typeboxCompileModule from "typebox/compile";
|
|
14
|
+
import * as typeboxSchemaModule from "typebox/schema";
|
|
15
|
+
import * as typeboxValueModule from "typebox/value";
|
|
16
|
+
import { AssertError, Value } from "typebox/value";
|
|
17
|
+
import { errorMessage, NornProjectLoadError } from "./internal/errors.ts";
|
|
18
|
+
import { assertCompatibleConfigurationOwners, assertWorkflowDefinition, NornWorkflowRegistry, type NornRegisteredWorkflow } from "./internal/workflow-registry.ts";
|
|
19
|
+
|
|
20
|
+
export const NORN_PROJECT_FILE_NAME = "norn.project.json";
|
|
21
|
+
const nornConfigSchema = Type.Object({
|
|
22
|
+
workflows: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
|
|
23
|
+
includes: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
|
|
24
|
+
config: Type.Record(Type.String(), Type.Unknown(), { default: {} }),
|
|
25
|
+
});
|
|
26
|
+
const nornProjectConfigSchema = Type.Object({ ...nornConfigSchema.properties, version: Type.Literal(1, { default: 1 }) });
|
|
27
|
+
type NornConfig = StaticDecode<typeof nornConfigSchema>;
|
|
28
|
+
type NornProjectConfig = StaticDecode<typeof nornProjectConfigSchema>;
|
|
29
|
+
type NornConfigFile = { readonly path: string; readonly root: string; readonly config: NornConfig };
|
|
30
|
+
type NornProjectConfigFile = Omit<NornConfigFile, "config"> & { readonly config: NornProjectConfig };
|
|
31
|
+
export type NornProject = {
|
|
32
|
+
readonly cwd: string; readonly projectPath: string; readonly projectRoot: string;
|
|
33
|
+
readonly configPath: string; readonly configRoot: string; readonly config: NornProjectConfig;
|
|
34
|
+
readonly configFiles: readonly NornConfigFile[]; readonly projectConfig: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
export type NornLoadedProject = NornProject & {
|
|
37
|
+
readonly definitions: readonly NornAnyWorkflowDeclaration[];
|
|
38
|
+
readonly modules: readonly NornWorkflowSource[];
|
|
39
|
+
readonly configurations: readonly NornProjectConfigurationInfo[];
|
|
40
|
+
readonly registry: NornWorkflowRegistry;
|
|
41
|
+
readonly workflows: readonly NornRegisteredWorkflow[];
|
|
42
|
+
};
|
|
43
|
+
type ImportedWorkflowModule = { readonly definitions: readonly NornAnyWorkflowDeclaration[]; readonly source: NornWorkflowSource };
|
|
44
|
+
|
|
45
|
+
export async function loadNornProject(cwd: string): Promise<NornLoadedProject> {
|
|
46
|
+
const { project, diagnostics } = await collectNornProject(cwd);
|
|
47
|
+
if (diagnostics.length) throw new NornProjectLoadError({ diagnostics });
|
|
48
|
+
return project;
|
|
49
|
+
}
|
|
50
|
+
export async function discoverNornProject(cwd: string): Promise<NornProjectInspection & NornWorkflowCatalogInfo> {
|
|
51
|
+
const { project, diagnostics } = await collectNornProject(cwd);
|
|
52
|
+
return { project: buildProjectInfo(project), workflows: project.registry.list(), isComplete: diagnostics.length === 0, diagnostics };
|
|
53
|
+
}
|
|
54
|
+
export async function inspectNornWorkflow(input: { readonly cwd: string; readonly workflowId: string }): Promise<NornWorkflowInspection> {
|
|
55
|
+
const { project, diagnostics } = await collectNornProject(input.cwd);
|
|
56
|
+
const summary = project.registry.list().find(workflow => workflow.id === input.workflowId);
|
|
57
|
+
if (!summary) {
|
|
58
|
+
if (!diagnostics.length) throw new Error(`Unknown workflow: ${input.workflowId}`);
|
|
59
|
+
return { workflow: null, isComplete: false, diagnostics };
|
|
60
|
+
}
|
|
61
|
+
try { return { workflow: project.registry.inspect(input.workflowId) ?? null, isComplete: diagnostics.length === 0, diagnostics }; }
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (!summary.source) throw error;
|
|
64
|
+
return { workflow: null, isComplete: false, diagnostics: [...diagnostics, createDiagnostic({ source: summary.source, workflowId: summary.id, scopeId: summary.scope?.id ?? null, stage: "schema", error })] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function buildProjectInfo(project: NornLoadedProject): NornProjectInfo {
|
|
68
|
+
return {
|
|
69
|
+
cwd: project.cwd, projectPath: project.projectPath, projectRoot: project.projectRoot,
|
|
70
|
+
configPath: project.configPath, configRoot: project.configRoot, configFiles: project.configFiles.map(file => file.path),
|
|
71
|
+
modules: project.modules, configurations: project.configurations,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function findNornProject(cwd: string): Promise<NornProject> {
|
|
75
|
+
const projectPath = await findNearestNornProject(cwd);
|
|
76
|
+
const projectFile = await readConfigFile({ path: projectPath, isProject: true });
|
|
77
|
+
const configFiles = await loadConfigTree(projectFile, new Set());
|
|
78
|
+
return {
|
|
79
|
+
cwd: resolve(cwd), projectPath, projectRoot: projectFile.root, configPath: projectPath, configRoot: projectFile.root,
|
|
80
|
+
config: projectFile.config, configFiles, projectConfig: mergeProjectConfig(configFiles),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function findNearestNornProject(cwd: string): Promise<string> {
|
|
84
|
+
let current = resolve(cwd);
|
|
85
|
+
while (true) {
|
|
86
|
+
const path = join(current, NORN_PROJECT_FILE_NAME);
|
|
87
|
+
try { await access(path); return path; }
|
|
88
|
+
catch (error) { if (!isNodeError(error) || error.code !== "ENOENT") throw error; }
|
|
89
|
+
const parent = dirname(current);
|
|
90
|
+
if (parent === current) throw new Error(`Could not find ${NORN_PROJECT_FILE_NAME} from ${cwd}`);
|
|
91
|
+
current = parent;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function loadConfigTree(file: NornConfigFile, visited: Set<string>): Promise<NornConfigFile[]> {
|
|
95
|
+
if (visited.has(file.path)) return [];
|
|
96
|
+
visited.add(file.path);
|
|
97
|
+
const files: NornConfigFile[] = [file];
|
|
98
|
+
for (const include of file.config.includes) {
|
|
99
|
+
for (const path of await expandIncludePath(file.root, include)) files.push(...await loadConfigTree(await readConfigFile({ path, isProject: false }), visited));
|
|
100
|
+
}
|
|
101
|
+
return files;
|
|
102
|
+
}
|
|
103
|
+
async function readConfigFile(input: { readonly path: string; readonly isProject: boolean }): Promise<NornProjectConfigFile> {
|
|
104
|
+
const raw: unknown = JSON.parse(await readFile(input.path, "utf8"));
|
|
105
|
+
if (isPlainObject(raw) && Object.hasOwn(raw, "plugins")) throw new Error(`Replace plugins with workflows in ${input.path}; registration modules must export workflow arrays`);
|
|
106
|
+
const schema = input.isProject ? nornProjectConfigSchema : nornConfigSchema;
|
|
107
|
+
const config = Value.Parse(schema, Value.Default(schema, raw));
|
|
108
|
+
return { path: input.path, root: dirname(input.path), config: { ...config, version: 1 } };
|
|
109
|
+
}
|
|
110
|
+
async function collectNornProject(cwd: string): Promise<{ readonly project: NornLoadedProject; readonly diagnostics: NornWorkflowDiagnostic[] }> {
|
|
111
|
+
const project = await findNornProject(cwd);
|
|
112
|
+
const imported = await importWorkflowModules(project);
|
|
113
|
+
const diagnostics = [...imported.diagnostics];
|
|
114
|
+
const registry = new NornWorkflowRegistry();
|
|
115
|
+
const loaded: ImportedWorkflowModule[] = [];
|
|
116
|
+
const conflicts = collectModuleConflicts(imported.modules);
|
|
117
|
+
for (const candidate of imported.modules) {
|
|
118
|
+
const conflict = conflicts.get(candidate);
|
|
119
|
+
if (conflict) { diagnostics.push(...conflict); continue; }
|
|
120
|
+
const unregister: NornDispose[] = [];
|
|
121
|
+
const errors: NornWorkflowDiagnostic[] = [];
|
|
122
|
+
for (const workflow of candidate.definitions) {
|
|
123
|
+
let stage: NornWorkflowDiagnostic["stage"] = "schema";
|
|
124
|
+
try {
|
|
125
|
+
if (workflow.config) inspectSchema(workflow.config);
|
|
126
|
+
if (workflow.scope?.config) inspectSchema(workflow.scope.config);
|
|
127
|
+
stage = "declaration";
|
|
128
|
+
assertWorkflowDefinition(workflow);
|
|
129
|
+
stage = "config";
|
|
130
|
+
unregister.push(registry.register({ workflow, config: project.projectConfig, source: candidate.source }));
|
|
131
|
+
} catch (error) { errors.push(createDiagnostic({ source: candidate.source, workflowId: workflow.id, scopeId: workflow.scope?.id ?? null, stage, error })); }
|
|
132
|
+
}
|
|
133
|
+
if (errors.length) { for (const dispose of unregister) dispose(); diagnostics.push(...errors); }
|
|
134
|
+
else loaded.push(candidate);
|
|
135
|
+
}
|
|
136
|
+
const definitions = loaded.flatMap(module => module.definitions);
|
|
137
|
+
return { project: { ...project, definitions, modules: loaded.map(module => module.source), configurations: registry.configurationInfos(), registry, workflows: registry.launchableEntries() }, diagnostics };
|
|
138
|
+
}
|
|
139
|
+
function collectModuleConflicts(modules: readonly ImportedWorkflowModule[]): Map<ImportedWorkflowModule, NornWorkflowDiagnostic[]> {
|
|
140
|
+
const conflicts = new Map<ImportedWorkflowModule, NornWorkflowDiagnostic[]>();
|
|
141
|
+
const byId = new Map<string, ImportedWorkflowModule[]>();
|
|
142
|
+
for (const module of modules) for (const workflow of module.definitions) byId.set(workflow.id, [...byId.get(workflow.id) ?? [], module]);
|
|
143
|
+
for (const [id, owners] of byId) {
|
|
144
|
+
if (owners.length < 2) continue;
|
|
145
|
+
for (const module of new Set(owners)) conflicts.set(module, [...conflicts.get(module) ?? [], createDiagnostic({ source: module.source, workflowId: id, scopeId: null, stage: "duplicate", error: new Error(`Workflow already registered: ${id}`) })]);
|
|
146
|
+
}
|
|
147
|
+
for (let index = 0; index < modules.length; index++) {
|
|
148
|
+
for (let other = index; other < modules.length; other++) {
|
|
149
|
+
const candidates = [...new Set([modules[index], modules[other]])];
|
|
150
|
+
try { assertCompatibleConfigurationOwners(candidates.flatMap(module => module.definitions)); }
|
|
151
|
+
catch (error) { for (const module of candidates) conflicts.set(module, [...conflicts.get(module) ?? [], createDiagnostic({ source: module.source, workflowId: null, scopeId: null, stage: "config", error })]); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return conflicts;
|
|
155
|
+
}
|
|
156
|
+
async function importWorkflowModules(project: NornProject): Promise<{ readonly modules: ImportedWorkflowModule[]; readonly diagnostics: NornWorkflowDiagnostic[] }> {
|
|
157
|
+
const modules: ImportedWorkflowModule[] = [];
|
|
158
|
+
const diagnostics: NornWorkflowDiagnostic[] = [];
|
|
159
|
+
const jiti = createJiti(pathToFileURL(project.configPath).href, { moduleCache: false, tryNative: false, virtualModules: nornWorkflowVirtualModules() });
|
|
160
|
+
const cache: ModuleCache = Object.create(null);
|
|
161
|
+
for (const file of project.configFiles) for (const path of file.config.workflows) {
|
|
162
|
+
const source = { configPath: file.path, path: resolveConfigPath(file.root, path) };
|
|
163
|
+
let stage: NornWorkflowDiagnostic["stage"] = "import";
|
|
164
|
+
try {
|
|
165
|
+
const cached = cache[source.path];
|
|
166
|
+
const module = (cached?.loaded ? cached.exports : await jiti.evalModule(await readFile(source.path, "utf8"), { filename: source.path, cache, async: true })) as { default?: unknown };
|
|
167
|
+
stage = "declaration";
|
|
168
|
+
if (!Array.isArray(module?.default) || !Array.from(module.default).every(isWorkflowDeclaration)) throw new Error(`Workflow module must default-export an array of complete workflow definitions: ${source.path}`);
|
|
169
|
+
modules.push({ definitions: module.default, source });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
for (const [path, module] of Object.entries(cache)) if (!module.loaded) delete cache[path];
|
|
172
|
+
diagnostics.push(createDiagnostic({ source, workflowId: null, scopeId: null, stage, error }));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { modules, diagnostics };
|
|
176
|
+
}
|
|
177
|
+
function createDiagnostic(input: { readonly source: NornWorkflowSource; readonly workflowId: string | null; readonly scopeId: string | null; readonly stage: NornWorkflowDiagnostic["stage"]; readonly error: unknown }): NornWorkflowDiagnostic {
|
|
178
|
+
return { configPath: input.source.configPath, modulePath: input.source.path, scopeId: input.scopeId, workflowId: input.workflowId, stage: input.stage, message: errorMessage(input.error), issues: input.error instanceof AssertError ? input.error.cause.errors : [] };
|
|
179
|
+
}
|
|
180
|
+
function nornWorkflowVirtualModules(): Record<string, unknown> {
|
|
181
|
+
return { "@vimhead.dev/norn": nornModule, "@vimhead.dev/norn/files": nornFilesModule, "@vimhead.dev/norn/schema": nornSchemaModule, typebox: typeboxModule, "typebox/value": typeboxValueModule, "typebox/compile": typeboxCompileModule, "typebox/schema": typeboxSchemaModule };
|
|
182
|
+
}
|
|
183
|
+
function mergeProjectConfig(files: readonly NornConfigFile[]): Record<string, unknown> {
|
|
184
|
+
const [project, ...reusable] = files;
|
|
185
|
+
let config: Record<string, unknown> = {};
|
|
186
|
+
for (const file of reusable) config = mergeConfig({ base: config, override: file.config.config, path: ["config"], rejectConflicts: true });
|
|
187
|
+
return mergeConfig({ base: config, override: project?.config.config ?? {}, path: ["config"], rejectConflicts: false });
|
|
188
|
+
}
|
|
189
|
+
function mergeConfig(input: { readonly base: Record<string, unknown>; readonly override: Record<string, unknown>; readonly path: readonly string[]; readonly rejectConflicts: boolean }): Record<string, unknown> {
|
|
190
|
+
const result = { ...input.base };
|
|
191
|
+
for (const [key, value] of Object.entries(input.override)) {
|
|
192
|
+
const previous = result[key];
|
|
193
|
+
const path = [...input.path, key];
|
|
194
|
+
if (isPlainObject(previous) && isPlainObject(value)) result[key] = mergeConfig({ base: previous, override: value, path, rejectConflicts: input.rejectConflicts });
|
|
195
|
+
else if (input.rejectConflicts && Object.hasOwn(result, key) && JSON.stringify(previous) !== JSON.stringify(value)) throw new Error(`Conflicting Norn reusable config at ${path.join(".")}`);
|
|
196
|
+
else result[key] = value;
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
async function expandIncludePath(root: string, path: string): Promise<string[]> {
|
|
201
|
+
const segments = path.split(/[\\/]+/).filter(segment => segment.length && segment !== ".");
|
|
202
|
+
return segments.includes("*") ? expandGlobSegments(isAbsolute(path) ? sep : root, segments) : [resolveConfigPath(root, path)];
|
|
203
|
+
}
|
|
204
|
+
async function expandGlobSegments(root: string, segments: readonly string[]): Promise<string[]> {
|
|
205
|
+
if (!segments.length) return [root];
|
|
206
|
+
const [segment, ...remaining] = segments;
|
|
207
|
+
if (segment !== "*") return expandGlobSegments(join(root, segment), remaining);
|
|
208
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
209
|
+
return (await Promise.all(entries.filter(entry => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name)).map(entry => expandGlobSegments(join(root, entry.name), remaining)))).flat();
|
|
210
|
+
}
|
|
211
|
+
function resolveConfigPath(root: string, path: string): string {
|
|
212
|
+
if (!path.length) throw new Error("Norn path must not be empty");
|
|
213
|
+
return isAbsolute(path) ? path : resolve(root, path);
|
|
214
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function createWorkflowTransition(input: { readonly workflowId: string; readonly
|
|
1
|
+
export function createWorkflowTransition(input: { readonly workflowId: string; readonly args: unknown }): { readonly type: "next"; readonly workflowId: string; readonly args: unknown } {
|
|
2
2
|
if (typeof input.workflowId !== "string" || input.workflowId.trim().length === 0) throw new Error("Workflow transition requires a nonempty workflow ID");
|
|
3
|
-
return { type: "next", workflowId: input.workflowId,
|
|
3
|
+
return { type: "next", workflowId: input.workflowId, args: input.args };
|
|
4
4
|
}
|