pi-workflow-engine 0.8.0 → 0.10.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 +134 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +276 -24
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +222 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +83 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +15 -69
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +24 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +200 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +96 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +66 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +81 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +14 -5
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +12 -8
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +6 -2
- package/README.md +11 -1
- package/USAGE.md +53 -8
- package/package.json +8 -8
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
|
|
4
|
+
export interface BoundedProcessOptions {
|
|
5
|
+
readonly file: string;
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
readonly maxBufferBytes?: number;
|
|
12
|
+
readonly killGraceMs?: number;
|
|
13
|
+
readonly abortError: string;
|
|
14
|
+
readonly timeoutError: string;
|
|
15
|
+
readonly maxBufferError?: string;
|
|
16
|
+
readonly exitError: (stderr: string, code: number | null, signal: NodeJS.Signals | null) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BoundedProcessResult {
|
|
20
|
+
readonly ok: boolean;
|
|
21
|
+
readonly stdout: string;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
readonly durationMs: number;
|
|
24
|
+
readonly bytes: number;
|
|
25
|
+
readonly error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runBoundedProcess(options: BoundedProcessOptions): Promise<BoundedProcessResult> {
|
|
29
|
+
const start = performance.now();
|
|
30
|
+
let stdout = "";
|
|
31
|
+
let stderr = "";
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
let error: string | undefined;
|
|
34
|
+
const child = spawn(options.file, [...options.args], {
|
|
35
|
+
cwd: options.cwd,
|
|
36
|
+
env: options.env ?? process.env,
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return await new Promise<BoundedProcessResult>((resolve) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
|
|
44
|
+
const finish = (ok: boolean, finishError?: string) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
cleanup();
|
|
48
|
+
resolve({ ok, stdout, stderr, durationMs: performance.now() - start, bytes, error: finishError });
|
|
49
|
+
};
|
|
50
|
+
const kill = (message: string) => {
|
|
51
|
+
error = error ?? message;
|
|
52
|
+
child.kill("SIGTERM");
|
|
53
|
+
forceKillTimer ??= setTimeout(() => {
|
|
54
|
+
child.kill("SIGKILL");
|
|
55
|
+
finish(false, error);
|
|
56
|
+
}, killGraceMs);
|
|
57
|
+
};
|
|
58
|
+
const onAbort = () => kill(options.abortError);
|
|
59
|
+
const timeout = setTimeout(() => kill(options.timeoutError), options.timeoutMs);
|
|
60
|
+
const cleanup = () => {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
63
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (options.signal?.aborted) {
|
|
67
|
+
kill(options.abortError);
|
|
68
|
+
} else {
|
|
69
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
73
|
+
bytes += chunk.length;
|
|
74
|
+
if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
|
|
75
|
+
kill(options.maxBufferError ?? `process output exceeded ${options.maxBufferBytes} bytes`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
stdout += chunk.toString("utf8");
|
|
79
|
+
});
|
|
80
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
81
|
+
stderr += chunk.toString("utf8");
|
|
82
|
+
});
|
|
83
|
+
child.on("error", (spawnError) => finish(false, spawnError.message));
|
|
84
|
+
child.on("close", (code, signal) => {
|
|
85
|
+
if (error) {
|
|
86
|
+
finish(false, error);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (code === 0) {
|
|
90
|
+
finish(true);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
finish(false, options.exitError(stderr, code, signal));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Static, TSchema } from "typebox";
|
|
2
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { WorkflowBudget } from "./budget.ts";
|
|
3
4
|
import type { Pipeline } from "./concurrency.ts";
|
|
4
5
|
import type { PerfSink, PerfSnapshot } from "./perf.ts";
|
|
5
6
|
import type { WorkflowProgressSnapshot } from "./progress.ts";
|
|
@@ -23,11 +24,23 @@ export interface WorkflowRunStats {
|
|
|
23
24
|
[key: string]: string | number;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
export interface WorkflowRunMetadata {
|
|
28
|
+
readonly runId: string;
|
|
29
|
+
readonly resumedFromRunId?: string;
|
|
30
|
+
readonly journalPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export interface WorkflowRunOptions {
|
|
27
34
|
inspect?: boolean;
|
|
28
35
|
perf?: boolean;
|
|
29
36
|
concurrency?: number;
|
|
30
37
|
parallelSubmissionLimit?: number;
|
|
38
|
+
/** Output-token ceiling for the whole run. Omit for no limit (budget.total === null). */
|
|
39
|
+
budget?: number;
|
|
40
|
+
/** Internal/test override for the generated run id. Omit to generate a new id. */
|
|
41
|
+
runId?: string;
|
|
42
|
+
/** Replay completed agent results from this prior run id when prompt/options hashes still match. */
|
|
43
|
+
resumeFromRunId?: string;
|
|
31
44
|
resultViewer?: "open" | "skip";
|
|
32
45
|
/** Additional abort signal to compose with the host context signal. */
|
|
33
46
|
signal?: AbortSignal;
|
|
@@ -39,6 +52,8 @@ export interface WorkflowRunOptions {
|
|
|
39
52
|
onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
|
|
40
53
|
/** Called with the final workflow subagent usage snapshot. */
|
|
41
54
|
onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
|
|
55
|
+
/** Called once run identity and journal paths are known. */
|
|
56
|
+
onRunMetadata?: (metadata: WorkflowRunMetadata) => void;
|
|
42
57
|
/** Called with the live progress source while a workflow is running, then undefined when it ends. */
|
|
43
58
|
onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
|
|
44
59
|
/** Called with the final completed progress snapshot after live workflow UI teardown. */
|
|
@@ -52,6 +67,14 @@ export interface WorkflowProgressSource {
|
|
|
52
67
|
/** Options for a single `agent()` call. */
|
|
53
68
|
export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
|
|
54
69
|
|
|
70
|
+
export type AgentToolHint = "search";
|
|
71
|
+
|
|
72
|
+
export interface IsolatedAgentResult<T> {
|
|
73
|
+
readonly result: T;
|
|
74
|
+
readonly patch: string;
|
|
75
|
+
readonly changed: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
export type WorkflowProgressEvent =
|
|
56
79
|
| { type: "counter"; key: string; label: string; value: number }
|
|
57
80
|
| { type: "counter_delta"; key: string; label: string; delta: number }
|
|
@@ -77,8 +100,28 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
77
100
|
model?: string;
|
|
78
101
|
/** Reasoning effort for this agent. */
|
|
79
102
|
thinkingLevel?: ThinkingLevel;
|
|
80
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Stable identity hint for resume replay. Use this for repeated logical calls
|
|
105
|
+
* with identical prompts/options, e.g. `${stage}:${item.id}`.
|
|
106
|
+
*/
|
|
107
|
+
cacheKey?: string;
|
|
108
|
+
/** Run this agent in a disposable git worktree and return its patch with the result. */
|
|
109
|
+
isolation?: "worktree";
|
|
110
|
+
/** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
|
|
81
111
|
tools?: string[];
|
|
112
|
+
/**
|
|
113
|
+
* Dynamically include installed tools matching semantic categories. Currently
|
|
114
|
+
* "search" matches grep/find/code-search style tools such as ast-grep,
|
|
115
|
+
* mgrep, ffgrep, fffind, ripgrep wrappers, and pi's built-in grep/find/ls.
|
|
116
|
+
*/
|
|
117
|
+
toolHints?: readonly AgentToolHint[];
|
|
118
|
+
/**
|
|
119
|
+
* Skill names to expose to this subagent. Subagents receive no skills by default.
|
|
120
|
+
* When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
|
|
121
|
+
* `use the name skill` is treated as a natural-language opt-in. Pass [] to suppress
|
|
122
|
+
* prompt inference for prompts that mention skills only as subject matter.
|
|
123
|
+
*/
|
|
124
|
+
skills?: readonly string[];
|
|
82
125
|
/** typebox schema. When set, the agent is forced to return matching structured data. */
|
|
83
126
|
schema?: S;
|
|
84
127
|
}
|
|
@@ -88,6 +131,13 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
88
131
|
* exports `meta` plus a default `async (api: WorkflowApi) => result`.
|
|
89
132
|
*/
|
|
90
133
|
export interface WorkflowApi {
|
|
134
|
+
/** Run a schema subagent in a disposable worktree and return its structured result plus patch. */
|
|
135
|
+
agent<S extends TSchema>(
|
|
136
|
+
prompt: string,
|
|
137
|
+
opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
|
|
138
|
+
): Promise<IsolatedAgentResult<Static<S> | null>>;
|
|
139
|
+
/** Run a text subagent in a disposable worktree and return its final text plus patch. */
|
|
140
|
+
agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
|
|
91
141
|
/** Run a subagent and return its validated structured output (null if it never produced one). */
|
|
92
142
|
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
|
|
93
143
|
/** Run a subagent and return its final assistant text. */
|
|
@@ -99,9 +149,15 @@ export interface WorkflowApi {
|
|
|
99
149
|
* a missing sub-workflow gracefully.
|
|
100
150
|
*/
|
|
101
151
|
workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
|
|
154
|
+
* failures become null slots; filter nulls before consuming survivors.
|
|
155
|
+
*/
|
|
156
|
+
parallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
|
|
157
|
+
/**
|
|
158
|
+
* Run each item through all stages independently — no barrier between stages.
|
|
159
|
+
* A recoverable stage failure drops that item to null and skips its later stages.
|
|
160
|
+
*/
|
|
105
161
|
pipeline: Pipeline;
|
|
106
162
|
/** Start a new phase; subsequent agents group under it in the progress tree. */
|
|
107
163
|
phase(title: string): void;
|
|
@@ -113,6 +169,12 @@ export interface WorkflowApi {
|
|
|
113
169
|
args: string;
|
|
114
170
|
/** Working directory of the host session (typically the repo root). */
|
|
115
171
|
cwd: string;
|
|
172
|
+
/**
|
|
173
|
+
* Token budget for the run. `budget.total` is the output-token ceiling (null when unset),
|
|
174
|
+
* `spent()`/`remaining()` are live. Guard loops with `while (budget.total && budget.remaining() > N)`;
|
|
175
|
+
* `agent()` throws `WorkflowBudgetExceededError` once the ceiling is reached.
|
|
176
|
+
*/
|
|
177
|
+
budget: WorkflowBudget;
|
|
116
178
|
/** Abort signal for the run, propagated from the host. */
|
|
117
179
|
signal: AbortSignal | undefined;
|
|
118
180
|
}
|
|
@@ -19,6 +19,8 @@ export interface WorkflowResultEnvelope {
|
|
|
19
19
|
completedAt: number;
|
|
20
20
|
usage?: WorkflowUsageSnapshot;
|
|
21
21
|
perf?: WorkflowPerfDetails;
|
|
22
|
+
runId?: string;
|
|
23
|
+
resumedFromRunId?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface AdvisoryWorkflowResult extends AdvisoryReport {
|
|
@@ -61,27 +63,61 @@ export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResul
|
|
|
61
63
|
return true;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
export
|
|
66
|
+
export interface WorkflowRunDisplayMetadata {
|
|
67
|
+
readonly runId?: string;
|
|
68
|
+
readonly resumedFromRunId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WorkflowDetailLineInput {
|
|
72
|
+
readonly usage?: unknown;
|
|
73
|
+
readonly perf?: WorkflowPerfDetails;
|
|
74
|
+
readonly metadata?: WorkflowRunDisplayMetadata;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderWorkflowResult(
|
|
78
|
+
name: string,
|
|
79
|
+
result: unknown,
|
|
80
|
+
expanded: boolean,
|
|
81
|
+
theme: Theme,
|
|
82
|
+
usage?: unknown,
|
|
83
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
84
|
+
perf?: WorkflowPerfDetails,
|
|
85
|
+
): Component {
|
|
65
86
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
66
|
-
box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage), 0, 0));
|
|
87
|
+
box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage, metadata, perf), 0, 0));
|
|
67
88
|
return box;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
export function renderWorkflowResultText(
|
|
91
|
+
export function renderWorkflowResultText(
|
|
92
|
+
name: string,
|
|
93
|
+
result: unknown,
|
|
94
|
+
expanded: boolean,
|
|
95
|
+
theme: Theme,
|
|
96
|
+
usage?: unknown,
|
|
97
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
98
|
+
perf?: WorkflowPerfDetails,
|
|
99
|
+
): string {
|
|
71
100
|
if (isAdvisoryReport(result)) {
|
|
72
|
-
return renderAdvisoryResult(name, result, expanded, theme, usage);
|
|
101
|
+
return renderAdvisoryResult(name, result, expanded, theme, usage, metadata, perf);
|
|
73
102
|
}
|
|
74
|
-
return renderGenericWorkflowResult(name, result, expanded, theme, usage);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function renderAdvisoryResult(
|
|
103
|
+
return renderGenericWorkflowResult(name, result, expanded, theme, usage, metadata, perf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderAdvisoryResult(
|
|
107
|
+
name: string,
|
|
108
|
+
result: AdvisoryWorkflowResult,
|
|
109
|
+
expanded: boolean,
|
|
110
|
+
theme: Theme,
|
|
111
|
+
usage?: unknown,
|
|
112
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
113
|
+
perf?: WorkflowPerfDetails,
|
|
114
|
+
): string {
|
|
78
115
|
const icon = theme.fg("success", "✓");
|
|
79
116
|
const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
|
|
80
117
|
const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
|
|
81
118
|
const stats = statsLine(result.stats, theme);
|
|
82
119
|
if (stats) lines.push(stats);
|
|
83
|
-
|
|
84
|
-
if (usageLine) lines.push(theme.fg("dim", usageLine));
|
|
120
|
+
pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
|
|
85
121
|
|
|
86
122
|
if (result.findings.length === 0) {
|
|
87
123
|
lines.push(theme.fg("success", "No findings."));
|
|
@@ -108,17 +144,49 @@ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): vo
|
|
|
108
144
|
}
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
function renderGenericWorkflowResult(
|
|
147
|
+
function renderGenericWorkflowResult(
|
|
148
|
+
name: string,
|
|
149
|
+
result: unknown,
|
|
150
|
+
expanded: boolean,
|
|
151
|
+
theme: Theme,
|
|
152
|
+
usage?: unknown,
|
|
153
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
154
|
+
perf?: WorkflowPerfDetails,
|
|
155
|
+
): string {
|
|
112
156
|
const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
|
|
113
157
|
const summary = extractSummary(result);
|
|
114
158
|
if (summary) lines.push(theme.fg("muted", summary));
|
|
115
|
-
|
|
116
|
-
if (usageLine) lines.push(theme.fg("dim", usageLine));
|
|
159
|
+
pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
|
|
117
160
|
if (expanded) lines.push(theme.fg("dim", safeJson(result)));
|
|
118
161
|
else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
|
|
119
162
|
return lines.join("\n");
|
|
120
163
|
}
|
|
121
164
|
|
|
165
|
+
function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
|
|
166
|
+
for (const line of formatWorkflowDetailLines(input)) {
|
|
167
|
+
lines.push(theme.fg("dim", line));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
|
|
172
|
+
return [
|
|
173
|
+
formatWorkflowRunLine(input.metadata),
|
|
174
|
+
formatWorkflowUsageLine(input.usage),
|
|
175
|
+
formatWorkflowPerfLine(input.perf),
|
|
176
|
+
].filter((line): line is string => line !== undefined);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
|
|
180
|
+
if (!metadata?.runId) return undefined;
|
|
181
|
+
return metadata.resumedFromRunId ? `Run: ${metadata.runId} (resumed from ${metadata.resumedFromRunId})` : `Run: ${metadata.runId}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
|
|
185
|
+
if (!perf) return undefined;
|
|
186
|
+
const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
187
|
+
return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
|
|
123
191
|
if (!stats) return undefined;
|
|
124
192
|
const ordered = ["files", "candidates", "dropped", "verified", "kept"];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
+
import { compactResults } from "./concurrency.ts";
|
|
2
3
|
import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
export interface AdvisoryLens {
|
|
@@ -20,7 +21,11 @@ export interface LensVerificationPipelineResult<Verified> {
|
|
|
20
21
|
refuted: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** Default concrete read/inspect tools for advisory workflows. */
|
|
25
|
+
export const DEFAULT_ADVISORY_TOOLS: NonNullable<AgentOptions["tools"]> = ["read", "bash", "grep", "find", "ls"];
|
|
26
|
+
|
|
27
|
+
/** Dynamically include installed grep/find/search-like extension tools. */
|
|
28
|
+
export const DEFAULT_ADVISORY_TOOL_HINTS: NonNullable<AgentOptions["toolHints"]> = ["search"];
|
|
24
29
|
|
|
25
30
|
export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
|
|
26
31
|
|
|
@@ -29,6 +34,7 @@ export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Veri
|
|
|
29
34
|
lenses: readonly Lens[];
|
|
30
35
|
perLens: number;
|
|
31
36
|
tools?: AgentOptions["tools"];
|
|
37
|
+
toolHints?: AgentOptions["toolHints"];
|
|
32
38
|
finderPhase?: string;
|
|
33
39
|
verifierPhase?: string;
|
|
34
40
|
schedulingMode?: AdvisorySchedulingMode;
|
|
@@ -60,6 +66,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
60
66
|
lenses,
|
|
61
67
|
perLens,
|
|
62
68
|
tools = DEFAULT_ADVISORY_TOOLS,
|
|
69
|
+
toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
|
|
63
70
|
finderPhase = "Find",
|
|
64
71
|
verifierPhase = "Verify",
|
|
65
72
|
schedulingMode = "pipeline",
|
|
@@ -77,6 +84,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
77
84
|
phase: finderPhase,
|
|
78
85
|
label: `find:${lens.label}`,
|
|
79
86
|
tools,
|
|
87
|
+
toolHints,
|
|
80
88
|
thinkingLevel: "low",
|
|
81
89
|
schema: AdvisoryCandidatesSchema,
|
|
82
90
|
});
|
|
@@ -113,6 +121,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
113
121
|
phase: verifierPhase,
|
|
114
122
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
115
123
|
tools,
|
|
124
|
+
toolHints,
|
|
116
125
|
thinkingLevel: "low",
|
|
117
126
|
schema: AdvisoryVerdictSchema,
|
|
118
127
|
});
|
|
@@ -125,9 +134,9 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
125
134
|
|
|
126
135
|
if (schedulingMode === "finder-barrier") {
|
|
127
136
|
const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
|
|
128
|
-
const novel = found.flatMap(dedupeFound);
|
|
137
|
+
const novel = compactResults(found).flatMap(dedupeFound);
|
|
129
138
|
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
130
|
-
return { verified: verdicts
|
|
139
|
+
return { verified: compactResults(verdicts), rawCandidates, dropped, refuted };
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
const perLensVerified = await api.pipeline(
|
|
@@ -136,11 +145,11 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
136
145
|
async (found) => {
|
|
137
146
|
const novel = dedupeFound(found);
|
|
138
147
|
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
139
|
-
return verdicts
|
|
148
|
+
return compactResults(verdicts);
|
|
140
149
|
},
|
|
141
150
|
);
|
|
142
151
|
|
|
143
|
-
return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
|
|
152
|
+
return { verified: compactResults(perLensVerified).flat(), rawCandidates, dropped, refuted };
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runBoundedProcess } from "./process-runner.ts";
|
|
5
|
+
|
|
6
|
+
export interface WorktreeGitCommandOptions {
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly args: readonly string[];
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
readonly maxBufferBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WorktreeGitCommandResult {
|
|
15
|
+
readonly ok: boolean;
|
|
16
|
+
readonly stdout: string;
|
|
17
|
+
readonly stderr: string;
|
|
18
|
+
readonly error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorktreeGitRunner {
|
|
22
|
+
runGit(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorktreeRef {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorktreeAddFailure {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly error: string;
|
|
32
|
+
readonly cleanup?: WorktreeGitCommandResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GitWorktreeProbe {
|
|
36
|
+
readonly ok: boolean;
|
|
37
|
+
readonly inside: boolean;
|
|
38
|
+
readonly error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorktreePatch {
|
|
46
|
+
readonly patch: string;
|
|
47
|
+
readonly changed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WorktreeRegistryOptions {
|
|
51
|
+
readonly repoCwd: string;
|
|
52
|
+
readonly runner?: WorktreeGitRunner;
|
|
53
|
+
readonly patchCapture?: WorktreePatchCapture;
|
|
54
|
+
readonly timeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WorktreePatchCapture = (options: {
|
|
58
|
+
readonly worktreePath: string;
|
|
59
|
+
readonly runner?: WorktreeGitRunner;
|
|
60
|
+
readonly signal?: AbortSignal;
|
|
61
|
+
readonly timeoutMs?: number;
|
|
62
|
+
}) => Promise<WorktreePatch | { readonly error: string }>;
|
|
63
|
+
|
|
64
|
+
const DEFAULT_WORKTREE_TIMEOUT_MS = 30_000;
|
|
65
|
+
const WORKTREE_DIFF_MAX_BYTES = 16 << 20;
|
|
66
|
+
let pathCounter = 0;
|
|
67
|
+
|
|
68
|
+
export class WorktreeRegistry {
|
|
69
|
+
private readonly paths = new Set<string>();
|
|
70
|
+
private readonly runner: WorktreeGitRunner;
|
|
71
|
+
private readonly patchCapture: WorktreePatchCapture;
|
|
72
|
+
private readonly timeoutMs: number;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly repoCwd: string, options: Omit<WorktreeRegistryOptions, "repoCwd"> = {}) {
|
|
75
|
+
this.runner = options.runner ?? spawnGitRunner;
|
|
76
|
+
this.patchCapture = options.patchCapture ?? captureWorktreePatch;
|
|
77
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get size(): number {
|
|
81
|
+
return this.paths.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
register(path: string): void {
|
|
85
|
+
this.paths.add(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async probe(signal?: AbortSignal): Promise<GitWorktreeProbe> {
|
|
89
|
+
return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
93
|
+
const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
94
|
+
this.register(added.path);
|
|
95
|
+
if ("error" in added) {
|
|
96
|
+
const cleanup = await this.remove(added.path);
|
|
97
|
+
return { ...added, cleanup };
|
|
98
|
+
}
|
|
99
|
+
return added;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async capturePatch(path: string, signal?: AbortSignal): Promise<WorktreePatch | { readonly error: string }> {
|
|
103
|
+
return await this.patchCapture({ worktreePath: path, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async remove(path: string): Promise<WorktreeGitCommandResult> {
|
|
107
|
+
const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs }).catch((error: unknown) => ({
|
|
108
|
+
ok: false,
|
|
109
|
+
stdout: "",
|
|
110
|
+
stderr: "",
|
|
111
|
+
error: formatError(error),
|
|
112
|
+
}));
|
|
113
|
+
if (result.ok) this.paths.delete(path);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
118
|
+
return await Promise.all(
|
|
119
|
+
[...this.paths].map(async (path) => ({
|
|
120
|
+
path,
|
|
121
|
+
...(await this.remove(path)),
|
|
122
|
+
})),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createWorktreePath(baseDir = tmpdir()): string {
|
|
128
|
+
pathCounter += 1;
|
|
129
|
+
return join(baseDir, `pi-workflow-${process.pid}-${pathCounter}-${randomUUID()}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function isGitWorktree(options: {
|
|
133
|
+
readonly repoCwd: string;
|
|
134
|
+
readonly runner?: WorktreeGitRunner;
|
|
135
|
+
readonly signal?: AbortSignal;
|
|
136
|
+
readonly timeoutMs?: number;
|
|
137
|
+
}): Promise<GitWorktreeProbe> {
|
|
138
|
+
const result = await (options.runner ?? spawnGitRunner)
|
|
139
|
+
.runGit({
|
|
140
|
+
cwd: options.repoCwd,
|
|
141
|
+
args: ["rev-parse", "--is-inside-work-tree"],
|
|
142
|
+
signal: options.signal,
|
|
143
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
144
|
+
})
|
|
145
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
146
|
+
if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
|
|
147
|
+
return { ok: true, inside: result.stdout.trim() === "true" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function addWorktree(options: {
|
|
151
|
+
readonly repoCwd: string;
|
|
152
|
+
readonly runner?: WorktreeGitRunner;
|
|
153
|
+
readonly signal?: AbortSignal;
|
|
154
|
+
readonly timeoutMs?: number;
|
|
155
|
+
readonly baseDir?: string;
|
|
156
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
157
|
+
const path = createWorktreePath(options.baseDir);
|
|
158
|
+
const result = await (options.runner ?? spawnGitRunner)
|
|
159
|
+
.runGit({
|
|
160
|
+
cwd: options.repoCwd,
|
|
161
|
+
args: ["worktree", "add", "--detach", path, "HEAD"],
|
|
162
|
+
signal: options.signal,
|
|
163
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
164
|
+
})
|
|
165
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
166
|
+
if (!result.ok) return { path, error: result.error ?? (result.stderr.trim() || "git worktree add failed") };
|
|
167
|
+
return { path };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function removeWorktree(options: {
|
|
171
|
+
readonly repoCwd: string;
|
|
172
|
+
readonly path: string;
|
|
173
|
+
readonly runner?: WorktreeGitRunner;
|
|
174
|
+
readonly timeoutMs?: number;
|
|
175
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
176
|
+
return await (options.runner ?? spawnGitRunner).runGit({
|
|
177
|
+
cwd: options.repoCwd,
|
|
178
|
+
args: ["worktree", "remove", "--force", options.path],
|
|
179
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function captureWorktreePatch(options: {
|
|
184
|
+
readonly worktreePath: string;
|
|
185
|
+
readonly runner?: WorktreeGitRunner;
|
|
186
|
+
readonly signal?: AbortSignal;
|
|
187
|
+
readonly timeoutMs?: number;
|
|
188
|
+
}): Promise<WorktreePatch | { readonly error: string }> {
|
|
189
|
+
const runner = options.runner ?? spawnGitRunner;
|
|
190
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
191
|
+
const intentToAdd = await runner
|
|
192
|
+
.runGit({
|
|
193
|
+
cwd: options.worktreePath,
|
|
194
|
+
args: ["add", "-N", "."],
|
|
195
|
+
signal: options.signal,
|
|
196
|
+
timeoutMs,
|
|
197
|
+
})
|
|
198
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
199
|
+
if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
|
|
200
|
+
|
|
201
|
+
const diff = await runner
|
|
202
|
+
.runGit({
|
|
203
|
+
cwd: options.worktreePath,
|
|
204
|
+
args: ["diff", "--no-color", "HEAD"],
|
|
205
|
+
signal: options.signal,
|
|
206
|
+
timeoutMs,
|
|
207
|
+
maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
|
|
208
|
+
})
|
|
209
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
210
|
+
if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
|
|
211
|
+
return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const spawnGitRunner: WorktreeGitRunner = {
|
|
215
|
+
async runGit(options) {
|
|
216
|
+
return await runGitCommand(options);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
|
|
221
|
+
return await runBoundedProcess({
|
|
222
|
+
file: "git",
|
|
223
|
+
args: options.args,
|
|
224
|
+
cwd: options.cwd,
|
|
225
|
+
env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
|
|
226
|
+
signal: options.signal,
|
|
227
|
+
timeoutMs: options.timeoutMs,
|
|
228
|
+
maxBufferBytes: options.maxBufferBytes,
|
|
229
|
+
abortError: "git worktree command aborted",
|
|
230
|
+
timeoutError: `git worktree command timed out after ${options.timeoutMs}ms`,
|
|
231
|
+
maxBufferError: options.maxBufferBytes === undefined ? undefined : `git worktree command exceeded ${options.maxBufferBytes} bytes`,
|
|
232
|
+
exitError: (stderr, code, signal) => stderr.trim() || `git exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatError(error: unknown): string {
|
|
237
|
+
return error instanceof Error ? error.message : String(error);
|
|
238
|
+
}
|