pi-workflow-engine 0.11.0 → 0.13.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 +255 -97
- 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 +13 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
- 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 +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
- 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 +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -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 +127 -41
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- 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 +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
- 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 -3
- 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/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -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 +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +270 -17
- package/package.json +1 -1
|
@@ -5,6 +5,14 @@ export class WorkflowAbortError extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Abort reason used when a durable run can be resumed after its host session shuts down. */
|
|
9
|
+
export class WorkflowPauseError extends WorkflowAbortError {
|
|
10
|
+
constructor(message = "Workflow paused because its host session shut down") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WorkflowPauseError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export function abortReason(signal: AbortSignal | undefined): Error {
|
|
9
17
|
const reason = signal?.reason;
|
|
10
18
|
if (safeInstanceOf(reason, Error)) return reason;
|
|
@@ -29,6 +37,10 @@ export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undef
|
|
|
29
37
|
return false;
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
export function isWorkflowPauseError(error: unknown): error is WorkflowPauseError {
|
|
41
|
+
return safeInstanceOf(error, WorkflowPauseError);
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
|
|
33
45
|
if (!parent) return () => {};
|
|
34
46
|
if (parent.aborted) {
|
|
@@ -40,6 +52,32 @@ export function linkAbortSignal(parent: AbortSignal | undefined, controller: Abo
|
|
|
40
52
|
return () => parent.removeEventListener("abort", onAbort);
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
export async function raceWithAbort<T>(operation: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
if (!signal) return await operation();
|
|
58
|
+
|
|
59
|
+
return await new Promise<T>((resolve, reject) => {
|
|
60
|
+
let settled = false;
|
|
61
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
62
|
+
const finish = (action: () => void) => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
cleanup();
|
|
66
|
+
action();
|
|
67
|
+
};
|
|
68
|
+
const onAbort = () => finish(() => reject(abortReason(signal)));
|
|
69
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
if (signal.aborted) {
|
|
71
|
+
onAbort();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
void operation().then(
|
|
75
|
+
(value) => finish(() => resolve(value)),
|
|
76
|
+
(error: unknown) => finish(() => reject(error)),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
43
81
|
function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
|
|
44
82
|
try {
|
|
45
83
|
return value instanceof constructor;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fuzzyFilter, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
interface ArgumentCompletionCandidate {
|
|
4
|
+
readonly value: string;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ArgumentPrefixParts {
|
|
9
|
+
readonly completed: readonly string[];
|
|
10
|
+
readonly current: string;
|
|
11
|
+
readonly base: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Split a slash-command argument prefix while retaining the text Pi will replace. */
|
|
15
|
+
export function splitArgumentPrefix(argumentPrefix: string): ArgumentPrefixParts {
|
|
16
|
+
const match = /(\S*)$/.exec(argumentPrefix);
|
|
17
|
+
const current = match?.[1] ?? "";
|
|
18
|
+
const base = argumentPrefix.slice(0, argumentPrefix.length - current.length);
|
|
19
|
+
const completed = base.trim().split(/\s+/).filter(Boolean);
|
|
20
|
+
return { completed, current, base };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Complete the current argument and return full replacement values, as Pi expects. */
|
|
24
|
+
export function completeCurrentArgument(
|
|
25
|
+
argumentPrefix: string,
|
|
26
|
+
candidates: readonly ArgumentCompletionCandidate[],
|
|
27
|
+
): AutocompleteItem[] | null {
|
|
28
|
+
const { current, base } = splitArgumentPrefix(argumentPrefix);
|
|
29
|
+
const matches = fuzzyFilter([...candidates], current, (candidate) => candidate.value)
|
|
30
|
+
.map((candidate) => ({
|
|
31
|
+
value: `${base}${candidate.value}`,
|
|
32
|
+
label: candidate.value,
|
|
33
|
+
...(candidate.description === undefined ? {} : { description: candidate.description }),
|
|
34
|
+
}));
|
|
35
|
+
return matches.length > 0 ? matches : null;
|
|
36
|
+
}
|
|
@@ -14,22 +14,28 @@ export class Semaphore {
|
|
|
14
14
|
private active = 0;
|
|
15
15
|
private readonly waiters: Array<() => void> = [];
|
|
16
16
|
|
|
17
|
-
constructor(private readonly max: number) {
|
|
17
|
+
constructor(private readonly max: number) {
|
|
18
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError("Semaphore capacity must be a positive integer");
|
|
19
|
+
}
|
|
18
20
|
|
|
19
21
|
async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
|
|
20
22
|
throwIfAborted(options.signal);
|
|
21
23
|
const queuedAt = performance.now();
|
|
22
24
|
if (this.active >= this.max) {
|
|
23
25
|
await this.waitForSlot(options.signal);
|
|
26
|
+
} else {
|
|
27
|
+
this.active++;
|
|
24
28
|
}
|
|
25
|
-
options.onQueueWaitMs?.(performance.now() - queuedAt);
|
|
26
|
-
throwIfAborted(options.signal);
|
|
27
|
-
this.active++;
|
|
28
29
|
try {
|
|
30
|
+
options.onQueueWaitMs?.(performance.now() - queuedAt);
|
|
31
|
+
throwIfAborted(options.signal);
|
|
29
32
|
return await fn();
|
|
30
33
|
} finally {
|
|
31
|
-
this
|
|
32
|
-
|
|
34
|
+
// Transfer ownership directly. A selected waiter owns this reservation
|
|
35
|
+
// even before its continuation runs, including if it is then cancelled.
|
|
36
|
+
const next = this.waiters.shift();
|
|
37
|
+
if (next) next();
|
|
38
|
+
else this.active--;
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -78,6 +84,8 @@ export interface ParallelSettledOptions {
|
|
|
78
84
|
export interface ParallelSettledError {
|
|
79
85
|
readonly name?: string;
|
|
80
86
|
readonly message: string;
|
|
87
|
+
readonly code?: string;
|
|
88
|
+
readonly details?: Readonly<Record<string, string | number | boolean | null>>;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
export type ParallelSettledResult<T> =
|
|
@@ -229,14 +237,48 @@ async function runParallel<T, Result>(
|
|
|
229
237
|
function serializeParallelError(error: unknown): ParallelSettledError {
|
|
230
238
|
const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
|
|
231
239
|
const name = readStringProperty(error, "name");
|
|
232
|
-
|
|
240
|
+
const code = readStringProperty(error, "code");
|
|
241
|
+
const details = readSerializableDetails(error);
|
|
242
|
+
return {
|
|
243
|
+
...(name && name.length > 0 ? { name } : {}),
|
|
244
|
+
message,
|
|
245
|
+
...(code && code.length > 0 ? { code } : {}),
|
|
246
|
+
...(details ? { details } : {}),
|
|
247
|
+
};
|
|
233
248
|
}
|
|
234
249
|
|
|
235
|
-
function readStringProperty(value: unknown, property: "message" | "name"): string | undefined {
|
|
250
|
+
function readStringProperty(value: unknown, property: "message" | "name" | "code"): string | undefined {
|
|
251
|
+
const candidate = readProperty(value, property);
|
|
252
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readSerializableDetails(
|
|
256
|
+
value: unknown,
|
|
257
|
+
): Readonly<Record<string, string | number | boolean | null>> | undefined {
|
|
258
|
+
const candidate = readProperty(value, "details");
|
|
259
|
+
if (typeof candidate !== "object" || candidate === null) return undefined;
|
|
260
|
+
try {
|
|
261
|
+
const details: Record<string, string | number | boolean | null> = {};
|
|
262
|
+
for (const [key, detail] of Object.entries(candidate)) {
|
|
263
|
+
if (
|
|
264
|
+
typeof detail === "string" ||
|
|
265
|
+
typeof detail === "boolean" ||
|
|
266
|
+
detail === null ||
|
|
267
|
+
(typeof detail === "number" && Number.isFinite(detail))
|
|
268
|
+
) {
|
|
269
|
+
details[key] = detail;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return Object.keys(details).length > 0 ? details : undefined;
|
|
273
|
+
} catch {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readProperty(value: unknown, property: string): unknown {
|
|
236
279
|
if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
|
|
237
280
|
try {
|
|
238
|
-
|
|
239
|
-
return typeof candidate === "string" ? candidate : undefined;
|
|
281
|
+
return Reflect.get(value, property);
|
|
240
282
|
} catch {
|
|
241
283
|
return undefined;
|
|
242
284
|
}
|
|
@@ -275,6 +317,21 @@ export async function pipeline<Item, A, B, C>(
|
|
|
275
317
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
276
318
|
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
277
319
|
): Promise<Array<C | null>>;
|
|
320
|
+
export async function pipeline<Item, A, B, C, D>(
|
|
321
|
+
items: readonly Item[],
|
|
322
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
323
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
324
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
325
|
+
stage4: (prev: C, item: Item, index: number) => Promise<D>,
|
|
326
|
+
): Promise<Array<D | null>>;
|
|
327
|
+
export async function pipeline<Item, A, B, C, D, E>(
|
|
328
|
+
items: readonly Item[],
|
|
329
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
330
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
331
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
332
|
+
stage4: (prev: C, item: Item, index: number) => Promise<D>,
|
|
333
|
+
stage5: (prev: D, item: Item, index: number) => Promise<E>,
|
|
334
|
+
): Promise<Array<E | null>>;
|
|
278
335
|
export async function pipeline(
|
|
279
336
|
items: readonly unknown[],
|
|
280
337
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
@@ -302,6 +359,21 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
|
|
|
302
359
|
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
303
360
|
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
304
361
|
): Promise<Array<C | null>>;
|
|
362
|
+
async function boundPipeline<Item, A, B, C, D>(
|
|
363
|
+
items: readonly Item[],
|
|
364
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
365
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
366
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
367
|
+
stage4: (prev: C, item: Item, index: number) => Promise<D>,
|
|
368
|
+
): Promise<Array<D | null>>;
|
|
369
|
+
async function boundPipeline<Item, A, B, C, D, E>(
|
|
370
|
+
items: readonly Item[],
|
|
371
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
372
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
373
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
374
|
+
stage4: (prev: C, item: Item, index: number) => Promise<D>,
|
|
375
|
+
stage5: (prev: D, item: Item, index: number) => Promise<E>,
|
|
376
|
+
): Promise<Array<E | null>>;
|
|
305
377
|
async function boundPipeline(
|
|
306
378
|
items: readonly unknown[],
|
|
307
379
|
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
@@ -1,30 +1,21 @@
|
|
|
1
|
-
import { Type, type Static } from "typebox";
|
|
2
|
-
import { Value } from "typebox/value";
|
|
3
1
|
import type { PerfSink } from "./perf.ts";
|
|
4
2
|
import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
|
|
22
|
-
|
|
23
|
-
export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
|
|
24
|
-
export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
|
|
25
|
-
|
|
26
|
-
/** Serializable semantic identity of the diff being reviewed. */
|
|
27
|
-
export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
|
|
3
|
+
import { parseAllowedDiffCommand, reviewDiffCommand, type ReviewDiffTarget } from "./review-diff-target.ts";
|
|
4
|
+
|
|
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";
|
|
28
19
|
|
|
29
20
|
interface DiffCaptureResultBase {
|
|
30
21
|
readonly stdout: string;
|
|
@@ -48,78 +39,6 @@ export interface DiffCaptureOptions {
|
|
|
48
39
|
readonly killGraceMs?: number;
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
|
|
52
|
-
const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
|
|
53
|
-
const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
54
|
-
|
|
55
|
-
export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
|
|
56
|
-
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
57
|
-
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
58
|
-
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
59
|
-
const parsed = parseGitDiff(tokens);
|
|
60
|
-
return "error" in parsed ? parsed : parsed.target;
|
|
61
|
-
}
|
|
62
|
-
if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
|
|
63
|
-
const number = Number(tokens[3]);
|
|
64
|
-
if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
|
|
65
|
-
const flags = tokens.slice(4);
|
|
66
|
-
if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
|
|
67
|
-
const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
|
|
68
|
-
if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
|
|
69
|
-
return { kind: "pull-request", number };
|
|
70
|
-
}
|
|
71
|
-
return { error: "diff command is not in the git/gh allowlist" };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function parseGitDiff(
|
|
75
|
-
tokens: readonly string[],
|
|
76
|
-
): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
|
|
77
|
-
const args = tokens.slice(2);
|
|
78
|
-
const safeArgs = ["diff", "--no-ext-diff"];
|
|
79
|
-
const operands: string[] = [];
|
|
80
|
-
let staged = false;
|
|
81
|
-
let pathMode = false;
|
|
82
|
-
|
|
83
|
-
for (const token of args) {
|
|
84
|
-
if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
|
|
85
|
-
if (token === "--") {
|
|
86
|
-
pathMode = true;
|
|
87
|
-
safeArgs.push(token);
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (!pathMode && token.startsWith("-")) {
|
|
91
|
-
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
92
|
-
staged ||= token === "--cached" || token === "--staged";
|
|
93
|
-
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (!pathMode) operands.push(token);
|
|
97
|
-
safeArgs.push(token);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const baseline = classifyGitDiffBaseline(staged, operands);
|
|
101
|
-
return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
|
|
105
|
-
if (operands.length > 1) {
|
|
106
|
-
return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
|
|
107
|
-
}
|
|
108
|
-
if (staged) return { kind: "index" };
|
|
109
|
-
|
|
110
|
-
const range = operands.find((operand) => operand.includes(".."));
|
|
111
|
-
if (range) {
|
|
112
|
-
const separator = range.includes("...") ? "..." : "..";
|
|
113
|
-
return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return { kind: "working-tree" };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function isAllowedGitDiffFlag(token: string): boolean {
|
|
120
|
-
return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
42
|
export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
|
|
124
43
|
const parsed = parseAllowedDiffCommand(command);
|
|
125
44
|
if ("error" in parsed) {
|
|
@@ -157,39 +76,6 @@ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffC
|
|
|
157
76
|
return result;
|
|
158
77
|
}
|
|
159
78
|
|
|
160
|
-
export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
|
|
161
|
-
return target.kind === "pull-request"
|
|
162
|
-
? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
|
|
163
|
-
: { file: "git", args: target.args };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
|
|
167
|
-
export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
|
|
168
|
-
const command = reviewDiffCommand(target);
|
|
169
|
-
return [command.file, ...command.args].join(" ");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
|
|
173
|
-
export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
|
|
174
|
-
const parsed = parseGitDiff(["git", ...target.args]);
|
|
175
|
-
if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
|
|
176
|
-
return parsed.baseline;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Validate persisted targets by round-tripping through the single command allowlist parser. */
|
|
180
|
-
export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
|
|
181
|
-
if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
|
|
182
|
-
const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
|
|
183
|
-
return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
|
|
187
|
-
if (left.kind !== right.kind) return false;
|
|
188
|
-
if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
|
|
189
|
-
if (right.kind !== "git" || left.args.length !== right.args.length) return false;
|
|
190
|
-
return left.args.every((arg, index) => arg === right.args[index]);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
79
|
function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
194
80
|
return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
|
|
195
81
|
}
|
|
@@ -1,13 +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";
|
|
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
9
|
import { captureTreeFingerprint } from "./tree-fingerprint.ts";
|
|
10
10
|
import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
|
|
11
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
11
12
|
|
|
12
13
|
export interface DiscoverWorkflowsOptions {
|
|
13
14
|
readonly refresh?: boolean;
|
|
@@ -15,6 +16,9 @@ export interface DiscoverWorkflowsOptions {
|
|
|
15
16
|
readonly userWorkflowDir?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
|
|
20
|
+
const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
|
|
21
|
+
|
|
18
22
|
const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
|
|
19
23
|
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
20
24
|
async function loadDir(
|
|
@@ -46,7 +50,7 @@ async function loadDir(
|
|
|
46
50
|
} catch (error) {
|
|
47
51
|
// Drop-in loading depends on the runtime resolving TS + the bundled typebox.
|
|
48
52
|
// Failures here are non-fatal: the static registry still works.
|
|
49
|
-
console.error(`[workflow-engine] skipped ${name}: ${
|
|
53
|
+
console.error(`[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`);
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
|
|
@@ -56,8 +60,8 @@ async function captureDiscoveryFingerprint(root: string) {
|
|
|
56
60
|
return await captureTreeFingerprint({
|
|
57
61
|
root,
|
|
58
62
|
excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
|
|
59
|
-
maxBytes:
|
|
60
|
-
maxFiles:
|
|
63
|
+
maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
|
|
64
|
+
maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
|
|
61
65
|
});
|
|
62
66
|
}
|
|
63
67
|
|
|
@@ -66,7 +70,7 @@ async function captureDiscoveryFingerprint(root: string) {
|
|
|
66
70
|
* bundled example is always the verified one even if a same-named file is dropped in.
|
|
67
71
|
*/
|
|
68
72
|
export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
|
|
69
|
-
const userWorkflowDir = options.userWorkflowDir ?? join(
|
|
73
|
+
const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
|
|
70
74
|
const cacheKey = `${repoDir}\0${userWorkflowDir}`;
|
|
71
75
|
const cached = discoveryCache.get(cacheKey);
|
|
72
76
|
if (cached && !options.refresh) {
|