@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.
Files changed (111) hide show
  1. package/assets/README.md +17 -26
  2. package/assets/docs/README.md +3 -3
  3. package/assets/docs/agents.md +12 -1
  4. package/assets/docs/cli.md +5 -5
  5. package/assets/docs/composition.md +21 -21
  6. package/assets/docs/persistence.md +7 -10
  7. package/assets/docs/projects.md +30 -18
  8. package/assets/docs/providers.md +1 -1
  9. package/assets/docs/recovery.md +13 -9
  10. package/assets/docs/resources.md +4 -19
  11. package/assets/docs/schemas.md +1 -1
  12. package/assets/docs/workflows.md +95 -7
  13. package/assets/examples/agent-then-analysis/README.md +4 -4
  14. package/assets/examples/agent-then-analysis/input.json +1 -1
  15. package/assets/examples/agent-then-analysis/norn.project.json +1 -1
  16. package/assets/examples/agent-then-analysis/plugin.ts +55 -68
  17. package/assets/examples/caller-selected-continuation/README.md +5 -5
  18. package/assets/examples/caller-selected-continuation/caller.ts +35 -38
  19. package/assets/examples/caller-selected-continuation/input.json +2 -2
  20. package/assets/examples/caller-selected-continuation/norn.project.json +1 -1
  21. package/assets/examples/caller-selected-continuation/producer.ts +15 -23
  22. package/assets/examples/coordinating-multiple-agents/README.md +2 -2
  23. package/assets/examples/coordinating-multiple-agents/input.json +1 -1
  24. package/assets/examples/coordinating-multiple-agents/norn.project.json +1 -1
  25. package/assets/examples/coordinating-multiple-agents/plugin.ts +52 -56
  26. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +4 -4
  27. package/assets/examples/getting-started/norn.project.json +1 -1
  28. package/assets/examples/getting-started/plugin.ts +16 -26
  29. package/assets/examples/minimal-workflow/README.md +8 -8
  30. package/assets/examples/minimal-workflow/norn.project.json +1 -1
  31. package/assets/examples/minimal-workflow/plugin.ts +11 -25
  32. package/assets/examples/shared-state/README.md +10 -2
  33. package/assets/examples/shared-state/input.json +1 -1
  34. package/assets/examples/shared-state/norn.project.json +1 -1
  35. package/assets/examples/shared-state/plugin.ts +36 -41
  36. package/assets/examples/shared-state/shared-state.ts +75 -0
  37. package/assets/{packages/sdk/src → examples/shared-state}/state-adapter.ts +17 -16
  38. package/assets/examples/worktree-development-loop/README.md +6 -7
  39. package/assets/examples/worktree-development-loop/norn.project.json +1 -1
  40. package/assets/examples/worktree-development-loop/plugin.ts +6 -26
  41. package/assets/examples/worktree-development-loop/scope.ts +4 -0
  42. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +14 -15
  43. package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +3 -4
  44. package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +2 -2
  45. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +34 -33
  46. package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +2 -3
  47. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +7 -3
  48. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +23 -16
  49. package/assets/examples/worktree-development-loop/workflows/planning/index.ts +2 -3
  50. package/assets/examples/worktree-development-loop/workflows/planning/schema.ts +4 -2
  51. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +40 -31
  52. package/assets/examples/worktree-development-loop/workflows/review/index.ts +3 -4
  53. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +5 -4
  54. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +49 -44
  55. package/assets/examples/worktree-development-loop/workflows/review-router/index.ts +2 -3
  56. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +4 -3
  57. package/assets/package.json +1 -1
  58. package/assets/packages/cli/src/cli.ts +35 -34
  59. package/assets/packages/cli/src/client.ts +9 -16
  60. package/assets/packages/cli/src/documentation-intro.ts +1 -1
  61. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  62. package/assets/packages/cli/src/internal/agent-response-tool.ts +3 -3
  63. package/assets/packages/cli/src/internal/engine.ts +38 -62
  64. package/assets/packages/cli/src/internal/errors.ts +4 -4
  65. package/assets/packages/cli/src/internal/launch-request.ts +6 -6
  66. package/assets/packages/cli/src/internal/run-state.ts +31 -21
  67. package/assets/packages/cli/src/internal/run.ts +2 -5
  68. package/assets/packages/cli/src/internal/workflow-registry.ts +114 -111
  69. package/assets/packages/cli/src/workflow-loader.ts +214 -0
  70. package/assets/packages/core/src/workflow-transition.ts +2 -2
  71. package/assets/packages/sdk/src/api.ts +133 -289
  72. package/assets/packages/sdk/src/index.ts +1 -1
  73. package/assets/packages/sdk/src/schema.ts +3 -3
  74. package/assets/tests/workflow-ref.test.ts +50 -51
  75. package/dist/cli.js +33 -32
  76. package/dist/client.d.ts +3 -4
  77. package/dist/client.js +3 -8
  78. package/dist/documentation-intro.js +1 -1
  79. package/dist/generated-build-info.d.ts +2 -2
  80. package/dist/generated-build-info.js +2 -2
  81. package/dist/internal/agent-response-tool.js +3 -3
  82. package/dist/internal/engine.d.ts +4 -8
  83. package/dist/internal/engine.js +36 -49
  84. package/dist/internal/errors.d.ts +3 -3
  85. package/dist/internal/errors.js +1 -1
  86. package/dist/internal/launch-request.d.ts +4 -4
  87. package/dist/internal/launch-request.js +2 -2
  88. package/dist/internal/run-state.d.ts +9 -8
  89. package/dist/internal/run-state.js +29 -20
  90. package/dist/internal/run.d.ts +1 -3
  91. package/dist/internal/run.js +3 -5
  92. package/dist/internal/workflow-registry.d.ts +25 -17
  93. package/dist/internal/workflow-registry.js +107 -72
  94. package/dist/{plugin-loader.d.ts → workflow-loader.d.ts} +7 -8
  95. package/dist/workflow-loader.js +235 -0
  96. package/package.json +2 -2
  97. package/assets/examples/worktree-development-loop/manifest.ts +0 -26
  98. package/assets/examples/worktree-development-loop/state.ts +0 -23
  99. package/assets/examples/worktree-development-loop/workflows/development-loop/declaration.ts +0 -8
  100. package/assets/examples/worktree-development-loop/workflows/implementation/declaration.ts +0 -8
  101. package/assets/examples/worktree-development-loop/workflows/planning/declaration.ts +0 -8
  102. package/assets/examples/worktree-development-loop/workflows/review/declaration.ts +0 -8
  103. package/assets/examples/worktree-development-loop/workflows/review-router/declaration.ts +0 -12
  104. package/assets/packages/cli/src/internal/run-resources.ts +0 -23
  105. package/assets/packages/cli/src/internal/state-store.ts +0 -83
  106. package/assets/packages/cli/src/plugin-loader.ts +0 -400
  107. package/dist/internal/run-resources.d.ts +0 -6
  108. package/dist/internal/run-resources.js +0 -26
  109. package/dist/internal/state-store.d.ts +0 -23
  110. package/dist/internal/state-store.js +0 -103
  111. 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 NornWorkflowImplementation, type NornWorkflowParams, type NornWorkflowPluginInfo } from "@vimhead.dev/norn";
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 { type TSchema } from "typebox";
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
- implementation: NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>;
9
- configuration?: NornRegisteredConfiguration;
10
- plugin?: NornWorkflowPluginInfo;
8
+ readonly workflow: NornAnyWorkflowDeclaration;
9
+ readonly configuration: NornRegisteredConfiguration;
10
+ readonly scopeId: string | undefined;
11
+ readonly source: NornWorkflowSource | undefined;
11
12
  };
12
13
 
13
- export type NornRegisteredConfiguration = { readonly schema: TSchema; readonly input: unknown; readonly value: unknown };
14
+ type NornRegisteredScope = { readonly definition: NornWorkflowScopeInfo; readonly configuration: NornRegisteredConfiguration };
14
15
 
15
- export function decodePluginConfiguration(input: { readonly pluginId: string; readonly schema: TSchema | undefined; readonly value: unknown }): NornRegisteredConfiguration | undefined {
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 for plugin without config schema: ${input.pluginId}`);
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<TWorkflow extends NornAnyWorkflowDeclaration>(
33
- workflow: TWorkflow,
34
- implementation: NornWorkflowImplementation<TWorkflow, unknown>,
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
- implementation: implementation as NornWorkflowImplementation<NornAnyWorkflowDeclaration, unknown>,
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
- assertWorkflowMetadata(workflow);
46
- assertGateWorkflow(workflow);
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) === entry) this.entries.delete(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
- const entries = options.entrypointsOnly ? this.launchableEntries() : this.sortedEntries();
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
- return entry ? inspectedWorkflowInfo(entry) : undefined;
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
- launchableEntries(): NornRegisteredWorkflow[] {
65
- return this.sortedEntries().filter(({ workflow }) => workflow.isEntrypoint);
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
- workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined {
69
- return this.entries.get(workflowId)?.workflow;
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: TWorkflow,
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 parsedParams = Value.Decode(workflow.params, params) as NornWorkflowParams<TWorkflow>;
82
- const parsedConfig = parseExecutionConfig(entry, configOverride);
83
- const description = await (entry.implementation as NornWorkflowImplementation<TWorkflow, unknown>).gate?.describe(run, parsedParams, parsedConfig);
84
- return validateGateDescription(description ?? workflow.id, workflow.id);
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: TWorkflow,
89
- run: NornRunFor<TWorkflow>,
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 sortedEntries(): NornRegisteredWorkflow[] {
106
- return Array.from(this.entries.values()).sort((left, right) => left.workflow.id.localeCompare(right.workflow.id));
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
- function defaultConfigInput(configSchema: TSchema, config: unknown): unknown {
111
- if (config !== undefined) return config;
112
- return schemaType(unwrapSchema(configSchema)) === "object" ? {} : undefined;
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 parseExecutionConfig(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
116
- const pluginConfigOverride = pluginConfigOverrideInput(entry, configOverride);
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 pluginConfigOverrideInput(entry: NornRegisteredWorkflow, configOverride: unknown): unknown {
129
- if (configOverride === undefined) return undefined;
130
- if (!isPlainObject(configOverride)) throw new Error("Run config override must be an object keyed by plugin id");
131
- const pluginId = entry.plugin?.id;
132
- return pluginId ? configOverride[pluginId] : undefined;
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: Record<string, unknown> = { ...base };
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 paramsSchema = unwrapSchema(workflow.params);
149
- if (schemaType(paramsSchema) !== "object") throw new Error(`Workflow gate fields require object params: ${workflow.id}`);
150
- const paramsShape = schemaShape(paramsSchema);
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
- instructions: entry.workflow.instructions,
174
- isEntrypoint: entry.workflow.isEntrypoint,
175
- isolation: entry.workflow.isolation,
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 params: unknown }): { readonly type: "next"; readonly workflowId: string; readonly params: unknown } {
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, params: input.params };
3
+ return { type: "next", workflowId: input.workflowId, args: input.args };
4
4
  }