@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.
- 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/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.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/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +12 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +165 -40
- 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/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +209 -29
- 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 +20 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +269 -60
- 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 +28 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +119 -3
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +55 -23
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -5
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/handoffs.ts +20 -8
- package/src/host.ts +146 -23
- package/src/index.ts +7 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +213 -39
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/replay.ts +206 -25
- package/src/rpc.ts +262 -33
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +288 -57
- package/src/subagents.ts +147 -17
- package/src/verification.ts +146 -3
- package/src/workflows.ts +47 -27
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
|
@@ -53,12 +53,124 @@ export interface VerificationOptions {
|
|
|
53
53
|
readonly exposeErrorStacks?: boolean
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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 =
|
|
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.
|
|
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 &&
|
|
184
|
-
await delay(Math.min(
|
|
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
|
-
|
|
242
|
+
while (failure === undefined) {
|
|
234
243
|
const index = next++
|
|
235
244
|
if (index >= steps.length) return
|
|
236
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|