pi-workflow-engine 0.10.3 → 0.11.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 (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +168 -121
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. package/package.json +9 -9
@@ -1,6 +1,9 @@
1
1
  import { DefaultResourceLoader, getAgentDir, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
2
+ import { dirname, resolve } from "node:path";
3
+ import { logicalWorkspacePath, type ReplayWorkspaceRoots } from "./replay-path-identity.ts";
4
+ import { captureTreeFingerprint } from "./tree-fingerprint.ts";
5
+ import type { ResolvedSkillIdentity } from "./resume-context.ts";
2
6
 
3
- export type AgentSkillOption = readonly string[] | undefined;
4
7
  export type AgentSkillRequestSource = "explicit" | "prompt";
5
8
 
6
9
  export interface AgentSkillRequest {
@@ -21,6 +24,18 @@ export interface AgentSkillResourceSetup {
21
24
  readonly diagnostics: readonly ResourceDiagnostic[];
22
25
  }
23
26
 
27
+ export type AgentSkillIdentityCapture =
28
+ | { readonly kind: "verified"; readonly skills: readonly ResolvedSkillIdentity[] }
29
+ | { readonly kind: "unverifiable"; readonly reason: string };
30
+
31
+ export interface AgentSkillIdentityOptions extends ReplayWorkspaceRoots {
32
+ readonly signal?: AbortSignal;
33
+ }
34
+
35
+ const SKILL_FINGERPRINT_EXCLUSIONS = new Set([".git", "node_modules"]);
36
+ const SKILL_FINGERPRINT_MAX_BYTES = 8 << 20;
37
+ const SKILL_FINGERPRINT_MAX_FILES = 1024;
38
+
24
39
  const SLASH_SKILL_PATTERN = /\/skill:([a-z0-9][a-z0-9-]{0,63})/gi;
25
40
  const SKILLS_FIELD_PATTERN = /(?<!\/)\bskills?\s*[:=]\s*([^\n.;]+)/gi;
26
41
  const VERB_SKILL_LIST_PATTERN = /\b(?:include|use|enable|load|provide|allow|grant|with)\s+(?:the\s+|this\s+|these\s+)?skills?\s+([^\n.;]+)/gi;
@@ -131,6 +146,33 @@ export function appendSkillReminder(prompt: string, skills: readonly Pick<Skill,
131
146
  return `${prompt}\n\nWorkflow subagent skills enabled: ${skillList}. If you rely on an enabled skill, first read its SKILL.md with the read tool and follow its instructions. No other skills are available in this subagent.`;
132
147
  }
133
148
 
149
+ /** Capture the exact selected skill directories before journal lookup. */
150
+ export async function captureAgentSkillIdentities(
151
+ skills: readonly Skill[],
152
+ options: AgentSkillIdentityOptions,
153
+ ): Promise<AgentSkillIdentityCapture> {
154
+ const identities: ResolvedSkillIdentity[] = [];
155
+ for (const skill of skills) {
156
+ const root = resolve(dirname(skill.filePath));
157
+ const capture = await captureTreeFingerprint({
158
+ root,
159
+ excludedRelativePaths: SKILL_FINGERPRINT_EXCLUSIONS,
160
+ maxBytes: SKILL_FINGERPRINT_MAX_BYTES,
161
+ maxFiles: SKILL_FINGERPRINT_MAX_FILES,
162
+ signal: options.signal,
163
+ });
164
+ if (capture.kind === "unverifiable") {
165
+ return { kind: "unverifiable", reason: `skill ${skill.name} could not be fingerprinted: ${capture.reason}` };
166
+ }
167
+ identities.push({
168
+ name: skill.name,
169
+ path: logicalWorkspacePath(root, options) ?? `external:${skill.name}`,
170
+ fingerprint: capture.fingerprint,
171
+ });
172
+ }
173
+ return { kind: "verified", skills: identities };
174
+ }
175
+
134
176
  function collectPatternMatches(text: string, pattern: RegExp, selectors: string[], captureIndex: number): void {
135
177
  pattern.lastIndex = 0;
136
178
  for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
@@ -0,0 +1,81 @@
1
+ import type { AgentExecutionOptions, RunContext } from "./agent-runner-types.ts";
2
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
+
4
+ interface AgentWorkspaceBase {
5
+ readonly cwd: string;
6
+ wrapResult(result: unknown): Promise<unknown>;
7
+ dispose(): Promise<void>;
8
+ }
9
+
10
+ export interface SharedAgentWorkspace extends AgentWorkspaceBase {
11
+ readonly kind: "shared";
12
+ }
13
+
14
+ export interface IsolatedAgentWorkspace extends AgentWorkspaceBase {
15
+ readonly kind: "isolated";
16
+ readonly baselineOid: string;
17
+ }
18
+
19
+ export type AgentWorkspace = SharedAgentWorkspace | IsolatedAgentWorkspace;
20
+
21
+ export async function createAgentWorkspace(
22
+ rc: RunContext,
23
+ opts: AgentExecutionOptions,
24
+ label: string,
25
+ ): Promise<AgentWorkspace> {
26
+ if (opts.isolation !== "worktree") {
27
+ return {
28
+ kind: "shared",
29
+ cwd: rc.cwd,
30
+ async wrapResult(result) {
31
+ return result;
32
+ },
33
+ async dispose() {},
34
+ };
35
+ }
36
+
37
+ const probe = await rc.worktrees.probe(rc.signal);
38
+ if (!probe.ok) {
39
+ throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
40
+ }
41
+ if (!probe.inside) {
42
+ throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
43
+ }
44
+
45
+ const added = await rc.worktrees.add(rc.signal, opts.worktreeBaseline);
46
+ if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
47
+ const worktreePath = added.path;
48
+ rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
49
+
50
+ return {
51
+ kind: "isolated",
52
+ cwd: worktreePath,
53
+ baselineOid: added.baselineOid,
54
+ async wrapResult(result) {
55
+ const patch = await rc.worktrees.capturePatch(worktreePath, added.baselineOid, rc.signal);
56
+ if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
57
+ return { result, patch: patch.patch, changed: patch.changed };
58
+ },
59
+ async dispose() {
60
+ const removed = await rc.worktrees.remove(worktreePath);
61
+ if (!removed.ok) {
62
+ rc.progress.log(
63
+ `${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
64
+ );
65
+ }
66
+ },
67
+ };
68
+ }
69
+
70
+ export async function disposeAgentWorkspace(
71
+ rc: RunContext,
72
+ label: string,
73
+ workspace: AgentWorkspace | undefined,
74
+ ): Promise<void> {
75
+ if (!workspace) return;
76
+ try {
77
+ await workspace.dispose();
78
+ } catch (error) {
79
+ rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
80
+ }
81
+ }
@@ -32,6 +32,13 @@ export interface WorkflowBudget {
32
32
  remaining(): number;
33
33
  }
34
34
 
35
+ /** Refuse a new model turn after the run has consumed its output-token budget. */
36
+ export function assertWorkflowBudgetAvailable(budget: WorkflowBudget): void {
37
+ if (budget.total !== null && budget.remaining() <= 0) {
38
+ throw new WorkflowBudgetExceededError(budget.total, budget.spent());
39
+ }
40
+ }
41
+
35
42
  /**
36
43
  * Build a budget backed by the run's usage sink. `spent()` reads
37
44
  * `usage.snapshot().totals.output` — the same output-token sum surfaced in the
@@ -7,7 +7,7 @@ export class WorkflowAbortError extends Error {
7
7
 
8
8
  export function abortReason(signal: AbortSignal | undefined): Error {
9
9
  const reason = signal?.reason;
10
- if (reason instanceof Error) return reason;
10
+ if (safeInstanceOf(reason, Error)) return reason;
11
11
  if (typeof reason === "string" && reason.length > 0) return new WorkflowAbortError(reason);
12
12
  return new WorkflowAbortError();
13
13
  }
@@ -18,8 +18,14 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
18
18
 
19
19
  export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
20
20
  if (signal?.aborted) return true;
21
- if (error instanceof WorkflowAbortError) return true;
22
- if (typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError") return true;
21
+ if (safeInstanceOf(error, WorkflowAbortError)) return true;
22
+ if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
23
+ try {
24
+ return error.name === "AbortError";
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
23
29
  return false;
24
30
  }
25
31
 
@@ -33,3 +39,11 @@ export function linkAbortSignal(parent: AbortSignal | undefined, controller: Abo
33
39
  parent.addEventListener("abort", onAbort, { once: true });
34
40
  return () => parent.removeEventListener("abort", onAbort);
35
41
  }
42
+
43
+ function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
44
+ try {
45
+ return value instanceof constructor;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
3
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
4
 
4
5
  /**
5
6
  * A counting semaphore: bounds how many async tasks run at once.
@@ -66,82 +67,191 @@ export interface ParallelOptions {
66
67
  readonly signal?: AbortSignal;
67
68
  readonly abortController?: AbortController;
68
69
  readonly limit?: number;
70
+ /** Maximum time to drain already-started tasks after a fatal failure. */
71
+ readonly drainTimeoutMs?: number;
72
+ }
73
+
74
+ export interface ParallelSettledOptions {
75
+ readonly settled: true;
76
+ }
77
+
78
+ export interface ParallelSettledError {
79
+ readonly name?: string;
80
+ readonly message: string;
81
+ }
82
+
83
+ export type ParallelSettledResult<T> =
84
+ | { readonly ok: true; readonly value: T }
85
+ | { readonly ok: false; readonly error: ParallelSettledError };
86
+
87
+ export interface WorkflowParallel {
88
+ <T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
89
+ <T>(thunks: Array<() => Promise<T>>, options: ParallelSettledOptions): Promise<Array<ParallelSettledResult<T>>>;
69
90
  }
70
91
 
71
92
  export interface PipelineOptions {
72
93
  readonly signal?: AbortSignal;
73
94
  readonly abortController?: AbortController;
95
+ /** Maximum time to drain already-started item chains after a fatal failure. */
96
+ readonly drainTimeoutMs?: number;
74
97
  }
75
98
 
99
+ const DEFAULT_FATAL_DRAIN_TIMEOUT_MS = 1_000;
100
+
76
101
  export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
77
102
  return values.filter((value): value is T => value != null);
78
103
  }
79
104
 
80
- /** Run every thunk concurrently and wait for all results; recoverable failures become null slots. */
81
- export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<Array<T | null>> {
105
+ /** Run every thunk concurrently and wait for all results; recoverable failures become null slots by default. */
106
+ export function parallel<T>(
107
+ thunks: Array<() => Promise<T>>,
108
+ options: ParallelOptions & ParallelSettledOptions,
109
+ ): Promise<Array<ParallelSettledResult<T>>>;
110
+ export function parallel<T>(thunks: Array<() => Promise<T>>, options?: ParallelOptions): Promise<Array<T | null>>;
111
+ export async function parallel<T>(
112
+ thunks: Array<() => Promise<T>>,
113
+ options: ParallelOptions & Partial<ParallelSettledOptions> = {},
114
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
115
+ if (options.settled) {
116
+ return await runParallel(
117
+ thunks,
118
+ options,
119
+ (value): ParallelSettledResult<T> => ({ ok: true, value }),
120
+ (error): ParallelSettledResult<T> => ({ ok: false, error: serializeParallelError(error) }),
121
+ );
122
+ }
123
+
124
+ return await runParallel(
125
+ thunks,
126
+ options,
127
+ (value): T | null => value,
128
+ (): T | null => null,
129
+ );
130
+ }
131
+
132
+ export function bindParallel(options: ParallelOptions): WorkflowParallel {
133
+ const executionOptions: ParallelOptions = {
134
+ signal: options.signal,
135
+ abortController: options.abortController,
136
+ limit: options.limit,
137
+ drainTimeoutMs: options.drainTimeoutMs,
138
+ };
139
+
140
+ function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
141
+ function boundParallel<T>(
142
+ thunks: Array<() => Promise<T>>,
143
+ workflowOptions: ParallelSettledOptions,
144
+ ): Promise<Array<ParallelSettledResult<T>>>;
145
+ async function boundParallel<T>(
146
+ thunks: Array<() => Promise<T>>,
147
+ workflowOptions?: ParallelSettledOptions,
148
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
149
+ if (workflowOptions?.settled) {
150
+ return await parallel(thunks, { ...executionOptions, settled: true });
151
+ }
152
+ return await parallel(thunks, executionOptions);
153
+ }
154
+
155
+ return boundParallel;
156
+ }
157
+
158
+ async function runParallel<T, Result>(
159
+ thunks: Array<() => Promise<T>>,
160
+ options: ParallelOptions,
161
+ onSuccess: (value: T) => Result,
162
+ onFailure: (error: unknown) => Result,
163
+ ): Promise<Result[]> {
82
164
  throwIfAborted(options.signal);
83
- const results = new Array<T | null>(thunks.length);
84
- let remaining = thunks.length;
85
- if (remaining === 0) return results;
165
+ const results = new Array<Result>(thunks.length);
166
+ if (thunks.length === 0) return results;
86
167
 
87
168
  const limit = normalizeLimit(options.limit, thunks.length);
88
- return await new Promise<Array<T | null>>((resolve, reject) => {
89
- let rejected = false;
90
- let active = 0;
91
- let next = 0;
92
- const cleanup = () => options.signal?.removeEventListener("abort", onAbort);
93
- const rejectOnce = (error: unknown) => {
94
- if (rejected) return;
95
- rejected = true;
96
- options.abortController?.abort(error);
169
+ return await new Promise<Result[]>((resolve, reject) => {
170
+ let terminal = false;
171
+ let nextIndex = 0;
172
+ let workersRemaining = limit;
173
+ let fatalFailure: { readonly error: unknown } | undefined;
174
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
175
+ const cleanup = () => {
176
+ options.signal?.removeEventListener("abort", onAbort);
177
+ if (drainTimer) clearTimeout(drainTimer);
178
+ };
179
+ const settle = (force = false) => {
180
+ if (terminal || (!force && workersRemaining > 0)) return;
181
+ terminal = true;
97
182
  cleanup();
98
- reject(error);
183
+ if (fatalFailure) reject(fatalFailure.error);
184
+ else resolve(results);
99
185
  };
100
- const settleSlot = (index: number, value: T | null) => {
101
- if (rejected) return;
102
- active--;
103
- results[index] = value;
104
- remaining--;
105
- if (remaining === 0) {
106
- cleanup();
107
- resolve(results);
108
- return;
109
- }
110
- launchMore();
186
+ const recordFatalFailure = (error: unknown) => {
187
+ if (fatalFailure) return;
188
+ fatalFailure = { error };
189
+ options.abortController?.abort(error);
190
+ const timeoutMs = normalizeDrainTimeout(options.drainTimeoutMs);
191
+ drainTimer = setTimeout(() => settle(true), timeoutMs);
192
+ };
193
+ const completeWorker = () => {
194
+ workersRemaining--;
195
+ settle();
111
196
  };
112
- const onAbort = () => rejectOnce(abortReason(options.signal));
113
- const launchMore = () => {
114
- while (!rejected && active < limit && next < thunks.length) {
115
- const index = next++;
116
- active++;
117
- void Promise.resolve()
118
- .then(() => {
119
- throwIfAborted(options.signal);
120
- return thunks[index]();
121
- })
122
- .then(
123
- (value) => settleSlot(index, value),
124
- (error: unknown) => {
125
- if (isFatalWorkflowError(error, options.signal)) {
126
- rejectOnce(error);
127
- return;
128
- }
129
- settleSlot(index, null);
130
- },
131
- );
197
+ const onAbort = () => recordFatalFailure(abortReason(options.signal));
198
+ const runWorker = async () => {
199
+ while (!fatalFailure) {
200
+ throwIfAborted(options.signal);
201
+ const index = nextIndex++;
202
+ if (index >= thunks.length) return;
203
+ try {
204
+ const value = await thunks[index]();
205
+ if (fatalFailure) return;
206
+ results[index] = onSuccess(value);
207
+ } catch (error) {
208
+ if (fatalFailure) return;
209
+ if (isFatalWorkflowError(error, options.signal)) {
210
+ recordFatalFailure(error);
211
+ return;
212
+ }
213
+ results[index] = onFailure(error);
214
+ }
132
215
  }
133
216
  };
134
217
 
135
218
  options.signal?.addEventListener("abort", onAbort, { once: true });
136
- launchMore();
219
+ if (options.signal?.aborted) onAbort();
220
+ for (let worker = 0; worker < limit; worker++) {
221
+ void runWorker().then(completeWorker, (error) => {
222
+ recordFatalFailure(error);
223
+ completeWorker();
224
+ });
225
+ }
137
226
  });
138
227
  }
139
228
 
229
+ function serializeParallelError(error: unknown): ParallelSettledError {
230
+ const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
231
+ const name = readStringProperty(error, "name");
232
+ return name && name.length > 0 ? { name, message } : { message };
233
+ }
234
+
235
+ function readStringProperty(value: unknown, property: "message" | "name"): string | undefined {
236
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
237
+ try {
238
+ const candidate = Reflect.get(value, property);
239
+ return typeof candidate === "string" ? candidate : undefined;
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
140
245
  function normalizeLimit(limit: number | undefined, itemCount: number): number {
141
246
  if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
142
247
  return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
143
248
  }
144
249
 
250
+ function normalizeDrainTimeout(timeoutMs: number | undefined): number {
251
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
252
+ return Math.max(0, Math.trunc(timeoutMs));
253
+ }
254
+
145
255
  /**
146
256
  * Run each item through all stages independently. There is NO barrier between
147
257
  * stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
@@ -207,24 +317,17 @@ export async function pipelineWithOptions(
207
317
  options: PipelineOptions = {},
208
318
  ): Promise<Array<unknown | null>> {
209
319
  throwIfAborted(options.signal);
210
- return Promise.all(
211
- items.map(async (item, index) => {
320
+ return await parallel(
321
+ items.map((item, index) => async () => {
212
322
  throwIfAborted(options.signal);
213
323
  let acc: unknown = item;
214
- try {
215
- for (const stage of stages) {
216
- throwIfAborted(options.signal);
217
- acc = await stage(acc, item, index);
218
- }
219
- return acc;
220
- } catch (error) {
221
- if (isFatalWorkflowError(error, options.signal)) {
222
- options.abortController?.abort(error);
223
- throw error;
224
- }
225
- return null;
324
+ for (const stage of stages) {
325
+ throwIfAborted(options.signal);
326
+ acc = await stage(acc, item, index);
226
327
  }
328
+ return acc;
227
329
  }),
330
+ options,
228
331
  );
229
332
  }
230
333
 
@@ -1,19 +1,43 @@
1
+ import { Type, type Static } from "typebox";
2
+ import { Value } from "typebox/value";
1
3
  import type { PerfSink } from "./perf.ts";
2
- import { runBoundedProcess } from "./process-runner.ts";
4
+ import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
3
5
 
4
- export interface AllowedDiffCommand {
5
- readonly file: "git" | "gh";
6
- readonly args: readonly string[];
7
- }
6
+ export type GitDiffBaselineTarget =
7
+ | { readonly kind: "working-tree" }
8
+ | { readonly kind: "index" }
9
+ | { readonly kind: "range"; readonly ref: string };
10
+
11
+ export const GitReviewDiffTargetSchema = Type.Object({
12
+ kind: Type.Literal("git"),
13
+ args: Type.Array(Type.String()),
14
+ });
15
+
16
+ export const PullRequestReviewDiffTargetSchema = Type.Object({
17
+ kind: Type.Literal("pull-request"),
18
+ number: Type.Integer({ minimum: 1 }),
19
+ });
20
+
21
+ export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
8
22
 
9
- export interface DiffCaptureResult {
10
- readonly ok: boolean;
23
+ export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
24
+ export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
25
+
26
+ /** Serializable semantic identity of the diff being reviewed. */
27
+ export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
28
+
29
+ interface DiffCaptureResultBase {
11
30
  readonly stdout: string;
12
31
  readonly durationMs: number;
13
32
  readonly bytes: number;
14
- readonly error?: string;
15
33
  }
16
34
 
35
+ export type DiffCaptureResult =
36
+ | (DiffCaptureResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
37
+ | (DiffCaptureResultBase & { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure });
38
+
39
+ export type DiffCaptureFailure = BoundedProcessFailure | { readonly kind: "invalid-target"; readonly message: string };
40
+
17
41
  export interface DiffCaptureOptions {
18
42
  readonly cwd: string;
19
43
  readonly signal?: AbortSignal;
@@ -25,25 +49,35 @@ export interface DiffCaptureOptions {
25
49
  }
26
50
 
27
51
  const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
28
- const SAFE_GH_FLAG = /^--[A-Za-z0-9-]+(=[A-Za-z0-9_./:@~+=,\-]+)?$/;
29
- const SAFE_GIT_DIFF_FLAGS = new Set(["--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
52
+ const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
53
+ const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
30
54
 
31
- export function parseAllowedDiffCommand(command: string): AllowedDiffCommand | { error: string } {
55
+ export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
32
56
  const tokens = command.trim().split(/\s+/).filter(Boolean);
33
57
  if (tokens.length < 2) return { error: "empty or incomplete diff command" };
34
- if (tokens[0] === "git" && tokens[1] === "diff") return parseGitDiff(tokens);
58
+ if (tokens[0] === "git" && tokens[1] === "diff") {
59
+ const parsed = parseGitDiff(tokens);
60
+ return "error" in parsed ? parsed : parsed.target;
61
+ }
35
62
  if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
63
+ const number = Number(tokens[3]);
64
+ if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
36
65
  const flags = tokens.slice(4);
37
- const unsafe = flags.find((token) => !SAFE_GH_FLAG.test(token));
38
- if (unsafe) return { error: `unsupported gh pr diff token: ${unsafe}` };
39
- return { file: "gh", args: ["pr", "diff", tokens[3], ...flags] };
66
+ if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
67
+ const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
68
+ if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
69
+ return { kind: "pull-request", number };
40
70
  }
41
71
  return { error: "diff command is not in the git/gh allowlist" };
42
72
  }
43
73
 
44
- function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error: string } {
74
+ function parseGitDiff(
75
+ tokens: readonly string[],
76
+ ): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
45
77
  const args = tokens.slice(2);
46
78
  const safeArgs = ["diff", "--no-ext-diff"];
79
+ const operands: string[] = [];
80
+ let staged = false;
47
81
  let pathMode = false;
48
82
 
49
83
  for (const token of args) {
@@ -55,13 +89,31 @@ function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error:
55
89
  }
56
90
  if (!pathMode && token.startsWith("-")) {
57
91
  if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
92
+ staged ||= token === "--cached" || token === "--staged";
58
93
  if (token !== "--no-ext-diff") safeArgs.push(token);
59
94
  continue;
60
95
  }
96
+ if (!pathMode) operands.push(token);
61
97
  safeArgs.push(token);
62
98
  }
63
99
 
64
- return { file: "git", args: safeArgs };
100
+ const baseline = classifyGitDiffBaseline(staged, operands);
101
+ return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
102
+ }
103
+
104
+ function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
105
+ if (operands.length > 1) {
106
+ return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
107
+ }
108
+ if (staged) return { kind: "index" };
109
+
110
+ const range = operands.find((operand) => operand.includes(".."));
111
+ if (range) {
112
+ const separator = range.includes("...") ? "..." : "..";
113
+ return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
114
+ }
115
+
116
+ return { kind: "working-tree" };
65
117
  }
66
118
 
67
119
  function isAllowedGitDiffFlag(token: string): boolean {
@@ -71,12 +123,24 @@ function isAllowedGitDiffFlag(token: string): boolean {
71
123
  export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
72
124
  const parsed = parseAllowedDiffCommand(command);
73
125
  if ("error" in parsed) {
74
- return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
126
+ return {
127
+ ok: false,
128
+ stdout: "",
129
+ durationMs: 0,
130
+ bytes: 0,
131
+ error: parsed.error,
132
+ failure: { kind: "invalid-target", message: parsed.error },
133
+ };
75
134
  }
76
135
 
136
+ return await captureDiffTarget(parsed, options);
137
+ }
138
+
139
+ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
140
+ const command = reviewDiffCommand(target);
77
141
  const result = await runBoundedProcess({
78
- file: parsed.file,
79
- args: parsed.args,
142
+ file: command.file,
143
+ args: command.args,
80
144
  cwd: options.cwd,
81
145
  env: diffCaptureEnv(options.env),
82
146
  signal: options.signal,
@@ -93,6 +157,39 @@ export async function captureDiff(command: string, options: DiffCaptureOptions):
93
157
  return result;
94
158
  }
95
159
 
160
+ export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
161
+ return target.kind === "pull-request"
162
+ ? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
163
+ : { file: "git", args: target.args };
164
+ }
165
+
166
+ /** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
167
+ export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
168
+ const command = reviewDiffCommand(target);
169
+ return [command.file, ...command.args].join(" ");
170
+ }
171
+
172
+ /** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
173
+ export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
174
+ const parsed = parseGitDiff(["git", ...target.args]);
175
+ if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
176
+ return parsed.baseline;
177
+ }
178
+
179
+ /** Validate persisted targets by round-tripping through the single command allowlist parser. */
180
+ export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
181
+ if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
182
+ const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
183
+ return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
184
+ }
185
+
186
+ function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
187
+ if (left.kind !== right.kind) return false;
188
+ if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
189
+ if (right.kind !== "git" || left.args.length !== right.args.length) return false;
190
+ return left.args.every((arg, index) => arg === right.args[index]);
191
+ }
192
+
96
193
  function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
97
194
  return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
98
195
  }