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.
- package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- 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
|
|
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<
|
|
84
|
-
|
|
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<
|
|
89
|
-
let
|
|
90
|
-
let
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
items.map(
|
|
386
|
+
return await parallel(
|
|
387
|
+
items.map((item, index) => async () => {
|
|
212
388
|
throwIfAborted(options.signal);
|
|
213
389
|
let acc: unknown = item;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
79
|
-
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
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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}: ${
|
|
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,
|
|
50
|
-
const userWorkflowDir = options.userWorkflowDir ?? join(
|
|
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,
|
|
60
|
-
|
|
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", () =>
|
|
65
|
-
|
|
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) {
|