@nifrajs/coding-agent 3.3.0 → 4.0.0

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 (88) hide show
  1. package/README.md +11 -4
  2. package/SECURITY.md +2 -1
  3. package/dist/approvals.d.ts +14 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +51 -43
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.js +29 -1
  12. package/dist/context.js.map +1 -1
  13. package/dist/diff.js +38 -2
  14. package/dist/diff.js.map +1 -1
  15. package/dist/handoffs.d.ts.map +1 -1
  16. package/dist/handoffs.js +12 -4
  17. package/dist/handoffs.js.map +1 -1
  18. package/dist/host.d.ts.map +1 -1
  19. package/dist/host.js +146 -23
  20. package/dist/host.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/isolated.js +37 -2
  26. package/dist/isolated.js.map +1 -1
  27. package/dist/native.d.ts +12 -0
  28. package/dist/native.d.ts.map +1 -1
  29. package/dist/native.js +165 -40
  30. package/dist/native.js.map +1 -1
  31. package/dist/orchestration/compile.d.ts +7 -0
  32. package/dist/orchestration/compile.d.ts.map +1 -1
  33. package/dist/orchestration/compile.js +16 -2
  34. package/dist/orchestration/compile.js.map +1 -1
  35. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  36. package/dist/orchestration/evidence-store.js +9 -4
  37. package/dist/orchestration/evidence-store.js.map +1 -1
  38. package/dist/orchestration/host.d.ts +10 -0
  39. package/dist/orchestration/host.d.ts.map +1 -1
  40. package/dist/orchestration/host.js +9 -0
  41. package/dist/orchestration/host.js.map +1 -1
  42. package/dist/replay.d.ts +4 -0
  43. package/dist/replay.d.ts.map +1 -1
  44. package/dist/replay.js +189 -24
  45. package/dist/replay.js.map +1 -1
  46. package/dist/rpc.d.ts +10 -0
  47. package/dist/rpc.d.ts.map +1 -1
  48. package/dist/rpc.js +209 -29
  49. package/dist/rpc.js.map +1 -1
  50. package/dist/session-migration.d.ts.map +1 -1
  51. package/dist/session-migration.js +3 -2
  52. package/dist/session-migration.js.map +1 -1
  53. package/dist/sessions.d.ts +20 -2
  54. package/dist/sessions.d.ts.map +1 -1
  55. package/dist/sessions.js +269 -60
  56. package/dist/sessions.js.map +1 -1
  57. package/dist/subagents.d.ts +30 -0
  58. package/dist/subagents.d.ts.map +1 -1
  59. package/dist/subagents.js +118 -15
  60. package/dist/subagents.js.map +1 -1
  61. package/dist/verification.d.ts +28 -1
  62. package/dist/verification.d.ts.map +1 -1
  63. package/dist/verification.js +119 -3
  64. package/dist/verification.js.map +1 -1
  65. package/dist/workflows.d.ts +1 -0
  66. package/dist/workflows.d.ts.map +1 -1
  67. package/dist/workflows.js +55 -23
  68. package/dist/workflows.js.map +1 -1
  69. package/package.json +9 -5
  70. package/src/approvals.ts +62 -45
  71. package/src/cli.ts +11 -2
  72. package/src/context.ts +32 -1
  73. package/src/diff.ts +40 -2
  74. package/src/handoffs.ts +20 -8
  75. package/src/host.ts +146 -23
  76. package/src/index.ts +7 -0
  77. package/src/isolated.ts +39 -2
  78. package/src/native.ts +213 -39
  79. package/src/orchestration/compile.ts +28 -2
  80. package/src/orchestration/evidence-store.ts +9 -3
  81. package/src/orchestration/host.ts +17 -0
  82. package/src/replay.ts +206 -25
  83. package/src/rpc.ts +262 -33
  84. package/src/session-migration.ts +3 -2
  85. package/src/sessions.ts +288 -57
  86. package/src/subagents.ts +147 -17
  87. package/src/verification.ts +146 -3
  88. package/src/workflows.ts +47 -27
package/src/subagents.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { relative, resolve, sep } from "node:path"
1
+ import { realpath } from "node:fs/promises"
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
2
3
 
3
4
  export interface SubagentSpec {
4
5
  readonly id: string
@@ -28,6 +29,10 @@ export interface SubagentExecutor {
28
29
 
29
30
  export interface SubagentWorkspaceLease {
30
31
  readonly cwd: string
32
+ /**
33
+ * Runs once the executor has settled. A run that times out or is cancelled returns at once, but
34
+ * its workspace stays until the executor finishes, so a child never loses its cwd mid-run.
35
+ */
31
36
  readonly cleanup?: () => void | PromiseLike<void>
32
37
  }
33
38
 
@@ -41,6 +46,22 @@ export interface SubagentWorkspacePolicy {
41
46
  ) => SubagentWorkspaceLease | PromiseLike<SubagentWorkspaceLease>
42
47
  }
43
48
 
49
+ /** A run that returned while its executor kept running, having ignored its abort signal. */
50
+ export interface SubagentAbandonment {
51
+ readonly spec: SubagentSpec
52
+ readonly reason: "timeout" | "cancelled"
53
+ /** The workspace the executor was given; undefined when it had none. */
54
+ readonly cwd: string | undefined
55
+ /** Settles once the executor does; its workspace lease is cleaned up after that. */
56
+ readonly settled: Promise<void>
57
+ }
58
+
59
+ /** Abandoned executors still running. Runners sharing one ledger are counted, and bounded by
60
+ * `maxAbandoned`, together. */
61
+ export interface SubagentAbandonmentLedger {
62
+ abandoned: number
63
+ }
64
+
44
65
  export interface SubagentRunnerOptions {
45
66
  readonly maxChildren?: number
46
67
  readonly maxDepth?: number
@@ -48,6 +69,25 @@ export interface SubagentRunnerOptions {
48
69
  readonly signal?: AbortSignal
49
70
  readonly allowedCapabilities?: readonly string[]
50
71
  readonly workspace?: SubagentWorkspacePolicy
72
+ /**
73
+ * Called when a run returns while its executor keeps running: it timed out or was cancelled and
74
+ * ignored its abort signal. Use it to stop the executor another way, such as ending its process.
75
+ */
76
+ readonly onAbandoned?: (abandonment: SubagentAbandonment) => void
77
+ /** Refuse new children while this many abandoned executors are still running. Default: no bound. */
78
+ readonly maxAbandoned?: number
79
+ /** Where abandoned executors are counted. Default: a ledger of this runner's own. */
80
+ readonly abandonment?: SubagentAbandonmentLedger
81
+ }
82
+
83
+ /** `work`'s outcome, or `stopped()` as soon as `signal` aborts, even when `work` ignores it. */
84
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal, stopped: () => Error): Promise<T> {
85
+ return new Promise<T>((resolve, reject) => {
86
+ const stop = (): void => reject(stopped())
87
+ if (signal.aborted) return stop()
88
+ signal.addEventListener("abort", stop, { once: true })
89
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop))
90
+ })
51
91
  }
52
92
 
53
93
  /** Explicitly bounded child execution. Recursive fan-out is impossible without a caller budget. */
@@ -58,6 +98,7 @@ export class BoundedSubagentRunner {
58
98
  > &
59
99
  SubagentRunnerOptions
60
100
  private children = 0
101
+ private readonly ledger: SubagentAbandonmentLedger
61
102
 
62
103
  constructor(executor: SubagentExecutor, options: SubagentRunnerOptions = {}) {
63
104
  this.executor = executor
@@ -67,15 +108,37 @@ export class BoundedSubagentRunner {
67
108
  maxDepth: options.maxDepth ?? 2,
68
109
  depth: options.depth ?? 0,
69
110
  }
70
- if (this.options.maxChildren < 1 || this.options.maxDepth < 0 || this.options.depth < 0)
111
+ this.ledger = options.abandonment ?? { abandoned: 0 }
112
+ if (
113
+ this.options.maxChildren < 1 ||
114
+ this.options.maxDepth < 0 ||
115
+ this.options.depth < 0 ||
116
+ (options.maxAbandoned !== undefined &&
117
+ (!Number.isSafeInteger(options.maxAbandoned) || options.maxAbandoned < 0))
118
+ )
71
119
  throw new RangeError(
72
120
  "subagents: limits must be non-negative and maxChildren must be positive",
73
121
  )
74
122
  }
75
123
 
124
+ /** Executors this runner's ledger holds as abandoned and still running. */
125
+ get abandoned(): number {
126
+ return this.ledger.abandoned
127
+ }
128
+
76
129
  async run(spec: SubagentSpec): Promise<SubagentResult> {
77
130
  if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(spec.id))
78
131
  return { id: spec.id, role: spec.role, ok: false, error: "invalid subagent id" }
132
+ if (
133
+ this.options.maxAbandoned !== undefined &&
134
+ this.ledger.abandoned >= this.options.maxAbandoned
135
+ )
136
+ return {
137
+ id: spec.id,
138
+ role: spec.role,
139
+ ok: false,
140
+ error: "subagent abandoned-executor limit reached",
141
+ }
79
142
  if (++this.children > this.options.maxChildren)
80
143
  return { id: spec.id, role: spec.role, ok: false, error: "subagent child limit exceeded" }
81
144
  if (this.options.depth > this.options.maxDepth)
@@ -110,13 +173,23 @@ export class BoundedSubagentRunner {
110
173
  }
111
174
  const controller = new AbortController()
112
175
  const onAbort = (): void => controller.abort(this.options.signal?.reason)
113
- this.options.signal?.addEventListener("abort", onAbort, { once: true })
176
+ if (this.options.signal?.aborted === true) onAbort()
177
+ else this.options.signal?.addEventListener("abort", onAbort, { once: true })
178
+ let timedOut = false
114
179
  const timeout =
115
180
  spec.timeoutMs === undefined
116
181
  ? undefined
117
- : setTimeout(() => controller.abort("timeout"), spec.timeoutMs)
182
+ : setTimeout(() => {
183
+ timedOut = true
184
+ controller.abort("timeout")
185
+ }, spec.timeoutMs)
186
+ const stopped = (): Error => new Error(timedOut ? "subagent timed out" : "subagent cancelled")
118
187
  let workspace: SubagentWorkspaceLease | undefined
188
+ let cwd: string | undefined
189
+ let executorDone: Promise<void> | undefined
190
+ let executorSettled = false
119
191
  try {
192
+ if (controller.signal.aborted) throw stopped()
120
193
  const requestedCwd =
121
194
  spec.cwd === undefined
122
195
  ? this.options.workspace?.root
@@ -124,7 +197,8 @@ export class BoundedSubagentRunner {
124
197
  ? resolve(spec.cwd)
125
198
  : resolve(this.options.workspace.root, spec.cwd)
126
199
  if (requestedCwd !== undefined && this.options.workspace !== undefined) {
127
- if (!within(this.options.workspace.root, requestedCwd))
200
+ const requested = await physicalPath(requestedCwd)
201
+ if (!(await within(this.options.workspace.root, requested)))
128
202
  return {
129
203
  id: spec.id,
130
204
  role: spec.role,
@@ -132,7 +206,7 @@ export class BoundedSubagentRunner {
132
206
  error: "subagent workspace escapes policy root",
133
207
  }
134
208
  const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
135
- if (!allowedRoots.some((root) => within(root, requestedCwd)))
209
+ if (!(await withinAny(allowedRoots, requested)))
136
210
  return {
137
211
  id: spec.id,
138
212
  role: spec.role,
@@ -145,11 +219,15 @@ export class BoundedSubagentRunner {
145
219
  } else if (requestedCwd !== undefined) {
146
220
  workspace = { cwd: requestedCwd }
147
221
  }
222
+ cwd = workspace?.cwd
148
223
  if (workspace !== undefined && this.options.workspace !== undefined) {
224
+ // The executor gets the path that was checked, with its links already resolved, so a link
225
+ // swapped after the check cannot move it out of the policy root.
226
+ cwd = await physicalPath(workspace.cwd)
149
227
  const allowedRoots = this.options.workspace.allowedRoots ?? [this.options.workspace.root]
150
228
  if (
151
- !within(this.options.workspace.root, workspace.cwd) ||
152
- !allowedRoots.some((root) => within(root, workspace!.cwd))
229
+ !(await within(this.options.workspace.root, cwd)) ||
230
+ !(await withinAny(allowedRoots, cwd))
153
231
  )
154
232
  return {
155
233
  id: spec.id,
@@ -158,11 +236,20 @@ export class BoundedSubagentRunner {
158
236
  error: "isolated worktree escapes workspace policy",
159
237
  }
160
238
  }
161
- const output = await this.executor.run({
162
- spec,
163
- signal: controller.signal,
164
- ...(workspace === undefined ? {} : { cwd: workspace.cwd }),
165
- })
239
+ if (controller.signal.aborted) throw stopped()
240
+ const work = Promise.resolve().then(() =>
241
+ this.executor.run({
242
+ spec,
243
+ signal: controller.signal,
244
+ ...(cwd === undefined ? {} : { cwd }),
245
+ }),
246
+ )
247
+ // Registered before untilAborted's reaction, so a settled executor is seen as settled below.
248
+ const settle = (): void => {
249
+ executorSettled = true
250
+ }
251
+ executorDone = work.then(settle, settle)
252
+ const output = await untilAborted(work, controller.signal, stopped)
166
253
  return { id: spec.id, role: spec.role, ok: true, output }
167
254
  } catch (error) {
168
255
  return {
@@ -172,9 +259,29 @@ export class BoundedSubagentRunner {
172
259
  error: error instanceof Error ? error.message : String(error),
173
260
  }
174
261
  } finally {
175
- await workspace?.cleanup?.()
176
262
  if (timeout !== undefined) clearTimeout(timeout)
177
263
  this.options.signal?.removeEventListener("abort", onAbort)
264
+ const cleanup = workspace?.cleanup
265
+ // An executor that honours the abort settles within a few ticks; give it one turn before
266
+ // calling it abandoned.
267
+ if (executorDone !== undefined && !executorSettled)
268
+ await Promise.race([executorDone, new Promise((resolve) => setTimeout(resolve, 0))])
269
+ if (executorDone !== undefined && !executorSettled) {
270
+ // The executor is counted until it settles, and may still be writing to its workspace, so
271
+ // the release waits for it too.
272
+ const ledger = this.ledger
273
+ ledger.abandoned++
274
+ const settled = executorDone.then(() => {
275
+ ledger.abandoned--
276
+ })
277
+ if (cleanup !== undefined) settled.then(() => cleanup()).catch(() => {})
278
+ this.options.onAbandoned?.({
279
+ spec,
280
+ reason: timedOut ? "timeout" : "cancelled",
281
+ cwd,
282
+ settled,
283
+ })
284
+ } else if (cleanup !== undefined) await cleanup()
178
285
  }
179
286
  }
180
287
 
@@ -201,10 +308,33 @@ export class BoundedSubagentRunner {
201
308
  }
202
309
  }
203
310
 
204
- function within(root: string, candidate: string): boolean {
205
- const relativePath = relative(resolve(root), resolve(candidate))
311
+ /** Whether `physical`, a path whose links are already resolved, lies inside the physical root, so a
312
+ * symlink inside the root cannot lead out of it. */
313
+ async function within(root: string, physical: string): Promise<boolean> {
314
+ const relativePath = relative(await physicalPath(root), physical)
206
315
  return (
207
316
  relativePath === "" ||
208
- (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !relativePath.startsWith("/"))
317
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath))
209
318
  )
210
319
  }
320
+
321
+ async function withinAny(roots: readonly string[], physical: string): Promise<boolean> {
322
+ for (const root of roots) if (await within(root, physical)) return true
323
+ return false
324
+ }
325
+
326
+ /** The path with its existing part's symlinks resolved; a part not created yet is kept as written. */
327
+ async function physicalPath(path: string): Promise<string> {
328
+ const missing: string[] = []
329
+ let existing = resolve(path)
330
+ for (;;) {
331
+ try {
332
+ return join(await realpath(existing), ...missing)
333
+ } catch {
334
+ const parent = dirname(existing)
335
+ if (parent === existing) return resolve(path)
336
+ missing.unshift(basename(existing))
337
+ existing = parent
338
+ }
339
+ }
340
+ }
@@ -53,12 +53,124 @@ export interface VerificationOptions {
53
53
  readonly exposeErrorStacks?: boolean
54
54
  }
55
55
 
56
- /** Run an existing Nifra gate without importing the large framework CLI into the agent runtime. */
56
+ export type ReviewExecutionErrorCode =
57
+ | "timeout"
58
+ | "output-truncated"
59
+ | "invalid-report"
60
+ | "spawn-failed"
61
+
62
+ export interface ReviewOptions {
63
+ readonly cwd: string
64
+ readonly command?: string
65
+ readonly commandArgs?: readonly string[]
66
+ readonly strict?: boolean
67
+ readonly diff?: string
68
+ readonly timeoutMs?: number
69
+ readonly maxOutputBytes?: number
70
+ readonly env?: Readonly<Record<string, string | undefined>>
71
+ }
72
+
73
+ export interface ReviewExecutionResult {
74
+ readonly name: "review"
75
+ readonly ok: boolean
76
+ readonly status: number | null
77
+ /** A parsed, content-free ReviewReport. Raw stdout/stderr is intentionally never returned. */
78
+ readonly report?: unknown
79
+ readonly errorCode?: ReviewExecutionErrorCode
80
+ }
81
+
82
+ const REVIEW_GIT_REF = /^[A-Za-z0-9][A-Za-z0-9._/@+-]{0,255}$/
83
+
84
+ /** Validate the only repository selector exposed by the host review RPC. */
85
+ export function isSafeReviewDiff(value: unknown): value is string {
86
+ return (
87
+ typeof value === "string" &&
88
+ REVIEW_GIT_REF.test(value) &&
89
+ !value.includes("..") &&
90
+ !value.includes("@{") &&
91
+ !value.includes("//") &&
92
+ !value.endsWith(".") &&
93
+ !value.endsWith("/") &&
94
+ !value
95
+ .split("/")
96
+ .some((part) => part.length === 0 || part === "." || part === ".." || part.startsWith("."))
97
+ )
98
+ }
99
+
100
+ /** Run `nifra review --json` with the same bounded process discipline as the existing gates. */
101
+ export async function runNifraReview(options: ReviewOptions): Promise<ReviewExecutionResult> {
102
+ if (options.strict !== undefined && typeof options.strict !== "boolean")
103
+ throw new TypeError("review strict must be a boolean")
104
+ if (options.diff !== undefined && !isSafeReviewDiff(options.diff))
105
+ throw new TypeError("review diff is not a safe Git ref")
106
+ const command = options.command ?? "nifra"
107
+ const commandArgs = options.commandArgs ?? []
108
+ const args = ["review", "--json"]
109
+ if (options.strict === true) args.push("--strict")
110
+ if (options.diff !== undefined) args.push("--diff", options.diff)
111
+ let proc: ReturnType<typeof Bun.spawn>
112
+ try {
113
+ proc = Bun.spawn([command, ...commandArgs, ...args], {
114
+ cwd: options.cwd,
115
+ stdin: "ignore",
116
+ stdout: "pipe",
117
+ stderr: "pipe",
118
+ env: safeEnv(options.env),
119
+ })
120
+ } catch {
121
+ return { name: "review", ok: false, status: null, errorCode: "spawn-failed" }
122
+ }
123
+ const timeoutMs = options.timeoutMs ?? 120_000
124
+ let timedOut = false
125
+ const timer = setTimeout(() => {
126
+ timedOut = true
127
+ proc.kill()
128
+ }, timeoutMs)
129
+ try {
130
+ const [stdout, stderr, status] = await Promise.all([
131
+ readBoundedText(proc.stdout, options.maxOutputBytes ?? 1_048_576),
132
+ readBoundedText(proc.stderr, options.maxOutputBytes ?? 1_048_576),
133
+ proc.exited,
134
+ ])
135
+ void stderr
136
+ if (timedOut) return { name: "review", ok: false, status, errorCode: "timeout" }
137
+ if (stdout.truncated || stderr.truncated)
138
+ return { name: "review", ok: false, status, errorCode: "output-truncated" }
139
+ try {
140
+ const raw = JSON.parse(stdout.text) as unknown
141
+ const { parseReviewReport } = await import("@nifrajs/agent-review")
142
+ const report = await parseReviewReport(raw)
143
+ return {
144
+ name: "review",
145
+ ok: status === 0 && report.ok,
146
+ status,
147
+ report,
148
+ }
149
+ } catch {
150
+ return { name: "review", ok: false, status, errorCode: "invalid-report" }
151
+ }
152
+ } catch {
153
+ return {
154
+ name: "review",
155
+ ok: false,
156
+ status: null,
157
+ errorCode: timedOut ? "timeout" : "spawn-failed",
158
+ }
159
+ } finally {
160
+ clearTimeout(timer)
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Run an existing Nifra gate without importing the large framework CLI into the agent runtime.
166
+ * `check` and `assure` are `nifra` commands; `test` is the project's own suite, run with `bun test`
167
+ * by the Bun this agent runs on.
168
+ */
57
169
  export async function runNifraVerification(
58
170
  name: "check" | "assure" | "test",
59
171
  options: VerificationOptions,
60
172
  ): Promise<VerificationResult> {
61
- const command = options.command ?? "nifra"
173
+ const command = options.command ?? (name === "test" ? process.execPath : "nifra")
62
174
  const commandArgs = options.commandArgs ?? []
63
175
  const args = name === "test" ? ["test"] : [name, "--json"]
64
176
  const proc = Bun.spawn([command, ...commandArgs, ...args], {
@@ -124,10 +236,41 @@ function safeEnv(
124
236
  "LC_ALL",
125
237
  "TERM",
126
238
  "CI",
239
+ ...(process.platform === "win32"
240
+ ? [
241
+ "SystemRoot",
242
+ "WINDIR",
243
+ "TEMP",
244
+ "TMP",
245
+ "USERPROFILE",
246
+ "LOCALAPPDATA",
247
+ "APPDATA",
248
+ "COMSPEC",
249
+ "PATHEXT",
250
+ "BUN_INSTALL",
251
+ "HOMEDRIVE",
252
+ "HOMEPATH",
253
+ "USERNAME",
254
+ "USERDOMAIN",
255
+ "ProgramData",
256
+ "ProgramFiles",
257
+ "ProgramFiles(x86)",
258
+ ]
259
+ : []),
127
260
  ...Object.keys(overrides ?? {}),
128
261
  ])) {
129
- const value = overrides?.[name] ?? process.env[name]
262
+ const value =
263
+ overrides !== undefined && Object.hasOwn(overrides, name) ? overrides[name] : readEnv(name)
130
264
  if (value !== undefined) result[name] = value
131
265
  }
132
266
  return result
133
267
  }
268
+
269
+ function readEnv(name: string): string | undefined {
270
+ const exact = process.env[name]
271
+ if (exact !== undefined || process.platform !== "win32") return exact
272
+ const key = Object.keys(process.env).find(
273
+ (candidate) => candidate.toLowerCase() === name.toLowerCase(),
274
+ )
275
+ return key === undefined ? undefined : process.env[key]
276
+ }
package/src/workflows.ts CHANGED
@@ -88,6 +88,7 @@ export interface WorkflowResult {
88
88
  export class WorkflowRunner {
89
89
  private readonly options: Required<Pick<WorkflowRunnerOptions, "maxSteps" | "maxDepth">> &
90
90
  WorkflowRunnerOptions
91
+ private readonly signal: AbortSignal
91
92
  private readonly values = new Map<string, unknown>()
92
93
  private readonly completed: string[] = []
93
94
  private steps = 0
@@ -98,6 +99,7 @@ export class WorkflowRunner {
98
99
  maxSteps: options.maxSteps ?? 128,
99
100
  maxDepth: options.maxDepth ?? 8,
100
101
  }
102
+ this.signal = options.signal ?? new AbortController().signal
101
103
  if (!Number.isSafeInteger(this.options.maxSteps) || this.options.maxSteps < 1)
102
104
  throw new RangeError("workflow: maxSteps must be positive")
103
105
  if (!Number.isSafeInteger(this.options.maxDepth) || this.options.maxDepth < 1)
@@ -128,12 +130,12 @@ export class WorkflowRunner {
128
130
  }
129
131
  }
130
132
 
131
- private context(): WorkflowContext {
133
+ private context(depth: number): WorkflowContext {
132
134
  return {
133
- signal: this.options.signal ?? new AbortController().signal,
135
+ signal: this.signal,
134
136
  values: this.values,
135
137
  set: (name, value) => this.values.set(name, value),
136
- run: (step) => this.execute(step, 1),
138
+ run: (step) => this.execute(step, depth + 1),
137
139
  }
138
140
  }
139
141
 
@@ -143,51 +145,55 @@ export class WorkflowRunner {
143
145
  if (this.options.signal?.aborted) throw new Error("workflow cancelled")
144
146
  switch (step.type) {
145
147
  case "task":
146
- return this.single(step.id, () => step.run(this.context()))
148
+ return this.single(step.id, () => step.run(this.context(depth)))
147
149
  case "verify": {
148
- const result = await this.single(step.id, () => step.run(this.context()))
150
+ const result = await this.single(step.id, () => step.run(this.context(depth)))
149
151
  const ok = typeof result === "boolean" ? result : isVerificationResult(result) && result.ok
150
152
  if (!ok) throw new Error(`verification failed: ${step.id}`)
151
153
  return result
152
154
  }
153
155
  case "approve": {
154
156
  await this.emit({ type: "approval.required", id: step.id, reason: step.reason })
155
- const approved = await this.single(step.id, () => step.run(this.context()))
157
+ const approved = await this.single(step.id, () => step.run(this.context(depth)))
156
158
  if (approved !== true) throw new Error(`approval denied: ${step.id}`)
157
159
  return approved
158
160
  }
159
161
  case "checkpoint": {
160
- const result = await this.single(step.id, () => step.run(this.context()))
162
+ const result = await this.single(step.id, () => step.run(this.context(depth)))
161
163
  await this.emit({ type: "checkpoint.created", id: step.id })
162
164
  return result
163
165
  }
164
166
  case "handoff":
165
- return this.single(step.id, () => step.run(this.context()))
167
+ return this.single(step.id, () => step.run(this.context(depth)))
166
168
  case "sequence": {
167
169
  const results: unknown[] = []
168
170
  for (const child of step.steps) results.push(await this.execute(child, depth + 1))
169
171
  return results
170
172
  }
171
173
  case "parallel":
174
+ if (step.steps.length === 0) return []
172
175
  return this.parallel(step.steps, step.maxConcurrency ?? step.steps.length, depth)
173
176
  case "retry": {
174
177
  const attempts = step.attempts ?? 3
175
178
  if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 16)
176
179
  throw new RangeError("workflow: attempts must be between 1 and 16")
180
+ const backoffMs = step.backoffMs ?? 0
181
+ if (!Number.isSafeInteger(backoffMs) || backoffMs < 0)
182
+ throw new RangeError("workflow: backoffMs must be a non-negative safe integer")
177
183
  let last: unknown
178
184
  for (let attempt = 0; attempt < attempts; attempt++) {
179
185
  try {
180
186
  return await this.execute(step.step, depth + 1)
181
187
  } catch (error) {
182
188
  last = error
183
- if (attempt + 1 < attempts && (step.backoffMs ?? 0) > 0)
184
- await delay(Math.min(step.backoffMs! * (attempt + 1), 10_000), this.options.signal)
189
+ if (attempt + 1 < attempts && backoffMs > 0)
190
+ await delay(Math.min(backoffMs * (attempt + 1), 10_000), this.options.signal)
185
191
  }
186
192
  }
187
193
  throw last instanceof Error ? last : new Error(String(last))
188
194
  }
189
195
  case "branch": {
190
- const selected = await this.single(step.id, () => step.when(this.context()))
196
+ const selected = await this.single(step.id, () => step.when(this.context(depth)))
191
197
  return selected === true
192
198
  ? this.execute(step.then, depth + 1)
193
199
  : step.otherwise === undefined
@@ -229,16 +235,24 @@ export class WorkflowRunner {
229
235
  throw new RangeError("workflow: maxConcurrency must be positive")
230
236
  const outputs: unknown[] = new Array(steps.length)
231
237
  let next = 0
238
+ // The first failure starts no further step, and the group settles only once the steps already
239
+ // running have, so nothing runs on after the workflow reports its failure.
240
+ let failure: { readonly error: unknown } | undefined
232
241
  const worker = async (): Promise<void> => {
233
- for (;;) {
242
+ while (failure === undefined) {
234
243
  const index = next++
235
244
  if (index >= steps.length) return
236
- outputs[index] = await this.execute(steps[index]!, depth + 1)
245
+ try {
246
+ outputs[index] = await this.execute(steps[index]!, depth + 1)
247
+ } catch (error) {
248
+ failure ??= { error }
249
+ }
237
250
  }
238
251
  }
239
252
  await Promise.all(
240
253
  Array.from({ length: Math.min(maxConcurrency, steps.length) }, () => worker()),
241
254
  )
255
+ if (failure !== undefined) throw failure.error
242
256
  return outputs
243
257
  }
244
258
 
@@ -249,21 +263,27 @@ export class WorkflowRunner {
249
263
 
250
264
  async function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
251
265
  await new Promise<void>((resolve, reject) => {
252
- const timer = setTimeout(resolve, ms)
253
- if (signal === undefined) return
254
- if (signal.aborted) {
255
- clearTimeout(timer)
256
- reject(new Error("workflow cancelled"))
257
- return
266
+ let settled = false
267
+ let timer: ReturnType<typeof setTimeout> | undefined
268
+ let onAbort: (() => void) | undefined
269
+ const cleanup = (): void => {
270
+ if (timer !== undefined) clearTimeout(timer)
271
+ if (signal !== undefined && onAbort !== undefined)
272
+ signal.removeEventListener("abort", onAbort)
258
273
  }
259
- signal.addEventListener(
260
- "abort",
261
- () => {
262
- clearTimeout(timer)
263
- reject(new Error("workflow cancelled"))
264
- },
265
- { once: true },
266
- )
274
+ const settle = (error?: Error): void => {
275
+ if (settled) return
276
+ settled = true
277
+ cleanup()
278
+ if (error === undefined) resolve()
279
+ else reject(error)
280
+ }
281
+ timer = setTimeout(() => settle(), ms)
282
+ if (signal === undefined) return
283
+ onAbort = () => settle(new Error("workflow cancelled"))
284
+ signal.addEventListener("abort", onAbort, { once: true })
285
+ // Abort can race listener registration; check again after subscribing.
286
+ if (signal.aborted) onAbort()
267
287
  })
268
288
  }
269
289