@nifrajs/coding-agent 3.5.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/SECURITY.md +2 -1
- package/dist/approvals.d.ts +14 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +51 -43
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +12 -4
- package/dist/handoffs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +7 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +28 -7
- package/dist/native.js.map +1 -1
- package/dist/orchestration/compile.d.ts +7 -0
- package/dist/orchestration/compile.d.ts.map +1 -1
- package/dist/orchestration/compile.js +16 -2
- package/dist/orchestration/compile.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +9 -4
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +10 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +9 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/rpc.d.ts +1 -1
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +10 -9
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +3 -2
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +6 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +17 -2
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts +30 -0
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +118 -15
- package/dist/subagents.js.map +1 -1
- package/dist/verification.d.ts +5 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +6 -2
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +12 -2
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -6
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/handoffs.ts +20 -8
- package/src/index.ts +2 -0
- package/src/native.ts +41 -8
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/rpc.ts +14 -13
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +18 -2
- package/src/subagents.ts +147 -17
- package/src/verification.ts +6 -2
- package/src/workflows.ts +10 -2
package/src/session-migration.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
|
|
2
2
|
import { basename, join, resolve } from "node:path"
|
|
3
|
+
import { sessionFileStem } from "./sessions.ts"
|
|
3
4
|
|
|
4
5
|
/** Version of the evidence-only legacy session file format. */
|
|
5
6
|
export const SESSION_EVIDENCE_VERSION = 1 as const
|
|
@@ -160,7 +161,7 @@ export async function migrateLegacySession(
|
|
|
160
161
|
const lines = records.map((record) => JSON.stringify(record))
|
|
161
162
|
const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`
|
|
162
163
|
const digest = await sha256Hex(content)
|
|
163
|
-
const targetFile = join(targetRoot, `${sessionId}.jsonl`)
|
|
164
|
+
const targetFile = join(targetRoot, `${sessionFileStem(sessionId)}.jsonl`)
|
|
164
165
|
await commitTarget(targetRoot, targetFile, content, options.signal)
|
|
165
166
|
// Re-read the committed target and validate every line before the caller treats this as active.
|
|
166
167
|
const committed = await readEvidenceTarget(targetFile, sessionId)
|
|
@@ -221,7 +222,7 @@ async function readLegacyEntries(
|
|
|
221
222
|
sessionId: string,
|
|
222
223
|
maxSourceBytes: number,
|
|
223
224
|
): Promise<readonly LegacyEntry[]> {
|
|
224
|
-
const path = join(sourceRoot, `${sessionId}.jsonl`)
|
|
225
|
+
const path = join(sourceRoot, `${sessionFileStem(sessionId)}.jsonl`)
|
|
225
226
|
let text: string
|
|
226
227
|
try {
|
|
227
228
|
const bytes = await readFile(path)
|
package/src/sessions.ts
CHANGED
|
@@ -132,7 +132,7 @@ export class FileSessionStore implements SessionStore {
|
|
|
132
132
|
async checkpoint(sessionId: string, payload: unknown): Promise<void> {
|
|
133
133
|
validateToken(sessionId, "sessionId")
|
|
134
134
|
await this.withSessionLock(sessionId, async () => {
|
|
135
|
-
const target = join(this.root, `${sessionId}.checkpoint.json`)
|
|
135
|
+
const target = join(this.root, `${sessionFileStem(sessionId)}.checkpoint.json`)
|
|
136
136
|
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
137
137
|
await mkdir(dirname(target), { recursive: true })
|
|
138
138
|
const content = JSON.stringify({
|
|
@@ -178,7 +178,7 @@ export class FileSessionStore implements SessionStore {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
private pathFor(sessionId: string): string {
|
|
181
|
-
return join(this.root, `${sessionId}.jsonl`)
|
|
181
|
+
return join(this.root, `${sessionFileStem(sessionId)}.jsonl`)
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
private async nextSequence(sessionId: string): Promise<number> {
|
|
@@ -418,6 +418,22 @@ async function rejectSessionSymlink(path: string): Promise<void> {
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
// Device names Windows reserves even with an extension after them: `nul.jsonl` is the NUL device.
|
|
422
|
+
const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The file name stem for a session id. On Windows a `:` in a file name addresses an NTFS alternate
|
|
426
|
+
* data stream, so it is written as `%3A` there, and an id that starts with a reserved device name has
|
|
427
|
+
* its first character percent-encoded. `%` is outside the id grammar, so no two ids collide.
|
|
428
|
+
*/
|
|
429
|
+
export function sessionFileStem(sessionId: string, platform = process.platform): string {
|
|
430
|
+
if (platform !== "win32") return sessionId
|
|
431
|
+
const stem = sessionId.replaceAll(":", "%3A")
|
|
432
|
+
return WINDOWS_DEVICE.test(stem)
|
|
433
|
+
? `%${stem.charCodeAt(0).toString(16).toUpperCase()}${stem.slice(1)}`
|
|
434
|
+
: stem
|
|
435
|
+
}
|
|
436
|
+
|
|
421
437
|
function validateToken(value: string, name: string): void {
|
|
422
438
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
|
|
423
439
|
throw new TypeError(`session store: ${name} must be a bounded token`)
|
package/src/subagents.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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?.
|
|
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(() =>
|
|
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
|
-
|
|
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 (!
|
|
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,
|
|
152
|
-
!
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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
|
+
}
|
package/src/verification.ts
CHANGED
|
@@ -161,12 +161,16 @@ export async function runNifraReview(options: ReviewOptions): Promise<ReviewExec
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/**
|
|
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
|
+
*/
|
|
165
169
|
export async function runNifraVerification(
|
|
166
170
|
name: "check" | "assure" | "test",
|
|
167
171
|
options: VerificationOptions,
|
|
168
172
|
): Promise<VerificationResult> {
|
|
169
|
-
const command = options.command ?? "nifra"
|
|
173
|
+
const command = options.command ?? (name === "test" ? process.execPath : "nifra")
|
|
170
174
|
const commandArgs = options.commandArgs ?? []
|
|
171
175
|
const args = name === "test" ? ["test"] : [name, "--json"]
|
|
172
176
|
const proc = Bun.spawn([command, ...commandArgs, ...args], {
|
package/src/workflows.ts
CHANGED
|
@@ -235,16 +235,24 @@ export class WorkflowRunner {
|
|
|
235
235
|
throw new RangeError("workflow: maxConcurrency must be positive")
|
|
236
236
|
const outputs: unknown[] = new Array(steps.length)
|
|
237
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
|
|
238
241
|
const worker = async (): Promise<void> => {
|
|
239
|
-
|
|
242
|
+
while (failure === undefined) {
|
|
240
243
|
const index = next++
|
|
241
244
|
if (index >= steps.length) return
|
|
242
|
-
|
|
245
|
+
try {
|
|
246
|
+
outputs[index] = await this.execute(steps[index]!, depth + 1)
|
|
247
|
+
} catch (error) {
|
|
248
|
+
failure ??= { error }
|
|
249
|
+
}
|
|
243
250
|
}
|
|
244
251
|
}
|
|
245
252
|
await Promise.all(
|
|
246
253
|
Array.from({ length: Math.min(maxConcurrency, steps.length) }, () => worker()),
|
|
247
254
|
)
|
|
255
|
+
if (failure !== undefined) throw failure.error
|
|
248
256
|
return outputs
|
|
249
257
|
}
|
|
250
258
|
|