pi-workflow-engine 0.2.0 → 0.4.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 +256 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +64 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +114 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
- package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
- package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +198 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +24 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +89 -145
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +244 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +188 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +181 -0
- package/.pi/settings.json +3 -0
- package/README.md +163 -63
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
- package/assets/preview.png +0 -0
- package/package.json +32 -6
- package/src/agent-runner.ts +0 -122
- package/src/concurrency.ts +0 -55
- package/src/discovery.ts +0 -54
- package/src/index.ts +0 -141
- package/src/ui/workflow-result-renderer.ts +0 -149
- package/src/ui/workflow-widget.ts +0 -143
- package/src/workflows.ts +0 -14
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { abortReason, throwIfAborted } from "./cancellation.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A counting semaphore: bounds how many async tasks run at once.
|
|
6
|
+
*
|
|
7
|
+
* This is the single global concurrency cap for a workflow run. Every `agent()`
|
|
8
|
+
* call acquires a slot before spawning a session and releases it when done, so the
|
|
9
|
+
* cap holds no matter how `parallel`/`pipeline` are nested — exactly like the
|
|
10
|
+
* built-in Workflow tool, where "concurrent agent() calls are capped and excess queue".
|
|
11
|
+
*/
|
|
12
|
+
export class Semaphore {
|
|
13
|
+
private active = 0;
|
|
14
|
+
private readonly waiters: Array<() => void> = [];
|
|
15
|
+
|
|
16
|
+
constructor(private readonly max: number) {}
|
|
17
|
+
|
|
18
|
+
async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
|
|
19
|
+
throwIfAborted(options.signal);
|
|
20
|
+
const queuedAt = performance.now();
|
|
21
|
+
if (this.active >= this.max) {
|
|
22
|
+
await this.waitForSlot(options.signal);
|
|
23
|
+
}
|
|
24
|
+
options.onQueueWaitMs?.(performance.now() - queuedAt);
|
|
25
|
+
throwIfAborted(options.signal);
|
|
26
|
+
this.active++;
|
|
27
|
+
try {
|
|
28
|
+
return await fn();
|
|
29
|
+
} finally {
|
|
30
|
+
this.active--;
|
|
31
|
+
this.waiters.shift()?.();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async waitForSlot(signal: AbortSignal | undefined): Promise<void> {
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
40
|
+
};
|
|
41
|
+
const waiter = () => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
cleanup();
|
|
45
|
+
resolve();
|
|
46
|
+
};
|
|
47
|
+
const onAbort = () => {
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
const index = this.waiters.indexOf(waiter);
|
|
51
|
+
if (index !== -1) this.waiters.splice(index, 1);
|
|
52
|
+
cleanup();
|
|
53
|
+
reject(abortReason(signal));
|
|
54
|
+
};
|
|
55
|
+
if (signal?.aborted) {
|
|
56
|
+
reject(abortReason(signal));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.waiters.push(waiter);
|
|
60
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ParallelOptions {
|
|
66
|
+
readonly signal?: AbortSignal;
|
|
67
|
+
readonly abortController?: AbortController;
|
|
68
|
+
readonly limit?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Run every thunk concurrently and wait for all results (a barrier). */
|
|
72
|
+
export async function parallel<T>(thunks: Array<() => Promise<T>>, options: ParallelOptions = {}): Promise<T[]> {
|
|
73
|
+
throwIfAborted(options.signal);
|
|
74
|
+
const results = new Array<T>(thunks.length);
|
|
75
|
+
let remaining = thunks.length;
|
|
76
|
+
if (remaining === 0) return results;
|
|
77
|
+
|
|
78
|
+
const limit = normalizeLimit(options.limit, thunks.length);
|
|
79
|
+
return await new Promise<T[]>((resolve, reject) => {
|
|
80
|
+
let rejected = false;
|
|
81
|
+
let active = 0;
|
|
82
|
+
let next = 0;
|
|
83
|
+
const cleanup = () => options.signal?.removeEventListener("abort", onAbort);
|
|
84
|
+
const rejectOnce = (error: unknown) => {
|
|
85
|
+
if (rejected) return;
|
|
86
|
+
rejected = true;
|
|
87
|
+
options.abortController?.abort(error);
|
|
88
|
+
cleanup();
|
|
89
|
+
reject(error);
|
|
90
|
+
};
|
|
91
|
+
const onAbort = () => rejectOnce(abortReason(options.signal));
|
|
92
|
+
const launchMore = () => {
|
|
93
|
+
while (!rejected && active < limit && next < thunks.length) {
|
|
94
|
+
const index = next++;
|
|
95
|
+
active++;
|
|
96
|
+
void Promise.resolve()
|
|
97
|
+
.then(() => {
|
|
98
|
+
throwIfAborted(options.signal);
|
|
99
|
+
return thunks[index]();
|
|
100
|
+
})
|
|
101
|
+
.then(
|
|
102
|
+
(value) => {
|
|
103
|
+
if (rejected) return;
|
|
104
|
+
active--;
|
|
105
|
+
results[index] = value;
|
|
106
|
+
remaining--;
|
|
107
|
+
if (remaining === 0) {
|
|
108
|
+
cleanup();
|
|
109
|
+
resolve(results);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
launchMore();
|
|
113
|
+
},
|
|
114
|
+
(error: unknown) => rejectOnce(error),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
120
|
+
launchMore();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeLimit(limit: number | undefined, itemCount: number): number {
|
|
125
|
+
if (limit === undefined || !Number.isFinite(limit)) return Math.max(1, itemCount);
|
|
126
|
+
return Math.max(1, Math.min(itemCount, Math.trunc(limit)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run each item through all stages independently. There is NO barrier between
|
|
131
|
+
* stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
|
|
132
|
+
* concurrency is bounded by the run's Semaphore (inside each `agent()`), not here —
|
|
133
|
+
* so launching every item at once is safe.
|
|
134
|
+
*
|
|
135
|
+
* Each stage receives (previousResult, originalItem, index).
|
|
136
|
+
*/
|
|
137
|
+
export async function pipeline<Item, A>(
|
|
138
|
+
items: readonly Item[],
|
|
139
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
140
|
+
): Promise<A[]>;
|
|
141
|
+
export async function pipeline<Item, A, B>(
|
|
142
|
+
items: readonly Item[],
|
|
143
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
144
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
145
|
+
): Promise<B[]>;
|
|
146
|
+
export async function pipeline<Item, A, B, C>(
|
|
147
|
+
items: readonly Item[],
|
|
148
|
+
stage1: (prev: Item, item: Item, index: number) => Promise<A>,
|
|
149
|
+
stage2: (prev: A, item: Item, index: number) => Promise<B>,
|
|
150
|
+
stage3: (prev: B, item: Item, index: number) => Promise<C>,
|
|
151
|
+
): Promise<C[]>;
|
|
152
|
+
export async function pipeline(
|
|
153
|
+
items: readonly unknown[],
|
|
154
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
155
|
+
): Promise<unknown[]>;
|
|
156
|
+
export async function pipeline(
|
|
157
|
+
items: readonly unknown[],
|
|
158
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
159
|
+
): Promise<unknown[]> {
|
|
160
|
+
return Promise.all(
|
|
161
|
+
items.map(async (item, index) => {
|
|
162
|
+
let acc: unknown = item;
|
|
163
|
+
for (const stage of stages) {
|
|
164
|
+
acc = await stage(acc, item, index);
|
|
165
|
+
}
|
|
166
|
+
return acc;
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type Pipeline = typeof pipeline;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import type { PerfSink } from "./perf.ts";
|
|
4
|
+
|
|
5
|
+
export interface AllowedDiffCommand {
|
|
6
|
+
readonly file: "git" | "gh";
|
|
7
|
+
readonly args: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DiffCaptureResult {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
readonly stdout: string;
|
|
13
|
+
readonly durationMs: number;
|
|
14
|
+
readonly bytes: number;
|
|
15
|
+
readonly error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiffCaptureOptions {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
readonly maxBufferBytes: number;
|
|
23
|
+
readonly perf?: PerfSink;
|
|
24
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
25
|
+
readonly killGraceMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
|
|
29
|
+
const SAFE_GH_FLAG = /^--[A-Za-z0-9-]+(=[A-Za-z0-9_./:@~+=,\-]+)?$/;
|
|
30
|
+
const SAFE_GIT_DIFF_FLAGS = new Set(["--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
31
|
+
|
|
32
|
+
export function parseAllowedDiffCommand(command: string): AllowedDiffCommand | { error: string } {
|
|
33
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
35
|
+
if (tokens[0] === "git" && tokens[1] === "diff") return parseGitDiff(tokens);
|
|
36
|
+
if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
|
|
37
|
+
const flags = tokens.slice(4);
|
|
38
|
+
const unsafe = flags.find((token) => !SAFE_GH_FLAG.test(token));
|
|
39
|
+
if (unsafe) return { error: `unsupported gh pr diff token: ${unsafe}` };
|
|
40
|
+
return { file: "gh", args: ["pr", "diff", tokens[3], ...flags] };
|
|
41
|
+
}
|
|
42
|
+
return { error: "diff command is not in the git/gh allowlist" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseGitDiff(tokens: readonly string[]): AllowedDiffCommand | { error: string } {
|
|
46
|
+
const args = tokens.slice(2);
|
|
47
|
+
const safeArgs = ["diff", "--no-ext-diff"];
|
|
48
|
+
let pathMode = false;
|
|
49
|
+
|
|
50
|
+
for (const token of args) {
|
|
51
|
+
if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
|
|
52
|
+
if (token === "--") {
|
|
53
|
+
pathMode = true;
|
|
54
|
+
safeArgs.push(token);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!pathMode && token.startsWith("-")) {
|
|
58
|
+
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
59
|
+
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
safeArgs.push(token);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { file: "git", args: safeArgs };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isAllowedGitDiffFlag(token: string): boolean {
|
|
69
|
+
return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
|
|
73
|
+
const parsed = parseAllowedDiffCommand(command);
|
|
74
|
+
if ("error" in parsed) {
|
|
75
|
+
return { ok: false, stdout: "", durationMs: 0, bytes: 0, error: parsed.error };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const start = performance.now();
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
let error: string | undefined;
|
|
83
|
+
const child = spawn(parsed.file, [...parsed.args], {
|
|
84
|
+
cwd: options.cwd,
|
|
85
|
+
env: diffCaptureEnv(options.env),
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return await new Promise<DiffCaptureResult>((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
const killGraceMs = Math.max(1, options.killGraceMs ?? 100);
|
|
93
|
+
const finish = (ok: boolean, finishError?: string) => {
|
|
94
|
+
if (settled) return;
|
|
95
|
+
settled = true;
|
|
96
|
+
cleanup();
|
|
97
|
+
const durationMs = performance.now() - start;
|
|
98
|
+
options.perf?.observe("diff.capture_ms", durationMs);
|
|
99
|
+
options.perf?.observe("diff.bytes", bytes);
|
|
100
|
+
resolve({ ok, stdout, durationMs, bytes, error: finishError });
|
|
101
|
+
};
|
|
102
|
+
const kill = (message: string) => {
|
|
103
|
+
error = error ?? message;
|
|
104
|
+
child.kill("SIGTERM");
|
|
105
|
+
forceKillTimer ??= setTimeout(() => {
|
|
106
|
+
child.kill("SIGKILL");
|
|
107
|
+
finish(false, error);
|
|
108
|
+
}, killGraceMs);
|
|
109
|
+
};
|
|
110
|
+
const onAbort = () => kill("diff capture aborted");
|
|
111
|
+
const timeout = setTimeout(() => kill(`diff capture timed out after ${options.timeoutMs}ms`), options.timeoutMs);
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
115
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (options.signal?.aborted) {
|
|
119
|
+
kill("diff capture aborted");
|
|
120
|
+
} else {
|
|
121
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
125
|
+
bytes += chunk.length;
|
|
126
|
+
if (bytes > options.maxBufferBytes) {
|
|
127
|
+
kill(`diff capture exceeded ${options.maxBufferBytes} bytes`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
stdout += chunk.toString("utf8");
|
|
131
|
+
});
|
|
132
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
133
|
+
stderr += chunk.toString("utf8");
|
|
134
|
+
});
|
|
135
|
+
child.on("error", (spawnError) => finish(false, spawnError.message));
|
|
136
|
+
child.on("close", (code, signal) => {
|
|
137
|
+
if (error) {
|
|
138
|
+
finish(false, error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (code === 0) {
|
|
142
|
+
finish(true);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
finish(false, stderr.trim() || `diff command exited with code ${code ?? `signal ${signal ?? "unknown"}`}`);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
151
|
+
return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
|
|
152
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type { WorkflowMeta, WorkflowModule } from "./types.ts";
|
|
6
|
+
import type { PerfSink } from "./perf.ts";
|
|
7
|
+
import { BUILTIN_WORKFLOW_FILES, BUILTIN_WORKFLOWS } from "./workflows.ts";
|
|
8
|
+
|
|
9
|
+
type WorkflowModuleCandidate = {
|
|
10
|
+
meta?: { name?: unknown; description?: unknown; phases?: unknown };
|
|
11
|
+
default?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface DiscoverWorkflowsOptions {
|
|
15
|
+
readonly refresh?: boolean;
|
|
16
|
+
readonly perf?: PerfSink;
|
|
17
|
+
readonly userWorkflowDir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const discoveryCache = new Map<string, Map<string, WorkflowModule>>();
|
|
21
|
+
|
|
22
|
+
function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
|
|
23
|
+
if (!value || typeof value !== "object") return { reason: "module export is not an object" };
|
|
24
|
+
const candidate = value as WorkflowModuleCandidate;
|
|
25
|
+
const meta = candidate.meta;
|
|
26
|
+
if (!meta || typeof meta !== "object") return { reason: "missing meta export" };
|
|
27
|
+
if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
|
|
28
|
+
|
|
29
|
+
const description = meta.description;
|
|
30
|
+
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
31
|
+
|
|
32
|
+
const phases = meta.phases;
|
|
33
|
+
if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
|
|
34
|
+
if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
|
|
35
|
+
|
|
36
|
+
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
37
|
+
if (phases !== undefined) workflowMeta.phases = phases;
|
|
38
|
+
return { module: { meta: workflowMeta, default: candidate.default } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
42
|
+
return typeof value === "function";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
|
|
46
|
+
return Array.isArray(value) && value.every((phase) => typeof phase === "object" && phase !== null && typeof (phase as { title?: unknown }).title === "string");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Best-effort dynamic load of every `*.ts` workflow in a directory. */
|
|
50
|
+
async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set()): Promise<WorkflowModule[]> {
|
|
51
|
+
let entries: string[];
|
|
52
|
+
try {
|
|
53
|
+
entries = await readdir(dir);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const modules: WorkflowModule[] = [];
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
if (excludeFiles.has(name)) continue;
|
|
61
|
+
if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
|
|
62
|
+
try {
|
|
63
|
+
const loaded: unknown = await import(pathToFileURL(join(dir, name)).href);
|
|
64
|
+
const parsed = parseWorkflowModule(loaded);
|
|
65
|
+
if ("module" in parsed) modules.push(parsed.module);
|
|
66
|
+
else console.error(`[workflow-engine] skipped ${name}: ${parsed.reason}`);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Drop-in loading depends on the runtime resolving TS + the bundled typebox.
|
|
69
|
+
// Failures here are non-fatal: the static registry still works.
|
|
70
|
+
console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return modules;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* All available workflows by name. Static registry wins on name collisions, so the
|
|
78
|
+
* bundled example is always the verified one even if a same-named file is dropped in.
|
|
79
|
+
*/
|
|
80
|
+
export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, WorkflowModule>> {
|
|
81
|
+
const userWorkflowDir = options.userWorkflowDir ?? join(homedir(), ".pi", "agent", "workflows");
|
|
82
|
+
const cacheKey = `${repoDir}\0${userWorkflowDir}`;
|
|
83
|
+
const cached = discoveryCache.get(cacheKey);
|
|
84
|
+
if (cached && !options.refresh) {
|
|
85
|
+
options.perf?.counter("discovery.cache_hit");
|
|
86
|
+
return new Map(cached);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const byName = await timed(options.perf, "discovery.total_ms", async () => {
|
|
90
|
+
const next = new Map<string, WorkflowModule>();
|
|
91
|
+
for (const mod of BUILTIN_WORKFLOWS) next.set(mod.meta.name, mod);
|
|
92
|
+
|
|
93
|
+
const repoWorkflowDir = join(repoDir, "workflows");
|
|
94
|
+
const [repoDynamic, userDynamic] = await Promise.all([
|
|
95
|
+
timed(options.perf, "discovery.repo_dir_ms", () => loadDir(repoWorkflowDir, BUILTIN_WORKFLOW_FILES)),
|
|
96
|
+
timed(options.perf, "discovery.user_dir_ms", () => loadDir(userWorkflowDir)),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
for (const mod of repoDynamic) {
|
|
100
|
+
if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
|
|
101
|
+
}
|
|
102
|
+
for (const mod of userDynamic) {
|
|
103
|
+
if (!next.has(mod.meta.name)) next.set(mod.meta.name, mod);
|
|
104
|
+
}
|
|
105
|
+
return next;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
discoveryCache.set(cacheKey, byName);
|
|
109
|
+
return new Map(byName);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function timed<T>(perf: PerfSink | undefined, name: string, fn: () => Promise<T>): Promise<T> {
|
|
113
|
+
return perf ? await perf.time(name, fn) : await fn();
|
|
114
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { cpus } from "node:os";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { parallel, pipeline, Semaphore } from "./concurrency.ts";
|
|
3
|
+
import { linkAbortSignal } from "./cancellation.ts";
|
|
4
4
|
import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
5
5
|
import { ProgressTracker } from "./progress.ts";
|
|
6
|
+
import { createPerfRecorder, type PerfSnapshot } from "./perf.ts";
|
|
7
|
+
import { defaultConcurrency, resolveWorkflowRunOptions } from "./options.ts";
|
|
6
8
|
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
7
9
|
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
8
10
|
|
|
9
11
|
/** Default global cap on concurrent agents per run. */
|
|
10
|
-
const DEFAULT_CONCURRENCY =
|
|
12
|
+
const DEFAULT_CONCURRENCY = defaultConcurrency();
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
@@ -19,8 +21,9 @@ export async function runWorkflow(
|
|
|
19
21
|
args: string,
|
|
20
22
|
options: WorkflowRunOptions = {},
|
|
21
23
|
): Promise<unknown> {
|
|
24
|
+
const resolvedOptions = resolveWorkflowRunOptions(options);
|
|
22
25
|
const progress = new ProgressTracker(ctx, mod.meta.name);
|
|
23
|
-
if (
|
|
26
|
+
if (resolvedOptions.inspect && ctx.hasUI) {
|
|
24
27
|
void ctx.ui
|
|
25
28
|
.custom<void>(
|
|
26
29
|
(tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
|
|
@@ -29,32 +32,57 @@ export async function runWorkflow(
|
|
|
29
32
|
.catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
|
|
36
|
+
const runAbortController = new AbortController();
|
|
37
|
+
const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
|
|
38
|
+
const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
|
|
32
39
|
const rc: RunContext = {
|
|
33
40
|
cwd: ctx.cwd,
|
|
34
41
|
hostModel: ctx.model,
|
|
35
42
|
modelRegistry: ctx.modelRegistry,
|
|
36
|
-
semaphore: new Semaphore(DEFAULT_CONCURRENCY),
|
|
43
|
+
semaphore: new Semaphore(resolvedOptions.concurrency ?? DEFAULT_CONCURRENCY),
|
|
37
44
|
progress,
|
|
38
|
-
signal:
|
|
45
|
+
signal: runAbortController.signal,
|
|
46
|
+
perf,
|
|
39
47
|
};
|
|
40
48
|
|
|
41
49
|
const agent = ((prompt: string, opts?: AgentOptions) => runAgent(rc, prompt, opts)) as WorkflowApi["agent"];
|
|
42
50
|
|
|
43
51
|
const api: WorkflowApi = {
|
|
44
52
|
agent,
|
|
45
|
-
parallel
|
|
53
|
+
parallel: (thunks) =>
|
|
54
|
+
parallel(thunks, {
|
|
55
|
+
signal: runAbortController.signal,
|
|
56
|
+
abortController: runAbortController,
|
|
57
|
+
limit: resolvedOptions.parallelSubmissionLimit ?? resolvedOptions.concurrency * 2,
|
|
58
|
+
}),
|
|
46
59
|
pipeline,
|
|
47
60
|
phase: (title) => progress.phase(title),
|
|
48
61
|
log: (message) => progress.log(message),
|
|
49
62
|
progress: (event) => progress.event(event),
|
|
50
63
|
args,
|
|
51
64
|
cwd: ctx.cwd,
|
|
52
|
-
signal:
|
|
65
|
+
signal: runAbortController.signal,
|
|
53
66
|
};
|
|
54
67
|
|
|
55
68
|
try {
|
|
56
|
-
return await mod.default(api);
|
|
69
|
+
return await perf.time("workflow.total_ms", () => mod.default(api));
|
|
57
70
|
} finally {
|
|
71
|
+
const snapshot = perf.snapshot();
|
|
72
|
+
if (resolvedOptions.perf) {
|
|
73
|
+
resolvedOptions.onPerfSnapshot?.(snapshot);
|
|
74
|
+
progress.log(formatPerfSummary(snapshot));
|
|
75
|
+
}
|
|
76
|
+
unlinkContextAbortSignal();
|
|
77
|
+
unlinkOptionAbortSignal();
|
|
58
78
|
progress.done();
|
|
59
79
|
}
|
|
60
80
|
}
|
|
81
|
+
|
|
82
|
+
function formatPerfSummary(snapshot: PerfSnapshot): string {
|
|
83
|
+
const parts = snapshot.aggregates
|
|
84
|
+
.filter((aggregate) => aggregate.count > 0)
|
|
85
|
+
.slice(0, 5)
|
|
86
|
+
.map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
87
|
+
return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
|
|
88
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cpus } from "node:os";
|
|
2
|
+
import type { WorkflowRunOptions } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface ResolvedWorkflowRunOptions extends WorkflowRunOptions {
|
|
5
|
+
readonly perf: boolean;
|
|
6
|
+
readonly concurrency: number;
|
|
7
|
+
readonly parallelSubmissionLimit?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defaultConcurrency(cpuCount = cpus().length): number {
|
|
11
|
+
return Math.min(8, Math.max(2, cpuCount));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveWorkflowRunOptions(
|
|
15
|
+
input: WorkflowRunOptions = {},
|
|
16
|
+
env: Record<string, string | undefined> = process.env,
|
|
17
|
+
): ResolvedWorkflowRunOptions {
|
|
18
|
+
const concurrency = clampInteger(input.concurrency ?? parseInteger(env.PI_WORKFLOW_CONCURRENCY), 1, 64, defaultConcurrency());
|
|
19
|
+
const parallelSubmissionLimit = optionalClampedInteger(
|
|
20
|
+
input.parallelSubmissionLimit ?? parseInteger(env.PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT),
|
|
21
|
+
1,
|
|
22
|
+
10_000,
|
|
23
|
+
);
|
|
24
|
+
return {
|
|
25
|
+
...input,
|
|
26
|
+
perf: input.perf ?? env.PI_WORKFLOW_PERF === "1",
|
|
27
|
+
concurrency,
|
|
28
|
+
parallelSubmissionLimit,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function optionalClampedInteger(value: number | undefined, min: number, max: number): number | undefined {
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
return clampInteger(value, min, max, value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampInteger(value: number | undefined, min: number, max: number, fallback: number): number {
|
|
38
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
39
|
+
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseInteger(value: string | undefined): number | undefined {
|
|
43
|
+
if (value === undefined || value.trim() === "") return undefined;
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
|
46
|
+
}
|