@vimhead.dev/norn-cli 0.1.0-tip.35614826665.1 → 0.1.0-tip.35724621678.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 +41 -13
- package/assets/docs/README.md +16 -16
- package/assets/docs/agents.md +23 -15
- package/assets/docs/cli.md +10 -8
- package/assets/docs/composition.md +7 -7
- package/assets/docs/persistence.md +22 -22
- package/assets/docs/projects.md +6 -6
- package/assets/docs/recovery.md +10 -10
- package/assets/docs/schemas.md +42 -36
- package/assets/docs/workflows.md +39 -32
- package/assets/examples/agent-then-analysis/input.json +3 -3
- package/assets/examples/agent-then-analysis/norn.project.json +2 -2
- package/assets/examples/agent-then-analysis/plugin.ts +37 -9
- package/assets/examples/caller-owned-routing/README.md +6 -6
- package/assets/examples/caller-owned-routing/assessment.ts +18 -4
- package/assets/examples/caller-owned-routing/contracts.ts +7 -2
- package/assets/examples/caller-owned-routing/input.json +12 -12
- package/assets/examples/caller-owned-routing/norn.project.json +2 -2
- package/assets/examples/caller-owned-routing/revision.ts +7 -2
- package/assets/examples/caller-owned-routing/router.ts +14 -3
- package/assets/examples/caller-selected-continuation/caller.ts +38 -12
- package/assets/examples/caller-selected-continuation/input.json +7 -7
- package/assets/examples/caller-selected-continuation/norn.project.json +2 -2
- package/assets/examples/caller-selected-continuation/producer.ts +10 -3
- package/assets/examples/coordinating-multiple-agents/README.md +6 -6
- package/assets/examples/coordinating-multiple-agents/input.json +20 -8
- package/assets/examples/coordinating-multiple-agents/norn.project.json +2 -2
- package/assets/examples/coordinating-multiple-agents/plugin.ts +165 -46
- package/assets/examples/coordinating-multiple-agents/queue-tools.ts +30 -7
- package/assets/examples/coordinating-multiple-agents/work-queue.ts +211 -63
- package/assets/examples/getting-started/norn.project.json +2 -2
- package/assets/examples/getting-started/plugin.ts +33 -13
- package/assets/examples/minimal-workflow/norn.project.json +2 -2
- package/assets/examples/minimal-workflow/plugin.ts +12 -3
- package/assets/examples/shared-state/README.md +6 -6
- package/assets/examples/shared-state/input.json +1 -1
- package/assets/examples/shared-state/norn.project.json +2 -2
- package/assets/examples/shared-state/plugin.ts +44 -15
- package/assets/examples/shared-state/shared-state.ts +46 -13
- package/assets/examples/shared-state/state-tools.ts +68 -16
- package/assets/examples/worktree-development-loop/README.md +12 -12
- package/assets/examples/worktree-development-loop/norn.project.json +7 -7
- package/assets/examples/worktree-development-loop/plugin.ts +7 -1
- package/assets/examples/worktree-development-loop/scope.ts +4 -1
- package/assets/examples/worktree-development-loop/shared/commands.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +18 -4
- package/assets/examples/worktree-development-loop/workflows/development-loop/index.ts +4 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +11 -1
- package/assets/examples/worktree-development-loop/workflows/development-loop/schema.ts +6 -2
- package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +40 -10
- package/assets/examples/worktree-development-loop/workflows/implementation/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +1 -1
- package/assets/examples/worktree-development-loop/workflows/planning/index.ts +5 -1
- package/assets/examples/worktree-development-loop/workflows/review/execute.ts +33 -9
- package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +51 -8
- package/assets/package.json +1 -1
- package/assets/packages/cli/src/build-info.ts +4 -1
- package/assets/packages/cli/src/bun/cli.ts +21 -5
- package/assets/packages/cli/src/cli.ts +585 -170
- package/assets/packages/cli/src/client.ts +194 -36
- package/assets/packages/cli/src/documentation.ts +135 -30
- package/assets/packages/cli/src/generated-build-info.ts +2 -2
- package/assets/packages/cli/src/internal/agent-directory.ts +7 -2
- package/assets/packages/cli/src/internal/agent-response-tool.ts +57 -15
- package/assets/packages/cli/src/internal/agents.ts +248 -60
- package/assets/packages/cli/src/internal/commands.ts +82 -20
- package/assets/packages/cli/src/internal/documentation-bundle.ts +44 -13
- package/assets/packages/cli/src/internal/engine.ts +396 -87
- package/assets/packages/cli/src/internal/errors.ts +13 -5
- package/assets/packages/cli/src/internal/execution-context.ts +20 -12
- package/assets/packages/cli/src/internal/file-coordinator.ts +95 -26
- package/assets/packages/cli/src/internal/file-names.ts +4 -1
- package/assets/packages/cli/src/internal/launch-request.ts +74 -22
- package/assets/packages/cli/src/internal/logs.ts +10 -2
- package/assets/packages/cli/src/internal/metrics.ts +247 -61
- package/assets/packages/cli/src/internal/pi-assets.ts +105 -23
- package/assets/packages/cli/src/internal/run-lease.ts +72 -22
- package/assets/packages/cli/src/internal/run-log.ts +49 -20
- package/assets/packages/cli/src/internal/run-names.ts +120 -12
- package/assets/packages/cli/src/internal/run-state.ts +306 -71
- package/assets/packages/cli/src/internal/run-store.ts +216 -53
- package/assets/packages/cli/src/internal/usage.ts +23 -7
- package/assets/packages/cli/src/internal/worker-directory.ts +9 -3
- package/assets/packages/cli/src/internal/workflow-registry.ts +281 -70
- package/assets/packages/cli/src/internal/working-directory.ts +2 -1
- package/assets/packages/cli/src/workflow-loader.ts +341 -74
- package/assets/packages/core/src/atomic-files.ts +13 -4
- package/assets/packages/core/src/workflow-transition.ts +13 -2
- package/assets/packages/sdk/src/api.ts +248 -80
- package/assets/packages/sdk/src/index.ts +6 -1
- package/assets/packages/sdk/src/schema.ts +75 -21
- package/assets/tests/workflow-ref.test.ts +242 -57
- package/bin/norn.mjs +13 -4
- package/dist/cli.js +344 -90
- package/dist/client.js +100 -22
- package/dist/documentation.js +102 -25
- package/dist/generated-build-info.d.ts +2 -2
- package/dist/generated-build-info.js +2 -2
- package/dist/internal/agent-directory.js +3 -1
- package/dist/internal/agent-response-tool.js +42 -11
- package/dist/internal/agents.js +149 -37
- package/dist/internal/commands.js +65 -16
- package/dist/internal/documentation-bundle.js +23 -11
- package/dist/internal/engine.js +304 -66
- package/dist/internal/errors.js +4 -2
- package/dist/internal/execution-context.js +2 -1
- package/dist/internal/file-coordinator.js +59 -19
- package/dist/internal/launch-request.js +32 -14
- package/dist/internal/metrics.js +138 -38
- package/dist/internal/pi-assets.js +70 -19
- package/dist/internal/run-lease.js +29 -9
- package/dist/internal/run-log.js +24 -12
- package/dist/internal/run-state.js +172 -48
- package/dist/internal/run-store.js +157 -45
- package/dist/internal/worker-directory.js +6 -2
- package/dist/internal/workflow-registry.js +123 -32
- package/dist/internal/working-directory.js +2 -1
- package/dist/workflow-loader.js +198 -41
- package/package.json +2 -2
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import * as nornModule from "@vimhead.dev/norn";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isWorkflowDeclaration,
|
|
4
|
+
type NornAnyWorkflowDeclaration,
|
|
5
|
+
type NornDispose,
|
|
6
|
+
type NornWorkflowDiagnostic,
|
|
7
|
+
type NornProjectInfo,
|
|
8
|
+
type NornProjectInspection,
|
|
9
|
+
type NornProjectConfigurationInfo,
|
|
10
|
+
type NornWorkflowCatalogInfo,
|
|
11
|
+
type NornWorkflowInspection,
|
|
12
|
+
type NornWorkflowSource,
|
|
13
|
+
} from "@vimhead.dev/norn";
|
|
3
14
|
import { isNodeError } from "@vimhead.dev/norn-core/errors";
|
|
4
15
|
import * as nornSchemaModule from "@vimhead.dev/norn/schema";
|
|
5
16
|
import { inspectSchema, isPlainObject } from "@vimhead.dev/norn/schema";
|
|
@@ -14,7 +25,12 @@ import * as typeboxSchemaModule from "typebox/schema";
|
|
|
14
25
|
import * as typeboxValueModule from "typebox/value";
|
|
15
26
|
import { AssertError, Value } from "typebox/value";
|
|
16
27
|
import { errorMessage, NornProjectLoadError } from "./internal/errors.ts";
|
|
17
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
assertCompatibleConfigurationOwners,
|
|
30
|
+
assertWorkflowDefinition,
|
|
31
|
+
NornWorkflowRegistry,
|
|
32
|
+
type NornRegisteredWorkflow,
|
|
33
|
+
} from "./internal/workflow-registry.ts";
|
|
18
34
|
|
|
19
35
|
export const NORN_PROJECT_FILE_NAME = "norn.project.json";
|
|
20
36
|
const nornConfigSchema = Type.Object({
|
|
@@ -22,15 +38,29 @@ const nornConfigSchema = Type.Object({
|
|
|
22
38
|
includes: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
|
|
23
39
|
config: Type.Record(Type.String(), Type.Unknown(), { default: {} }),
|
|
24
40
|
});
|
|
25
|
-
const nornProjectConfigSchema = Type.Object({
|
|
41
|
+
const nornProjectConfigSchema = Type.Object({
|
|
42
|
+
...nornConfigSchema.properties,
|
|
43
|
+
version: Type.Literal(1, { default: 1 }),
|
|
44
|
+
});
|
|
26
45
|
type NornConfig = StaticDecode<typeof nornConfigSchema>;
|
|
27
46
|
type NornProjectConfig = StaticDecode<typeof nornProjectConfigSchema>;
|
|
28
|
-
type NornConfigFile = {
|
|
29
|
-
|
|
47
|
+
type NornConfigFile = {
|
|
48
|
+
readonly path: string;
|
|
49
|
+
readonly root: string;
|
|
50
|
+
readonly config: NornConfig;
|
|
51
|
+
};
|
|
52
|
+
type NornProjectConfigFile = Omit<NornConfigFile, "config"> & {
|
|
53
|
+
readonly config: NornProjectConfig;
|
|
54
|
+
};
|
|
30
55
|
export type NornProject = {
|
|
31
|
-
readonly cwd: string;
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
56
|
+
readonly cwd: string;
|
|
57
|
+
readonly projectPath: string;
|
|
58
|
+
readonly projectRoot: string;
|
|
59
|
+
readonly configPath: string;
|
|
60
|
+
readonly configRoot: string;
|
|
61
|
+
readonly config: NornProjectConfig;
|
|
62
|
+
readonly configFiles: readonly NornConfigFile[];
|
|
63
|
+
readonly projectConfig: Record<string, unknown>;
|
|
34
64
|
};
|
|
35
65
|
export type NornLoadedProject = NornProject & {
|
|
36
66
|
readonly definitions: readonly NornAnyWorkflowDeclaration[];
|
|
@@ -39,74 +69,149 @@ export type NornLoadedProject = NornProject & {
|
|
|
39
69
|
readonly registry: NornWorkflowRegistry;
|
|
40
70
|
readonly workflows: readonly NornRegisteredWorkflow[];
|
|
41
71
|
};
|
|
42
|
-
type ImportedWorkflowModule = {
|
|
72
|
+
type ImportedWorkflowModule = {
|
|
73
|
+
readonly definitions: readonly NornAnyWorkflowDeclaration[];
|
|
74
|
+
readonly source: NornWorkflowSource;
|
|
75
|
+
};
|
|
43
76
|
|
|
44
77
|
export async function loadNornProject(cwd: string): Promise<NornLoadedProject> {
|
|
45
78
|
const { project, diagnostics } = await collectNornProject(cwd);
|
|
46
79
|
if (diagnostics.length) throw new NornProjectLoadError({ diagnostics });
|
|
47
80
|
return project;
|
|
48
81
|
}
|
|
49
|
-
export async function discoverNornProject(
|
|
82
|
+
export async function discoverNornProject(
|
|
83
|
+
cwd: string,
|
|
84
|
+
): Promise<NornProjectInspection & NornWorkflowCatalogInfo> {
|
|
50
85
|
const { project, diagnostics } = await collectNornProject(cwd);
|
|
51
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
project: buildProjectInfo(project),
|
|
88
|
+
workflows: project.registry.list(),
|
|
89
|
+
isComplete: diagnostics.length === 0,
|
|
90
|
+
diagnostics,
|
|
91
|
+
};
|
|
52
92
|
}
|
|
53
|
-
export async function inspectNornWorkflow(input: {
|
|
93
|
+
export async function inspectNornWorkflow(input: {
|
|
94
|
+
readonly cwd: string;
|
|
95
|
+
readonly workflowId: string;
|
|
96
|
+
}): Promise<NornWorkflowInspection> {
|
|
54
97
|
const { project, diagnostics } = await collectNornProject(input.cwd);
|
|
55
|
-
const summary = project.registry
|
|
98
|
+
const summary = project.registry
|
|
99
|
+
.list()
|
|
100
|
+
.find((workflow) => workflow.id === input.workflowId);
|
|
56
101
|
if (!summary) {
|
|
57
|
-
if (!diagnostics.length)
|
|
102
|
+
if (!diagnostics.length)
|
|
103
|
+
throw new Error(`Unknown workflow: ${input.workflowId}`);
|
|
58
104
|
return { workflow: null, isComplete: false, diagnostics };
|
|
59
105
|
}
|
|
60
|
-
try {
|
|
61
|
-
|
|
106
|
+
try {
|
|
107
|
+
return {
|
|
108
|
+
workflow: project.registry.inspect(input.workflowId) ?? null,
|
|
109
|
+
isComplete: diagnostics.length === 0,
|
|
110
|
+
diagnostics,
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
62
113
|
if (!summary.source) throw error;
|
|
63
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
workflow: null,
|
|
116
|
+
isComplete: false,
|
|
117
|
+
diagnostics: [
|
|
118
|
+
...diagnostics,
|
|
119
|
+
createDiagnostic({
|
|
120
|
+
source: summary.source,
|
|
121
|
+
workflowId: summary.id,
|
|
122
|
+
scopeId: summary.scope?.id ?? null,
|
|
123
|
+
stage: "schema",
|
|
124
|
+
error,
|
|
125
|
+
}),
|
|
126
|
+
],
|
|
127
|
+
};
|
|
64
128
|
}
|
|
65
129
|
}
|
|
66
130
|
function buildProjectInfo(project: NornLoadedProject): NornProjectInfo {
|
|
67
131
|
return {
|
|
68
|
-
cwd: project.cwd,
|
|
69
|
-
|
|
70
|
-
|
|
132
|
+
cwd: project.cwd,
|
|
133
|
+
projectPath: project.projectPath,
|
|
134
|
+
projectRoot: project.projectRoot,
|
|
135
|
+
configPath: project.configPath,
|
|
136
|
+
configRoot: project.configRoot,
|
|
137
|
+
configFiles: project.configFiles.map((file) => file.path),
|
|
138
|
+
modules: project.modules,
|
|
139
|
+
configurations: project.configurations,
|
|
71
140
|
};
|
|
72
141
|
}
|
|
73
142
|
export async function findNornProject(cwd: string): Promise<NornProject> {
|
|
74
143
|
const projectPath = await findNearestNornProject(cwd);
|
|
75
|
-
const projectFile = await readConfigFile({
|
|
144
|
+
const projectFile = await readConfigFile({
|
|
145
|
+
path: projectPath,
|
|
146
|
+
isProject: true,
|
|
147
|
+
});
|
|
76
148
|
const configFiles = await loadConfigTree(projectFile, new Set());
|
|
77
149
|
return {
|
|
78
|
-
cwd: resolve(cwd),
|
|
79
|
-
|
|
150
|
+
cwd: resolve(cwd),
|
|
151
|
+
projectPath,
|
|
152
|
+
projectRoot: projectFile.root,
|
|
153
|
+
configPath: projectPath,
|
|
154
|
+
configRoot: projectFile.root,
|
|
155
|
+
config: projectFile.config,
|
|
156
|
+
configFiles,
|
|
157
|
+
projectConfig: mergeProjectConfig(configFiles),
|
|
80
158
|
};
|
|
81
159
|
}
|
|
82
160
|
async function findNearestNornProject(cwd: string): Promise<string> {
|
|
83
161
|
let current = resolve(cwd);
|
|
84
162
|
while (true) {
|
|
85
163
|
const path = join(current, NORN_PROJECT_FILE_NAME);
|
|
86
|
-
try {
|
|
87
|
-
|
|
164
|
+
try {
|
|
165
|
+
await access(path);
|
|
166
|
+
return path;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
169
|
+
}
|
|
88
170
|
const parent = dirname(current);
|
|
89
|
-
if (parent === current)
|
|
171
|
+
if (parent === current)
|
|
172
|
+
throw new Error(`Could not find ${NORN_PROJECT_FILE_NAME} from ${cwd}`);
|
|
90
173
|
current = parent;
|
|
91
174
|
}
|
|
92
175
|
}
|
|
93
|
-
async function loadConfigTree(
|
|
176
|
+
async function loadConfigTree(
|
|
177
|
+
file: NornConfigFile,
|
|
178
|
+
visited: Set<string>,
|
|
179
|
+
): Promise<NornConfigFile[]> {
|
|
94
180
|
if (visited.has(file.path)) return [];
|
|
95
181
|
visited.add(file.path);
|
|
96
182
|
const files: NornConfigFile[] = [file];
|
|
97
183
|
for (const include of file.config.includes) {
|
|
98
|
-
for (const path of await expandIncludePath(file.root, include))
|
|
184
|
+
for (const path of await expandIncludePath(file.root, include))
|
|
185
|
+
files.push(
|
|
186
|
+
...(await loadConfigTree(
|
|
187
|
+
await readConfigFile({ path, isProject: false }),
|
|
188
|
+
visited,
|
|
189
|
+
)),
|
|
190
|
+
);
|
|
99
191
|
}
|
|
100
192
|
return files;
|
|
101
193
|
}
|
|
102
|
-
async function readConfigFile(input: {
|
|
194
|
+
async function readConfigFile(input: {
|
|
195
|
+
readonly path: string;
|
|
196
|
+
readonly isProject: boolean;
|
|
197
|
+
}): Promise<NornProjectConfigFile> {
|
|
103
198
|
const raw: unknown = JSON.parse(await readFile(input.path, "utf8"));
|
|
104
|
-
if (isPlainObject(raw) && Object.hasOwn(raw, "plugins"))
|
|
199
|
+
if (isPlainObject(raw) && Object.hasOwn(raw, "plugins"))
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Replace plugins with workflows in ${input.path}; registration modules must export workflow arrays`,
|
|
202
|
+
);
|
|
105
203
|
const schema = input.isProject ? nornProjectConfigSchema : nornConfigSchema;
|
|
106
204
|
const config = Value.Parse(schema, Value.Default(schema, raw));
|
|
107
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
path: input.path,
|
|
207
|
+
root: dirname(input.path),
|
|
208
|
+
config: { ...config, version: 1 },
|
|
209
|
+
};
|
|
108
210
|
}
|
|
109
|
-
async function collectNornProject(cwd: string): Promise<{
|
|
211
|
+
async function collectNornProject(cwd: string): Promise<{
|
|
212
|
+
readonly project: NornLoadedProject;
|
|
213
|
+
readonly diagnostics: NornWorkflowDiagnostic[];
|
|
214
|
+
}> {
|
|
110
215
|
const project = await findNornProject(cwd);
|
|
111
216
|
const imported = await importWorkflowModules(project);
|
|
112
217
|
const diagnostics = [...imported.diagnostics];
|
|
@@ -115,7 +220,10 @@ async function collectNornProject(cwd: string): Promise<{ readonly project: Norn
|
|
|
115
220
|
const conflicts = collectModuleConflicts(imported.modules);
|
|
116
221
|
for (const candidate of imported.modules) {
|
|
117
222
|
const conflict = conflicts.get(candidate);
|
|
118
|
-
if (conflict) {
|
|
223
|
+
if (conflict) {
|
|
224
|
+
diagnostics.push(...conflict);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
119
227
|
const unregister: NornDispose[] = [];
|
|
120
228
|
const errors: NornWorkflowDiagnostic[] = [];
|
|
121
229
|
for (const workflow of candidate.definitions) {
|
|
@@ -126,86 +234,245 @@ async function collectNornProject(cwd: string): Promise<{ readonly project: Norn
|
|
|
126
234
|
stage = "declaration";
|
|
127
235
|
assertWorkflowDefinition(workflow);
|
|
128
236
|
stage = "config";
|
|
129
|
-
unregister.push(
|
|
130
|
-
|
|
237
|
+
unregister.push(
|
|
238
|
+
registry.register({
|
|
239
|
+
workflow,
|
|
240
|
+
config: project.projectConfig,
|
|
241
|
+
source: candidate.source,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
errors.push(
|
|
246
|
+
createDiagnostic({
|
|
247
|
+
source: candidate.source,
|
|
248
|
+
workflowId: workflow.id,
|
|
249
|
+
scopeId: workflow.scope?.id ?? null,
|
|
250
|
+
stage,
|
|
251
|
+
error,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
131
255
|
}
|
|
132
|
-
if (errors.length) {
|
|
133
|
-
|
|
256
|
+
if (errors.length) {
|
|
257
|
+
for (const dispose of unregister) dispose();
|
|
258
|
+
diagnostics.push(...errors);
|
|
259
|
+
} else loaded.push(candidate);
|
|
134
260
|
}
|
|
135
|
-
const definitions = loaded.flatMap(module => module.definitions);
|
|
136
|
-
return {
|
|
261
|
+
const definitions = loaded.flatMap((module) => module.definitions);
|
|
262
|
+
return {
|
|
263
|
+
project: {
|
|
264
|
+
...project,
|
|
265
|
+
definitions,
|
|
266
|
+
modules: loaded.map((module) => module.source),
|
|
267
|
+
configurations: registry.configurationInfos(),
|
|
268
|
+
registry,
|
|
269
|
+
workflows: registry.launchableEntries(),
|
|
270
|
+
},
|
|
271
|
+
diagnostics,
|
|
272
|
+
};
|
|
137
273
|
}
|
|
138
|
-
function collectModuleConflicts(
|
|
274
|
+
function collectModuleConflicts(
|
|
275
|
+
modules: readonly ImportedWorkflowModule[],
|
|
276
|
+
): Map<ImportedWorkflowModule, NornWorkflowDiagnostic[]> {
|
|
139
277
|
const conflicts = new Map<ImportedWorkflowModule, NornWorkflowDiagnostic[]>();
|
|
140
278
|
const byId = new Map<string, ImportedWorkflowModule[]>();
|
|
141
|
-
for (const module of modules)
|
|
279
|
+
for (const module of modules)
|
|
280
|
+
for (const workflow of module.definitions)
|
|
281
|
+
byId.set(workflow.id, [...(byId.get(workflow.id) ?? []), module]);
|
|
142
282
|
for (const [id, owners] of byId) {
|
|
143
283
|
if (owners.length < 2) continue;
|
|
144
|
-
for (const module of new Set(owners))
|
|
284
|
+
for (const module of new Set(owners))
|
|
285
|
+
conflicts.set(module, [
|
|
286
|
+
...(conflicts.get(module) ?? []),
|
|
287
|
+
createDiagnostic({
|
|
288
|
+
source: module.source,
|
|
289
|
+
workflowId: id,
|
|
290
|
+
scopeId: null,
|
|
291
|
+
stage: "duplicate",
|
|
292
|
+
error: new Error(`Workflow already registered: ${id}`),
|
|
293
|
+
}),
|
|
294
|
+
]);
|
|
145
295
|
}
|
|
146
296
|
for (let index = 0; index < modules.length; index++) {
|
|
147
297
|
for (let other = index; other < modules.length; other++) {
|
|
148
298
|
const candidates = [...new Set([modules[index], modules[other]])];
|
|
149
|
-
try {
|
|
150
|
-
|
|
299
|
+
try {
|
|
300
|
+
assertCompatibleConfigurationOwners(
|
|
301
|
+
candidates.flatMap((module) => module.definitions),
|
|
302
|
+
);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
for (const module of candidates)
|
|
305
|
+
conflicts.set(module, [
|
|
306
|
+
...(conflicts.get(module) ?? []),
|
|
307
|
+
createDiagnostic({
|
|
308
|
+
source: module.source,
|
|
309
|
+
workflowId: null,
|
|
310
|
+
scopeId: null,
|
|
311
|
+
stage: "config",
|
|
312
|
+
error,
|
|
313
|
+
}),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
151
316
|
}
|
|
152
317
|
}
|
|
153
318
|
return conflicts;
|
|
154
319
|
}
|
|
155
|
-
async function importWorkflowModules(project: NornProject): Promise<{
|
|
320
|
+
async function importWorkflowModules(project: NornProject): Promise<{
|
|
321
|
+
readonly modules: ImportedWorkflowModule[];
|
|
322
|
+
readonly diagnostics: NornWorkflowDiagnostic[];
|
|
323
|
+
}> {
|
|
156
324
|
const modules: ImportedWorkflowModule[] = [];
|
|
157
325
|
const diagnostics: NornWorkflowDiagnostic[] = [];
|
|
158
|
-
const jiti = createJiti(pathToFileURL(project.configPath).href, {
|
|
326
|
+
const jiti = createJiti(pathToFileURL(project.configPath).href, {
|
|
327
|
+
moduleCache: false,
|
|
328
|
+
tryNative: false,
|
|
329
|
+
virtualModules: nornWorkflowVirtualModules(),
|
|
330
|
+
});
|
|
159
331
|
const cache: ModuleCache = Object.create(null);
|
|
160
|
-
for (const file of project.configFiles)
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
stage = "
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
332
|
+
for (const file of project.configFiles)
|
|
333
|
+
for (const path of file.config.workflows) {
|
|
334
|
+
const source = {
|
|
335
|
+
configPath: file.path,
|
|
336
|
+
path: resolveConfigPath(file.root, path),
|
|
337
|
+
};
|
|
338
|
+
let stage: NornWorkflowDiagnostic["stage"] = "import";
|
|
339
|
+
try {
|
|
340
|
+
const cached = cache[source.path];
|
|
341
|
+
const module = (
|
|
342
|
+
cached?.loaded
|
|
343
|
+
? cached.exports
|
|
344
|
+
: await jiti.evalModule(await readFile(source.path, "utf8"), {
|
|
345
|
+
filename: source.path,
|
|
346
|
+
cache,
|
|
347
|
+
async: true,
|
|
348
|
+
})
|
|
349
|
+
) as { default?: unknown };
|
|
350
|
+
stage = "declaration";
|
|
351
|
+
if (
|
|
352
|
+
!Array.isArray(module?.default) ||
|
|
353
|
+
!Array.from(module.default).every(isWorkflowDeclaration)
|
|
354
|
+
)
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Workflow module must default-export an array of complete workflow definitions: ${source.path}`,
|
|
357
|
+
);
|
|
358
|
+
modules.push({ definitions: module.default, source });
|
|
359
|
+
} catch (error) {
|
|
360
|
+
for (const [path, module] of Object.entries(cache))
|
|
361
|
+
if (!module.loaded) delete cache[path];
|
|
362
|
+
diagnostics.push(
|
|
363
|
+
createDiagnostic({
|
|
364
|
+
source,
|
|
365
|
+
workflowId: null,
|
|
366
|
+
scopeId: null,
|
|
367
|
+
stage,
|
|
368
|
+
error,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
172
372
|
}
|
|
173
|
-
}
|
|
174
373
|
return { modules, diagnostics };
|
|
175
374
|
}
|
|
176
|
-
function createDiagnostic(input: {
|
|
177
|
-
|
|
375
|
+
function createDiagnostic(input: {
|
|
376
|
+
readonly source: NornWorkflowSource;
|
|
377
|
+
readonly workflowId: string | null;
|
|
378
|
+
readonly scopeId: string | null;
|
|
379
|
+
readonly stage: NornWorkflowDiagnostic["stage"];
|
|
380
|
+
readonly error: unknown;
|
|
381
|
+
}): NornWorkflowDiagnostic {
|
|
382
|
+
return {
|
|
383
|
+
configPath: input.source.configPath,
|
|
384
|
+
modulePath: input.source.path,
|
|
385
|
+
scopeId: input.scopeId,
|
|
386
|
+
workflowId: input.workflowId,
|
|
387
|
+
stage: input.stage,
|
|
388
|
+
message: errorMessage(input.error),
|
|
389
|
+
issues: input.error instanceof AssertError ? input.error.cause.errors : [],
|
|
390
|
+
};
|
|
178
391
|
}
|
|
179
392
|
function nornWorkflowVirtualModules(): Record<string, unknown> {
|
|
180
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
"@vimhead.dev/norn": nornModule,
|
|
395
|
+
"@vimhead.dev/norn/schema": nornSchemaModule,
|
|
396
|
+
typebox: typeboxModule,
|
|
397
|
+
"typebox/value": typeboxValueModule,
|
|
398
|
+
"typebox/compile": typeboxCompileModule,
|
|
399
|
+
"typebox/schema": typeboxSchemaModule,
|
|
400
|
+
};
|
|
181
401
|
}
|
|
182
|
-
function mergeProjectConfig(
|
|
402
|
+
function mergeProjectConfig(
|
|
403
|
+
files: readonly NornConfigFile[],
|
|
404
|
+
): Record<string, unknown> {
|
|
183
405
|
const [project, ...reusable] = files;
|
|
184
406
|
let config: Record<string, unknown> = {};
|
|
185
|
-
for (const file of reusable)
|
|
186
|
-
|
|
407
|
+
for (const file of reusable)
|
|
408
|
+
config = mergeConfig({
|
|
409
|
+
base: config,
|
|
410
|
+
override: file.config.config,
|
|
411
|
+
path: ["config"],
|
|
412
|
+
rejectConflicts: true,
|
|
413
|
+
});
|
|
414
|
+
return mergeConfig({
|
|
415
|
+
base: config,
|
|
416
|
+
override: project?.config.config ?? {},
|
|
417
|
+
path: ["config"],
|
|
418
|
+
rejectConflicts: false,
|
|
419
|
+
});
|
|
187
420
|
}
|
|
188
|
-
function mergeConfig(input: {
|
|
421
|
+
function mergeConfig(input: {
|
|
422
|
+
readonly base: Record<string, unknown>;
|
|
423
|
+
readonly override: Record<string, unknown>;
|
|
424
|
+
readonly path: readonly string[];
|
|
425
|
+
readonly rejectConflicts: boolean;
|
|
426
|
+
}): Record<string, unknown> {
|
|
189
427
|
const result = { ...input.base };
|
|
190
428
|
for (const [key, value] of Object.entries(input.override)) {
|
|
191
429
|
const previous = result[key];
|
|
192
430
|
const path = [...input.path, key];
|
|
193
|
-
if (isPlainObject(previous) && isPlainObject(value))
|
|
194
|
-
|
|
431
|
+
if (isPlainObject(previous) && isPlainObject(value))
|
|
432
|
+
result[key] = mergeConfig({
|
|
433
|
+
base: previous,
|
|
434
|
+
override: value,
|
|
435
|
+
path,
|
|
436
|
+
rejectConflicts: input.rejectConflicts,
|
|
437
|
+
});
|
|
438
|
+
else if (
|
|
439
|
+
input.rejectConflicts &&
|
|
440
|
+
Object.hasOwn(result, key) &&
|
|
441
|
+
JSON.stringify(previous) !== JSON.stringify(value)
|
|
442
|
+
)
|
|
443
|
+
throw new Error(`Conflicting Norn reusable config at ${path.join(".")}`);
|
|
195
444
|
else result[key] = value;
|
|
196
445
|
}
|
|
197
446
|
return result;
|
|
198
447
|
}
|
|
199
|
-
async function expandIncludePath(
|
|
200
|
-
|
|
201
|
-
|
|
448
|
+
async function expandIncludePath(
|
|
449
|
+
root: string,
|
|
450
|
+
path: string,
|
|
451
|
+
): Promise<string[]> {
|
|
452
|
+
const segments = path
|
|
453
|
+
.split(/[\\/]+/)
|
|
454
|
+
.filter((segment) => segment.length && segment !== ".");
|
|
455
|
+
return segments.includes("*")
|
|
456
|
+
? expandGlobSegments(isAbsolute(path) ? sep : root, segments)
|
|
457
|
+
: [resolveConfigPath(root, path)];
|
|
202
458
|
}
|
|
203
|
-
async function expandGlobSegments(
|
|
459
|
+
async function expandGlobSegments(
|
|
460
|
+
root: string,
|
|
461
|
+
segments: readonly string[],
|
|
462
|
+
): Promise<string[]> {
|
|
204
463
|
if (!segments.length) return [root];
|
|
205
464
|
const [segment, ...remaining] = segments;
|
|
206
|
-
if (segment !== "*")
|
|
465
|
+
if (segment !== "*")
|
|
466
|
+
return expandGlobSegments(join(root, segment), remaining);
|
|
207
467
|
const entries = await readdir(root, { withFileTypes: true });
|
|
208
|
-
return (
|
|
468
|
+
return (
|
|
469
|
+
await Promise.all(
|
|
470
|
+
entries
|
|
471
|
+
.filter((entry) => entry.isDirectory())
|
|
472
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
473
|
+
.map((entry) => expandGlobSegments(join(root, entry.name), remaining)),
|
|
474
|
+
)
|
|
475
|
+
).flat();
|
|
209
476
|
}
|
|
210
477
|
function resolveConfigPath(root: string, path: string): string {
|
|
211
478
|
if (!path.length) throw new Error("Norn path must not be empty");
|
|
@@ -3,15 +3,24 @@ import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { isNodeError } from "./errors.ts";
|
|
5
5
|
|
|
6
|
-
export async function writeJsonAtomically(
|
|
6
|
+
export async function writeJsonAtomically(
|
|
7
|
+
path: string,
|
|
8
|
+
value: unknown,
|
|
9
|
+
): Promise<void> {
|
|
7
10
|
await writeTextAtomically(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
export async function writeTextAtomically(
|
|
13
|
+
export async function writeTextAtomically(
|
|
14
|
+
path: string,
|
|
15
|
+
content: string,
|
|
16
|
+
): Promise<void> {
|
|
11
17
|
await mkdir(dirname(path), { recursive: true });
|
|
12
18
|
let existingMode: number | undefined;
|
|
13
|
-
try {
|
|
14
|
-
|
|
19
|
+
try {
|
|
20
|
+
existingMode = (await stat(path)).mode & 0o777;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
23
|
+
}
|
|
15
24
|
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
16
25
|
try {
|
|
17
26
|
await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
export function createWorkflowTransition(input: {
|
|
2
|
-
|
|
1
|
+
export function createWorkflowTransition(input: {
|
|
2
|
+
readonly workflowId: string;
|
|
3
|
+
readonly args: unknown;
|
|
4
|
+
}): {
|
|
5
|
+
readonly type: "next";
|
|
6
|
+
readonly workflowId: string;
|
|
7
|
+
readonly args: unknown;
|
|
8
|
+
} {
|
|
9
|
+
if (
|
|
10
|
+
typeof input.workflowId !== "string" ||
|
|
11
|
+
input.workflowId.trim().length === 0
|
|
12
|
+
)
|
|
13
|
+
throw new Error("Workflow transition requires a nonempty workflow ID");
|
|
3
14
|
return { type: "next", workflowId: input.workflowId, args: input.args };
|
|
4
15
|
}
|