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.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- 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
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
149
|
-
this.
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
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
|
|
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
|
|
331
|
+
if (record.state !== "completed") return truncateText(`Workflow ${record.state}: ${record.message}`, SUMMARY_LIMIT);
|
|
366
332
|
if (record.result.kind === "unavailable") {
|
|
367
|
-
return
|
|
333
|
+
return truncateText(`Workflow completed; retained result is unavailable: ${record.result.reason}`, SUMMARY_LIMIT);
|
|
368
334
|
}
|
|
369
|
-
const
|
|
370
|
-
|
|
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.
|
|
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:
|
|
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(
|
|
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 (
|
|
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
|
|
32
|
-
|
|
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, { ...
|
|
148
|
+
return await parallel(thunks, { ...options, settled: true });
|
|
153
149
|
}
|
|
154
|
-
return await parallel(thunks,
|
|
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
|
-
(
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
38
|
+
const before = await captureProvenance?.();
|
|
38
39
|
const modules: Array<{ readonly path: string; readonly module: Parameters<typeof loadWorkflow>[0] }> = [];
|
|
39
|
-
for (const name of
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
}
|