pi-workflow-engine 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,18 +1,20 @@
1
1
  import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
3
+ import { isRecord } from "./guards.ts";
3
4
  import type { WorkflowBackgroundOrigin } from "./types.ts";
4
5
  import {
6
+ stopWorkflowRunRecord,
5
7
  transitionWorkflowRun,
6
8
  type WorkflowRunRecord,
7
9
  type WorkflowRunState,
8
10
  } from "./workflow-run-record.ts";
9
- import { updateWorkflowRunDelivery } from "./workflow-run-background.ts";
11
+ import { updateWorkflowRunDelivery, type WorkflowRunDelivery } from "./workflow-run-background.ts";
10
12
  import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
13
+ import { truncateText } from "./text.ts";
14
+ import { workflowResultSummary } from "./workflow-execution.ts";
11
15
  import { unknownErrorMessage } from "./unknown-error.ts";
12
- import { emptyWorkflowUsageTotals } from "./usage.ts";
13
16
 
14
17
  const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
15
- const BACKGROUND_WIDGET_KEY = "workflow-background";
16
18
  const SHUTDOWN_WAIT_MS = 5_000;
17
19
  const SUMMARY_LIMIT = 500;
18
20
 
@@ -25,7 +27,10 @@ export interface BackgroundWorkflowStartInput {
25
27
 
26
28
  export interface BackgroundWorkflowResultDetails {
27
29
  readonly name: string;
28
- readonly result: { readonly summary: string };
30
+ /** The retained result of a completed run, so the delivery renders like a foreground result; else `{ summary }`. */
31
+ readonly result: unknown;
32
+ readonly summary: string;
33
+ readonly startedAt?: number;
29
34
  readonly completedAt: number;
30
35
  readonly usage: WorkflowRunRecord["usage"];
31
36
  readonly runId: string;
@@ -56,7 +61,7 @@ type BackgroundWorkflowSettledListener = (ctx: ExtensionContext, runId: string)
56
61
  /** Owns background runs for one extension instance and routes their durable completion delivery. */
57
62
  export class BackgroundWorkflowCoordinator {
58
63
  private readonly active = new Map<string, ActiveBackgroundRun>();
59
- private readonly settledListeners = new Set<BackgroundWorkflowSettledListener>();
64
+ private settledListener: BackgroundWorkflowSettledListener | undefined;
60
65
  private readonly pendingDelivery = new Map<string, Set<string>>();
61
66
  private readonly shuttingDown = new Set<string>();
62
67
  private readonly storeForCwd: (cwd: string) => WorkflowRunStore;
@@ -82,13 +87,8 @@ export class BackgroundWorkflowCoordinator {
82
87
  let accepted = false;
83
88
  let deliveryScheduled = false;
84
89
  let settled = false;
85
- let startedSignalled = false;
86
- let resolveStarted: (() => void) | undefined;
87
- let rejectStarted: ((error: unknown) => void) | undefined;
88
- const started = new Promise<void>((resolve, reject) => {
89
- resolveStarted = resolve;
90
- rejectStarted = reject;
91
- });
90
+ // Settles once: a rejection after the run published its metadata is ignored.
91
+ const started = Promise.withResolvers<void>();
92
92
 
93
93
  const scheduleDelivery = (): void => {
94
94
  if (deliveryScheduled || !accepted || !settled || this.shuttingDown.has(sessionId)) return;
@@ -100,18 +100,13 @@ export class BackgroundWorkflowCoordinator {
100
100
 
101
101
  const settledPromise = (async () => {
102
102
  try {
103
- await input.run(controller.signal, () => {
104
- if (startedSignalled) return;
105
- startedSignalled = true;
106
- resolveStarted?.();
107
- });
108
- if (!startedSignalled) rejectStarted?.(new Error("Background workflow ended before publishing run metadata."));
103
+ await input.run(controller.signal, () => started.resolve());
104
+ started.reject(new Error("Background workflow ended before publishing run metadata."));
109
105
  } catch (error) {
110
- if (!startedSignalled) rejectStarted?.(error);
106
+ started.reject(error);
111
107
  } finally {
112
108
  settled = true;
113
109
  this.active.delete(input.runId);
114
- this.updateBackgroundSurface(input.ctx);
115
110
  await this.notifyRunSettled(input.ctx, input.runId);
116
111
  scheduleDelivery();
117
112
  }
@@ -124,7 +119,7 @@ export class BackgroundWorkflowCoordinator {
124
119
  isSettled: () => settled,
125
120
  });
126
121
 
127
- await started;
122
+ await started.promise;
128
123
  const record = await this.storeForCwd(input.ctx.cwd).load(input.runId);
129
124
  if (record?.state !== "running" || !record.background || record.background.origin.sessionId !== sessionId) {
130
125
  controller.abort(new WorkflowAbortError("Background workflow could not persist its origin metadata."));
@@ -132,7 +127,6 @@ export class BackgroundWorkflowCoordinator {
132
127
  }
133
128
 
134
129
  accepted = true;
135
- this.updateBackgroundSurface(input.ctx);
136
130
  scheduleDelivery();
137
131
  }
138
132
 
@@ -145,9 +139,8 @@ export class BackgroundWorkflowCoordinator {
145
139
  );
146
140
  }
147
141
 
148
- onRunSettled(listener: BackgroundWorkflowSettledListener): () => void {
149
- this.settledListeners.add(listener);
150
- return () => this.settledListeners.delete(listener);
142
+ onRunSettled(listener: BackgroundWorkflowSettledListener): void {
143
+ this.settledListener = listener;
151
144
  }
152
145
 
153
146
  async stop(ctx: ExtensionContext, runId: string): Promise<WorkflowRunRecord> {
@@ -243,7 +236,6 @@ export class BackgroundWorkflowCoordinator {
243
236
  this.log(`[workflow:${runId}] failed to force paused state during shutdown: ${unknownErrorMessage(error)}`);
244
237
  }
245
238
  }
246
- if (ctx.hasUI) ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
247
239
  }
248
240
 
249
241
  private async queueOrDeliver(ctx: ExtensionContext, runId: string): Promise<void> {
@@ -262,12 +254,10 @@ export class BackgroundWorkflowCoordinator {
262
254
  }
263
255
 
264
256
  private async notifyRunSettled(ctx: ExtensionContext, runId: string): Promise<void> {
265
- for (const listener of this.settledListeners) {
266
- try {
267
- await listener(ctx, runId);
268
- } catch (error) {
269
- this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
270
- }
257
+ try {
258
+ await this.settledListener?.(ctx, runId);
259
+ } catch (error) {
260
+ this.log(`[workflow:${runId}] background settlement listener failed: ${unknownErrorMessage(error)}`);
271
261
  }
272
262
  }
273
263
 
@@ -292,23 +282,6 @@ export class BackgroundWorkflowCoordinator {
292
282
  this.pendingDelivery.set(sessionId, pending);
293
283
  }
294
284
 
295
- private updateBackgroundSurface(ctx: ExtensionContext): void {
296
- if (!ctx.hasUI) return;
297
- const sessionId = ctx.sessionManager.getSessionId();
298
- const runs = [...this.active.entries()]
299
- .filter(([, run]) => run.sessionId === sessionId)
300
- .map(([runId, run]) => ({ runId, name: run.name }));
301
- if (runs.length === 0) {
302
- ctx.ui.setWidget(BACKGROUND_WIDGET_KEY, undefined);
303
- return;
304
- }
305
- ctx.ui.setWidget(
306
- BACKGROUND_WIDGET_KEY,
307
- [formatBackgroundActivity(runs, ctx.ui.theme)],
308
- { placement: "aboveEditor" },
309
- );
310
- }
311
-
312
285
  private async deliver(ctx: ExtensionContext, runId: string): Promise<boolean> {
313
286
  const store = this.storeForCwd(ctx.cwd);
314
287
  const record = await store.load(runId);
@@ -333,25 +306,18 @@ export class BackgroundWorkflowCoordinator {
333
306
  }
334
307
  }
335
308
 
336
- function formatBackgroundActivity(
337
- runs: readonly { readonly runId: string; readonly name: string }[],
338
- theme: ExtensionContext["ui"]["theme"],
339
- ): string {
340
- const visible = runs.slice(0, 2).map((run) => `${run.name} ${run.runId.slice(0, 8)}`);
341
- const hidden = runs.length - visible.length;
342
- const suffix = hidden > 0 ? ` · +${hidden} more` : "";
343
- return `${theme.fg("accent", "●")} ${theme.bold("Background workflows")} ${theme.fg("dim", `· ${visible.join(" · ")}${suffix}`)}`;
344
- }
345
-
346
309
  export function backgroundOrigin(ctx: Pick<ExtensionContext, "sessionManager">, requestedAt = Date.now()): WorkflowBackgroundOrigin {
347
310
  return { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
348
311
  }
349
312
 
350
313
  export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
351
314
  if (!isDeliverableState(record.state)) throw new Error(`Workflow run ${record.runId} has not finished or paused.`);
315
+ const summary = backgroundSummary(record);
352
316
  return {
353
317
  name: record.workflow.name,
354
- result: { summary: backgroundSummary(record) },
318
+ result: record.state === "completed" && record.result.kind === "value" ? record.result.value : { summary },
319
+ summary,
320
+ startedAt: record.startedAt,
355
321
  completedAt: record.endedAt ?? record.updatedAt,
356
322
  usage: record.usage,
357
323
  runId: record.runId,
@@ -362,14 +328,12 @@ export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWo
362
328
  }
363
329
 
364
330
  function backgroundSummary(record: WorkflowRunRecord): string {
365
- if (record.state !== "completed") return boundedSummary(`Workflow ${record.state}: ${record.message}`);
331
+ if (record.state !== "completed") return truncateText(`Workflow ${record.state}: ${record.message}`, SUMMARY_LIMIT);
366
332
  if (record.result.kind === "unavailable") {
367
- return boundedSummary(`Workflow completed; retained result is unavailable: ${record.result.reason}`);
333
+ return truncateText(`Workflow completed; retained result is unavailable: ${record.result.reason}`, SUMMARY_LIMIT);
368
334
  }
369
- const value = record.result.value;
370
- if (typeof value === "string") return boundedSummary(value);
371
- if (isRecord(value) && typeof value.summary === "string") return boundedSummary(value.summary);
372
- return "Workflow completed. Open run history for the retained result.";
335
+ const summary = workflowResultSummary(record.result.value);
336
+ return summary === undefined ? "Workflow completed. Open run history for the retained result." : truncateText(summary, SUMMARY_LIMIT);
373
337
  }
374
338
 
375
339
  function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): string {
@@ -379,7 +343,7 @@ function formatBackgroundDelivery(details: BackgroundWorkflowResultDetails): str
379
343
  `Run ID: ${details.runId}`,
380
344
  `State: ${details.status}`,
381
345
  "",
382
- details.result.summary,
346
+ details.summary,
383
347
  ].join("\n");
384
348
  }
385
349
 
@@ -404,7 +368,7 @@ function isDeliverableState(state: WorkflowRunState): state is "completed" | "fa
404
368
  async function markDelivery(
405
369
  store: WorkflowRunStore,
406
370
  runId: string,
407
- delivery: Parameters<typeof updateWorkflowRunDelivery>[1],
371
+ delivery: WorkflowRunDelivery,
408
372
  ): Promise<void> {
409
373
  const latest = await store.load(runId);
410
374
  if (!latest?.background || latest.background.delivery.state !== "pending") return;
@@ -424,16 +388,7 @@ async function forcePausedRecord(store: WorkflowRunStore, runId: string): Promis
424
388
  async function forceStoppedRecord(store: WorkflowRunStore, runId: string): Promise<void> {
425
389
  const record = await store.load(runId);
426
390
  if (!record || (record.state !== "queued" && record.state !== "running")) return;
427
- await store.save(transitionWorkflowRun(record, {
428
- state: "stopped",
429
- progress: record.progress,
430
- usage: record.usage ?? record.progress.usage ?? {
431
- agents: [],
432
- totals: emptyWorkflowUsageTotals(),
433
- assistantMessages: 0,
434
- },
435
- error: new WorkflowAbortError("Workflow stopped by user."),
436
- }));
391
+ await store.save(stopWorkflowRunRecord(record));
437
392
  }
438
393
 
439
394
  async function reconcileInterruptedRun(
@@ -482,11 +437,3 @@ async function waitForRuns(runs: readonly Promise<void>[], timeoutMs: number): P
482
437
  if (timer) clearTimeout(timer);
483
438
  }
484
439
  }
485
-
486
- function boundedSummary(value: string): string {
487
- return value.length <= SUMMARY_LIMIT ? value : `${value.slice(0, SUMMARY_LIMIT - 1)}…`;
488
- }
489
-
490
- function isRecord(value: unknown): value is Record<string, unknown> {
491
- return typeof value === "object" && value !== null && !Array.isArray(value);
492
- }
@@ -27,7 +27,7 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
27
27
  export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
28
28
  if (signal?.aborted) return true;
29
29
  if (safeInstanceOf(error, WorkflowAbortError)) return true;
30
- if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
30
+ if (safeInstanceOf(error, DOMException)) {
31
31
  try {
32
32
  return error.name === "AbortError";
33
33
  } catch {
@@ -67,10 +67,6 @@ export async function raceWithAbort<T>(operation: () => Promise<T>, signal: Abor
67
67
  };
68
68
  const onAbort = () => finish(() => reject(abortReason(signal)));
69
69
  signal.addEventListener("abort", onAbort, { once: true });
70
- if (signal.aborted) {
71
- onAbort();
72
- return;
73
- }
74
70
  void operation().then(
75
71
  (value) => finish(() => resolve(value)),
76
72
  (error: unknown) => finish(() => reject(error)),
@@ -1,5 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { abortReason, isFatalWorkflowError, throwIfAborted } from "./cancellation.ts";
3
+ import { isFiniteNumber } from "./guards.ts";
3
4
  import { unknownErrorMessage } from "./unknown-error.ts";
4
5
 
5
6
  /**
@@ -14,22 +15,28 @@ export class Semaphore {
14
15
  private active = 0;
15
16
  private readonly waiters: Array<() => void> = [];
16
17
 
17
- constructor(private readonly max: number) {}
18
+ constructor(private readonly max: number) {
19
+ if (!Number.isInteger(max) || max < 1) throw new RangeError("Semaphore capacity must be a positive integer");
20
+ }
18
21
 
19
22
  async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
20
23
  throwIfAborted(options.signal);
21
24
  const queuedAt = performance.now();
22
25
  if (this.active >= this.max) {
23
26
  await this.waitForSlot(options.signal);
27
+ } else {
28
+ this.active++;
24
29
  }
25
- options.onQueueWaitMs?.(performance.now() - queuedAt);
26
- throwIfAborted(options.signal);
27
- this.active++;
28
30
  try {
31
+ options.onQueueWaitMs?.(performance.now() - queuedAt);
32
+ throwIfAborted(options.signal);
29
33
  return await fn();
30
34
  } finally {
31
- this.active--;
32
- this.waiters.shift()?.();
35
+ // Transfer ownership directly. A selected waiter owns this reservation
36
+ // even before its continuation runs, including if it is then cancelled.
37
+ const next = this.waiters.shift();
38
+ if (next) next();
39
+ else this.active--;
33
40
  }
34
41
  }
35
42
 
@@ -53,10 +60,6 @@ export class Semaphore {
53
60
  cleanup();
54
61
  reject(abortReason(signal));
55
62
  };
56
- if (signal?.aborted) {
57
- reject(abortReason(signal));
58
- return;
59
- }
60
63
  this.waiters.push(waiter);
61
64
  signal?.addEventListener("abort", onAbort, { once: true });
62
65
  });
@@ -132,13 +135,6 @@ export async function parallel<T>(
132
135
  }
133
136
 
134
137
  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
138
  function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
143
139
  function boundParallel<T>(
144
140
  thunks: Array<() => Promise<T>>,
@@ -149,9 +145,9 @@ export function bindParallel(options: ParallelOptions): WorkflowParallel {
149
145
  workflowOptions?: ParallelSettledOptions,
150
146
  ): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
151
147
  if (workflowOptions?.settled) {
152
- return await parallel(thunks, { ...executionOptions, settled: true });
148
+ return await parallel(thunks, { ...options, settled: true });
153
149
  }
154
- return await parallel(thunks, executionOptions);
150
+ return await parallel(thunks, options);
155
151
  }
156
152
 
157
153
  return boundParallel;
@@ -218,7 +214,6 @@ async function runParallel<T, Result>(
218
214
  };
219
215
 
220
216
  options.signal?.addEventListener("abort", onAbort, { once: true });
221
- if (options.signal?.aborted) onAbort();
222
217
  for (let worker = 0; worker < limit; worker++) {
223
218
  void runWorker().then(completeWorker, (error) => {
224
219
  recordFatalFailure(error);
@@ -258,7 +253,7 @@ function readSerializableDetails(
258
253
  typeof detail === "string" ||
259
254
  typeof detail === "boolean" ||
260
255
  detail === null ||
261
- (typeof detail === "number" && Number.isFinite(detail))
256
+ isFiniteNumber(detail)
262
257
  ) {
263
258
  details[key] = detail;
264
259
  }
@@ -279,12 +274,12 @@ function readProperty(value: unknown, property: string): unknown {
279
274
  }
280
275
 
281
276
  function normalizeLimit(limit: number | undefined, itemCount: number): number {
282
- if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
277
+ if (!isFiniteNumber(limit)) return Math.max(1, itemCount);
283
278
  return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
284
279
  }
285
280
 
286
281
  function normalizeDrainTimeout(timeoutMs: number | undefined): number {
287
- if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
282
+ if (!isFiniteNumber(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
288
283
  return Math.max(0, Math.trunc(timeoutMs));
289
284
  }
290
285
 
@@ -296,48 +291,7 @@ function normalizeDrainTimeout(timeoutMs: number | undefined): number {
296
291
  *
297
292
  * Each stage receives (previousResult, originalItem, index).
298
293
  */
299
- export async function pipeline<Item, A>(
300
- items: readonly Item[],
301
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
302
- ): Promise<Array<A | null>>;
303
- export async function pipeline<Item, A, B>(
304
- items: readonly Item[],
305
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
306
- stage2: (prev: A, item: Item, index: number) => Promise<B>,
307
- ): Promise<Array<B | null>>;
308
- export async function pipeline<Item, A, B, C>(
309
- items: readonly Item[],
310
- stage1: (prev: Item, item: Item, index: number) => Promise<A>,
311
- stage2: (prev: A, item: Item, index: number) => Promise<B>,
312
- stage3: (prev: B, item: Item, index: number) => Promise<C>,
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>>;
329
- export async function pipeline(
330
- items: readonly unknown[],
331
- ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
332
- ): Promise<Array<unknown | null>>;
333
- export async function pipeline(
334
- items: readonly unknown[],
335
- ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
336
- ): Promise<Array<unknown | null>> {
337
- return pipelineWithOptions(items, stages);
338
- }
339
-
340
- export function bindPipeline(options: PipelineOptions): Pipeline {
294
+ export function bindPipeline(options: PipelineOptions) {
341
295
  async function boundPipeline<Item, A>(
342
296
  items: readonly Item[],
343
297
  stage1: (prev: Item, item: Item, index: number) => Promise<A>,
@@ -368,33 +322,30 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
368
322
  stage4: (prev: C, item: Item, index: number) => Promise<D>,
369
323
  stage5: (prev: D, item: Item, index: number) => Promise<E>,
370
324
  ): Promise<Array<E | null>>;
325
+ async function boundPipeline(
326
+ items: readonly unknown[],
327
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
328
+ ): Promise<Array<unknown | null>>;
371
329
  async function boundPipeline(
372
330
  items: readonly unknown[],
373
331
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
374
332
  ): Promise<Array<unknown | null>> {
375
- return pipelineWithOptions(items, stages, options);
333
+ return await parallel(
334
+ items.map((item, index) => async () => {
335
+ let acc: unknown = item;
336
+ for (const stage of stages) {
337
+ throwIfAborted(options.signal);
338
+ acc = await stage(acc, item, index);
339
+ }
340
+ return acc;
341
+ }),
342
+ options,
343
+ );
376
344
  }
377
345
  return boundPipeline;
378
346
  }
379
347
 
380
- export async function pipelineWithOptions(
381
- items: readonly unknown[],
382
- stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>,
383
- options: PipelineOptions = {},
384
- ): Promise<Array<unknown | null>> {
385
- throwIfAborted(options.signal);
386
- return await parallel(
387
- items.map((item, index) => async () => {
388
- throwIfAborted(options.signal);
389
- let acc: unknown = item;
390
- for (const stage of stages) {
391
- throwIfAborted(options.signal);
392
- acc = await stage(acc, item, index);
393
- }
394
- return acc;
395
- }),
396
- options,
397
- );
398
- }
348
+ export type Pipeline = ReturnType<typeof bindPipeline>;
399
349
 
400
- export type Pipeline = typeof pipeline;
350
+ /** `pipeline` without a run's abort signal (tests, benchmarks). */
351
+ export const pipeline: Pipeline = bindPipeline({});
@@ -1,13 +1,12 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
7
  import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
8
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";
9
+ import { captureSourceTreeFingerprint, type FingerprintCapture } from "./tree-fingerprint.ts";
11
10
  import { unknownErrorMessage } from "./unknown-error.ts";
12
11
 
13
12
  export interface DiscoverWorkflowsOptions {
@@ -16,16 +15,13 @@ export interface DiscoverWorkflowsOptions {
16
15
  readonly userWorkflowDir?: string;
17
16
  }
18
17
 
19
- const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
20
- const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
21
-
22
18
  const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
23
19
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
24
20
  async function loadDir(
25
21
  dir: string,
26
22
  sourceIdentity: (path: string) => WorkflowSourceIdentity,
27
23
  excludeFiles: ReadonlySet<string> = new Set(),
28
- provenanceRoot?: string,
24
+ captureProvenance?: () => Promise<FingerprintCapture>,
29
25
  ): Promise<LoadedWorkflow[]> {
30
26
  let entries: string[];
31
27
  try {
@@ -33,12 +29,15 @@ async function loadDir(
33
29
  } catch {
34
30
  return [];
35
31
  }
32
+ const candidates = entries.filter(
33
+ (name) => !excludeFiles.has(name) && name.endsWith(".ts") && !name.startsWith("_") && !name.startsWith("."),
34
+ );
35
+ // The shipped workflows/ dir holds only excluded built-ins; skip the tree hash when nothing will be imported.
36
+ if (candidates.length === 0) return [];
36
37
 
37
- const before = provenanceRoot ? await captureDiscoveryFingerprint(provenanceRoot) : undefined;
38
+ const before = await captureProvenance?.();
38
39
  const modules: Array<{ readonly path: string; readonly module: Parameters<typeof loadWorkflow>[0] }> = [];
39
- for (const name of entries) {
40
- if (excludeFiles.has(name)) continue;
41
- if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
40
+ for (const name of candidates) {
42
41
  try {
43
42
  const path = join(dir, name);
44
43
  const importUrl = pathToFileURL(path);
@@ -56,15 +55,6 @@ async function loadDir(
56
55
  return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
57
56
  }
58
57
 
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
- });
66
- }
67
-
68
58
  /**
69
59
  * All available workflows by name. Static registry wins on name collisions, so the
70
60
  * bundled example is always the verified one even if a same-named file is dropped in.
@@ -80,14 +70,14 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
80
70
 
81
71
  const byName = await timed(options.perf, "discovery.total_ms", async () => {
82
72
  const next = new Map<string, LoadedWorkflow>();
83
- const builtinSource = await captureDiscoveryFingerprint(BUILTIN_SOURCE_ROOT);
73
+ const builtinSource = await captureSourceTreeFingerprint(BUILTIN_SOURCE_ROOT);
84
74
  for (const definition of BUILTIN_WORKFLOW_DEFINITIONS) {
85
75
  const source: WorkflowSourceIdentity =
86
76
  builtinSource.kind === "verified"
87
77
  ? {
88
78
  kind: "file",
89
79
  path: definition.path,
90
- root: definition.root,
80
+ root: BUILTIN_SOURCE_ROOT,
91
81
  fingerprint: builtinSource.fingerprint,
92
82
  }
93
83
  : {
@@ -98,6 +88,9 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
98
88
  }
99
89
 
100
90
  const repoWorkflowDir = join(repoDir, "workflows");
91
+ // The shipped extension dir is both the built-in root and the drop-in root; hash that tree once.
92
+ const repoProvenance =
93
+ resolve(repoDir) === resolve(BUILTIN_SOURCE_ROOT) ? async () => builtinSource : () => captureSourceTreeFingerprint(repoDir);
101
94
  const [repoDynamic, userDynamic] = await Promise.all([
102
95
  timed(options.perf, "discovery.repo_dir_ms", () =>
103
96
  loadDir(
@@ -107,7 +100,7 @@ export async function discoverWorkflows(repoDir: string, options: DiscoverWorkfl
107
100
  reason: "dynamic workflow module graphs are not loaded from an immutable source snapshot",
108
101
  }),
109
102
  BUILTIN_WORKFLOW_FILES,
110
- repoDir,
103
+ repoProvenance,
111
104
  ),
112
105
  ),
113
106
  timed(options.perf, "discovery.user_dir_ms", () =>
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { Key, type KeyId } from "@earendil-works/pi-tui";
5
+ import { isRecord } from "./guards.ts";
5
6
 
6
7
  export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
7
8
  export const DEFAULT_REVIEW_RESULTS_SHORTCUT = "ctrl+shift+r" satisfies KeyId;
@@ -112,10 +113,6 @@ function defaultDynamaxShortcuts(): DynamaxShortcuts {
112
113
  return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT, results: DEFAULT_REVIEW_RESULTS_SHORTCUT };
113
114
  }
114
115
 
115
- function isRecord(value: unknown): value is Record<string, unknown> {
116
- return typeof value === "object" && value !== null && !Array.isArray(value);
117
- }
118
-
119
116
  function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
120
117
  console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default workflow shortcuts.`);
121
118
  }