pi-workflow-engine 0.3.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/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/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
- 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/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +7 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/ping.ts +1 -0
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
- package/.pi/settings.json +3 -0
- package/README.md +70 -23
- 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-widget.ts +0 -143
- /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
|
|
3
|
+
export type PerfTags = Record<string, string | number>;
|
|
4
|
+
|
|
5
|
+
export interface PerfSample {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly durationMs?: number;
|
|
8
|
+
readonly value?: number;
|
|
9
|
+
readonly tags?: PerfTags;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PerfAggregate {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly count: number;
|
|
15
|
+
readonly total: number;
|
|
16
|
+
readonly min: number;
|
|
17
|
+
readonly max: number;
|
|
18
|
+
readonly mean: number;
|
|
19
|
+
readonly p50: number;
|
|
20
|
+
readonly p95: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PerfSnapshot {
|
|
24
|
+
readonly enabled: boolean;
|
|
25
|
+
readonly startedAt: number;
|
|
26
|
+
readonly samples: readonly PerfSample[];
|
|
27
|
+
readonly aggregates: readonly PerfAggregate[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PerfSink {
|
|
31
|
+
time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T>;
|
|
32
|
+
timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T;
|
|
33
|
+
observe(name: string, value: number, tags?: PerfTags): void;
|
|
34
|
+
counter(name: string, delta?: number, tags?: PerfTags): void;
|
|
35
|
+
snapshot(): PerfSnapshot;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PerfRecorder implements PerfSink {
|
|
39
|
+
private readonly samples: PerfSample[] = [];
|
|
40
|
+
|
|
41
|
+
constructor(private readonly startedAt = Date.now()) {}
|
|
42
|
+
|
|
43
|
+
async time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T> {
|
|
44
|
+
const start = performance.now();
|
|
45
|
+
try {
|
|
46
|
+
return await fn();
|
|
47
|
+
} finally {
|
|
48
|
+
this.observeDuration(name, performance.now() - start, tags);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T {
|
|
53
|
+
const start = performance.now();
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
} finally {
|
|
57
|
+
this.observeDuration(name, performance.now() - start, tags);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
observe(name: string, value: number, tags?: PerfTags): void {
|
|
62
|
+
if (!Number.isFinite(value)) return;
|
|
63
|
+
this.samples.push({ name, value, tags });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
counter(name: string, delta = 1, tags?: PerfTags): void {
|
|
67
|
+
this.observe(name, delta, tags);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
snapshot(): PerfSnapshot {
|
|
71
|
+
return {
|
|
72
|
+
enabled: true,
|
|
73
|
+
startedAt: this.startedAt,
|
|
74
|
+
samples: this.samples.map((sample) => ({ ...sample, tags: sample.tags ? { ...sample.tags } : undefined })),
|
|
75
|
+
aggregates: aggregateSamples(this.samples),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private observeDuration(name: string, durationMs: number, tags?: PerfTags): void {
|
|
80
|
+
if (!Number.isFinite(durationMs)) return;
|
|
81
|
+
this.samples.push({ name, durationMs, tags });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class NoopPerfRecorder implements PerfSink {
|
|
86
|
+
constructor(private readonly startedAt = Date.now()) {}
|
|
87
|
+
|
|
88
|
+
async time<T>(_name: string, fn: () => Promise<T>, _tags?: PerfTags): Promise<T> {
|
|
89
|
+
return await fn();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
timeSync<T>(_name: string, fn: () => T, _tags?: PerfTags): T {
|
|
93
|
+
return fn();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
observe(_name: string, _value: number, _tags?: PerfTags): void {}
|
|
97
|
+
|
|
98
|
+
counter(_name: string, _delta?: number, _tags?: PerfTags): void {}
|
|
99
|
+
|
|
100
|
+
snapshot(): PerfSnapshot {
|
|
101
|
+
return { enabled: false, startedAt: this.startedAt, samples: [], aggregates: [] };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createPerfRecorder(enabled: boolean, startedAt?: number): PerfSink {
|
|
106
|
+
return enabled ? new PerfRecorder(startedAt) : new NoopPerfRecorder(startedAt);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function aggregateSamples(samples: readonly PerfSample[]): PerfAggregate[] {
|
|
110
|
+
const byName = new Map<string, number[]>();
|
|
111
|
+
for (const sample of samples) {
|
|
112
|
+
const value = sample.durationMs ?? sample.value;
|
|
113
|
+
if (value === undefined || !Number.isFinite(value)) continue;
|
|
114
|
+
const values = byName.get(sample.name) ?? [];
|
|
115
|
+
values.push(value);
|
|
116
|
+
byName.set(sample.name, values);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return [...byName.entries()].map(([name, rawValues]) => {
|
|
120
|
+
const values = [...rawValues].sort((a, b) => a - b);
|
|
121
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
122
|
+
return {
|
|
123
|
+
name,
|
|
124
|
+
count: values.length,
|
|
125
|
+
total,
|
|
126
|
+
min: values[0] ?? 0,
|
|
127
|
+
max: values[values.length - 1] ?? 0,
|
|
128
|
+
mean: values.length === 0 ? 0 : total / values.length,
|
|
129
|
+
p50: percentile(values, 0.5),
|
|
130
|
+
p95: percentile(values, 0.95),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function percentile(sortedValues: readonly number[], p: number): number {
|
|
136
|
+
if (sortedValues.length === 0) return 0;
|
|
137
|
+
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * p) - 1));
|
|
138
|
+
return sortedValues[index] ?? 0;
|
|
139
|
+
}
|