pi-workflow-engine 0.10.3 → 0.11.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 +168 -121
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
- 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 +8 -3
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
- package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -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 +17 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
- 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 +295 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- 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/workflows.ts +28 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
- package/README.md +170 -226
- package/USAGE.md +92 -12
- package/package.json +9 -9
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { DefaultResourceLoader, getAgentDir, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { logicalWorkspacePath, type ReplayWorkspaceRoots } from "./replay-path-identity.ts";
|
|
4
|
+
import { captureTreeFingerprint } from "./tree-fingerprint.ts";
|
|
5
|
+
import type { ResolvedSkillIdentity } from "./resume-context.ts";
|
|
2
6
|
|
|
3
|
-
export type AgentSkillOption = readonly string[] | undefined;
|
|
4
7
|
export type AgentSkillRequestSource = "explicit" | "prompt";
|
|
5
8
|
|
|
6
9
|
export interface AgentSkillRequest {
|
|
@@ -21,6 +24,18 @@ export interface AgentSkillResourceSetup {
|
|
|
21
24
|
readonly diagnostics: readonly ResourceDiagnostic[];
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
export type AgentSkillIdentityCapture =
|
|
28
|
+
| { readonly kind: "verified"; readonly skills: readonly ResolvedSkillIdentity[] }
|
|
29
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
30
|
+
|
|
31
|
+
export interface AgentSkillIdentityOptions extends ReplayWorkspaceRoots {
|
|
32
|
+
readonly signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SKILL_FINGERPRINT_EXCLUSIONS = new Set([".git", "node_modules"]);
|
|
36
|
+
const SKILL_FINGERPRINT_MAX_BYTES = 8 << 20;
|
|
37
|
+
const SKILL_FINGERPRINT_MAX_FILES = 1024;
|
|
38
|
+
|
|
24
39
|
const SLASH_SKILL_PATTERN = /\/skill:([a-z0-9][a-z0-9-]{0,63})/gi;
|
|
25
40
|
const SKILLS_FIELD_PATTERN = /(?<!\/)\bskills?\s*[:=]\s*([^\n.;]+)/gi;
|
|
26
41
|
const VERB_SKILL_LIST_PATTERN = /\b(?:include|use|enable|load|provide|allow|grant|with)\s+(?:the\s+|this\s+|these\s+)?skills?\s+([^\n.;]+)/gi;
|
|
@@ -131,6 +146,33 @@ export function appendSkillReminder(prompt: string, skills: readonly Pick<Skill,
|
|
|
131
146
|
return `${prompt}\n\nWorkflow subagent skills enabled: ${skillList}. If you rely on an enabled skill, first read its SKILL.md with the read tool and follow its instructions. No other skills are available in this subagent.`;
|
|
132
147
|
}
|
|
133
148
|
|
|
149
|
+
/** Capture the exact selected skill directories before journal lookup. */
|
|
150
|
+
export async function captureAgentSkillIdentities(
|
|
151
|
+
skills: readonly Skill[],
|
|
152
|
+
options: AgentSkillIdentityOptions,
|
|
153
|
+
): Promise<AgentSkillIdentityCapture> {
|
|
154
|
+
const identities: ResolvedSkillIdentity[] = [];
|
|
155
|
+
for (const skill of skills) {
|
|
156
|
+
const root = resolve(dirname(skill.filePath));
|
|
157
|
+
const capture = await captureTreeFingerprint({
|
|
158
|
+
root,
|
|
159
|
+
excludedRelativePaths: SKILL_FINGERPRINT_EXCLUSIONS,
|
|
160
|
+
maxBytes: SKILL_FINGERPRINT_MAX_BYTES,
|
|
161
|
+
maxFiles: SKILL_FINGERPRINT_MAX_FILES,
|
|
162
|
+
signal: options.signal,
|
|
163
|
+
});
|
|
164
|
+
if (capture.kind === "unverifiable") {
|
|
165
|
+
return { kind: "unverifiable", reason: `skill ${skill.name} could not be fingerprinted: ${capture.reason}` };
|
|
166
|
+
}
|
|
167
|
+
identities.push({
|
|
168
|
+
name: skill.name,
|
|
169
|
+
path: logicalWorkspacePath(root, options) ?? `external:${skill.name}`,
|
|
170
|
+
fingerprint: capture.fingerprint,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return { kind: "verified", skills: identities };
|
|
174
|
+
}
|
|
175
|
+
|
|
134
176
|
function collectPatternMatches(text: string, pattern: RegExp, selectors: string[], captureIndex: number): void {
|
|
135
177
|
pattern.lastIndex = 0;
|
|
136
178
|
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AgentExecutionOptions, RunContext } from "./agent-runner-types.ts";
|
|
2
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
3
|
+
|
|
4
|
+
interface AgentWorkspaceBase {
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
wrapResult(result: unknown): Promise<unknown>;
|
|
7
|
+
dispose(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SharedAgentWorkspace extends AgentWorkspaceBase {
|
|
11
|
+
readonly kind: "shared";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IsolatedAgentWorkspace extends AgentWorkspaceBase {
|
|
15
|
+
readonly kind: "isolated";
|
|
16
|
+
readonly baselineOid: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AgentWorkspace = SharedAgentWorkspace | IsolatedAgentWorkspace;
|
|
20
|
+
|
|
21
|
+
export async function createAgentWorkspace(
|
|
22
|
+
rc: RunContext,
|
|
23
|
+
opts: AgentExecutionOptions,
|
|
24
|
+
label: string,
|
|
25
|
+
): Promise<AgentWorkspace> {
|
|
26
|
+
if (opts.isolation !== "worktree") {
|
|
27
|
+
return {
|
|
28
|
+
kind: "shared",
|
|
29
|
+
cwd: rc.cwd,
|
|
30
|
+
async wrapResult(result) {
|
|
31
|
+
return result;
|
|
32
|
+
},
|
|
33
|
+
async dispose() {},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const probe = await rc.worktrees.probe(rc.signal);
|
|
38
|
+
if (!probe.ok) {
|
|
39
|
+
throw new Error(`Failed to check git worktree availability for isolated agent: ${probe.error ?? "unknown git error"}`);
|
|
40
|
+
}
|
|
41
|
+
if (!probe.inside) {
|
|
42
|
+
throw new Error("Agent requested worktree isolation, but the workflow cwd is not inside a git work tree.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const added = await rc.worktrees.add(rc.signal, opts.worktreeBaseline);
|
|
46
|
+
if ("error" in added) throw new Error(`Failed to create isolated worktree: ${added.error}`);
|
|
47
|
+
const worktreePath = added.path;
|
|
48
|
+
rc.progress.log(`${label}: using isolated worktree ${worktreePath}`);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
kind: "isolated",
|
|
52
|
+
cwd: worktreePath,
|
|
53
|
+
baselineOid: added.baselineOid,
|
|
54
|
+
async wrapResult(result) {
|
|
55
|
+
const patch = await rc.worktrees.capturePatch(worktreePath, added.baselineOid, rc.signal);
|
|
56
|
+
if ("error" in patch) throw new Error(`Failed to capture isolated worktree patch: ${patch.error}`);
|
|
57
|
+
return { result, patch: patch.patch, changed: patch.changed };
|
|
58
|
+
},
|
|
59
|
+
async dispose() {
|
|
60
|
+
const removed = await rc.worktrees.remove(worktreePath);
|
|
61
|
+
if (!removed.ok) {
|
|
62
|
+
rc.progress.log(
|
|
63
|
+
`${label}: failed to remove isolated worktree (${removed.error ?? (removed.stderr.trim() || "unknown error")})`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function disposeAgentWorkspace(
|
|
71
|
+
rc: RunContext,
|
|
72
|
+
label: string,
|
|
73
|
+
workspace: AgentWorkspace | undefined,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (!workspace) return;
|
|
76
|
+
try {
|
|
77
|
+
await workspace.dispose();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
rc.progress.log(`${label}: failed to dispose isolated workspace (${unknownErrorMessage(error)})`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -32,6 +32,13 @@ export interface WorkflowBudget {
|
|
|
32
32
|
remaining(): number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Refuse a new model turn after the run has consumed its output-token budget. */
|
|
36
|
+
export function assertWorkflowBudgetAvailable(budget: WorkflowBudget): void {
|
|
37
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
38
|
+
throw new WorkflowBudgetExceededError(budget.total, budget.spent());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
/**
|
|
36
43
|
* Build a budget backed by the run's usage sink. `spent()` reads
|
|
37
44
|
* `usage.snapshot().totals.output` — the same output-token sum surfaced in the
|
|
@@ -7,7 +7,7 @@ export class WorkflowAbortError extends Error {
|
|
|
7
7
|
|
|
8
8
|
export function abortReason(signal: AbortSignal | undefined): Error {
|
|
9
9
|
const reason = signal?.reason;
|
|
10
|
-
if (reason
|
|
10
|
+
if (safeInstanceOf(reason, Error)) return reason;
|
|
11
11
|
if (typeof reason === "string" && reason.length > 0) return new WorkflowAbortError(reason);
|
|
12
12
|
return new WorkflowAbortError();
|
|
13
13
|
}
|
|
@@ -18,8 +18,14 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
|
18
18
|
|
|
19
19
|
export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
20
20
|
if (signal?.aborted) return true;
|
|
21
|
-
if (error
|
|
22
|
-
if (typeof DOMException !== "undefined" && error
|
|
21
|
+
if (safeInstanceOf(error, WorkflowAbortError)) return true;
|
|
22
|
+
if (typeof DOMException !== "undefined" && safeInstanceOf(error, DOMException)) {
|
|
23
|
+
try {
|
|
24
|
+
return error.name === "AbortError";
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
23
29
|
return false;
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -33,3 +39,11 @@ export function linkAbortSignal(parent: AbortSignal | undefined, controller: Abo
|
|
|
33
39
|
parent.addEventListener("abort", onAbort, { once: true });
|
|
34
40
|
return () => parent.removeEventListener("abort", onAbort);
|
|
35
41
|
}
|
|
42
|
+
|
|
43
|
+
function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
|
|
44
|
+
try {
|
|
45
|
+
return value instanceof constructor;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -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,191 @@ 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
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ParallelSettledResult<T> =
|
|
84
|
+
| { readonly ok: true; readonly value: T }
|
|
85
|
+
| { readonly ok: false; readonly error: ParallelSettledError };
|
|
86
|
+
|
|
87
|
+
export interface WorkflowParallel {
|
|
88
|
+
<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
|
|
89
|
+
<T>(thunks: Array<() => Promise<T>>, options: ParallelSettledOptions): Promise<Array<ParallelSettledResult<T>>>;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface PipelineOptions {
|
|
72
93
|
readonly signal?: AbortSignal;
|
|
73
94
|
readonly abortController?: AbortController;
|
|
95
|
+
/** Maximum time to drain already-started item chains after a fatal failure. */
|
|
96
|
+
readonly drainTimeoutMs?: number;
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
const DEFAULT_FATAL_DRAIN_TIMEOUT_MS = 1_000;
|
|
100
|
+
|
|
76
101
|
export function compactResults<T>(values: ReadonlyArray<T | null | undefined>): T[] {
|
|
77
102
|
return values.filter((value): value is T => value != null);
|
|
78
103
|
}
|
|
79
104
|
|
|
80
|
-
/** Run every thunk concurrently and wait for all results; recoverable failures become null slots. */
|
|
81
|
-
export
|
|
105
|
+
/** Run every thunk concurrently and wait for all results; recoverable failures become null slots by default. */
|
|
106
|
+
export function parallel<T>(
|
|
107
|
+
thunks: Array<() => Promise<T>>,
|
|
108
|
+
options: ParallelOptions & ParallelSettledOptions,
|
|
109
|
+
): Promise<Array<ParallelSettledResult<T>>>;
|
|
110
|
+
export function parallel<T>(thunks: Array<() => Promise<T>>, options?: ParallelOptions): Promise<Array<T | null>>;
|
|
111
|
+
export async function parallel<T>(
|
|
112
|
+
thunks: Array<() => Promise<T>>,
|
|
113
|
+
options: ParallelOptions & Partial<ParallelSettledOptions> = {},
|
|
114
|
+
): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
|
|
115
|
+
if (options.settled) {
|
|
116
|
+
return await runParallel(
|
|
117
|
+
thunks,
|
|
118
|
+
options,
|
|
119
|
+
(value): ParallelSettledResult<T> => ({ ok: true, value }),
|
|
120
|
+
(error): ParallelSettledResult<T> => ({ ok: false, error: serializeParallelError(error) }),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return await runParallel(
|
|
125
|
+
thunks,
|
|
126
|
+
options,
|
|
127
|
+
(value): T | null => value,
|
|
128
|
+
(): T | null => null,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bindParallel(options: ParallelOptions): WorkflowParallel {
|
|
133
|
+
const executionOptions: ParallelOptions = {
|
|
134
|
+
signal: options.signal,
|
|
135
|
+
abortController: options.abortController,
|
|
136
|
+
limit: options.limit,
|
|
137
|
+
drainTimeoutMs: options.drainTimeoutMs,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function boundParallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
|
|
141
|
+
function boundParallel<T>(
|
|
142
|
+
thunks: Array<() => Promise<T>>,
|
|
143
|
+
workflowOptions: ParallelSettledOptions,
|
|
144
|
+
): Promise<Array<ParallelSettledResult<T>>>;
|
|
145
|
+
async function boundParallel<T>(
|
|
146
|
+
thunks: Array<() => Promise<T>>,
|
|
147
|
+
workflowOptions?: ParallelSettledOptions,
|
|
148
|
+
): Promise<Array<T | null> | Array<ParallelSettledResult<T>>> {
|
|
149
|
+
if (workflowOptions?.settled) {
|
|
150
|
+
return await parallel(thunks, { ...executionOptions, settled: true });
|
|
151
|
+
}
|
|
152
|
+
return await parallel(thunks, executionOptions);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return boundParallel;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function runParallel<T, Result>(
|
|
159
|
+
thunks: Array<() => Promise<T>>,
|
|
160
|
+
options: ParallelOptions,
|
|
161
|
+
onSuccess: (value: T) => Result,
|
|
162
|
+
onFailure: (error: unknown) => Result,
|
|
163
|
+
): Promise<Result[]> {
|
|
82
164
|
throwIfAborted(options.signal);
|
|
83
|
-
const results = new Array<
|
|
84
|
-
|
|
85
|
-
if (remaining === 0) return results;
|
|
165
|
+
const results = new Array<Result>(thunks.length);
|
|
166
|
+
if (thunks.length === 0) return results;
|
|
86
167
|
|
|
87
168
|
const limit = normalizeLimit(options.limit, thunks.length);
|
|
88
|
-
return await new Promise<
|
|
89
|
-
let
|
|
90
|
-
let
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
169
|
+
return await new Promise<Result[]>((resolve, reject) => {
|
|
170
|
+
let terminal = false;
|
|
171
|
+
let nextIndex = 0;
|
|
172
|
+
let workersRemaining = limit;
|
|
173
|
+
let fatalFailure: { readonly error: unknown } | undefined;
|
|
174
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
175
|
+
const cleanup = () => {
|
|
176
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
177
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
178
|
+
};
|
|
179
|
+
const settle = (force = false) => {
|
|
180
|
+
if (terminal || (!force && workersRemaining > 0)) return;
|
|
181
|
+
terminal = true;
|
|
97
182
|
cleanup();
|
|
98
|
-
reject(error);
|
|
183
|
+
if (fatalFailure) reject(fatalFailure.error);
|
|
184
|
+
else resolve(results);
|
|
99
185
|
};
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
launchMore();
|
|
186
|
+
const recordFatalFailure = (error: unknown) => {
|
|
187
|
+
if (fatalFailure) return;
|
|
188
|
+
fatalFailure = { error };
|
|
189
|
+
options.abortController?.abort(error);
|
|
190
|
+
const timeoutMs = normalizeDrainTimeout(options.drainTimeoutMs);
|
|
191
|
+
drainTimer = setTimeout(() => settle(true), timeoutMs);
|
|
192
|
+
};
|
|
193
|
+
const completeWorker = () => {
|
|
194
|
+
workersRemaining--;
|
|
195
|
+
settle();
|
|
111
196
|
};
|
|
112
|
-
const onAbort = () =>
|
|
113
|
-
const
|
|
114
|
-
while (!
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
},
|
|
131
|
-
);
|
|
197
|
+
const onAbort = () => recordFatalFailure(abortReason(options.signal));
|
|
198
|
+
const runWorker = async () => {
|
|
199
|
+
while (!fatalFailure) {
|
|
200
|
+
throwIfAborted(options.signal);
|
|
201
|
+
const index = nextIndex++;
|
|
202
|
+
if (index >= thunks.length) return;
|
|
203
|
+
try {
|
|
204
|
+
const value = await thunks[index]();
|
|
205
|
+
if (fatalFailure) return;
|
|
206
|
+
results[index] = onSuccess(value);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (fatalFailure) return;
|
|
209
|
+
if (isFatalWorkflowError(error, options.signal)) {
|
|
210
|
+
recordFatalFailure(error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
results[index] = onFailure(error);
|
|
214
|
+
}
|
|
132
215
|
}
|
|
133
216
|
};
|
|
134
217
|
|
|
135
218
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
-
|
|
219
|
+
if (options.signal?.aborted) onAbort();
|
|
220
|
+
for (let worker = 0; worker < limit; worker++) {
|
|
221
|
+
void runWorker().then(completeWorker, (error) => {
|
|
222
|
+
recordFatalFailure(error);
|
|
223
|
+
completeWorker();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
137
226
|
});
|
|
138
227
|
}
|
|
139
228
|
|
|
229
|
+
function serializeParallelError(error: unknown): ParallelSettledError {
|
|
230
|
+
const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
|
|
231
|
+
const name = readStringProperty(error, "name");
|
|
232
|
+
return name && name.length > 0 ? { name, message } : { message };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readStringProperty(value: unknown, property: "message" | "name"): string | undefined {
|
|
236
|
+
if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
|
|
237
|
+
try {
|
|
238
|
+
const candidate = Reflect.get(value, property);
|
|
239
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
140
245
|
function normalizeLimit(limit: number | undefined, itemCount: number): number {
|
|
141
246
|
if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
|
|
142
247
|
return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
|
|
143
248
|
}
|
|
144
249
|
|
|
250
|
+
function normalizeDrainTimeout(timeoutMs: number | undefined): number {
|
|
251
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return DEFAULT_FATAL_DRAIN_TIMEOUT_MS;
|
|
252
|
+
return Math.max(0, Math.trunc(timeoutMs));
|
|
253
|
+
}
|
|
254
|
+
|
|
145
255
|
/**
|
|
146
256
|
* Run each item through all stages independently. There is NO barrier between
|
|
147
257
|
* stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
|
|
@@ -207,24 +317,17 @@ export async function pipelineWithOptions(
|
|
|
207
317
|
options: PipelineOptions = {},
|
|
208
318
|
): Promise<Array<unknown | null>> {
|
|
209
319
|
throwIfAborted(options.signal);
|
|
210
|
-
return
|
|
211
|
-
items.map(
|
|
320
|
+
return await parallel(
|
|
321
|
+
items.map((item, index) => async () => {
|
|
212
322
|
throwIfAborted(options.signal);
|
|
213
323
|
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;
|
|
324
|
+
for (const stage of stages) {
|
|
325
|
+
throwIfAborted(options.signal);
|
|
326
|
+
acc = await stage(acc, item, index);
|
|
226
327
|
}
|
|
328
|
+
return acc;
|
|
227
329
|
}),
|
|
330
|
+
options,
|
|
228
331
|
);
|
|
229
332
|
}
|
|
230
333
|
|
|
@@ -1,19 +1,43 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
1
3
|
import type { PerfSink } from "./perf.ts";
|
|
2
|
-
import { runBoundedProcess } from "./process-runner.ts";
|
|
4
|
+
import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
|
|
3
5
|
|
|
4
|
-
export
|
|
5
|
-
readonly
|
|
6
|
-
readonly
|
|
7
|
-
}
|
|
6
|
+
export type GitDiffBaselineTarget =
|
|
7
|
+
| { readonly kind: "working-tree" }
|
|
8
|
+
| { readonly kind: "index" }
|
|
9
|
+
| { readonly kind: "range"; readonly ref: string };
|
|
10
|
+
|
|
11
|
+
export const GitReviewDiffTargetSchema = Type.Object({
|
|
12
|
+
kind: Type.Literal("git"),
|
|
13
|
+
args: Type.Array(Type.String()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const PullRequestReviewDiffTargetSchema = Type.Object({
|
|
17
|
+
kind: Type.Literal("pull-request"),
|
|
18
|
+
number: Type.Integer({ minimum: 1 }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
|
|
8
22
|
|
|
9
|
-
export
|
|
10
|
-
|
|
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>;
|
|
28
|
+
|
|
29
|
+
interface DiffCaptureResultBase {
|
|
11
30
|
readonly stdout: string;
|
|
12
31
|
readonly durationMs: number;
|
|
13
32
|
readonly bytes: number;
|
|
14
|
-
readonly error?: string;
|
|
15
33
|
}
|
|
16
34
|
|
|
35
|
+
export type DiffCaptureResult =
|
|
36
|
+
| (DiffCaptureResultBase & { readonly ok: true; readonly error?: undefined; readonly failure?: undefined })
|
|
37
|
+
| (DiffCaptureResultBase & { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure });
|
|
38
|
+
|
|
39
|
+
export type DiffCaptureFailure = BoundedProcessFailure | { readonly kind: "invalid-target"; readonly message: string };
|
|
40
|
+
|
|
17
41
|
export interface DiffCaptureOptions {
|
|
18
42
|
readonly cwd: string;
|
|
19
43
|
readonly signal?: AbortSignal;
|
|
@@ -25,25 +49,35 @@ export interface DiffCaptureOptions {
|
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
|
|
28
|
-
const
|
|
29
|
-
const SAFE_GIT_DIFF_FLAGS = new Set(["--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
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"]);
|
|
30
54
|
|
|
31
|
-
export function parseAllowedDiffCommand(command: string):
|
|
55
|
+
export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
|
|
32
56
|
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
33
57
|
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
34
|
-
if (tokens[0] === "git" && tokens[1] === "diff")
|
|
58
|
+
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
59
|
+
const parsed = parseGitDiff(tokens);
|
|
60
|
+
return "error" in parsed ? parsed : parsed.target;
|
|
61
|
+
}
|
|
35
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" };
|
|
36
65
|
const flags = tokens.slice(4);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return {
|
|
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 };
|
|
40
70
|
}
|
|
41
71
|
return { error: "diff command is not in the git/gh allowlist" };
|
|
42
72
|
}
|
|
43
73
|
|
|
44
|
-
function parseGitDiff(
|
|
74
|
+
function parseGitDiff(
|
|
75
|
+
tokens: readonly string[],
|
|
76
|
+
): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
|
|
45
77
|
const args = tokens.slice(2);
|
|
46
78
|
const safeArgs = ["diff", "--no-ext-diff"];
|
|
79
|
+
const operands: string[] = [];
|
|
80
|
+
let staged = false;
|
|
47
81
|
let pathMode = false;
|
|
48
82
|
|
|
49
83
|
for (const token of args) {
|
|
@@ -55,13 +89,31 @@ function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error:
|
|
|
55
89
|
}
|
|
56
90
|
if (!pathMode && token.startsWith("-")) {
|
|
57
91
|
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
92
|
+
staged ||= token === "--cached" || token === "--staged";
|
|
58
93
|
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
59
94
|
continue;
|
|
60
95
|
}
|
|
96
|
+
if (!pathMode) operands.push(token);
|
|
61
97
|
safeArgs.push(token);
|
|
62
98
|
}
|
|
63
99
|
|
|
64
|
-
|
|
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" };
|
|
65
117
|
}
|
|
66
118
|
|
|
67
119
|
function isAllowedGitDiffFlag(token: string): boolean {
|
|
@@ -71,12 +123,24 @@ function isAllowedGitDiffFlag(token: string): boolean {
|
|
|
71
123
|
export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
|
|
72
124
|
const parsed = parseAllowedDiffCommand(command);
|
|
73
125
|
if ("error" in parsed) {
|
|
74
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
stdout: "",
|
|
129
|
+
durationMs: 0,
|
|
130
|
+
bytes: 0,
|
|
131
|
+
error: parsed.error,
|
|
132
|
+
failure: { kind: "invalid-target", message: parsed.error },
|
|
133
|
+
};
|
|
75
134
|
}
|
|
76
135
|
|
|
136
|
+
return await captureDiffTarget(parsed, options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
|
|
140
|
+
const command = reviewDiffCommand(target);
|
|
77
141
|
const result = await runBoundedProcess({
|
|
78
|
-
file:
|
|
79
|
-
args:
|
|
142
|
+
file: command.file,
|
|
143
|
+
args: command.args,
|
|
80
144
|
cwd: options.cwd,
|
|
81
145
|
env: diffCaptureEnv(options.env),
|
|
82
146
|
signal: options.signal,
|
|
@@ -93,6 +157,39 @@ export async function captureDiff(command: string, options: DiffCaptureOptions):
|
|
|
93
157
|
return result;
|
|
94
158
|
}
|
|
95
159
|
|
|
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
|
+
|
|
96
193
|
function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
97
194
|
return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
|
|
98
195
|
}
|