@vimhead.dev/norn-cli 0.1.0-tip.35390859630.1 → 0.1.0-tip.35568631545.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 (97) hide show
  1. package/assets/README.md +36 -15
  2. package/assets/docs/README.md +4 -4
  3. package/assets/docs/agents.md +39 -11
  4. package/assets/docs/cli.md +6 -6
  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/recovery.md +1 -1
  9. package/assets/docs/workflows.md +29 -17
  10. package/assets/examples/agent-then-analysis/README.md +8 -8
  11. package/assets/examples/agent-then-analysis/plugin.ts +23 -21
  12. package/assets/examples/caller-selected-continuation/README.md +11 -10
  13. package/assets/examples/caller-selected-continuation/caller.ts +15 -13
  14. package/assets/examples/caller-selected-continuation/producer.ts +11 -8
  15. package/assets/examples/coordinating-multiple-agents/README.md +27 -17
  16. package/assets/examples/coordinating-multiple-agents/plugin.ts +64 -41
  17. package/assets/examples/coordinating-multiple-agents/queue-tools.ts +43 -0
  18. package/assets/examples/coordinating-multiple-agents/work-queue.ts +60 -65
  19. package/assets/examples/getting-started/README.md +25 -10
  20. package/assets/examples/getting-started/plugin.ts +30 -11
  21. package/assets/examples/minimal-workflow/README.md +7 -6
  22. package/assets/examples/minimal-workflow/plugin.ts +8 -5
  23. package/assets/examples/shared-state/README.md +5 -5
  24. package/assets/examples/shared-state/plugin.ts +38 -25
  25. package/assets/examples/shared-state/shared-state.ts +30 -49
  26. package/assets/examples/shared-state/state-tools.ts +68 -0
  27. package/assets/examples/worktree-development-loop/README.md +14 -12
  28. package/assets/examples/worktree-development-loop/scope.ts +1 -1
  29. package/assets/examples/worktree-development-loop/workflows/development-loop/execute.ts +3 -3
  30. package/assets/examples/worktree-development-loop/workflows/development-loop/repository.ts +6 -4
  31. package/assets/examples/worktree-development-loop/workflows/implementation/execute.ts +12 -11
  32. package/assets/examples/worktree-development-loop/workflows/implementation/schema.ts +2 -3
  33. package/assets/examples/worktree-development-loop/workflows/planning/execute.ts +10 -6
  34. package/assets/examples/worktree-development-loop/workflows/review/execute.ts +14 -11
  35. package/assets/examples/worktree-development-loop/workflows/review/schema.ts +1 -2
  36. package/assets/examples/worktree-development-loop/workflows/review-router/execute.ts +16 -15
  37. package/assets/examples/worktree-development-loop/workflows/review-router/schema.ts +1 -2
  38. package/assets/package.json +1 -1
  39. package/assets/packages/cli/src/cli.ts +9 -3
  40. package/assets/packages/cli/src/generated-build-info.ts +2 -2
  41. package/assets/packages/cli/src/internal/agents.ts +24 -32
  42. package/assets/packages/cli/src/internal/commands.ts +2 -14
  43. package/assets/packages/cli/src/internal/engine.ts +35 -49
  44. package/assets/packages/cli/src/internal/execution-context.ts +64 -0
  45. package/assets/packages/cli/src/internal/logs.ts +1 -1
  46. package/assets/packages/cli/src/internal/run-log.ts +1 -1
  47. package/assets/packages/cli/src/internal/run-state.ts +7 -2
  48. package/assets/packages/cli/src/internal/worker-directory.ts +17 -0
  49. package/assets/packages/cli/src/internal/workflow-registry.ts +24 -9
  50. package/assets/packages/cli/src/internal/working-directory.ts +6 -0
  51. package/assets/packages/cli/src/workflow-loader.ts +1 -2
  52. package/assets/packages/sdk/src/api.ts +65 -91
  53. package/assets/packages/sdk/src/index.ts +0 -3
  54. package/assets/tests/workflow-ref.test.ts +9 -9
  55. package/dist/cli.js +8 -3
  56. package/dist/generated-build-info.d.ts +2 -2
  57. package/dist/generated-build-info.js +2 -2
  58. package/dist/internal/agents.d.ts +0 -4
  59. package/dist/internal/agents.js +30 -46
  60. package/dist/internal/commands.d.ts +0 -4
  61. package/dist/internal/commands.js +2 -10
  62. package/dist/internal/engine.d.ts +1 -1
  63. package/dist/internal/engine.js +29 -43
  64. package/dist/internal/execution-context.d.ts +26 -0
  65. package/dist/internal/execution-context.js +55 -0
  66. package/dist/internal/file-coordinator.d.ts +17 -0
  67. package/dist/internal/file-coordinator.js +162 -0
  68. package/dist/internal/logs.d.ts +1 -1
  69. package/dist/internal/run-log.d.ts +1 -1
  70. package/dist/internal/run-state.d.ts +2 -0
  71. package/dist/internal/run-state.js +5 -2
  72. package/dist/internal/worker-directory.d.ts +1 -0
  73. package/dist/internal/worker-directory.js +26 -0
  74. package/dist/internal/workflow-registry.d.ts +13 -3
  75. package/dist/internal/workflow-registry.js +12 -7
  76. package/dist/internal/working-directory.d.ts +1 -0
  77. package/dist/internal/working-directory.js +9 -0
  78. package/dist/workflow-loader.js +1 -2
  79. package/package.json +2 -2
  80. package/assets/docs/resources.md +0 -46
  81. package/assets/examples/coordinating-multiple-agents/queue-adapter.ts +0 -51
  82. package/assets/examples/shared-state/state-adapter.ts +0 -76
  83. package/assets/packages/cli/src/internal/artifacts.ts +0 -26
  84. package/assets/packages/cli/src/internal/resource-bindings.ts +0 -35
  85. package/assets/packages/cli/src/internal/run.ts +0 -127
  86. package/assets/packages/cli/src/resources.ts +0 -69
  87. package/assets/packages/sdk/src/agent-resource-adapter.ts +0 -11
  88. package/assets/packages/sdk/src/resources.ts +0 -20
  89. package/dist/internal/artifacts.d.ts +0 -10
  90. package/dist/internal/artifacts.js +0 -29
  91. package/dist/internal/resource-bindings.d.ts +0 -13
  92. package/dist/internal/resource-bindings.js +0 -34
  93. package/dist/internal/run.d.ts +0 -49
  94. package/dist/internal/run.js +0 -103
  95. package/dist/resources.d.ts +0 -11
  96. package/dist/resources.js +0 -100
  97. /package/assets/packages/{sdk/src/files.ts → cli/src/internal/file-coordinator.ts} +0 -0
@@ -33,7 +33,7 @@ export declare class NornEngine {
33
33
  private nextWorkflowStep;
34
34
  private createRunSession;
35
35
  private openRunSession;
36
- private buildRun;
36
+ private buildExecutionContext;
37
37
  private startActiveRun;
38
38
  private finishActiveRun;
39
39
  private failActiveRun;
@@ -1,20 +1,18 @@
1
1
  // src/internal/engine.ts
2
- import { createRunFileCoordinator } from "@vimhead.dev/norn/files";
2
+ import { createRunFileCoordinator } from "./file-coordinator.js";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { mkdir } from "node:fs/promises";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, join, resolve } from "node:path";
6
6
  import { Value } from "typebox/value";
7
7
  import { NornAgentResponseCollector } from "./agent-response-tool.js";
8
- import { NornArtifacts } from "./artifacts.js";
9
8
  import { errorMessage, NornRunStoppedError } from "./errors.js";
10
9
  import { clearRunResumeRequest, matchesRunResumeRequest, readOptionalRunResumeRequest, RESUME_START_GRACE_MS } from "./launch-request.js";
11
10
  import { NornRunLogs } from "./logs.js";
12
11
  import { NornRunLease } from "./run-lease.js";
13
12
  import { NornRunLogger } from "./run-log.js";
14
- import { NornRunResources } from "../resources.js";
15
13
  import { assertRunVersion, getRunInfo, mergeInterruptedWorkflowArgs, NornRunStateStore, resolveRunRoot } from "./run-state.js";
16
14
  import { NornRunStore, runCurrentRoot } from "./run-store.js";
17
- import { NornRunContext } from "./run.js";
15
+ import { NornExecutionContext } from "./execution-context.js";
18
16
  import { NornWorkflowRegistry } from "./workflow-registry.js";
19
17
  var RUNS_DIR_NAME = ".norn";
20
18
  var MANIFEST_FILE_NAME = "manifest.json";
@@ -103,14 +101,14 @@ var NornEngine = class {
103
101
  const rawArgs = mergeInterruptedWorkflowArgs(current.args, args, current.interruption?.fields);
104
102
  Value.Decode(workflow.args, rawArgs);
105
103
  await session.state.replaceCurrentArgs(rawArgs);
106
- await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
104
+ await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.cwd });
107
105
  const resumedCurrent = session.state.currentState().current;
108
106
  if (!resumedCurrent) throw new Error(`Run is not resumable: ${runRoot}`);
109
107
  isLeaseOwnedByScheduler = true;
110
108
  return this.runScheduler(session, toWorkflowStep(workflow, resumedCurrent));
111
109
  }
112
110
  if (state.status !== "pendingResume") throw new Error(`Run must be rolled back before resuming: ${runRoot}`);
113
- await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.run.cwd });
111
+ await recordRunEvent(session, { type: "run.resumed", workflowId: workflow.id, cwd: session.cwd });
114
112
  isLeaseOwnedByScheduler = true;
115
113
  return this.runScheduler(session, toWorkflowStep(workflow, current));
116
114
  } finally {
@@ -128,28 +126,27 @@ var NornEngine = class {
128
126
  try {
129
127
  for (let step = 1; step <= 1e3; step++) {
130
128
  throwIfRunAborted(session.activeRun);
131
- const stepRuntime = session.run.forWorkflow(currentStep.workflow);
132
129
  if (shouldPauseForGate(currentStep)) {
133
- const description = await this.registry.describeGate(currentStep.workflow, stepRuntime, currentStep.args, session.state.currentState().configOverride);
130
+ const description = await this.registry.describeGate({ workflow: currentStep.workflow, execution: session.execution, paths: session.paths, args: currentStep.args, configOverride: session.state.currentState().configOverride });
134
131
  const interruption = { description, fields: currentStep.workflow.gate?.fields };
135
132
  await session.state.interruptCurrent(currentStep.args, interruption);
136
133
  await recordRunEvent(session, { type: "run.interrupted", workflowId: currentStep.workflow.id });
137
134
  await commitRunBoundary(session, `run interrupted: ${currentStep.workflow.id}`);
138
- return interruptedLaunchResult({ id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd }, currentStep.workflow, currentStep.args, interruption);
135
+ return interruptedLaunchResult({ id: session.execution.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd }, currentStep.workflow, currentStep.args, interruption);
139
136
  }
140
137
  await session.state.startStep(toRunStateStep(currentStep));
141
- const stepResult = await this.executeWorkflowStep(session, currentStep, stepRuntime);
138
+ const stepResult = await this.executeWorkflowStep(session, currentStep);
142
139
  throwIfRunAborted(session.activeRun);
143
140
  if (stepResult.type === "complete") {
144
141
  await session.state.completeRun(stepResult.workflow.id, stepResult.metadata);
145
142
  await recordRunEvent(session, { type: "run.completed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
146
143
  await commitRunBoundary(session, `run completed: ${stepResult.workflow.id}`);
147
- return { status: "completed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
144
+ return { status: "completed", id: session.execution.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
148
145
  }
149
146
  if (stepResult.type === "fail") {
150
147
  await session.state.failRun(stepResult.workflow.id, stepResult.metadata);
151
148
  await recordRunEvent(session, { type: "run.failed", workflowId: stepResult.workflow.id, metadata: stepResult.metadata });
152
- return { status: "failed", id: stepRuntime.id, name: session.state.currentState().name, workspace: stepRuntime.workspace, cwd: stepRuntime.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
149
+ return { status: "failed", id: session.execution.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: stepResult.workflow.id, metadata: stepResult.metadata };
153
150
  }
154
151
  const nextStep = this.nextWorkflowStep(stepResult);
155
152
  await session.state.completeWithNext(currentStep.workflow.id, toRunStateStep(nextStep));
@@ -162,7 +159,7 @@ var NornEngine = class {
162
159
  if (isRunStopped(session, error)) {
163
160
  await session.state.stopCurrent();
164
161
  await recordRunEvent(session, { type: "run.stopped", workflowId: currentStep.workflow.id });
165
- return { status: "stopped", id: session.run.id, name: session.state.currentState().name, workspace: session.run.workspace, cwd: session.run.cwd, workflowId: currentStep.workflow.id };
162
+ return { status: "stopped", id: session.execution.run.id, name: session.state.currentState().name, workspace: session.paths.workspace, cwd: session.cwd, workflowId: currentStep.workflow.id };
166
163
  }
167
164
  await session.state.failCurrent(errorMessage(error));
168
165
  await recordRunEvent(session, { type: "run.failed", workflowId: currentStep.workflow.id, error: errorMessage(error) });
@@ -172,13 +169,13 @@ var NornEngine = class {
172
169
  this.finishActiveRun(session.runRoot, session.activeRun);
173
170
  }
174
171
  }
175
- async executeWorkflowStep(session, step, run) {
172
+ async executeWorkflowStep(session, step) {
176
173
  const startedAtMs = Date.now();
177
174
  try {
178
- await assertWorkspaceBoundary(session, run.workspace);
175
+ await assertWorkspaceBoundary(session, session.paths.workspace);
179
176
  await recordRunEvent(session, { type: "workflow.started", workflowId: step.workflow.id });
180
- const result = await this.registry.execute(step.workflow, run, step.args, session.state.currentState().configOverride);
181
- await assertWorkspaceBoundary(session, run.workspace);
177
+ const result = await this.registry.execute({ workflow: step.workflow, execution: session.execution, paths: session.paths, args: step.args, configOverride: session.state.currentState().configOverride });
178
+ await assertWorkspaceBoundary(session, session.paths.workspace);
182
179
  const durationMs = Date.now() - startedAtMs;
183
180
  if (result.type === "complete") await recordRunEvent(session, { type: "workflow.completed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
184
181
  else if (result.type === "fail") await recordRunEvent(session, { type: "workflow.failed", workflowId: result.workflow.id, durationMs, metadata: result.metadata });
@@ -209,7 +206,8 @@ var NornEngine = class {
209
206
  const runRoot = defaultRunRoot(this.input.cwd, id);
210
207
  const currentRoot = runCurrentRoot(runRoot);
211
208
  const workspace = join(currentRoot, "workspace");
212
- const cwd = workflowDefaultCwd(this.input.cwd, workspace, workflow);
209
+ const paths = { project: resolve(this.input.cwd), workspace };
210
+ const cwd = process.cwd();
213
211
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
214
212
  await mkdir(runRoot, { recursive: true });
215
213
  const lease = await NornRunLease.acquire(runRoot);
@@ -226,8 +224,9 @@ var NornEngine = class {
226
224
  initialCwd: cwd,
227
225
  startedAt
228
226
  } });
229
- const run = await this.buildRun({ id, currentRoot, workspace, cwd, isolationMode: workflow.isolation.mode, signal: activeRun.controller.signal, logger });
227
+ const execution = await this.buildExecutionContext({ id, currentRoot, paths, signal: activeRun.controller.signal, logger });
230
228
  const state = await NornRunStateStore.create(runRoot, {
229
+ projectRoot: paths.project,
231
230
  id,
232
231
  name,
233
232
  entrypointWorkflowId: workflow.id,
@@ -242,7 +241,7 @@ var NornEngine = class {
242
241
  },
243
242
  startedAt
244
243
  });
245
- const session = { runRoot, run, state, lease, runStore, logger, activeRun };
244
+ const session = { runRoot, cwd, execution, paths, state, lease, runStore, logger, activeRun };
246
245
  await logger.record({ type: "run.started", workflowId: workflow.id, cwd, workspace });
247
246
  await commitRunBoundary(session, `run started: ${workflow.id}`);
248
247
  return session;
@@ -261,37 +260,27 @@ var NornEngine = class {
261
260
  const runStore = await NornRunStore.open(runRoot);
262
261
  const currentRoot = runCurrentRoot(runRoot);
263
262
  const logger = await NornRunLogger.load(join(currentRoot, MANIFEST_FILE_NAME), createRunFileCoordinator(runRoot));
264
- const workflow = currentState.current ? this.registry.workflowById(currentState.current.workflowId) : void 0;
265
- const isolationMode = workflow?.isolation.mode ?? "runWorkspace";
266
- const cwd = workflowDefaultCwd(this.input.cwd, currentState.workspace, workflow);
267
- const run = await this.buildRun({ id: currentState.id, currentRoot, workspace: currentState.workspace, cwd, isolationMode, signal: activeRun.controller.signal, logger });
268
- return { runRoot, run, state, lease, runStore, logger, activeRun };
263
+ const paths = { project: currentState.projectRoot ?? resolve(runRoot, "../../.."), workspace: currentState.workspace };
264
+ const execution = await this.buildExecutionContext({ id: currentState.id, currentRoot, paths, signal: activeRun.controller.signal, logger });
265
+ return { runRoot, cwd: process.cwd(), execution, paths, state, lease, runStore, logger, activeRun };
269
266
  } catch (error) {
270
267
  this.failActiveRun(runRoot, activeRun);
271
268
  throw error;
272
269
  }
273
270
  }
274
- async buildRun(input) {
275
- const artifactsRoot = join(input.currentRoot, "artifacts");
271
+ async buildExecutionContext(input) {
276
272
  const logsRoot = join(input.currentRoot, "logs");
277
- await mkdir(input.workspace, { recursive: true });
278
- await mkdir(artifactsRoot, { recursive: true });
273
+ await mkdir(input.paths.workspace, { recursive: true });
279
274
  await mkdir(logsRoot, { recursive: true });
280
- const resources = await NornRunResources.initialize(dirname(input.currentRoot));
281
- return new NornRunContext({
275
+ const files = createRunFileCoordinator(dirname(input.currentRoot));
276
+ return new NornExecutionContext({
282
277
  id: input.id,
283
278
  runRoot: input.currentRoot,
284
- workspace: input.workspace,
285
- projectRoot: this.input.cwd,
286
- cwd: input.cwd,
287
- isolationMode: input.isolationMode,
288
279
  signal: input.signal,
289
280
  agentDir: this.input.agentDir,
290
281
  responseCollector: this.responseCollector,
291
- resources,
292
282
  logger: input.logger,
293
- artifacts: new NornArtifacts(artifactsRoot, resources.files),
294
- logs: new NornRunLogs(logsRoot, resources.files)
283
+ logs: new NornRunLogs(logsRoot, files)
295
284
  });
296
285
  }
297
286
  startActiveRun(runRoot) {
@@ -326,10 +315,7 @@ var NornEngine = class {
326
315
  }
327
316
  };
328
317
  function defaultRunRoot(cwd, id) {
329
- return join(cwd, RUNS_DIR_NAME, "runs", id);
330
- }
331
- function workflowDefaultCwd(projectRoot, workspace, workflow) {
332
- return workflow?.isolation.mode === "project" ? projectRoot : workspace;
318
+ return join(resolve(cwd), RUNS_DIR_NAME, "runs", id);
333
319
  }
334
320
  function toWorkflowStep(workflow, step) {
335
321
  return {
@@ -0,0 +1,26 @@
1
+ import type { CreateAgentSessionOptions } from "@earendil-works/pi-coding-agent";
2
+ import type { NornAgents, NornCommands, NornLogs, NornRun } from "@vimhead.dev/norn";
3
+ import type { NornAgentResponseCollector } from "./agent-response-tool.ts";
4
+ import type { NornRunLogs } from "./logs.ts";
5
+ import type { NornRunLogger } from "./run-log.ts";
6
+ type NornExecutionContextInput = {
7
+ readonly id: string;
8
+ readonly runRoot: string;
9
+ readonly signal?: AbortSignal;
10
+ readonly model?: CreateAgentSessionOptions["model"];
11
+ readonly thinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
12
+ readonly agentDir?: string;
13
+ readonly responseCollector: NornAgentResponseCollector;
14
+ readonly logger: NornRunLogger;
15
+ readonly logs: NornRunLogs;
16
+ };
17
+ export declare class NornExecutionContext {
18
+ private readonly input;
19
+ readonly run: NornRun;
20
+ readonly logs: NornLogs;
21
+ readonly commands: NornCommands;
22
+ readonly agents: NornAgents;
23
+ constructor(input: NornExecutionContextInput);
24
+ private createAgentRunner;
25
+ }
26
+ export {};
@@ -0,0 +1,55 @@
1
+ // ../core/src/workflow-transition.ts
2
+ function createWorkflowTransition(input) {
3
+ if (typeof input.workflowId !== "string" || input.workflowId.trim().length === 0) throw new Error("Workflow transition requires a nonempty workflow ID");
4
+ return { type: "next", workflowId: input.workflowId, args: input.args };
5
+ }
6
+
7
+ // src/internal/execution-context.ts
8
+ import { NornAgentRunner } from "./agents.js";
9
+ import { NornCommandRunner } from "./commands.js";
10
+ var NornExecutionContext = class {
11
+ constructor(input) {
12
+ this.input = input;
13
+ this.run = {
14
+ id: input.id,
15
+ next: (workflowId, args) => createWorkflowTransition({ workflowId, args }),
16
+ complete: (metadata) => ({ type: "complete", metadata }),
17
+ fail: (metadata) => ({ type: "fail", metadata })
18
+ };
19
+ this.logs = {
20
+ read: (log) => this.input.logs.read(log)
21
+ };
22
+ this.commands = {
23
+ run: (commandInput) => new NornCommandRunner({
24
+ signal: this.input.signal,
25
+ logs: this.input.logs,
26
+ logger: this.input.logger
27
+ }).run(commandInput)
28
+ };
29
+ this.agents = {
30
+ createSession: (agentInput) => this.createAgentRunner().createSession(agentInput),
31
+ prompt: (agentInput) => this.createAgentRunner().prompt(agentInput)
32
+ };
33
+ }
34
+ input;
35
+ run;
36
+ logs;
37
+ commands;
38
+ agents;
39
+ createAgentRunner() {
40
+ return new NornAgentRunner({
41
+ id: this.input.id,
42
+ runRoot: this.input.runRoot,
43
+ signal: this.input.signal,
44
+ model: this.input.model,
45
+ thinkingLevel: this.input.thinkingLevel,
46
+ agentDir: this.input.agentDir,
47
+ logs: this.input.logs,
48
+ logger: this.input.logger,
49
+ responseCollector: this.input.responseCollector
50
+ });
51
+ }
52
+ };
53
+ export {
54
+ NornExecutionContext
55
+ };
@@ -0,0 +1,17 @@
1
+ export declare class NornFileCoordinator {
2
+ private readonly input;
3
+ private readonly lockRoot;
4
+ constructor(input: {
5
+ readonly lockRoot: string;
6
+ readonly waitTimeoutMs: number;
7
+ });
8
+ withExclusiveLock<T>(path: string, operation: (lockedPath: string) => Promise<T>): Promise<T>;
9
+ readText(path: string): Promise<string>;
10
+ writeText(path: string, content: string): Promise<void>;
11
+ private withCleanup;
12
+ private resolveTarget;
13
+ private acquire;
14
+ private reclaimDeadOwner;
15
+ private removeEmptyLock;
16
+ }
17
+ export declare function createRunFileCoordinator(runRoot: string): NornFileCoordinator;
@@ -0,0 +1,162 @@
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 writeTextAtomically(path, content) {
13
+ await mkdir(dirname(path), { recursive: true });
14
+ let existingMode;
15
+ try {
16
+ existingMode = (await stat(path)).mode & 511;
17
+ } catch (error) {
18
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
19
+ }
20
+ const tmpPath = `${path}.${randomUUID()}.tmp`;
21
+ try {
22
+ await writeFile(tmpPath, content, { encoding: "utf8", mode: existingMode });
23
+ if (existingMode !== void 0) await chmod(tmpPath, existingMode);
24
+ await rename(tmpPath, path);
25
+ } catch (error) {
26
+ await rm(tmpPath, { force: true }).catch(() => void 0);
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ // src/internal/file-coordinator.ts
32
+ import { createHash, randomUUID as randomUUID2 } from "node:crypto";
33
+ import { lstat, mkdir as mkdir2, mkdtemp, readFile, readdir, realpath, rename as rename2, rm as rm2, rmdir, unlink, writeFile as writeFile2 } from "node:fs/promises";
34
+ import { hostname } from "node:os";
35
+ import { basename, dirname as dirname2, join, resolve } from "node:path";
36
+ import { setTimeout as delay } from "node:timers/promises";
37
+ import { Type } from "typebox";
38
+ import { Value } from "typebox/value";
39
+ var ownerSchema = Type.Object({
40
+ token: Type.String({ format: "uuid" }),
41
+ pid: Type.Integer({ exclusiveMinimum: 0 }),
42
+ host: Type.String(),
43
+ target: Type.String()
44
+ }, { additionalProperties: false });
45
+ var NornFileCoordinator = class {
46
+ constructor(input) {
47
+ this.input = input;
48
+ if (!Number.isSafeInteger(input.waitTimeoutMs) || input.waitTimeoutMs <= 0) throw new Error("Lock wait timeout must be a positive integer");
49
+ this.lockRoot = resolve(input.lockRoot);
50
+ }
51
+ input;
52
+ lockRoot;
53
+ async withExclusiveLock(path, operation) {
54
+ const target = await this.resolveTarget(path);
55
+ await mkdir2(this.lockRoot, { recursive: true, mode: 448 });
56
+ const key = createHash("sha256").update(target).digest("hex");
57
+ const lockPath = join(this.lockRoot, `${key}.lock`);
58
+ const stagingPath = await mkdtemp(join(this.lockRoot, `${key}.pending-`));
59
+ const token = randomUUID2();
60
+ const marker = `${token}.json`;
61
+ return this.withCleanup({
62
+ operation: async () => {
63
+ await writeFile2(join(stagingPath, marker), JSON.stringify({ token, pid: process.pid, host: hostname(), target }), { mode: 384 });
64
+ await this.acquire({ stagingPath, lockPath, target });
65
+ return this.withCleanup({
66
+ operation: () => operation(target),
67
+ cleanup: async () => {
68
+ await unlink(join(lockPath, marker));
69
+ await this.removeEmptyLock(lockPath);
70
+ }
71
+ });
72
+ },
73
+ cleanup: () => rm2(stagingPath, { recursive: true, force: true })
74
+ });
75
+ }
76
+ async readText(path) {
77
+ return this.withExclusiveLock(path, (lockedPath) => readFile(lockedPath, "utf8"));
78
+ }
79
+ async writeText(path, content) {
80
+ await mkdir2(dirname2(path), { recursive: true });
81
+ await this.withExclusiveLock(path, (lockedPath) => writeTextAtomically(lockedPath, content));
82
+ }
83
+ async withCleanup(input) {
84
+ let result;
85
+ try {
86
+ result = await input.operation();
87
+ } catch (error) {
88
+ try {
89
+ await input.cleanup();
90
+ } catch (cleanupError) {
91
+ throw new AggregateError([error, cleanupError], "File operation and lock cleanup failed");
92
+ }
93
+ throw error;
94
+ }
95
+ await input.cleanup();
96
+ return result;
97
+ }
98
+ async resolveTarget(path) {
99
+ try {
100
+ return await realpath(path);
101
+ } catch (error) {
102
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
103
+ const entry = await lstat(path).catch((error2) => {
104
+ if (isNodeError(error2) && error2.code === "ENOENT") return void 0;
105
+ throw error2;
106
+ });
107
+ if (entry?.isSymbolicLink()) throw new Error(`Cannot lock a dangling symbolic link: ${path}`);
108
+ return join(await realpath(dirname2(path)), basename(path));
109
+ }
110
+ }
111
+ async acquire(input) {
112
+ const deadline = Date.now() + this.input.waitTimeoutMs;
113
+ while (true) {
114
+ try {
115
+ await rename2(input.stagingPath, input.lockPath);
116
+ return;
117
+ } catch (error) {
118
+ if (!isNodeError(error) || !["ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
119
+ }
120
+ await this.reclaimDeadOwner(input);
121
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for file lock: ${input.target}`);
122
+ await delay(10);
123
+ }
124
+ }
125
+ async reclaimDeadOwner(input) {
126
+ try {
127
+ const entries = await readdir(input.lockPath);
128
+ if (entries.length === 0) return;
129
+ if (entries.length !== 1) throw new Error(`Invalid file lock ownership: ${input.lockPath}`);
130
+ const marker = entries[0];
131
+ const owner = Value.Parse(ownerSchema, JSON.parse(await readFile(join(input.lockPath, marker), "utf8")));
132
+ if (marker !== `${owner.token}.json` || owner.target !== input.target || owner.host !== hostname()) {
133
+ throw new Error(`Incompatible file lock ownership: ${input.lockPath}`);
134
+ }
135
+ try {
136
+ process.kill(owner.pid, 0);
137
+ return;
138
+ } catch (error) {
139
+ if (isNodeError(error) && error.code === "EPERM") return;
140
+ if (!isNodeError(error) || error.code !== "ESRCH") throw error;
141
+ }
142
+ await unlink(join(input.lockPath, marker));
143
+ await this.removeEmptyLock(input.lockPath);
144
+ } catch (error) {
145
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
146
+ }
147
+ }
148
+ async removeEmptyLock(path) {
149
+ try {
150
+ await rmdir(path);
151
+ } catch (error) {
152
+ if (!isNodeError(error) || !["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? "")) throw error;
153
+ }
154
+ }
155
+ };
156
+ function createRunFileCoordinator(runRoot) {
157
+ return new NornFileCoordinator({ lockRoot: join(runRoot, "locks"), waitTimeoutMs: 3e4 });
158
+ }
159
+ export {
160
+ NornFileCoordinator,
161
+ createRunFileCoordinator
162
+ };
@@ -1,5 +1,5 @@
1
1
  import { type WriteStream } from "node:fs";
2
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
2
+ import type { NornFileCoordinator } from "./file-coordinator.ts";
3
3
  import type { NornLogRef } from "@vimhead.dev/norn";
4
4
  export type NornLogWriteStream = {
5
5
  readonly log: NornLogRef;
@@ -1,4 +1,4 @@
1
- import type { NornFileCoordinator } from "@vimhead.dev/norn/files";
1
+ import type { NornFileCoordinator } from "./file-coordinator.ts";
2
2
  export type NornRunManifestEvent = {
3
3
  readonly at: string;
4
4
  readonly type: string;
@@ -40,6 +40,7 @@ type NornRunWorkflowOutcome = {
40
40
  };
41
41
  export type NornRunState = {
42
42
  readonly version: 1 | typeof RUN_STATE_VERSION;
43
+ readonly projectRoot?: string;
43
44
  readonly id: string;
44
45
  readonly name: string;
45
46
  readonly entrypointWorkflowId: string;
@@ -54,6 +55,7 @@ export type NornRunState = {
54
55
  readonly updatedAt: string;
55
56
  };
56
57
  type CreateNornRunStateInput = {
58
+ readonly projectRoot: string;
57
59
  readonly id: string;
58
60
  readonly name: string;
59
61
  readonly entrypointWorkflowId: string;
@@ -39,7 +39,7 @@ async function writeTextAtomically(path, content) {
39
39
  }
40
40
 
41
41
  // src/internal/run-state.ts
42
- import { createRunFileCoordinator } from "@vimhead.dev/norn/files";
42
+ import { createRunFileCoordinator } from "./file-coordinator.js";
43
43
  import { runCurrentRoot } from "./run-store.js";
44
44
  import { jsonValueSchema } from "@vimhead.dev/norn/schema";
45
45
  import { Value } from "typebox/value";
@@ -70,7 +70,8 @@ var NornRunStateStore = class _NornRunStateStore {
70
70
  id: input.id,
71
71
  name: input.name,
72
72
  entrypointWorkflowId: input.entrypointWorkflowId,
73
- workspace: input.workspace,
73
+ projectRoot: resolve(input.projectRoot),
74
+ workspace: resolve(input.workspace),
74
75
  configOverride: input.configOverride,
75
76
  status: "running",
76
77
  current: input.current,
@@ -204,6 +205,7 @@ async function getRunInfo(runRoot) {
204
205
  id: state.id,
205
206
  name: state.name,
206
207
  path: runRoot,
208
+ paths: { project: state.projectRoot ?? resolve(runRoot, "../../.."), workspace: state.workspace },
207
209
  entrypointWorkflowId: state.entrypointWorkflowId,
208
210
  currentWorkflowId: state.current?.workflowId,
209
211
  status: resumeRequest ? "running" : state.status,
@@ -260,6 +262,7 @@ async function getLaunchedRunInfo(runRoot) {
260
262
  id: launchRequest.id,
261
263
  name: launchRequest.name,
262
264
  path: runRoot,
265
+ paths: { project: resolve(runRoot, "../../.."), workspace: join(runCurrentRoot(resolve(runRoot)), "workspace") },
263
266
  entrypointWorkflowId: launchRequest.workflowId,
264
267
  currentWorkflowId: launchRequest.workflowId,
265
268
  status: "running",
@@ -0,0 +1 @@
1
+ export declare function prepareRunWorkerDirectory(runRoot: string): Promise<string>;
@@ -0,0 +1,26 @@
1
+ // src/internal/worker-directory.ts
2
+ import { chmod, lstat, mkdir, readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ // ../core/src/errors.ts
6
+ function isNodeError(error) {
7
+ return error instanceof Error && "code" in error;
8
+ }
9
+
10
+ // src/internal/worker-directory.ts
11
+ async function prepareRunWorkerDirectory(runRoot) {
12
+ const directory = join(runRoot, "worker");
13
+ try {
14
+ await mkdir(directory, { mode: 365 });
15
+ } catch (error) {
16
+ if (!isNodeError(error) || error.code !== "EEXIST") throw error;
17
+ }
18
+ const metadata = await lstat(directory);
19
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Run worker cwd must be a directory, not a symlink: ${directory}`);
20
+ if ((await readdir(directory)).length !== 0) throw new Error(`Run worker cwd must be empty: ${directory}`);
21
+ await chmod(directory, 365);
22
+ return directory;
23
+ }
24
+ export {
25
+ prepareRunWorkerDirectory
26
+ };
@@ -1,4 +1,4 @@
1
- import { type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRunFor, type NornRunNext, type NornWorkflowSource, type NornProjectConfigurationInfo } from "@vimhead.dev/norn";
1
+ import { type NornAnyWorkflowDeclaration, type NornDispose, type NornInspectedWorkflowInfo, type NornRegisteredWorkflowInfo, type NornRunComplete, type NornRunFail, type NornRunNext, type NornWorkflowPaths, type NornWorkflowContext, type NornWorkflowSource, type NornProjectConfigurationInfo } from "@vimhead.dev/norn";
2
2
  import type { TSchema } from "typebox";
3
3
  export type NornRegisteredConfiguration = {
4
4
  readonly key: string;
@@ -27,6 +27,15 @@ export type NornWorkflowStepResult = NornRunNext | {
27
27
  readonly workflow: NornAnyWorkflowDeclaration;
28
28
  readonly metadata: NornRunFail["metadata"];
29
29
  };
30
+ type NornExecutionContextInput = {
31
+ readonly execution: Pick<NornWorkflowContext, "run" | "agents" | "commands" | "logs">;
32
+ readonly paths: NornWorkflowPaths;
33
+ readonly args: unknown;
34
+ readonly configOverride: unknown;
35
+ };
36
+ type NornWorkflowExecutionInput = NornExecutionContextInput & {
37
+ readonly workflow: NornAnyWorkflowDeclaration;
38
+ };
30
39
  export declare class NornWorkflowRegistry {
31
40
  private readonly entries;
32
41
  private readonly scopes;
@@ -42,10 +51,11 @@ export declare class NornWorkflowRegistry {
42
51
  configurationInfos(): NornProjectConfigurationInfo[];
43
52
  launchableEntries(): NornRegisteredWorkflow[];
44
53
  workflowById(workflowId: string): NornAnyWorkflowDeclaration | undefined;
45
- describeGate<TWorkflow extends NornAnyWorkflowDeclaration>(workflow: TWorkflow, run: NornRunFor<TWorkflow>, args: unknown, configOverride?: unknown): Promise<string>;
46
- execute<TWorkflow extends NornAnyWorkflowDeclaration>(workflow: TWorkflow, run: NornRunFor<TWorkflow>, args: unknown, configOverride?: unknown): Promise<NornWorkflowStepResult>;
54
+ describeGate(input: NornWorkflowExecutionInput): Promise<string>;
55
+ execute(input: NornWorkflowExecutionInput): Promise<NornWorkflowStepResult>;
47
56
  private createExecutionContext;
48
57
  private requireEntry;
49
58
  private sortedEntries;
50
59
  }
51
60
  export declare function assertWorkflowDefinition(workflow: NornAnyWorkflowDeclaration): void;
61
+ export {};
@@ -81,30 +81,36 @@ var NornWorkflowRegistry = class {
81
81
  workflowById(workflowId) {
82
82
  return this.entries.get(workflowId)?.workflow;
83
83
  }
84
- async describeGate(workflow, run, args, configOverride) {
84
+ async describeGate(input) {
85
+ const { workflow } = input;
85
86
  const entry = this.requireEntry(workflow.id);
86
87
  if (!workflow.gate) throw new Error(`Workflow is not gated: ${workflow.id}`);
87
- const context = this.createExecutionContext({ entry, run, args, configOverride });
88
+ const context = this.createExecutionContext({ ...input, entry });
88
89
  const description = await entry.workflow.gate?.describe?.(context) ?? workflow.id;
89
90
  if (typeof description !== "string" || description.trim().length === 0) throw new Error(`Workflow gate description must not be empty: ${workflow.id}`);
90
91
  return description.trim();
91
92
  }
92
- async execute(workflow, run, args, configOverride) {
93
+ async execute(input) {
94
+ const { workflow } = input;
93
95
  const entry = this.requireEntry(workflow.id);
94
- const result = await entry.workflow.execute(this.createExecutionContext({ entry, run, args, configOverride }));
96
+ const result = await entry.workflow.execute(this.createExecutionContext({ ...input, entry }));
95
97
  if (isWorkflowNext(result)) return result;
96
98
  if (isWorkflowComplete(result)) return { type: "complete", workflow, metadata: result.metadata };
97
99
  if (isWorkflowFail(result)) return { type: "fail", workflow, metadata: result.metadata };
98
100
  throw new Error(`Workflow returned invalid control result: ${workflow.id}`);
99
101
  }
100
102
  createExecutionContext(input) {
101
- const { entry, run, configOverride } = input;
103
+ const { entry, execution, paths, configOverride } = input;
102
104
  const scope = entry.scopeId ? this.scopes.get(entry.scopeId) : void 0;
103
105
  return {
104
106
  args: Value.Decode(entry.workflow.args, input.args),
105
107
  config: parseExecutionConfig(entry.configuration, configOverride),
106
108
  ...scope ? { scope: { id: scope.definition.id, config: parseExecutionConfig(scope.configuration, configOverride) } } : {},
107
- run
109
+ paths,
110
+ run: execution.run,
111
+ agents: execution.agents,
112
+ commands: execution.commands,
113
+ logs: execution.logs
108
114
  };
109
115
  }
110
116
  requireEntry(id) {
@@ -152,7 +158,6 @@ function workflowInfo(entry) {
152
158
  id: entry.workflow.id,
153
159
  instructions: entry.workflow.instructions,
154
160
  isEntrypoint: entry.workflow.isEntrypoint,
155
- isolation: entry.workflow.isolation,
156
161
  source: entry.source,
157
162
  configKey: entry.workflow.id,
158
163
  scope: entry.scopeId ? { id: entry.scopeId } : void 0
@@ -0,0 +1 @@
1
+ export declare function requireAbsoluteWorkingDirectory(cwd: string): string;