@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,23 +0,0 @@
1
- import { join, resolve } from "node:path";
2
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
- import { NornRunResources } from "../resources.ts";
4
- import { NornJsonWorkflowState } from "./state-store.ts";
5
-
6
- export async function initializeRunResources(runRoot: string): Promise<{ readonly resources: NornRunResources; readonly state: NornJsonWorkflowState }> {
7
- const resources = await NornRunResources.initialize(runRoot);
8
- const state = await resources.ensure({
9
- name: "workflow-state",
10
- kind: "norn.state",
11
- configuration: { format: 1, path: "state.json" },
12
- async initialize({ files, mode }) {
13
- const state = new NornJsonWorkflowState({
14
- stateFile: join(resolve(runRoot), "current", "state.json"),
15
- files,
16
- coordinateMutation: withFileMutationQueue,
17
- });
18
- await state.initialize(mode);
19
- return state;
20
- },
21
- });
22
- return { resources, state };
23
- }
@@ -1,83 +0,0 @@
1
- import type { NornWorkflowState, NornWorkflowStateDefinition } from "@vimhead.dev/norn";
2
- import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
3
- import { isNodeError } from "@vimhead.dev/norn-core/errors";
4
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
5
- import { jsonValueSchema } from "@vimhead.dev/norn/schema";
6
- import { readFile } from "node:fs/promises";
7
- import type { Static, TSchema } from "typebox";
8
- import { Value } from "typebox/value";
9
-
10
- function copyStateValue<Schema extends TSchema>(schema: Schema, value: unknown): Static<Schema> {
11
- Value.Assert(jsonValueSchema, value);
12
- return structuredClone(Value.Parse(schema, value));
13
- }
14
-
15
- export class NornMemoryWorkflowState implements NornWorkflowState {
16
- private readonly data = new Map<string, unknown>();
17
-
18
- async get<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema>> {
19
- const value = await this.getOptional(state);
20
- if (value === undefined) throw new Error(`Missing workflow state: ${state.id}`);
21
- return value;
22
- }
23
-
24
- async getOptional<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema> | undefined> {
25
- if (!this.data.has(state.id)) return undefined;
26
- return copyStateValue(state.schema, this.data.get(state.id));
27
- }
28
-
29
- async set<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>, value: NoInfer<Static<Schema>>): Promise<void> {
30
- this.data.set(state.id, copyStateValue(state.schema, value));
31
- }
32
- }
33
-
34
- export class NornJsonWorkflowState implements NornWorkflowState {
35
- readonly stateFile: string;
36
-
37
- constructor(private readonly input: {
38
- readonly stateFile: string;
39
- readonly files: NornFileCoordinator;
40
- readonly coordinateMutation: <T>(path: string, operation: () => Promise<T>) => Promise<T>;
41
- }) {
42
- this.stateFile = input.stateFile;
43
- }
44
-
45
- async initialize(mode: "create" | "open"): Promise<void> {
46
- await this.input.files.withExclusiveLock(this.stateFile, async (path) => {
47
- try {
48
- await this.readStateFile(path);
49
- } catch (error) {
50
- if (mode !== "create" || !isNodeError(error) || error.code !== "ENOENT") throw error;
51
- await writeJsonAtomically(path, {});
52
- }
53
- });
54
- }
55
-
56
- async get<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema>> {
57
- const value = await this.getOptional(state);
58
- if (value === undefined) throw new Error(`Missing workflow state: ${state.id}`);
59
- return value;
60
- }
61
-
62
- async getOptional<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema> | undefined> {
63
- const data = await this.input.files.withExclusiveLock(this.stateFile, (path) => this.readStateFile(path));
64
- if (!Object.prototype.hasOwnProperty.call(data, state.id)) return undefined;
65
- return copyStateValue(state.schema, data[state.id]);
66
- }
67
-
68
- async set<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>, value: NoInfer<Static<Schema>>): Promise<void> {
69
- const parsedValue = copyStateValue(state.schema, value);
70
- await this.input.coordinateMutation(this.stateFile, () => this.input.files.withExclusiveLock(this.stateFile, async (path) => {
71
- const data = await this.readStateFile(path);
72
- Object.defineProperty(data, state.id, { value: parsedValue, enumerable: true, configurable: true, writable: true });
73
- await writeJsonAtomically(path, data);
74
- }));
75
- }
76
-
77
- private async readStateFile(path: string): Promise<Record<string, unknown>> {
78
- const content = await readFile(path, "utf8");
79
- const parsed = JSON.parse(content) as unknown;
80
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
81
- throw new Error(`Invalid workflow state document: ${path}`);
82
- }
83
- }
@@ -1,400 +0,0 @@
1
- import * as nornModule from "@vimhead.dev/norn";
2
- import { isWorkflowPlugin, type NornDispose, type NornPluginDiagnostic, type NornProjectInfo, type NornProjectInspection, type NornProjectPluginInfo, type NornWorkflowCatalogInfo, type NornWorkflowInspection, type NornWorkflowPlugin, type NornWorkflowPluginInfo } 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 } from "@vimhead.dev/norn/schema";
7
- import { createJiti } 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 { NornMemoryWorkflowState } from "./internal/state-store.ts";
19
- import { decodePluginConfiguration, NornWorkflowRegistry, type NornRegisteredWorkflow } from "./internal/workflow-registry.ts";
20
-
21
- export const NORN_PROJECT_FILE_NAME = "norn.project.json";
22
-
23
- const nornConfigSchema = Type.Object({
24
- plugins: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
25
- includes: Type.Array(Type.String({ minLength: 1 }), { default: [] }),
26
- config: Type.Record(Type.String(), Type.Unknown(), { default: {} }),
27
- });
28
- const nornProjectConfigSchema = Type.Object({
29
- ...nornConfigSchema.properties,
30
- version: Type.Literal(1, { default: 1 }),
31
- });
32
-
33
- type NornConfig = StaticDecode<typeof nornConfigSchema>;
34
- type NornProjectConfig = StaticDecode<typeof nornProjectConfigSchema>;
35
-
36
- type NornConfigFile = {
37
- readonly path: string;
38
- readonly root: string;
39
- readonly config: NornConfig;
40
- };
41
-
42
- export type NornProject = {
43
- readonly cwd: string;
44
- readonly projectPath: string;
45
- readonly projectRoot: string;
46
- readonly configPath: string;
47
- readonly configRoot: string;
48
- readonly config: NornProjectConfig;
49
- readonly configFiles: readonly NornConfigFile[];
50
- readonly projectConfig: Record<string, unknown>;
51
- };
52
-
53
- export type NornLoadedProject = NornProject & {
54
- readonly plugins: readonly NornWorkflowPlugin[];
55
- readonly pluginInfos: readonly NornProjectPluginInfo[];
56
- readonly registry: NornWorkflowRegistry;
57
- readonly workflows: readonly NornRegisteredWorkflow[];
58
- readonly state: NornMemoryWorkflowState;
59
- };
60
-
61
- type LoadedNornWorkflowPlugin = {
62
- readonly plugin: NornWorkflowPlugin;
63
- readonly info: NornProjectPluginInfo;
64
- };
65
-
66
- export async function loadNornProject(cwd: string): Promise<NornLoadedProject> {
67
- const { project, diagnostics } = await collectNornProject(cwd);
68
- if (diagnostics.length > 0) throw new NornProjectLoadError({ diagnostics });
69
- return project;
70
- }
71
-
72
- export async function discoverNornProject(cwd: string): Promise<NornProjectInspection & NornWorkflowCatalogInfo> {
73
- const { project, diagnostics } = await collectNornProject(cwd);
74
- return { project: buildProjectInfo(project), workflows: project.registry.list(), isComplete: diagnostics.length === 0, diagnostics };
75
- }
76
-
77
- export async function inspectNornWorkflow(input: { readonly cwd: string; readonly workflowId: string }): Promise<NornWorkflowInspection> {
78
- const { project, diagnostics } = await collectNornProject(input.cwd);
79
- const summary = project.registry.list().find(workflow => workflow.id === input.workflowId);
80
- if (!summary) {
81
- if (diagnostics.length === 0) throw new Error(`Unknown workflow: ${input.workflowId}`);
82
- return { workflow: null, isComplete: false, diagnostics };
83
- }
84
- const info = summary.plugin;
85
- if (!info?.path || !info.configPath) throw new Error(`Loaded workflow lacks plugin source: ${input.workflowId}`);
86
- try {
87
- return { workflow: project.registry.inspect(input.workflowId) ?? null, isComplete: diagnostics.length === 0, diagnostics };
88
- } catch (error) {
89
- return { workflow: null, isComplete: false, diagnostics: [...diagnostics, createPluginDiagnostic({
90
- source: { configPath: info.configPath, pluginPath: info.path, pluginId: info.id },
91
- workflowId: input.workflowId, stage: "schema", error,
92
- })] };
93
- }
94
- }
95
-
96
- function buildProjectInfo(project: NornLoadedProject): NornProjectInfo {
97
- return {
98
- cwd: project.cwd, projectPath: project.projectPath, projectRoot: project.projectRoot,
99
- configPath: project.configPath, configRoot: project.configRoot,
100
- configFiles: project.configFiles.map(file => file.path), plugins: project.pluginInfos,
101
- };
102
- }
103
-
104
- export async function findNornProject(cwd: string): Promise<NornProject> {
105
- const projectPath = await findNearestNornProject(cwd);
106
- const projectRootConfig = await readNornProjectConfigFile(projectPath);
107
- const configFiles = await loadIncludedNornConfigFiles(projectRootConfig, new Set([projectRootConfig.path]));
108
- return {
109
- cwd: resolve(cwd),
110
- projectPath: projectRootConfig.path,
111
- projectRoot: projectRootConfig.root,
112
- configPath: projectRootConfig.path,
113
- configRoot: projectRootConfig.root,
114
- config: projectRootConfig.config,
115
- configFiles,
116
- projectConfig: mergeProjectConfig(configFiles),
117
- };
118
- }
119
-
120
- async function findNearestNornProject(cwd: string): Promise<string> {
121
- let current = resolve(cwd);
122
- while (true) {
123
- const projectPath = join(current, NORN_PROJECT_FILE_NAME);
124
- try {
125
- await access(projectPath);
126
- return projectPath;
127
- } catch (error) {
128
- if (!isNodeError(error) || error.code !== "ENOENT") throw error;
129
- }
130
- const parent = dirname(current);
131
- if (parent === current) throw new Error(`Could not find ${NORN_PROJECT_FILE_NAME} from ${cwd}`);
132
- current = parent;
133
- }
134
- }
135
-
136
- type NornProjectConfigFile = {
137
- readonly path: string;
138
- readonly root: string;
139
- readonly config: NornProjectConfig;
140
- };
141
-
142
- async function loadIncludedNornConfigFiles(projectFile: NornProjectConfigFile, visitedPaths: Set<string>): Promise<NornConfigFile[]> {
143
- const includedConfigFiles = await Promise.all(projectFile.config.includes.map(async (includePath) => {
144
- const configPaths = await expandIncludePath(projectFile.root, includePath);
145
- return Promise.all(configPaths.map(readNornConfigFile));
146
- }));
147
- const descendants = await Promise.all(includedConfigFiles.flat().map((includedConfigFile) => loadNornConfigTree(includedConfigFile, visitedPaths)));
148
- return [createProjectConfigEntry(projectFile), ...descendants.flat()];
149
- }
150
-
151
- async function loadNornConfigTree(configFile: NornConfigFile, visitedPaths: Set<string>): Promise<NornConfigFile[]> {
152
- if (visitedPaths.has(configFile.path)) return [];
153
- visitedPaths.add(configFile.path);
154
- const includedConfigFiles = await Promise.all(configFile.config.includes.map(async (includePath) => {
155
- const configPaths = await expandIncludePath(configFile.root, includePath);
156
- return Promise.all(configPaths.map(readNornConfigFile));
157
- }));
158
- const descendants = await Promise.all(includedConfigFiles.flat().map((includedConfigFile) => loadNornConfigTree(includedConfigFile, visitedPaths)));
159
- return [configFile, ...descendants.flat()];
160
- }
161
-
162
- async function readNornProjectConfigFile(path: string): Promise<NornProjectConfigFile> {
163
- const config = Value.Parse(nornProjectConfigSchema, Value.Default(nornProjectConfigSchema, JSON.parse(await readFile(path, "utf8"))));
164
- return { path, root: dirname(path), config };
165
- }
166
-
167
- async function readNornConfigFile(path: string): Promise<NornConfigFile> {
168
- const config = Value.Parse(nornConfigSchema, Value.Default(nornConfigSchema, JSON.parse(await readFile(path, "utf8"))));
169
- return { path, root: dirname(path), config };
170
- }
171
-
172
- function createProjectConfigEntry(projectFile: NornProjectConfigFile): NornConfigFile {
173
- return {
174
- path: projectFile.path,
175
- root: projectFile.root,
176
- config: {
177
- plugins: projectFile.config.plugins,
178
- includes: projectFile.config.includes,
179
- config: projectFile.config.config,
180
- },
181
- };
182
- }
183
-
184
- type NornPluginSource = Pick<NornPluginDiagnostic, "configPath" | "pluginPath" | "pluginId">;
185
- type ImportedNornPlugin = { readonly plugin: NornWorkflowPlugin; readonly source: NornPluginSource };
186
-
187
- async function collectNornProject(cwd: string): Promise<{ readonly project: NornLoadedProject; readonly diagnostics: NornPluginDiagnostic[] }> {
188
- const project = await findNornProject(cwd);
189
- const imported = await importWorkflowPlugins(project);
190
- const diagnostics = [...imported.diagnostics];
191
- const registry = new NornWorkflowRegistry();
192
- const state = new NornMemoryWorkflowState();
193
- const loaded: LoadedNornWorkflowPlugin[] = [];
194
- const conflicts = collectPluginConflicts(imported.plugins);
195
- for (const candidate of imported.plugins) {
196
- const pluginConflicts = conflicts.get(candidate);
197
- if (pluginConflicts) {
198
- diagnostics.push(...pluginConflicts);
199
- continue;
200
- }
201
- const result = registerProjectPlugin({ candidate, project, registry, state });
202
- diagnostics.push(...result.diagnostics);
203
- if (result.loaded) loaded.push(result.loaded);
204
- }
205
- return {
206
- project: { ...project, plugins: loaded.map(entry => entry.plugin), pluginInfos: loaded.map(entry => entry.info), registry, workflows: registry.launchableEntries(), state },
207
- diagnostics,
208
- };
209
- }
210
-
211
- function collectPluginConflicts(plugins: readonly ImportedNornPlugin[]): Map<ImportedNornPlugin, NornPluginDiagnostic[]> {
212
- const byPluginId = new Map<string, ImportedNornPlugin[]>();
213
- const byWorkflowId = new Map<string, ImportedNornPlugin[]>();
214
- for (const candidate of plugins) {
215
- const id = candidate.plugin.manifest.id;
216
- byPluginId.set(id, [...(byPluginId.get(id) ?? []), candidate]);
217
- for (const workflowId of new Set(Object.values(candidate.plugin.manifest.workflows).map(workflow => workflow.id))) {
218
- byWorkflowId.set(workflowId, [...(byWorkflowId.get(workflowId) ?? []), candidate]);
219
- }
220
- }
221
- const conflicts = new Map<ImportedNornPlugin, NornPluginDiagnostic[]>();
222
- for (const [pluginId, candidates] of byPluginId) {
223
- if (candidates.length < 2) continue;
224
- for (const candidate of candidates) conflicts.set(candidate, [createPluginDiagnostic({
225
- source: candidate.source, workflowId: null, stage: "duplicate",
226
- error: new Error(`Duplicate Norn plugin id: ${pluginId} (${candidates.map(other => other.source.pluginPath).join(", ")})`),
227
- })]);
228
- }
229
- for (const [workflowId, candidates] of byWorkflowId) {
230
- const distinctPlugins = candidates.filter(candidate => byPluginId.get(candidate.plugin.manifest.id)?.length === 1);
231
- if (distinctPlugins.length < 2) continue;
232
- for (const candidate of distinctPlugins) conflicts.set(candidate, [...(conflicts.get(candidate) ?? []), createPluginDiagnostic({
233
- source: candidate.source, workflowId, stage: "duplicate",
234
- error: new Error(`Workflow already registered: ${workflowId} (${distinctPlugins.map(other => other.source.pluginPath).join(", ")})`),
235
- })]);
236
- }
237
- return conflicts;
238
- }
239
-
240
- async function importWorkflowPlugins(project: NornProject): Promise<{ readonly plugins: ImportedNornPlugin[]; readonly diagnostics: NornPluginDiagnostic[] }> {
241
- const plugins: ImportedNornPlugin[] = [];
242
- const diagnostics: NornPluginDiagnostic[] = [];
243
- for (const configFile of project.configFiles) {
244
- const jiti = createJiti(pathToFileURL(configFile.path).href, { moduleCache: false, virtualModules: nornWorkflowVirtualModules() });
245
- for (const pluginPath of configFile.config.plugins) {
246
- const source: NornPluginSource = { configPath: configFile.path, pluginPath: resolveConfigPath(configFile.root, pluginPath), pluginId: null };
247
- let stage: NornPluginDiagnostic["stage"] = "import";
248
- try {
249
- const module = await jiti.import(pathToFileURL(source.pluginPath).href) as { default?: unknown };
250
- stage = "declaration";
251
- if (!isWorkflowPlugin(module?.default)) throw new Error(`Norn plugin must be the default export: ${source.pluginPath}`);
252
- plugins.push({ plugin: module.default, source: { ...source, pluginId: module.default.manifest.id } });
253
- } catch (error) {
254
- diagnostics.push(createPluginDiagnostic({ source, workflowId: null, stage, error }));
255
- }
256
- }
257
- }
258
- return { plugins, diagnostics };
259
- }
260
-
261
- function registerProjectPlugin(input: {
262
- readonly candidate: ImportedNornPlugin;
263
- readonly project: NornProject;
264
- readonly registry: NornWorkflowRegistry;
265
- readonly state: NornMemoryWorkflowState;
266
- }): { readonly loaded: LoadedNornWorkflowPlugin | null; readonly diagnostics: NornPluginDiagnostic[] } {
267
- const { plugin, source } = input.candidate;
268
- const diagnostics: NornPluginDiagnostic[] = [];
269
- const unregister: NornDispose[] = [];
270
- let stage: NornPluginDiagnostic["stage"] = "config";
271
- try {
272
- const configInput = input.project.projectConfig[plugin.manifest.id];
273
- const configuration = decodePluginConfiguration({ pluginId: plugin.manifest.id, schema: plugin.manifest.config, value: configInput });
274
- stage = "schema";
275
- const info: NornProjectPluginInfo = {
276
- id: plugin.manifest.id, path: source.pluginPath, configPath: source.configPath,
277
- configSchema: plugin.manifest.config ? inspectSchema(plugin.manifest.config) : null, config: configuration?.value,
278
- };
279
- stage = "implementation";
280
- const implementation = typeof plugin.implementation === "function" ? plugin.implementation({ cwd: input.project.cwd, state: input.state }) : plugin.implementation;
281
- for (const [key, workflow] of Object.entries(plugin.manifest.workflows)) {
282
- stage = "implementation";
283
- try {
284
- const workflowImplementation = implementation.workflows?.[key];
285
- if (typeof workflowImplementation?.execute !== "function") throw new Error(`Missing implementation for workflow ${workflow.id}`);
286
- stage = "duplicate";
287
- if (input.registry.workflowById(workflow.id)) throw new Error(`Workflow already registered: ${workflow.id}`);
288
- stage = "declaration";
289
- unregister.push(input.registry.register(workflow, workflowImplementation, { plugin: workflowPluginInfo(info), configuration }));
290
- } catch (error) {
291
- diagnostics.push(createPluginDiagnostic({ source, workflowId: workflow.id, stage, error }));
292
- }
293
- }
294
- if (diagnostics.length === 0) return { loaded: { plugin, info }, diagnostics };
295
- } catch (error) {
296
- diagnostics.push(createPluginDiagnostic({ source, workflowId: null, stage, error }));
297
- }
298
- for (const dispose of unregister) dispose();
299
- return { loaded: null, diagnostics };
300
- }
301
-
302
- function createPluginDiagnostic(input: {
303
- readonly source: NornPluginSource;
304
- readonly workflowId: string | null;
305
- readonly stage: NornPluginDiagnostic["stage"];
306
- readonly error: unknown;
307
- }): NornPluginDiagnostic {
308
- return {
309
- ...input.source, workflowId: input.workflowId, stage: input.stage, message: errorMessage(input.error),
310
- issues: input.error instanceof AssertError ? input.error.cause.errors : [],
311
- };
312
- }
313
-
314
- function workflowPluginInfo(info: NornProjectPluginInfo): NornWorkflowPluginInfo {
315
- return { id: info.id, path: info.path, configPath: info.configPath };
316
- }
317
-
318
- function nornWorkflowVirtualModules(): Record<string, unknown> {
319
- return {
320
- "@vimhead.dev/norn": nornModule,
321
- "@vimhead.dev/norn/files": nornFilesModule,
322
- "@vimhead.dev/norn/schema": nornSchemaModule,
323
- typebox: typeboxModule,
324
- "typebox/value": typeboxValueModule,
325
- "typebox/compile": typeboxCompileModule,
326
- "typebox/schema": typeboxSchemaModule,
327
- };
328
- }
329
-
330
- function mergeProjectConfig(configFiles: readonly NornConfigFile[]): Record<string, unknown> {
331
- const [projectFile, ...reusableConfigFiles] = configFiles;
332
- let reusableConfig: Record<string, unknown> = {};
333
- for (const configFile of reusableConfigFiles) {
334
- reusableConfig = mergeReusableConfigObjects(reusableConfig, configFile.config.config, ["config"]);
335
- }
336
- return mergeProjectConfigObjects(reusableConfig, projectFile?.config.config ?? {});
337
- }
338
-
339
- function mergeReusableConfigObjects(base: Record<string, unknown>, override: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
340
- const result: Record<string, unknown> = { ...base };
341
- for (const [key, overrideValue] of Object.entries(override)) {
342
- result[key] = Object.prototype.hasOwnProperty.call(result, key)
343
- ? mergeReusableConfigValue(result[key], overrideValue, [...path, key])
344
- : overrideValue;
345
- }
346
- return result;
347
- }
348
-
349
- function mergeReusableConfigValue(base: unknown, override: unknown, path: readonly string[]): unknown {
350
- if (isPlainObject(base) && isPlainObject(override)) return mergeReusableConfigObjects(base, override, path);
351
- if (JSON.stringify(base) === JSON.stringify(override)) return base;
352
- throw new Error(`Conflicting Norn reusable config at ${path.join(".")}`);
353
- }
354
-
355
- function mergeProjectConfigObjects(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
356
- const result: Record<string, unknown> = { ...base };
357
- for (const [key, overrideValue] of Object.entries(override)) {
358
- result[key] = isPlainObject(result[key]) && isPlainObject(overrideValue)
359
- ? mergeProjectConfigObjects(result[key], overrideValue)
360
- : overrideValue;
361
- }
362
- return result;
363
- }
364
-
365
- function isPlainObject(value: unknown): value is Record<string, unknown> {
366
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
367
- }
368
-
369
- async function expandIncludePath(configRoot: string, includePath: string): Promise<string[]> {
370
- const resolvedIncludePath = resolveConfigPath(configRoot, includePath);
371
- if (!hasGlobSegment(includePath)) return [resolvedIncludePath];
372
- return expandGlobSegments(isAbsolute(includePath) ? sep : configRoot, splitPathSegments(includePath));
373
- }
374
-
375
- async function expandGlobSegments(root: string, segments: readonly string[]): Promise<string[]> {
376
- if (segments.length === 0) return [root];
377
- const [segment, ...remainingSegments] = segments;
378
- if (segment === "*") {
379
- const entries = await readdir(root, { withFileTypes: true });
380
- const expanded = await Promise.all(entries
381
- .filter((entry) => entry.isDirectory())
382
- .sort((left, right) => left.name.localeCompare(right.name))
383
- .map((entry) => expandGlobSegments(join(root, entry.name), remainingSegments)));
384
- return expanded.flat();
385
- }
386
- return expandGlobSegments(join(root, segment), remainingSegments);
387
- }
388
-
389
- function resolveConfigPath(configRoot: string, path: string): string {
390
- if (path.length === 0) throw new Error("Norn path must not be empty");
391
- return isAbsolute(path) ? path : resolve(configRoot, path);
392
- }
393
-
394
- function hasGlobSegment(path: string): boolean {
395
- return splitPathSegments(path).includes("*");
396
- }
397
-
398
- function splitPathSegments(path: string): string[] {
399
- return path.split(/[\\/]+/).filter((segment) => segment.length > 0 && segment !== ".");
400
- }
@@ -1,6 +0,0 @@
1
- import { NornRunResources } from "../resources.ts";
2
- import { NornJsonWorkflowState } from "./state-store.ts";
3
- export declare function initializeRunResources(runRoot: string): Promise<{
4
- readonly resources: NornRunResources;
5
- readonly state: NornJsonWorkflowState;
6
- }>;
@@ -1,26 +0,0 @@
1
- // src/internal/run-resources.ts
2
- import { join, resolve } from "node:path";
3
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
- import { NornRunResources } from "../resources.js";
5
- import { NornJsonWorkflowState } from "./state-store.js";
6
- async function initializeRunResources(runRoot) {
7
- const resources = await NornRunResources.initialize(runRoot);
8
- const state = await resources.ensure({
9
- name: "workflow-state",
10
- kind: "norn.state",
11
- configuration: { format: 1, path: "state.json" },
12
- async initialize({ files, mode }) {
13
- const state2 = new NornJsonWorkflowState({
14
- stateFile: join(resolve(runRoot), "current", "state.json"),
15
- files,
16
- coordinateMutation: withFileMutationQueue
17
- });
18
- await state2.initialize(mode);
19
- return state2;
20
- }
21
- });
22
- return { resources, state };
23
- }
24
- export {
25
- initializeRunResources
26
- };
@@ -1,23 +0,0 @@
1
- import type { NornWorkflowState, NornWorkflowStateDefinition } from "@vimhead.dev/norn";
2
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
3
- import type { Static, TSchema } from "typebox";
4
- export declare class NornMemoryWorkflowState implements NornWorkflowState {
5
- private readonly data;
6
- get<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema>>;
7
- getOptional<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema> | undefined>;
8
- set<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>, value: NoInfer<Static<Schema>>): Promise<void>;
9
- }
10
- export declare class NornJsonWorkflowState implements NornWorkflowState {
11
- private readonly input;
12
- readonly stateFile: string;
13
- constructor(input: {
14
- readonly stateFile: string;
15
- readonly files: NornFileCoordinator;
16
- readonly coordinateMutation: <T>(path: string, operation: () => Promise<T>) => Promise<T>;
17
- });
18
- initialize(mode: "create" | "open"): Promise<void>;
19
- get<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema>>;
20
- getOptional<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>): Promise<Static<Schema> | undefined>;
21
- set<Schema extends TSchema>(state: NornWorkflowStateDefinition<Schema>, value: NoInfer<Static<Schema>>): Promise<void>;
22
- private readStateFile;
23
- }
@@ -1,103 +0,0 @@
1
- // ../core/src/atomic-files.ts
2
- import { randomUUID } from "node:crypto";
3
- import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
- import { dirname } from "node:path";
5
-
6
- // ../core/src/errors.ts
7
- function isNodeError(error) {
8
- return error instanceof Error && "code" in error;
9
- }
10
-
11
- // ../core/src/atomic-files.ts
12
- async function writeJsonAtomically(path, value) {
13
- await writeTextAtomically(path, `${JSON.stringify(value, null, 2)}
14
- `);
15
- }
16
- async function writeTextAtomically(path, content) {
17
- await mkdir(dirname(path), { recursive: true });
18
- let existingMode;
19
- try {
20
- existingMode = (await stat(path)).mode & 511;
21
- } catch (error) {
22
- if (!isNodeError(error) || error.code !== "ENOENT") throw error;
23
- }
24
- const tmpPath = `${path}.${randomUUID()}.tmp`;
25
- try {
26
- await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
27
- if (existingMode !== void 0) await chmod(tmpPath, existingMode);
28
- await rename(tmpPath, path);
29
- } catch (error) {
30
- await rm(tmpPath, { force: true }).catch(() => void 0);
31
- throw error;
32
- }
33
- }
34
-
35
- // src/internal/state-store.ts
36
- import { jsonValueSchema } from "@vimhead.dev/norn/schema";
37
- import { readFile } from "node:fs/promises";
38
- import { Value } from "typebox/value";
39
- function copyStateValue(schema, value) {
40
- Value.Assert(jsonValueSchema, value);
41
- return structuredClone(Value.Parse(schema, value));
42
- }
43
- var NornMemoryWorkflowState = class {
44
- data = /* @__PURE__ */ new Map();
45
- async get(state) {
46
- const value = await this.getOptional(state);
47
- if (value === void 0) throw new Error(`Missing workflow state: ${state.id}`);
48
- return value;
49
- }
50
- async getOptional(state) {
51
- if (!this.data.has(state.id)) return void 0;
52
- return copyStateValue(state.schema, this.data.get(state.id));
53
- }
54
- async set(state, value) {
55
- this.data.set(state.id, copyStateValue(state.schema, value));
56
- }
57
- };
58
- var NornJsonWorkflowState = class {
59
- constructor(input) {
60
- this.input = input;
61
- this.stateFile = input.stateFile;
62
- }
63
- input;
64
- stateFile;
65
- async initialize(mode) {
66
- await this.input.files.withExclusiveLock(this.stateFile, async (path) => {
67
- try {
68
- await this.readStateFile(path);
69
- } catch (error) {
70
- if (mode !== "create" || !isNodeError(error) || error.code !== "ENOENT") throw error;
71
- await writeJsonAtomically(path, {});
72
- }
73
- });
74
- }
75
- async get(state) {
76
- const value = await this.getOptional(state);
77
- if (value === void 0) throw new Error(`Missing workflow state: ${state.id}`);
78
- return value;
79
- }
80
- async getOptional(state) {
81
- const data = await this.input.files.withExclusiveLock(this.stateFile, (path) => this.readStateFile(path));
82
- if (!Object.prototype.hasOwnProperty.call(data, state.id)) return void 0;
83
- return copyStateValue(state.schema, data[state.id]);
84
- }
85
- async set(state, value) {
86
- const parsedValue = copyStateValue(state.schema, value);
87
- await this.input.coordinateMutation(this.stateFile, () => this.input.files.withExclusiveLock(this.stateFile, async (path) => {
88
- const data = await this.readStateFile(path);
89
- Object.defineProperty(data, state.id, { value: parsedValue, enumerable: true, configurable: true, writable: true });
90
- await writeJsonAtomically(path, data);
91
- }));
92
- }
93
- async readStateFile(path) {
94
- const content = await readFile(path, "utf8");
95
- const parsed = JSON.parse(content);
96
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
97
- throw new Error(`Invalid workflow state document: ${path}`);
98
- }
99
- };
100
- export {
101
- NornJsonWorkflowState,
102
- NornMemoryWorkflowState
103
- };