pi-workflow-engine 0.10.3 → 0.12.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/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -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,227 @@ 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
+ readonly code?: string;
82
+ readonly details?: Readonly<Record<string, string | number | boolean | null>>;
83
+ }
84
+
85
+ export type ParallelSettledResult<T> =
86
+ | { readonly ok: true; readonly value: T }
87
+ | { readonly ok: false; readonly error: ParallelSettledError };
88
+
89
+ export interface WorkflowParallel {
90
+ <T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
91
+ <T>(thunks: Array<() => Promise<T>>, options: ParallelSettledOptions): Promise<Array<ParallelSettledResult<T>>>;
69
92
  }
70
93
 
71
94
  export interface PipelineOptions {
72
95
  readonly signal?: AbortSignal;
73
96
  readonly abortController?: AbortController;
97
+ /** Maximum time to drain already-started item chains after a fatal failure. */
98
+ readonly drainTimeoutMs?: number;
74
99
  }
75
100
 
101
+ const DEFAULT_FATAL_DRAIN_TIMEOUT_MS = 1_000;
102
+
76
103
  export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
77
104
  return values.filter((value): value is T => value != null);
78
105
  }
79
106
 
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>> {
107
+ /** Run every thunk concurrently and wait for all results; recoverable failures become null slots by default. */
108
+ export function parallel<T>(
109
+ thunks: Array<() => Promise<T>>,
110
+ options: ParallelOptions & ParallelSettledOptions,
111
+ ): Promise<Array<ParallelSettledResult<T>>>;
112
+ export function parallel<T>(thunks: Array<() => Promise<T>>, options?: ParallelOptions): Promise<Array<T | null>>;
113
+ export async function parallel<T>(
114
+ thunks: Array<() => Promise<T>>,
115
+ options: ParallelOptions & Partial<ParallelSettledOptions> = {},
116
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
117
+ if (options.settled) {
118
+ return await runParallel(
119
+ thunks,
120
+ options,
121
+ (value): ParallelSettledResult<T> => ({ ok: true, value }),
122
+ (error): ParallelSettledResult<T> => ({ ok: false, error: serializeParallelError(error) }),
123
+ );
124
+ }
125
+
126
+ return await runParallel(
127
+ thunks,
128
+ options,
129
+ (value): T | null => value,
130
+ (): T | null => null,
131
+ );
132
+ }
133
+
134
+ export function bindParallel(options: ParallelOptions): WorkflowParallel {
135
+ const executionOptions: ParallelOptions = {
136
+ signal: options.signal,
137
+ abortController: options.abortController,
138
+ limit: options.limit,
139
+ drainTimeoutMs: options.drainTimeoutMs,
140
+ };
141
+
142
+ function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
143
+ function boundParallel<T>(
144
+ thunks: Array<() => Promise<T>>,
145
+ workflowOptions: ParallelSettledOptions,
146
+ ): Promise<Array<ParallelSettledResult<T>>>;
147
+ async function boundParallel<T>(
148
+ thunks: Array<() => Promise<T>>,
149
+ workflowOptions?: ParallelSettledOptions,
150
+ ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
151
+ if (workflowOptions?.settled) {
152
+ return await parallel(thunks, { ...executionOptions, settled: true });
153
+ }
154
+ return await parallel(thunks, executionOptions);
155
+ }
156
+
157
+ return boundParallel;
158
+ }
159
+
160
+ async function runParallel<T, Result>(
161
+ thunks: Array<() => Promise<T>>,
162
+ options: ParallelOptions,
163
+ onSuccess: (value: T) => Result,
164
+ onFailure: (error: unknown) => Result,
165
+ ): Promise<Result[]> {
82
166
  throwIfAborted(options.signal);
83
- const results = new Array<T | null>(thunks.length);
84
- let remaining = thunks.length;
85
- if (remaining === 0) return results;
167
+ const results = new Array<Result>(thunks.length);
168
+ if (thunks.length === 0) return results;
86
169
 
87
170
  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);
171
+ return await new Promise<Result[]>((resolve, reject) => {
172
+ let terminal = false;
173
+ let nextIndex = 0;
174
+ let workersRemaining = limit;
175
+ let fatalFailure: { readonly error: unknown } | undefined;
176
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
177
+ const cleanup = () => {
178
+ options.signal?.removeEventListener("abort", onAbort);
179
+ if (drainTimer) clearTimeout(drainTimer);
180
+ };
181
+ const settle = (force = false) => {
182
+ if (terminal || (!force && workersRemaining > 0)) return;
183
+ terminal = true;
97
184
  cleanup();
98
- reject(error);
185
+ if (fatalFailure) reject(fatalFailure.error);
186
+ else resolve(results);
99
187
  };
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();
188
+ const recordFatalFailure = (error: unknown) => {
189
+ if (fatalFailure) return;
190
+ fatalFailure = { error };
191
+ options.abortController?.abort(error);
192
+ const timeoutMs = normalizeDrainTimeout(options.drainTimeoutMs);
193
+ drainTimer = setTimeout(() => settle(true), timeoutMs);
111
194
  };
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
- );
195
+ const completeWorker = () => {
196
+ workersRemaining--;
197
+ settle();
198
+ };
199
+ const onAbort = () => recordFatalFailure(abortReason(options.signal));
200
+ const runWorker = async () => {
201
+ while (!fatalFailure) {
202
+ throwIfAborted(options.signal);
203
+ const index = nextIndex++;
204
+ if (index >= thunks.length) return;
205
+ try {
206
+ const value = await thunks[index]();
207
+ if (fatalFailure) return;
208
+ results[index] = onSuccess(value);
209
+ } catch (error) {
210
+ if (fatalFailure) return;
211
+ if (isFatalWorkflowError(error, options.signal)) {
212
+ recordFatalFailure(error);
213
+ return;
214
+ }
215
+ results[index] = onFailure(error);
216
+ }
132
217
  }
133
218
  };
134
219
 
135
220
  options.signal?.addEventListener("abort", onAbort, { once: true });
136
- launchMore();
221
+ if (options.signal?.aborted) onAbort();
222
+ for (let worker = 0; worker < limit; worker++) {
223
+ void runWorker().then(completeWorker, (error) => {
224
+ recordFatalFailure(error);
225
+ completeWorker();
226
+ });
227
+ }
137
228
  });
138
229
  }
139
230
 
231
+ function serializeParallelError(error: unknown): ParallelSettledError {
232
+ const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
233
+ const name = readStringProperty(error, "name");
234
+ const code = readStringProperty(error, "code");
235
+ const details = readSerializableDetails(error);
236
+ return {
237
+ ...(name && name.length > 0 ? { name } : {}),
238
+ message,
239
+ ...(code && code.length > 0 ? { code } : {}),
240
+ ...(details ? { details } : {}),
241
+ };
242
+ }
243
+
244
+ function readStringProperty(value: unknown, property: "message" | "name" | "code"): string | undefined {
245
+ const candidate = readProperty(value, property);
246
+ return typeof candidate === "string" ? candidate : undefined;
247
+ }
248
+
249
+ function readSerializableDetails(
250
+ value: unknown,
251
+ ): Readonly<Record<string, string | number | boolean | null>> | undefined {
252
+ const candidate = readProperty(value, "details");
253
+ if (typeof candidate !== "object" || candidate === null) return undefined;
254
+ try {
255
+ const details: Record<string, string | number | boolean | null> = {};
256
+ for (const [key, detail] of Object.entries(candidate)) {
257
+ if (
258
+ typeof detail === "string" ||
259
+ typeof detail === "boolean" ||
260
+ detail === null ||
261
+ (typeof detail === "number" && Number.isFinite(detail))
262
+ ) {
263
+ details[key] = detail;
264
+ }
265
+ }
266
+ return Object.keys(details).length > 0 ? details : undefined;
267
+ } catch {
268
+ return undefined;
269
+ }
270
+ }
271
+
272
+ function readProperty(value: unknown, property: string): unknown {
273
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
274
+ try {
275
+ return Reflect.get(value, property);
276
+ } catch {
277
+ return undefined;
278
+ }
279
+ }
280
+
140
281
  function normalizeLimit(limit: number | undefined, itemCount: number): number {
141
282
  if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
142
283
  return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
143
284
  }
144
285
 
286
+ function normalizeDrainTimeout(timeoutMs: number | undefined): number {
287
+ if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
288
+ return Math.max(0, Math.trunc(timeoutMs));
289
+ }
290
+
145
291
  /**
146
292
  * Run each item through all stages independently. There is NO barrier between
147
293
  * stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
@@ -165,6 +311,21 @@ export async function pipeline<Item, A, B, C>(
165
311
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
166
312
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
167
313
  ): Promise<Array<C | null>>;
314
+ export async function pipeline<Item, A, B, C, D>(
315
+ items: readonly Item[],
316
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
317
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
318
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
319
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
320
+ ): Promise<Array<D | null>>;
321
+ export async function pipeline<Item, A, B, C, D, E>(
322
+ items: readonly Item[],
323
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
324
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
325
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
326
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
327
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
328
+ ): Promise<Array<E | null>>;
168
329
  export async function pipeline(
169
330
  items: readonly unknown[],
170
331
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -192,6 +353,21 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
192
353
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
193
354
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
194
355
  ): Promise<Array<C | null>>;
356
+ async function boundPipeline<Item, A, B, C, D>(
357
+ items: readonly Item[],
358
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
359
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
360
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
361
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
362
+ ): Promise<Array<D | null>>;
363
+ async function boundPipeline<Item, A, B, C, D, E>(
364
+ items: readonly Item[],
365
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
366
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
367
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
368
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
369
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
370
+ ): Promise<Array<E | null>>;
195
371
  async function boundPipeline(
196
372
  items: readonly unknown[],
197
373
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -207,24 +383,17 @@ export async function pipelineWithOptions(
207
383
  options: PipelineOptions = {},
208
384
  ): Promise<Array<unknown | null>> {
209
385
  throwIfAborted(options.signal);
210
- return Promise.all(
211
- items.map(async (item, index) => {
386
+ return await parallel(
387
+ items.map((item, index) => async () => {
212
388
  throwIfAborted(options.signal);
213
389
  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;
390
+ for (const stage of stages) {
391
+ throwIfAborted(options.signal);
392
+ acc = await stage(acc, item, index);
226
393
  }
394
+ return acc;
227
395
  }),
396
+ options,
228
397
  );
229
398
  }
230
399
 
@@ -1,19 +1,34 @@
1
1
  import type { PerfSink } from "./perf.ts";
2
- import { runBoundedProcess } from "./process-runner.ts";
2
+ import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
3
+ import { parseAllowedDiffCommand, reviewDiffCommand, type ReviewDiffTarget } from "./review-diff-target.ts";
3
4
 
4
- export interface AllowedDiffCommand {
5
- readonly file: "git" | "gh";
6
- readonly args: readonly string[];
7
- }
5
+ export {
6
+ formatReviewDiffTarget,
7
+ GitReviewDiffTargetSchema,
8
+ isReviewDiffTarget,
9
+ parseAllowedDiffCommand,
10
+ PullRequestReviewDiffTargetSchema,
11
+ reviewDiffCommand,
12
+ ReviewDiffTargetSchema,
13
+ reviewGitDiffBaseline,
14
+ type GitDiffBaselineTarget,
15
+ type GitReviewDiffTarget,
16
+ type PullRequestReviewDiffTarget,
17
+ type ReviewDiffTarget,
18
+ } from "./review-diff-target.ts";
8
19
 
9
- export interface DiffCaptureResult {
10
- readonly ok: boolean;
20
+ interface DiffCaptureResultBase {
11
21
  readonly stdout: string;
12
22
  readonly durationMs: number;
13
23
  readonly bytes: number;
14
- readonly error?: string;
15
24
  }
16
25
 
26
+ export type DiffCaptureResult =
27
+ | (DiffCaptureResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
28
+ | (DiffCaptureResultBase & { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure });
29
+
30
+ export type DiffCaptureFailure = BoundedProcessFailure | { readonly kind: "invalid-target"; readonly message: string };
31
+
17
32
  export interface DiffCaptureOptions {
18
33
  readonly cwd: string;
19
34
  readonly signal?: AbortSignal;
@@ -24,59 +39,27 @@ export interface DiffCaptureOptions {
24
39
  readonly killGraceMs?: number;
25
40
  }
26
41
 
27
- 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"]);
30
-
31
- export function parseAllowedDiffCommand(command: string): AllowedDiffCommand | { error: string } {
32
- const tokens = command.trim().split(/\s+/).filter(Boolean);
33
- if (tokens.length < 2) return { error: "empty or incomplete diff command" };
34
- if (tokens[0] === "git" && tokens[1] === "diff") return parseGitDiff(tokens);
35
- if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
36
- 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] };
40
- }
41
- return { error: "diff command is not in the git/gh allowlist" };
42
- }
43
-
44
- function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error: string } {
45
- const args = tokens.slice(2);
46
- const safeArgs = ["diff", "--no-ext-diff"];
47
- let pathMode = false;
48
-
49
- for (const token of args) {
50
- if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
51
- if (token === "--") {
52
- pathMode = true;
53
- safeArgs.push(token);
54
- continue;
55
- }
56
- if (!pathMode && token.startsWith("-")) {
57
- if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
58
- if (token !== "--no-ext-diff") safeArgs.push(token);
59
- continue;
60
- }
61
- safeArgs.push(token);
62
- }
63
-
64
- return { file: "git", args: safeArgs };
65
- }
66
-
67
- function isAllowedGitDiffFlag(token: string): boolean {
68
- return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
69
- }
70
-
71
42
  export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
72
43
  const parsed = parseAllowedDiffCommand(command);
73
44
  if ("error" in parsed) {
74
- return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
45
+ return {
46
+ ok: false,
47
+ stdout: "",
48
+ durationMs: 0,
49
+ bytes: 0,
50
+ error: parsed.error,
51
+ failure: { kind: "invalid-target", message: parsed.error },
52
+ };
75
53
  }
76
54
 
55
+ return await captureDiffTarget(parsed, options);
56
+ }
57
+
58
+ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
59
+ const command = reviewDiffCommand(target);
77
60
  const result = await runBoundedProcess({
78
- file: parsed.file,
79
- args: parsed.args,
61
+ file: command.file,
62
+ args: command.args,
80
63
  cwd: options.cwd,
81
64
  env: diffCaptureEnv(options.env),
82
65
  signal: options.signal,
@@ -1,11 +1,14 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
3
  import { pathToFileURL } from "node:url";
5
- import type { WorkflowModule } from "./types.ts";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
- import { parseWorkflowModule } from "./workflow-module.ts";
8
- import { BUILTIN_WORKFLOW_FILES, BUILTIN_WORKFLOWS } from "./workflows.ts";
7
+ import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
8
+ import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
9
+ import { captureTreeFingerprint } from "./tree-fingerprint.ts";
10
+ import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
11
+ import { unknownErrorMessage } from "./unknown-error.ts";
9
12
 
10
13
  export interface DiscoverWorkflowsOptions {
11
14
  readonly refresh?: boolean;
@@ -13,10 +16,17 @@ export interface DiscoverWorkflowsOptions {
13
16
  readonly userWorkflowDir?: string;
14
17
  }
15
18
 
16
- const discoveryCache = new Map<string, Map<string, WorkflowModule>>();
19
+ const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
20
+ const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
17
21
 
22
+ const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
18
23
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
19
- async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set()): Promise<WorkflowModule[]> {
24
+ async function loadDir(
25
+ dir: string,
26
+ sourceIdentity: (path: string) => WorkflowSourceIdentity,
27
+ excludeFiles: ReadonlySet<string> = new Set(),
28
+ provenanceRoot?: string,
29
+ ): Promise<LoadedWorkflow[]> {
20
30
  let entries: string[];
21
31
  try {
22
32
  entries = await readdir(dir);
@@ -24,30 +34,43 @@ async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set(
24
34
  return [];
25
35
  }
26
36
 
27
- const modules: WorkflowModule[] = [];
37
+ const before = provenanceRoot ? await captureDiscoveryFingerprint(provenanceRoot) : undefined;
38
+ const modules: Array<{ readonly path: string; readonly module: Parameters<typeof loadWorkflow>[0] }> = [];
28
39
  for (const name of entries) {
29
40
  if (excludeFiles.has(name)) continue;
30
41
  if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
31
42
  try {
32
- const loaded: unknown = await import(pathToFileURL(join(dir, name)).href);
43
+ const path = join(dir, name);
44
+ const importUrl = pathToFileURL(path);
45
+ if (before?.kind === "verified") importUrl.searchParams.set("workflowSource", before.fingerprint);
46
+ const loaded: unknown = await import(importUrl.href);
33
47
  const parsed = parseWorkflowModule(loaded);
34
- if ("module" in parsed) modules.push(parsed.module);
48
+ if ("module" in parsed) modules.push({ path, module: parsed.module });
35
49
  else console.error(`[workflow-engine] skipped ${name}: ${parsed.reason}`);
36
50
  } catch (error) {
37
51
  // Drop-in loading depends on the runtime resolving TS + the bundled typebox.
38
52
  // Failures here are non-fatal: the static registry still works.
39
- console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
53
+ console.error(`[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`);
40
54
  }
41
55
  }
42
- return modules;
56
+ return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
57
+ }
58
+
59
+ async function captureDiscoveryFingerprint(root: string) {
60
+ return await captureTreeFingerprint({
61
+ root,
62
+ excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
63
+ maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
64
+ maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
65
+ });
43
66
  }
44
67
 
45
68
  /**
46
69
  * All available workflows by name. Static registry wins on name collisions, so the
47
70
  * bundled example is always the verified one even if a same-named file is dropped in.
48
71
  */
49
- export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, WorkflowModule>> {
50
- const userWorkflowDir = options.userWorkflowDir ?? join(homedir(), ".pi", "agent", "workflows");
72
+ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
73
+ const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
51
74
  const cacheKey = `${repoDir}\0${userWorkflowDir}`;
52
75
  const cached = discoveryCache.get(cacheKey);
53
76
  if (cached && !options.refresh) {
@@ -56,13 +79,43 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
56
79
  }
57
80
 
58
81
  const byName = await timed(options.perf, "discovery.total_ms", async () => {
59
- const next = new Map<string, WorkflowModule>();
60
- for (const mod of BUILTIN_WORKFLOWS) next.set(mod.meta.name, mod);
82
+ const next = new Map<string, LoadedWorkflow>();
83
+ const builtinSource = await captureDiscoveryFingerprint(BUILTIN_SOURCE_ROOT);
84
+ for (const definition of BUILTIN_WORKFLOW_DEFINITIONS) {
85
+ const source: WorkflowSourceIdentity =
86
+ builtinSource.kind === "verified"
87
+ ? {
88
+ kind: "file",
89
+ path: definition.path,
90
+ root: definition.root,
91
+ fingerprint: builtinSource.fingerprint,
92
+ }
93
+ : {
94
+ kind: "unverifiable",
95
+ reason: `built-in workflow source capture failed: ${builtinSource.reason}`,
96
+ };
97
+ next.set(definition.module.meta.name, loadWorkflow(definition.module, source));
98
+ }
61
99
 
62
100
  const repoWorkflowDir = join(repoDir, "workflows");
63
101
  const [repoDynamic, userDynamic] = await Promise.all([
64
- timed(options.perf, "discovery.repo_dir_ms", () => loadDir(repoWorkflowDir, BUILTIN_WORKFLOW_FILES)),
65
- timed(options.perf, "discovery.user_dir_ms", () => loadDir(userWorkflowDir)),
102
+ timed(options.perf, "discovery.repo_dir_ms", () =>
103
+ loadDir(
104
+ repoWorkflowDir,
105
+ () => ({
106
+ kind: "unverifiable",
107
+ reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
108
+ }),
109
+ BUILTIN_WORKFLOW_FILES,
110
+ repoDir,
111
+ ),
112
+ ),
113
+ timed(options.perf, "discovery.user_dir_ms", () =>
114
+ loadDir(userWorkflowDir, () => ({
115
+ kind: "unverifiable",
116
+ reason: "dynamic user workflow dependencies do not have a declared trusted source root",
117
+ })),
118
+ ),
66
119
  ]);
67
120
 
68
121
  for (const mod of repoDynamic) {