@vimhead.dev/norn-cli 0.1.0-tip.35390859630.1 → 0.1.0-tip.35436871363.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/assets/README.md +8 -4
  2. package/assets/docs/README.md +4 -4
  3. package/assets/docs/agents.md +37 -9
  4. package/assets/docs/cli.md +3 -3
  5. package/assets/docs/composition.md +12 -12
  6. package/assets/docs/persistence.md +31 -17
  7. package/assets/docs/providers.md +2 -2
  8. package/assets/docs/workflows.md +15 -10
  9. package/assets/examples/agent-then-analysis/README.md +8 -8
  10. package/assets/examples/agent-then-analysis/plugin.ts +18 -16
  11. package/assets/examples/caller-selected-continuation/README.md +11 -10
  12. package/assets/examples/caller-selected-continuation/caller.ts +12 -10
  13. package/assets/examples/caller-selected-continuation/producer.ts +9 -6
  14. package/assets/examples/coordinating-multiple-agents/README.md +26 -16
  15. package/assets/examples/coordinating-multiple-agents/plugin.ts +58 -35
  16. package/assets/examples/coordinating-multiple-agents/queue-tools.ts +43 -0
  17. package/assets/examples/coordinating-multiple-agents/work-queue.ts +60 -65
  18. package/assets/examples/getting-started/README.md +2 -2
  19. package/assets/examples/getting-started/plugin.ts +7 -3
  20. package/assets/examples/minimal-workflow/README.md +5 -4
  21. package/assets/examples/minimal-workflow/plugin.ts +7 -4
  22. package/assets/examples/shared-state/README.md +5 -5
  23. package/assets/examples/shared-state/plugin.ts +35 -22
  24. package/assets/examples/shared-state/shared-state.ts +30 -49
  25. package/assets/examples/shared-state/state-tools.ts +68 -0
  26. package/assets/examples/worktree-development-loop/README.md +14 -12
  27. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +2 -2
  28. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +5 -3
  29. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +8 -7
  30. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +2 -3
  31. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +8 -4
  32. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +10 -7
  33. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +1 -2
  34. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +15 -14
  35. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +1 -2
  36. package/assets/package.json +1 -1
  37. package/assets/packages/cli/src/cli.ts +9 -3
  38. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  39. package/assets/packages/cli/src/internal/agents.ts +24 -32
  40. package/assets/packages/cli/src/internal/commands.ts +2 -14
  41. package/assets/packages/cli/src/internal/engine.ts +30 -44
  42. package/assets/packages/cli/src/internal/logs.ts +1 -1
  43. package/assets/packages/cli/src/internal/run-log.ts +1 -1
  44. package/assets/packages/cli/src/internal/run-state.ts +7 -2
  45. package/assets/packages/cli/src/internal/run.ts +2 -57
  46. package/assets/packages/cli/src/internal/worker-directory.ts +17 -0
  47. package/assets/packages/cli/src/internal/workflow-registry.ts +20 -8
  48. package/assets/packages/cli/src/internal/working-directory.ts +6 -0
  49. package/assets/packages/cli/src/workflow-loader.ts +1 -2
  50. package/assets/packages/sdk/src/api.ts +27 -59
  51. package/assets/packages/sdk/src/index.ts +0 -3
  52. package/assets/tests/workflow-ref.test.ts +6 -7
  53. package/dist/cli.js +8 -3
  54. package/dist/generated-build-info.d.ts +2 -2
  55. package/dist/generated-build-info.js +2 -2
  56. package/dist/internal/agents.d.ts +0 -4
  57. package/dist/internal/agents.js +30 -46
  58. package/dist/internal/commands.d.ts +0 -4
  59. package/dist/internal/commands.js +2 -10
  60. package/dist/internal/engine.js +26 -40
  61. package/dist/internal/file-coordinator.d.ts +17 -0
  62. package/dist/internal/file-coordinator.js +162 -0
  63. package/dist/internal/logs.d.ts +1 -1
  64. package/dist/internal/run-log.d.ts +1 -1
  65. package/dist/internal/run-state.d.ts +2 -0
  66. package/dist/internal/run-state.js +5 -2
  67. package/dist/internal/run.d.ts +2 -20
  68. package/dist/internal/run.js +1 -45
  69. package/dist/internal/worker-directory.d.ts +1 -0
  70. package/dist/internal/worker-directory.js +26 -0
  71. package/dist/internal/workflow-registry.d.ts +13 -3
  72. package/dist/internal/workflow-registry.js +8 -6
  73. package/dist/internal/working-directory.d.ts +1 -0
  74. package/dist/internal/working-directory.js +9 -0
  75. package/dist/workflow-loader.js +1 -2
  76. package/package.json +2 -2
  77. package/assets/docs/resources.md +0 -46
  78. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +0 -51
  79. package/assets/examples/shared-state/state-adapter.ts +0 -76
  80. package/assets/packages/cli/src/internal/artifacts.ts +0 -26
  81. package/assets/packages/cli/src/internal/resource-bindings.ts +0 -35
  82. package/assets/packages/cli/src/resources.ts +0 -69
  83. package/assets/packages/sdk/src/agent-resource-adapter.ts +0 -11
  84. package/assets/packages/sdk/src/resources.ts +0 -20
  85. package/dist/internal/artifacts.d.ts +0 -10
  86. package/dist/internal/artifacts.js +0 -29
  87. package/dist/internal/resource-bindings.d.ts +0 -13
  88. package/dist/internal/resource-bindings.js +0 -34
  89. package/dist/resources.d.ts +0 -11
  90. package/dist/resources.js +0 -100
  91. /package/assets/packages/{sdk/src/files.ts → cli/src/internal/file-coordinator.ts} +0 -0
@@ -12,6 +12,7 @@ import { Value } from "typebox/value";
12
12
  import { NORN_BUILD_INFO, type NornBuildInfo, type NornGithubReleaseBinaryBuildInfo } from "./build-info.ts";
13
13
  import { renderNornDocumentationIntro, resolveDocumentationCacheRoot, resolveNornDocumentation, type NornDocumentationSource } from "./documentation.ts";
14
14
  import { NornEngine } from "./internal/engine.ts";
15
+ import { resolveNornAgentDirectory } from "./internal/agent-directory.ts";
15
16
  import { errorMessage, NornProjectLoadError, NornRunStoppedError } from "./internal/errors.ts";
16
17
  import { clearRunResumeRequest, readRunLaunchRequest, readRunResumeRequest, writeRunLaunchRequest, writeRunResumeRequest, type NornRunResumeRequest } from "./internal/launch-request.ts";
17
18
  import { readRunMetrics } from "./internal/metrics.ts";
@@ -19,6 +20,7 @@ import { getRunLeaseOwner, NornRunLease } from "./internal/run-lease.ts";
19
20
  import { generateRunName } from "./internal/run-names.ts";
20
21
  import { assertRunVersion, getRunInfo, listRuns, mergeInterruptedWorkflowArgs, resolveRunRoot } from "./internal/run-state.ts";
21
22
  import { NornRunStore } from "./internal/run-store.ts";
23
+ import { prepareRunWorkerDirectory } from "./internal/worker-directory.ts";
22
24
  import { discoverNornProject, findNornProject, inspectNornWorkflow, loadNornProject, NORN_PROJECT_FILE_NAME } from "./workflow-loader.ts";
23
25
 
24
26
  const RUNS_ROOT = join(".norn", "runs");
@@ -784,7 +786,7 @@ async function startRun(workflowId: string, commandArgs: readonly string[]): Pro
784
786
  const createdAt = new Date().toISOString();
785
787
  await writeRunLaunchRequest(runRoot, { version: 2, type: "run", id, name, workflowId, args, configOverride, createdAt });
786
788
  await startDetachedExecuteRun(id, project.projectRoot);
787
- writeJson({ run: startedRunInfo({ id, name, workflow, runRoot, createdAt }) });
789
+ writeJson({ run: startedRunInfo({ id, name, workflow, runRoot, projectRoot: project.projectRoot, createdAt }) });
788
790
  }
789
791
 
790
792
  async function resumeRun(run: string, args: readonly string[]): Promise<void> {
@@ -899,8 +901,10 @@ async function executeRun(runId: string): Promise<void> {
899
901
  const runRoot = resolve(location.projectRoot, RUNS_ROOT, runId);
900
902
  const request = await readOptionalRunLaunchRequest(runRoot) ?? await readRunResumeRequest(runRoot);
901
903
  try {
902
- const project = await loadNornProject(process.cwd());
903
- const engine = new NornEngine({ cwd: project.projectRoot, signal: abortController.signal, gateMode: "pause", config: project.projectConfig });
904
+ const agentDir = resolveNornAgentDirectory({ home: homedir(), environment: process.env });
905
+ process.chdir(await prepareRunWorkerDirectory(runRoot));
906
+ const project = await loadNornProject(location.projectRoot);
907
+ const engine = new NornEngine({ cwd: project.projectRoot, agentDir, signal: abortController.signal, gateMode: "pause", config: project.projectConfig });
904
908
  engine.registerWorkflows(project.definitions);
905
909
  if (request.type === "run") {
906
910
  const workflow = project.registry.workflowById(request.workflowId);
@@ -1000,6 +1004,7 @@ function detachedExecuteRunArgs(runId: string): readonly string[] {
1000
1004
  }
1001
1005
 
1002
1006
  function startedRunInfo(input: {
1007
+ readonly projectRoot: string;
1003
1008
  readonly id: string;
1004
1009
  readonly name: string;
1005
1010
  readonly workflow: NornAnyWorkflowDeclaration;
@@ -1011,6 +1016,7 @@ function startedRunInfo(input: {
1011
1016
  id: input.id,
1012
1017
  name: input.name,
1013
1018
  path: input.runRoot,
1019
+ paths: { project: input.projectRoot, workspace: join(input.runRoot, "current", "workspace") },
1014
1020
  entrypointWorkflowId: input.workflow.id,
1015
1021
  currentWorkflowId: input.workflow.id,
1016
1022
  status: "running",
@@ -2,8 +2,8 @@ import type { NornBuildInfo } from "./build-info.ts";
2
2
 
3
3
  export const NORN_GENERATED_BUILD_INFO = {
4
4
  "kind": "npm-registry",
5
- "version": "0.1.0-tip.35390859630.1",
6
- "commit": "7a7976170d62c342c29c6479444394d542c3f22b",
5
+ "version": "0.1.0-tip.35436871363.1",
6
+ "commit": "49b14d0ed0c9633fc6ee530a557ec7babbf52b22",
7
7
  "packageSpec": "@vimhead.dev/norn-cli@tip",
8
8
  "upgrade": {
9
9
  "supported": false,
@@ -5,6 +5,7 @@ import {
5
5
  createEventBus,
6
6
  type AgentSession,
7
7
  type CreateAgentSessionOptions,
8
+ type ToolDefinition,
8
9
  } from "@earendil-works/pi-coding-agent";
9
10
  import type { NornAgentCreateSessionInput, NornAgentPromptInput, NornAgentRunRawAttempt, NornAgentRunResult, NornAgentSession, NornAgentSessionEvents, NornAgentSinglePromptInput, NornAgentUsage } from "@vimhead.dev/norn";
10
11
  import { AGENT_RESPONSE_TOOL_NAME } from "@vimhead.dev/norn-core/agent-protocol";
@@ -12,7 +13,8 @@ import { inspectSchema } from "@vimhead.dev/norn/schema";
12
13
  import { randomUUID } from "node:crypto";
13
14
  import { mkdir } from "node:fs/promises";
14
15
  import { homedir } from "node:os";
15
- import { isAbsolute, relative, resolve, sep } from "node:path";
16
+ import { resolve } from "node:path";
17
+ import { requireAbsoluteWorkingDirectory } from "./working-directory.ts";
16
18
  import { type StaticDecode, type TSchema } from "typebox";
17
19
  import { resolveNornAgentDirectory } from "./agent-directory.ts";
18
20
  import {
@@ -23,19 +25,14 @@ import {
23
25
  import { errorMessage } from "./errors.ts";
24
26
  import { safeFileName } from "./file-names.ts";
25
27
  import type { NornRunLogs } from "./logs.ts";
26
- import { NornSessionResourceBindings } from "./resource-bindings.ts";
27
28
  import type { NornRunLogger } from "./run-log.ts";
28
29
  import { agentUsageFromValue, emptyAgentUsage, totalAgentUsage } from "./usage.ts";
29
30
 
30
31
  const DEFAULT_AGENT_ATTEMPTS = 3;
31
- const DEFAULT_AGENT_TOOL_ALLOWLIST = ["read", "bash", "edit", "write", AGENT_RESPONSE_TOOL_NAME] as const;
32
32
 
33
33
  type NornAgentRunnerInput = {
34
34
  readonly id: string;
35
35
  readonly runRoot: string;
36
- readonly boundaryRoot: string;
37
- readonly boundaryName: string;
38
- readonly cwd: string;
39
36
  readonly signal?: AbortSignal;
40
37
  readonly model?: CreateAgentSessionOptions["model"];
41
38
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
@@ -49,7 +46,6 @@ type CreatedNornAgentSessionInput = NornAgentRunnerInput & {
49
46
  readonly label: string;
50
47
  readonly cwd: string;
51
48
  readonly session: AgentSession;
52
- readonly resourceBindings: NornSessionResourceBindings;
53
49
  readonly events: NornAgentSessionEvents;
54
50
  };
55
51
 
@@ -61,7 +57,7 @@ export class NornAgentRunner {
61
57
  }
62
58
 
63
59
  async createSession(agentInput: NornAgentCreateSessionInput): Promise<NornAgentSession> {
64
- const cwd = agentInput.cwd ? this.resolveFromCwd(agentInput.cwd) : this.input.cwd;
60
+ const cwd = requireAbsoluteWorkingDirectory(agentInput.cwd);
65
61
  const sessionDir = resolve(this.input.runRoot, "sessions");
66
62
  await mkdir(sessionDir, { recursive: true });
67
63
 
@@ -82,19 +78,19 @@ export class NornAgentRunner {
82
78
  ...loader.getExtensions().errors.map(error => `Failed to load extension ${error.path}: ${error.error}`),
83
79
  ];
84
80
  if (errors.length > 0) throw new Error(errors.join("\n"));
85
- const resourceBindings = new NornSessionResourceBindings();
81
+ const customTools = agentInput.customTools ?? [];
82
+ assertCustomToolNames({
83
+ tools: customTools,
84
+ reservedNames: ["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", AGENT_RESPONSE_TOOL_NAME,
85
+ ...loader.getExtensions().extensions.flatMap((extension) => [...extension.tools.keys()])],
86
+ });
86
87
  let session: AgentSession | undefined;
87
88
  try {
88
- await resourceBindings.bind({
89
- adapters: agentInput.resourceAdapters ?? [], runId: this.input.id, label: agentInput.label,
90
- reservedTools: ["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", AGENT_RESPONSE_TOOL_NAME,
91
- ...loader.getExtensions().extensions.flatMap((extension) => [...extension.tools.keys()])],
92
- });
93
89
  ({ session } = await createAgentSessionFromServices({
94
90
  services,
95
91
  sessionManager: SessionManager.create(cwd, sessionDir),
96
- tools: [...withAgentResponseTool(agentInput.tools), ...resourceBindings.tools.map((tool) => tool.name)],
97
- customTools: [this.responseToolFactory.create(), ...resourceBindings.tools],
92
+ tools: withAgentResponseTool(agentInput.tools),
93
+ customTools: [this.responseToolFactory.create(), ...customTools],
98
94
  model: agentInput.model ?? this.input.model,
99
95
  thinkingLevel: agentInput.thinkingLevel ?? this.input.thinkingLevel,
100
96
  }));
@@ -102,13 +98,12 @@ export class NornAgentRunner {
102
98
  await session.bindExtensions({});
103
99
  await this.input.logger.record({ type: "agent.spawned", label: agentInput.label, cwd });
104
100
  return new CreatedNornAgentSession({
105
- ...this.input, label: agentInput.label, cwd, session, resourceBindings, events: eventBus,
101
+ ...this.input, label: agentInput.label, cwd, session, events: eventBus,
106
102
  });
107
103
  } catch (error) {
108
104
  const errors = [error];
109
105
  try { session?.dispose(); } catch (cleanupError) { errors.push(cleanupError); }
110
- try { await resourceBindings.dispose(); } catch (cleanupError) { errors.push(cleanupError); }
111
- if (errors.length > 1) throw new AggregateError(errors, "Agent creation and resource cleanup failed");
106
+ if (errors.length > 1) throw new AggregateError(errors, "Agent creation and cleanup failed");
112
107
  throw error;
113
108
  }
114
109
  }
@@ -121,15 +116,6 @@ export class NornAgentRunner {
121
116
  await agent.dispose();
122
117
  }
123
118
  }
124
-
125
- private resolveFromCwd(path: string): string {
126
- const resolvedPath = isAbsolute(path) ? path : resolve(this.input.cwd, path);
127
- const pathFromBoundary = relative(this.input.boundaryRoot, resolvedPath);
128
- if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
129
- throw new Error(`Agent cwd escapes ${this.input.boundaryName} isolation: ${path}`);
130
- }
131
- return resolvedPath;
132
- }
133
119
  }
134
120
 
135
121
  function systemPromptOverride(agentInput: NornAgentCreateSessionInput): (() => string) | undefined {
@@ -229,7 +215,6 @@ class CreatedNornAgentSession implements NornAgentSession {
229
215
  const errors: unknown[] = [];
230
216
  try { await this.input.session.abort(); } catch (error) { errors.push(error); }
231
217
  try { this.input.session.dispose(); } catch (error) { errors.push(error); }
232
- try { await this.input.resourceBindings.dispose(); } catch (error) { errors.push(error); }
233
218
  if (errors.length) throw new AggregateError(errors, "Agent disposal failed");
234
219
  await this.input.logger.record({ type: "agent.disposed", label: this.label });
235
220
  }
@@ -360,7 +345,14 @@ function withResponseToolFinalizationInstruction(
360
345
  ].join("\n");
361
346
  }
362
347
 
363
- function withAgentResponseTool(tools: readonly string[] | undefined): string[] {
364
- if (!tools) return [...DEFAULT_AGENT_TOOL_ALLOWLIST];
365
- return Array.from(new Set([...tools, AGENT_RESPONSE_TOOL_NAME]));
348
+ function withAgentResponseTool(tools: readonly string[] | undefined): string[] | undefined {
349
+ return tools === undefined ? undefined : Array.from(new Set([...tools, AGENT_RESPONSE_TOOL_NAME]));
350
+ }
351
+
352
+ function assertCustomToolNames(input: { readonly tools: readonly ToolDefinition[]; readonly reservedNames: readonly string[] }): void {
353
+ const names = new Set(input.reservedNames);
354
+ for (const tool of input.tools) {
355
+ if (names.has(tool.name)) throw new Error(`Custom tool name collision: ${tool.name}`);
356
+ names.add(tool.name);
357
+ }
366
358
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { requireAbsoluteWorkingDirectory } from "./working-directory.ts";
4
4
  import type { WriteStream } from "node:fs";
5
5
  import type { NornCommandRunInput, NornCommandRunResult, NornLogRef } from "@vimhead.dev/norn";
6
6
  import { errorMessage } from "./errors.ts";
@@ -9,9 +9,6 @@ import type { NornRunLogs } from "./logs.ts";
9
9
  import type { NornRunLogger } from "./run-log.ts";
10
10
 
11
11
  type NornCommandRunnerInput = {
12
- readonly boundaryRoot: string;
13
- readonly boundaryName: string;
14
- readonly cwd: string;
15
12
  readonly signal?: AbortSignal;
16
13
  readonly logs: NornRunLogs;
17
14
  readonly logger: NornRunLogger;
@@ -21,7 +18,7 @@ export class NornCommandRunner {
21
18
  constructor(private readonly input: NornCommandRunnerInput) {}
22
19
 
23
20
  async run(commandInput: NornCommandRunInput): Promise<NornCommandRunResult> {
24
- const cwd = this.resolveFromCwd(commandInput.cwd ?? this.input.cwd);
21
+ const cwd = requireAbsoluteWorkingDirectory(commandInput.cwd);
25
22
  const startedAtMs = Date.now();
26
23
  const invocationId = randomUUID();
27
24
  const stdoutRef = commandLog({ label: commandInput.label, invocationId, stream: "stdout" });
@@ -67,15 +64,6 @@ export class NornCommandRunner {
67
64
  });
68
65
  return commandResult;
69
66
  }
70
-
71
- private resolveFromCwd(path: string): string {
72
- const resolvedPath = isAbsolute(path) ? path : resolve(this.input.cwd, path);
73
- const pathFromBoundary = relative(this.input.boundaryRoot, resolvedPath);
74
- if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
75
- throw new Error(`Command cwd escapes ${this.input.boundaryName} isolation: ${path}`);
76
- }
77
- return resolvedPath;
78
- }
79
67
  }
80
68
 
81
69
  type SpawnCommandInput = {
@@ -7,20 +7,19 @@ import {
7
7
  type NornRunNext,
8
8
  type NornRunResult,
9
9
  type NornRunStartOptions,
10
+ type NornWorkflowPaths,
10
11
  } from "@vimhead.dev/norn";
11
- import { createRunFileCoordinator } from "@vimhead.dev/norn/files";
12
+ import { createRunFileCoordinator } from "./file-coordinator.ts";
12
13
  import { randomUUID } from "node:crypto";
13
14
  import { mkdir } from "node:fs/promises";
14
- import { dirname, join } from "node:path";
15
+ import { dirname, join, resolve } from "node:path";
15
16
  import { Value } from "typebox/value";
16
17
  import { NornAgentResponseCollector } from "./agent-response-tool.ts";
17
- import { NornArtifacts } from "./artifacts.ts";
18
18
  import { errorMessage, NornRunStoppedError } from "./errors.ts";
19
19
  import { clearRunResumeRequest, matchesRunResumeRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS, type NornRunResumeRequest } from "./launch-request.ts";
20
20
  import { NornRunLogs } from "./logs.ts";
21
21
  import { NornRunLease } from "./run-lease.ts";
22
22
  import { NornRunLogger } from "./run-log.ts";
23
- import { NornRunResources } from "../resources.ts";
24
23
  import { assertRunVersion, getRunInfo, mergeInterruptedWorkflowArgs, NornRunStateStore, resolveRunRoot, type NornRunState } from "./run-state.ts";
25
24
  import { NornRunStore, runCurrentRoot } from "./run-store.ts";
26
25
  import { NornRunContext } from "./run.ts";
@@ -40,7 +39,9 @@ export type NornEngineInput = {
40
39
 
41
40
  type RunSession = {
42
41
  readonly runRoot: string;
42
+ readonly cwd: string;
43
43
  readonly run: NornRunContext;
44
+ readonly paths: NornWorkflowPaths;
44
45
  readonly state: NornRunStateStore;
45
46
  readonly lease: NornRunLease;
46
47
  readonly runStore: NornRunStore;
@@ -157,14 +158,14 @@ export class NornEngine {
157
158
  const rawArgs = mergeInterruptedWorkflowArgs(current.args, args, current.interruption?.fields);
158
159
  Value.Decode(workflow.args, rawArgs);
159
160
  await session.state.replaceCurrentArgs(rawArgs);
160
- await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
161
+ await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.cwd });
161
162
  const resumedCurrent = session.state.currentState().current;
162
163
  if (!resumedCurrent) throw new Error(`Run is not resumable: ${runRoot}`);
163
164
  isLeaseOwnedByScheduler = true;
164
165
  return this.runScheduler(session, toWorkflowStep(workflow, resumedCurrent));
165
166
  }
166
167
  if (state.status !== "pendingResume") throw new Error(`Run must be rolled back before resuming: ${runRoot}`);
167
- await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
168
+ await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.cwd });
168
169
  isLeaseOwnedByScheduler = true;
169
170
  return this.runScheduler(session, toWorkflowStep(workflow, current));
170
171
  } finally {
@@ -183,28 +184,27 @@ export class NornEngine {
183
184
  try {
184
185
  for (let step = 1; step <= 1_000; step++) {
185
186
  throwIfRunAborted(session.activeRun);
186
- const stepRuntime = session.run.forWorkflow(currentStep.workflow);
187
187
  if (shouldPauseForGate(currentStep)) {
188
- const description = await this.registry.describeGate(currentStep.workflow, stepRuntime, currentStep.args, session.state.currentState().configOverride);
188
+ const description = await this.registry.describeGate({ workflow: currentStep.workflow, run: session.run, paths: session.paths, args: currentStep.args, configOverride: session.state.currentState().configOverride });
189
189
  const interruption = { description, fields: currentStep.workflow.gate?.fields };
190
190
  await session.state.interruptCurrent(currentStep.args, interruption);
191
191
  await recordRunEvent(session, { type: "run.interrupted", workflowId: currentStep.workflow.id });
192
192
  await commitRunBoundary(session, `run interrupted: ${currentStep.workflow.id}`);
193
- return interruptedLaunchResult({ id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd }, currentStep.workflow, currentStep.args, interruption);
193
+ return interruptedLaunchResult({ id: session.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd }, currentStep.workflow, currentStep.args, interruption);
194
194
  }
195
195
  await session.state.startStep(toRunStateStep(currentStep));
196
- const stepResult = await this.executeWorkflowStep(session, currentStep, stepRuntime);
196
+ const stepResult = await this.executeWorkflowStep(session, currentStep);
197
197
  throwIfRunAborted(session.activeRun);
198
198
  if (stepResult.type === "complete") {
199
199
  await session.state.completeRun(stepResult.workflow.id, stepResult.metadata);
200
200
  await recordRunEvent(session, { type: "run.completed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
201
201
  await commitRunBoundary(session, `run completed: ${stepResult.workflow.id}`);
202
- return { status: "completed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
202
+ return { status: "completed", id: session.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
203
203
  }
204
204
  if (stepResult.type === "fail") {
205
205
  await session.state.failRun(stepResult.workflow.id, stepResult.metadata);
206
206
  await recordRunEvent(session, { type: "run.failed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
207
- return { status: "failed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
207
+ return { status: "failed", id: session.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
208
208
  }
209
209
  const nextStep = this.nextWorkflowStep(stepResult);
210
210
  await session.state.completeWithNext(currentStep.workflow.id, toRunStateStep(nextStep));
@@ -217,7 +217,7 @@ export class NornEngine {
217
217
  if (isRunStopped(session, error)) {
218
218
  await session.state.stopCurrent();
219
219
  await recordRunEvent(session, { type: "run.stopped", workflowId: currentStep.workflow.id });
220
- return { status: "stopped", id: session.run.id, name: session.state.currentState().name, workspace: session.run.workspace, cwd: session.run.cwd, workflowId: currentStep.workflow.id };
220
+ return { status: "stopped", id: session.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: currentStep.workflow.id };
221
221
  }
222
222
  await session.state.failCurrent(errorMessage(error));
223
223
  await recordRunEvent(session, { type: "run.failed", workflowId: currentStep.workflow.id, error: errorMessage(error) });
@@ -228,13 +228,13 @@ export class NornEngine {
228
228
  }
229
229
  }
230
230
 
231
- private async executeWorkflowStep(session: RunSession, step: WorkflowStep, run: NornRunContext): Promise<NornWorkflowStepResult> {
231
+ private async executeWorkflowStep(session: RunSession, step: WorkflowStep): Promise<NornWorkflowStepResult> {
232
232
  const startedAtMs = Date.now();
233
233
  try {
234
- await assertWorkspaceBoundary(session, run.workspace);
234
+ await assertWorkspaceBoundary(session, session.paths.workspace);
235
235
  await recordRunEvent(session, { type: "workflow.started", workflowId: step.workflow.id });
236
- const result = await this.registry.execute(step.workflow, run, step.args, session.state.currentState().configOverride);
237
- await assertWorkspaceBoundary(session, run.workspace);
236
+ const result = await this.registry.execute({ workflow: step.workflow, run: session.run, paths: session.paths, args: step.args, configOverride: session.state.currentState().configOverride });
237
+ await assertWorkspaceBoundary(session, session.paths.workspace);
238
238
  const durationMs = Date.now() - startedAtMs;
239
239
  if (result.type === "complete") await recordRunEvent(session, { type: "workflow.completed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
240
240
  else if (result.type === "fail") await recordRunEvent(session, { type: "workflow.failed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
@@ -271,7 +271,8 @@ export class NornEngine {
271
271
  const runRoot = defaultRunRoot(this.input.cwd, id);
272
272
  const currentRoot = runCurrentRoot(runRoot);
273
273
  const workspace = join(currentRoot, "workspace");
274
- const cwd = workflowDefaultCwd(this.input.cwd, workspace, workflow);
274
+ const paths: NornWorkflowPaths = { project: resolve(this.input.cwd), workspace: workspace };
275
+ const cwd = process.cwd();
275
276
  const startedAt = new Date().toISOString();
276
277
  await mkdir(runRoot, { recursive: true });
277
278
  const lease = await NornRunLease.acquire(runRoot);
@@ -288,8 +289,9 @@ export class NornEngine {
288
289
  initialCwd: cwd,
289
290
  startedAt,
290
291
  } });
291
- const run = await this.buildRun({ id, currentRoot, workspace, cwd, isolationMode: workflow.isolation.mode, signal: activeRun.controller.signal, logger });
292
+ const run = await this.buildRun({ id, currentRoot, paths, signal: activeRun.controller.signal, logger });
292
293
  const state = await NornRunStateStore.create(runRoot, {
294
+ projectRoot: paths.project,
293
295
  id,
294
296
  name,
295
297
  entrypointWorkflowId: workflow.id,
@@ -301,7 +303,7 @@ export class NornEngine {
301
303
  },
302
304
  startedAt,
303
305
  });
304
- const session = { runRoot, run, state, lease, runStore, logger, activeRun };
306
+ const session = { runRoot, cwd, run, paths, state, lease, runStore, logger, activeRun };
305
307
  await logger.record({ type: "run.started", workflowId: workflow.id, cwd, workspace });
306
308
  await commitRunBoundary(session, `run started: ${workflow.id}`);
307
309
  return session;
@@ -321,11 +323,9 @@ export class NornEngine {
321
323
  const runStore = await NornRunStore.open(runRoot);
322
324
  const currentRoot = runCurrentRoot(runRoot);
323
325
  const logger = await NornRunLogger.load(join(currentRoot, MANIFEST_FILE_NAME), createRunFileCoordinator(runRoot));
324
- const workflow = currentState.current ? this.registry.workflowById(currentState.current.workflowId) : undefined;
325
- const isolationMode = workflow?.isolation.mode ?? "runWorkspace";
326
- const cwd = workflowDefaultCwd(this.input.cwd, currentState.workspace, workflow);
327
- const run = await this.buildRun({ id: currentState.id, currentRoot, workspace: currentState.workspace, cwd, isolationMode, signal: activeRun.controller.signal, logger });
328
- return { runRoot, run, state, lease, runStore, logger, activeRun };
326
+ const paths: NornWorkflowPaths = { project: currentState.projectRoot ?? resolve(runRoot, "../../.."), workspace: currentState.workspace };
327
+ const run = await this.buildRun({ id: currentState.id, currentRoot, paths, signal: activeRun.controller.signal, logger });
328
+ return { runRoot, cwd: process.cwd(), run, paths, state, lease, runStore, logger, activeRun };
329
329
  } catch (error) {
330
330
  this.failActiveRun(runRoot, activeRun);
331
331
  throw error;
@@ -335,32 +335,22 @@ export class NornEngine {
335
335
  private async buildRun(input: {
336
336
  readonly id: string;
337
337
  readonly currentRoot: string;
338
- readonly workspace: string;
339
- readonly cwd: string;
340
- readonly isolationMode: "runWorkspace" | "project";
338
+ readonly paths: NornWorkflowPaths;
341
339
  readonly signal: AbortSignal;
342
340
  readonly logger: NornRunLogger;
343
341
  }): Promise<NornRunContext> {
344
- const artifactsRoot = join(input.currentRoot, "artifacts");
345
342
  const logsRoot = join(input.currentRoot, "logs");
346
- await mkdir(input.workspace, { recursive: true });
347
- await mkdir(artifactsRoot, { recursive: true });
343
+ await mkdir(input.paths.workspace, { recursive: true });
348
344
  await mkdir(logsRoot, { recursive: true });
349
- const resources = await NornRunResources.initialize(dirname(input.currentRoot));
345
+ const files = createRunFileCoordinator(dirname(input.currentRoot));
350
346
  return new NornRunContext({
351
347
  id: input.id,
352
348
  runRoot: input.currentRoot,
353
- workspace: input.workspace,
354
- projectRoot: this.input.cwd,
355
- cwd: input.cwd,
356
- isolationMode: input.isolationMode,
357
349
  signal: input.signal,
358
350
  agentDir: this.input.agentDir,
359
351
  responseCollector: this.responseCollector,
360
- resources,
361
352
  logger: input.logger,
362
- artifacts: new NornArtifacts(artifactsRoot, resources.files),
363
- logs: new NornRunLogs(logsRoot, resources.files),
353
+ logs: new NornRunLogs(logsRoot, files),
364
354
  });
365
355
  }
366
356
 
@@ -400,11 +390,7 @@ export class NornEngine {
400
390
  }
401
391
 
402
392
  function defaultRunRoot(cwd: string, id: string): string {
403
- return join(cwd, RUNS_DIR_NAME, "runs", id);
404
- }
405
-
406
- function workflowDefaultCwd(projectRoot: string, workspace: string, workflow: NornAnyWorkflowDeclaration | undefined): string {
407
- return workflow?.isolation.mode === "project" ? projectRoot : workspace;
393
+ return join(resolve(cwd), RUNS_DIR_NAME, "runs", id);
408
394
  }
409
395
 
410
396
  function toWorkflowStep(workflow: NornAnyWorkflowDeclaration, step: NonNullable<NornRunState["current"]>): WorkflowStep {
@@ -1,6 +1,6 @@
1
1
  import { createWriteStream, type WriteStream } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
3
+ import type { NornFileCoordinator } from "./file-coordinator.ts";
4
4
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import type { NornLogRef } from "@vimhead.dev/norn";
6
6
 
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
2
+ import type { NornFileCoordinator } from "./file-coordinator.ts";
3
3
  import { isNodeError } from "@vimhead.dev/norn-core/errors";
4
4
  import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
5
5
 
@@ -5,7 +5,7 @@ import { isNodeError } from "@vimhead.dev/norn-core/errors";
5
5
  import { readRunLaunchRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS } from "./launch-request.ts";
6
6
  import { getRunLeaseHealth } from "./run-lease.ts";
7
7
  import { writeJsonAtomically } from "@vimhead.dev/norn-core/atomic-files";
8
- import { createRunFileCoordinator, type NornFileCoordinator } from "@vimhead.dev/norn/files";
8
+ import { createRunFileCoordinator, type NornFileCoordinator } from "./file-coordinator.ts";
9
9
  import { runCurrentRoot } from "./run-store.ts";
10
10
  import { jsonValueSchema } from "@vimhead.dev/norn/schema";
11
11
  import { Value } from "typebox/value";
@@ -59,6 +59,7 @@ type NornRunWorkflowOutcome = {
59
59
 
60
60
  export type NornRunState = {
61
61
  readonly version: 1 | typeof RUN_STATE_VERSION;
62
+ readonly projectRoot?: string;
62
63
  readonly id: string;
63
64
  readonly name: string;
64
65
  readonly entrypointWorkflowId: string;
@@ -74,6 +75,7 @@ export type NornRunState = {
74
75
  };
75
76
 
76
77
  type CreateNornRunStateInput = {
78
+ readonly projectRoot: string;
77
79
  readonly id: string;
78
80
  readonly name: string;
79
81
  readonly entrypointWorkflowId: string;
@@ -101,7 +103,8 @@ export class NornRunStateStore {
101
103
  id: input.id,
102
104
  name: input.name,
103
105
  entrypointWorkflowId: input.entrypointWorkflowId,
104
- workspace: input.workspace,
106
+ projectRoot: resolve(input.projectRoot),
107
+ workspace: resolve(input.workspace),
105
108
  configOverride: input.configOverride,
106
109
  status: "running",
107
110
  current: input.current,
@@ -252,6 +255,7 @@ export async function getRunInfo(runRoot: string): Promise<NornRunInfo> {
252
255
  id: state.id,
253
256
  name: state.name,
254
257
  path: runRoot,
258
+ paths: { project: state.projectRoot ?? resolve(runRoot, "../../.."), workspace: state.workspace },
255
259
  entrypointWorkflowId: state.entrypointWorkflowId,
256
260
  currentWorkflowId: state.current?.workflowId,
257
261
  status: resumeRequest ? "running" : state.status,
@@ -317,6 +321,7 @@ async function getLaunchedRunInfo(runRoot: string): Promise<NornRunInfo> {
317
321
  id: launchRequest.id,
318
322
  name: launchRequest.name,
319
323
  path: runRoot,
324
+ paths: { project: resolve(runRoot, "../../.."), workspace: join(runCurrentRoot(resolve(runRoot)), "workspace") },
320
325
  entrypointWorkflowId: launchRequest.workflowId,
321
326
  currentWorkflowId: launchRequest.workflowId,
322
327
  status: "running",
@@ -1,60 +1,38 @@
1
- import { isAbsolute, relative, resolve, sep } from "node:path";
2
1
  import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
3
- import type { NornAnyWorkflowDeclaration, NornProjectRun, NornRunOutcomeMetadata, NornWorkflowIsolationMode, NornRun } from "@vimhead.dev/norn";
2
+ import type { NornRunOutcomeMetadata, NornRun } from "@vimhead.dev/norn";
4
3
  import { createWorkflowTransition } from "@vimhead.dev/norn-core/workflow-transition";
5
4
  import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
6
- import type { NornArtifacts } from "./artifacts.ts";
7
5
  import { NornAgentRunner } from "./agents.ts";
8
6
  import { NornCommandRunner } from "./commands.ts";
9
7
  import type { NornRunLogs } from "./logs.ts";
10
8
  import type { NornRunLogger } from "./run-log.ts";
11
- import type { NornRunResources } from "../resources.ts";
12
9
 
13
10
  type DefaultNornRunInput = {
14
11
  readonly id: string;
15
12
  readonly runRoot: string;
16
- readonly projectRoot: string;
17
- readonly workspace: string;
18
- readonly cwd: string;
19
- readonly isolationMode: NornWorkflowIsolationMode;
20
13
  readonly signal?: AbortSignal;
21
14
  readonly model?: CreateAgentSessionOptions["model"];
22
15
  readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
23
16
  readonly agentDir?: string;
24
17
  readonly responseCollector: NornAgentResponseCollector;
25
- readonly resources: NornRunResources;
26
18
  readonly logger: NornRunLogger;
27
- readonly artifacts: NornArtifacts;
28
19
  readonly logs: NornRunLogs;
29
20
  };
30
21
 
31
- export class NornRunContext implements NornProjectRun {
32
- readonly resources: NornRunResources;
33
- readonly artifacts: NornRun["artifacts"];
22
+ export class NornRunContext implements NornRun {
34
23
  readonly logs: NornRun["logs"];
35
24
  readonly commands: NornRun["commands"];
36
25
  readonly agents: NornRun["agents"];
37
26
  readonly id: string;
38
- readonly workspace: string;
39
- readonly projectRoot: string;
40
- readonly cwd: string;
41
27
 
42
28
  constructor(private readonly input: DefaultNornRunInput) {
43
29
  this.id = input.id;
44
- this.workspace = input.workspace;
45
- this.projectRoot = input.projectRoot;
46
- this.cwd = input.cwd;
47
- this.resources = input.resources;
48
- this.artifacts = input.artifacts;
49
30
  this.logs = {
50
31
  read: (log) => this.input.logs.read(log),
51
32
  };
52
33
  this.commands = {
53
34
  run: (commandInput) =>
54
35
  new NornCommandRunner({
55
- boundaryRoot: this.boundaryRoot,
56
- boundaryName: this.input.isolationMode,
57
- cwd: this.cwd,
58
36
  signal: this.input.signal,
59
37
  logs: this.input.logs,
60
38
  logger: this.input.logger,
@@ -66,26 +44,10 @@ export class NornRunContext implements NornProjectRun {
66
44
  };
67
45
  }
68
46
 
69
- forWorkflow(workflow: NornAnyWorkflowDeclaration): NornRunContext {
70
- const isolationMode = workflow.isolation.mode;
71
- return new NornRunContext({
72
- ...this.input,
73
- isolationMode,
74
- cwd: isolationMode === "project" ? this.projectRoot : this.workspace,
75
- });
76
- }
77
-
78
- private get boundaryRoot(): string {
79
- return this.input.isolationMode === "project" ? this.projectRoot : this.workspace;
80
- }
81
-
82
47
  private createAgentRunner(): NornAgentRunner {
83
48
  return new NornAgentRunner({
84
49
  id: this.id,
85
50
  runRoot: this.input.runRoot,
86
- boundaryRoot: this.boundaryRoot,
87
- boundaryName: this.input.isolationMode,
88
- cwd: this.cwd,
89
51
  signal: this.input.signal,
90
52
  model: this.input.model,
91
53
  thinkingLevel: this.input.thinkingLevel,
@@ -96,14 +58,6 @@ export class NornRunContext implements NornProjectRun {
96
58
  });
97
59
  }
98
60
 
99
- path(relativePath: string): string {
100
- return this.resolveFromRoot(this.cwd, this.boundaryRoot, relativePath);
101
- }
102
-
103
- projectPath(relativePath: string): string {
104
- return this.resolveFromRoot(this.projectRoot, this.projectRoot, relativePath);
105
- }
106
-
107
61
  next(workflowId: string, args: unknown): ReturnType<NornRun["next"]> {
108
62
  return createWorkflowTransition({ workflowId, args });
109
63
  }
@@ -115,13 +69,4 @@ export class NornRunContext implements NornProjectRun {
115
69
  fail(metadata: NornRunOutcomeMetadata & { readonly summary: string }): ReturnType<NornRun["fail"]> {
116
70
  return { type: "fail", metadata };
117
71
  }
118
-
119
- private resolveFromRoot(root: string, boundaryRoot: string, path: string): string {
120
- const resolvedPath = isAbsolute(path) ? path : resolve(root, path);
121
- const pathFromBoundary = relative(boundaryRoot, resolvedPath);
122
- if (pathFromBoundary === ".." || pathFromBoundary.startsWith(`..${sep}`) || isAbsolute(pathFromBoundary)) {
123
- throw new Error(`Workflow path escapes ${this.input.isolationMode} isolation: ${path}`);
124
- }
125
- return resolvedPath;
126
- }
127
72
  }
@@ -0,0 +1,17 @@
1
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isNodeError } from "@vimhead.dev/norn-core/errors";
4
+
5
+ export async function prepareRunWorkerDirectory(runRoot: string): Promise<string> {
6
+ const directory = join(runRoot, "worker");
7
+ try {
8
+ await mkdir(directory, { mode: 0o555 });
9
+ } catch (error) {
10
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
11
+ }
12
+ const metadata = await lstat(directory);
13
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Run worker cwd must be a directory, not a symlink: ${directory}`);
14
+ if ((await readdir(directory)).length !== 0) throw new Error(`Run worker cwd must be empty: ${directory}`);
15
+ await chmod(directory, 0o555);
16
+ return directory;
17
+ }