pi-workflow-engine 0.12.0 → 1.0.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 +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +90 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -56,9 +56,9 @@ export function resolveWorkflowRunOptions(
|
|
|
56
56
|
input: WorkflowRunOptions = {},
|
|
57
57
|
env: Record<string, string | undefined> = process.env,
|
|
58
58
|
): ResolvedWorkflowRunOptions {
|
|
59
|
-
const concurrency = clampInteger(input.concurrency ??
|
|
59
|
+
const concurrency = clampInteger(input.concurrency ?? parseWorkflowIntegerString(env.PI_WORKFLOW_CONCURRENCY), 1, 64, defaultConcurrency());
|
|
60
60
|
const parallelSubmissionLimit = optionalClampedInteger(
|
|
61
|
-
input.parallelSubmissionLimit ??
|
|
61
|
+
input.parallelSubmissionLimit ?? parseWorkflowIntegerString(env.PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT),
|
|
62
62
|
1,
|
|
63
63
|
10_000,
|
|
64
64
|
);
|
|
@@ -80,7 +80,7 @@ export function resolveWorkflowRunOptions(
|
|
|
80
80
|
WORKFLOW_AGENT_RETRIES_MAX,
|
|
81
81
|
DEFAULT_WORKFLOW_AGENT_RETRIES,
|
|
82
82
|
);
|
|
83
|
-
const budget =
|
|
83
|
+
const budget = input.budget !== undefined ? normalizeExplicitBudget(input.budget) : parseWorkflowBudgetString(env.PI_WORKFLOW_BUDGET);
|
|
84
84
|
const usageLimitMaxAttempts = clampInteger(
|
|
85
85
|
input.usageLimitMaxAttempts ?? parseWorkflowIntegerString(env.PI_WORKFLOW_USAGE_LIMIT_MAX_ATTEMPTS),
|
|
86
86
|
WORKFLOW_USAGE_LIMIT_ATTEMPTS_MIN,
|
|
@@ -129,23 +129,12 @@ function clampInteger(value: number | undefined, min: number, max: number, fallb
|
|
|
129
129
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
function parseInteger(value: string | undefined): number | undefined {
|
|
133
|
-
if (value === undefined || value.trim() === "") return undefined;
|
|
134
|
-
const parsed = Number(value);
|
|
135
|
-
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
132
|
export function parseWorkflowIntegerString(value: string | undefined): number | undefined {
|
|
139
133
|
if (value === undefined || value.trim() === "") return undefined;
|
|
140
134
|
const parsed = Number(value);
|
|
141
135
|
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
142
136
|
}
|
|
143
137
|
|
|
144
|
-
function resolveBudget(inputBudget: number | undefined, envBudget: string | undefined): number | undefined {
|
|
145
|
-
if (inputBudget !== undefined) return normalizeExplicitBudget(inputBudget);
|
|
146
|
-
return envBudget === undefined ? undefined : parseWorkflowBudgetString(envBudget);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
138
|
function normalizeExplicitBudget(value: number): number {
|
|
150
139
|
if (!Number.isFinite(value) || !Number.isInteger(value) || value < WORKFLOW_BUDGET_MIN || value > WORKFLOW_BUDGET_MAX) {
|
|
151
140
|
throw new RangeError(`Workflow budget must be an integer between ${WORKFLOW_BUDGET_MIN} and ${WORKFLOW_BUDGET_MAX}.`);
|
|
@@ -153,10 +142,102 @@ function normalizeExplicitBudget(value: number): number {
|
|
|
153
142
|
return value;
|
|
154
143
|
}
|
|
155
144
|
|
|
156
|
-
export function parseWorkflowBudgetString(value: string): number | undefined {
|
|
157
|
-
const trimmed = value
|
|
145
|
+
export function parseWorkflowBudgetString(value: string | undefined): number | undefined {
|
|
146
|
+
const trimmed = value?.trim() ?? "";
|
|
158
147
|
if (!/^\d+$/.test(trimmed)) return undefined;
|
|
159
148
|
const parsed = Number(trimmed);
|
|
160
149
|
if (!Number.isSafeInteger(parsed) || parsed < WORKFLOW_BUDGET_MIN || parsed > WORKFLOW_BUDGET_MAX) return undefined;
|
|
161
150
|
return parsed;
|
|
162
151
|
}
|
|
152
|
+
|
|
153
|
+
function parseRunIdString(value: string | undefined): string | undefined {
|
|
154
|
+
return value === undefined || value === "" || value.startsWith("--") ? undefined : value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A `/workflow <name> [flags] [args]` command line with its flags lifted into run options. */
|
|
158
|
+
export interface WorkflowInvocation {
|
|
159
|
+
readonly name: string;
|
|
160
|
+
readonly args: string;
|
|
161
|
+
readonly options: WorkflowRunOptions;
|
|
162
|
+
readonly refreshDiscovery: boolean;
|
|
163
|
+
readonly optionErrors: readonly string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type WorkflowValueOptions = Pick<
|
|
167
|
+
WorkflowRunOptions,
|
|
168
|
+
"concurrency" | "parallelSubmissionLimit" | "maxAgents" | "agentTimeoutMs" | "agentRetries" | "budget" | "resumeFromRunId"
|
|
169
|
+
>;
|
|
170
|
+
|
|
171
|
+
interface WorkflowValueFlag<K extends keyof WorkflowValueOptions> {
|
|
172
|
+
readonly flag: string;
|
|
173
|
+
readonly key: K;
|
|
174
|
+
readonly parse: (value: string | undefined) => WorkflowValueOptions[K] | undefined;
|
|
175
|
+
readonly error: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type AnyWorkflowValueFlag = { [K in keyof WorkflowValueOptions]-?: WorkflowValueFlag<K> }[keyof WorkflowValueOptions];
|
|
179
|
+
|
|
180
|
+
/** `--flag=V` or `--flag V`; the next token is consumed only when it parses, so a following flag or argument survives. */
|
|
181
|
+
const WORKFLOW_VALUE_FLAGS: readonly AnyWorkflowValueFlag[] = [
|
|
182
|
+
{ flag: "--concurrency", key: "concurrency", parse: parseWorkflowIntegerString, error: "--concurrency requires an integer" },
|
|
183
|
+
{ flag: "--parallel-limit", key: "parallelSubmissionLimit", parse: parseWorkflowIntegerString, error: "--parallel-limit requires an integer" },
|
|
184
|
+
{ flag: "--max-agents", key: "maxAgents", parse: parseWorkflowIntegerString, error: "--max-agents requires an integer" },
|
|
185
|
+
{ flag: "--agent-timeout-ms", key: "agentTimeoutMs", parse: parseWorkflowIntegerString, error: "--agent-timeout-ms requires an integer" },
|
|
186
|
+
{ flag: "--agent-retries", key: "agentRetries", parse: parseWorkflowIntegerString, error: "--agent-retries requires an integer" },
|
|
187
|
+
{ flag: "--budget", key: "budget", parse: parseWorkflowBudgetString, error: "--budget requires a positive integer output-token count" },
|
|
188
|
+
{ flag: "--resume", key: "resumeFromRunId", parse: parseRunIdString, error: "--resume requires a workflow run id" },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const WORKFLOW_SWITCH_FLAGS = new Map<string, Partial<WorkflowRunOptions>>([
|
|
192
|
+
["--inspect", { inspect: true }],
|
|
193
|
+
["--perf", { perf: true }],
|
|
194
|
+
["--resume-edited", { resumeEditedWorkflow: true }],
|
|
195
|
+
["--result-viewer", { resultViewer: "open" }],
|
|
196
|
+
["--review-viewer", { resultViewer: "open" }],
|
|
197
|
+
["--no-result-viewer", { resultViewer: "skip" }],
|
|
198
|
+
["--no-review-viewer", { resultViewer: "skip" }],
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
export function parseWorkflowInvocation(input: string): WorkflowInvocation {
|
|
202
|
+
const [name = "", ...tokens] = input.split(/\s+/).filter(Boolean);
|
|
203
|
+
const kept: string[] = [];
|
|
204
|
+
const options: WorkflowRunOptions = {};
|
|
205
|
+
const optionErrors: string[] = [];
|
|
206
|
+
let refreshDiscovery = false;
|
|
207
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
208
|
+
const token = tokens[i];
|
|
209
|
+
if (token === "--refresh") {
|
|
210
|
+
refreshDiscovery = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const switched = WORKFLOW_SWITCH_FLAGS.get(token);
|
|
214
|
+
if (switched) {
|
|
215
|
+
Object.assign(options, switched);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const valueFlag = WORKFLOW_VALUE_FLAGS.find(({ flag }) => token === flag || token.startsWith(`${flag}=`));
|
|
219
|
+
if (!valueFlag) {
|
|
220
|
+
kept.push(token);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const inline = token !== valueFlag.flag;
|
|
224
|
+
if (applyValueFlag(options, valueFlag, inline ? token.slice(valueFlag.flag.length + 1) : tokens[i + 1])) {
|
|
225
|
+
if (!inline) i++;
|
|
226
|
+
} else {
|
|
227
|
+
optionErrors.push(valueFlag.error);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (options.resumeEditedWorkflow && !options.resumeFromRunId) optionErrors.push("--resume-edited requires --resume <run-id>");
|
|
231
|
+
return { name, args: kept.join(" "), options, refreshDiscovery, optionErrors };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function applyValueFlag<K extends keyof WorkflowValueOptions>(
|
|
235
|
+
options: WorkflowValueOptions,
|
|
236
|
+
valueFlag: WorkflowValueFlag<K>,
|
|
237
|
+
value: string | undefined,
|
|
238
|
+
): boolean {
|
|
239
|
+
const parsed = valueFlag.parse(value);
|
|
240
|
+
if (parsed === undefined) return false;
|
|
241
|
+
options[valueFlag.key] = parsed;
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
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
3
|
export interface PerfAggregate {
|
|
13
4
|
readonly name: string;
|
|
14
5
|
readonly count: number;
|
|
@@ -22,118 +13,98 @@ export interface PerfAggregate {
|
|
|
22
13
|
|
|
23
14
|
export interface PerfSnapshot {
|
|
24
15
|
readonly enabled: boolean;
|
|
25
|
-
readonly startedAt: number;
|
|
26
|
-
readonly samples: readonly PerfSample[];
|
|
27
16
|
readonly aggregates: readonly PerfAggregate[];
|
|
28
17
|
}
|
|
29
18
|
|
|
30
19
|
export interface PerfSink {
|
|
31
|
-
time<T>(name: string, fn: () => Promise<T
|
|
32
|
-
timeSync<T>(name: string, fn: () => T
|
|
33
|
-
observe(name: string, value: number
|
|
34
|
-
counter(name: string, delta?: number
|
|
20
|
+
time<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
21
|
+
timeSync<T>(name: string, fn: () => T): T;
|
|
22
|
+
observe(name: string, value: number): void;
|
|
23
|
+
counter(name: string, delta?: number): void;
|
|
35
24
|
snapshot(): PerfSnapshot;
|
|
36
25
|
}
|
|
37
26
|
|
|
38
27
|
export class PerfRecorder implements PerfSink {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
constructor(private readonly startedAt = Date.now()) {}
|
|
28
|
+
/** Finite observed values per metric, in first-observation order. */
|
|
29
|
+
private readonly values = new Map<string, number[]>();
|
|
42
30
|
|
|
43
|
-
async time<T>(name: string, fn: () => Promise<T
|
|
31
|
+
async time<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
44
32
|
const start = performance.now();
|
|
45
33
|
try {
|
|
46
34
|
return await fn();
|
|
47
35
|
} finally {
|
|
48
|
-
this.
|
|
36
|
+
this.observe(name, performance.now() - start);
|
|
49
37
|
}
|
|
50
38
|
}
|
|
51
39
|
|
|
52
|
-
timeSync<T>(name: string, fn: () => T
|
|
40
|
+
timeSync<T>(name: string, fn: () => T): T {
|
|
53
41
|
const start = performance.now();
|
|
54
42
|
try {
|
|
55
43
|
return fn();
|
|
56
44
|
} finally {
|
|
57
|
-
this.
|
|
45
|
+
this.observe(name, performance.now() - start);
|
|
58
46
|
}
|
|
59
47
|
}
|
|
60
48
|
|
|
61
|
-
observe(name: string, value: number
|
|
49
|
+
observe(name: string, value: number): void {
|
|
62
50
|
if (!Number.isFinite(value)) return;
|
|
63
|
-
this.
|
|
51
|
+
const values = this.values.get(name);
|
|
52
|
+
if (values) values.push(value);
|
|
53
|
+
else this.values.set(name, [value]);
|
|
64
54
|
}
|
|
65
55
|
|
|
66
|
-
counter(name: string, delta = 1
|
|
67
|
-
this.observe(name, delta
|
|
56
|
+
counter(name: string, delta = 1): void {
|
|
57
|
+
this.observe(name, delta);
|
|
68
58
|
}
|
|
69
59
|
|
|
70
60
|
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 });
|
|
61
|
+
return { enabled: true, aggregates: [...this.values].map(([name, values]) => aggregate(name, values)) };
|
|
82
62
|
}
|
|
83
63
|
}
|
|
84
64
|
|
|
85
65
|
export class NoopPerfRecorder implements PerfSink {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
async time<T>(_name: string, fn: () => Promise<T>, _tags?: PerfTags): Promise<T> {
|
|
66
|
+
async time<T>(_name: string, fn: () => Promise<T>): Promise<T> {
|
|
89
67
|
return await fn();
|
|
90
68
|
}
|
|
91
69
|
|
|
92
|
-
timeSync<T>(_name: string, fn: () => T
|
|
70
|
+
timeSync<T>(_name: string, fn: () => T): T {
|
|
93
71
|
return fn();
|
|
94
72
|
}
|
|
95
73
|
|
|
96
|
-
observe(
|
|
74
|
+
observe(): void {}
|
|
97
75
|
|
|
98
|
-
counter(
|
|
76
|
+
counter(): void {}
|
|
99
77
|
|
|
100
78
|
snapshot(): PerfSnapshot {
|
|
101
|
-
return { enabled: false,
|
|
79
|
+
return { enabled: false, aggregates: [] };
|
|
102
80
|
}
|
|
103
81
|
}
|
|
104
82
|
|
|
105
|
-
export function createPerfRecorder(enabled: boolean
|
|
106
|
-
return enabled ? new PerfRecorder(
|
|
83
|
+
export function createPerfRecorder(enabled: boolean): PerfSink {
|
|
84
|
+
return enabled ? new PerfRecorder() : new NoopPerfRecorder();
|
|
107
85
|
}
|
|
108
86
|
|
|
109
|
-
function
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
}
|
|
87
|
+
export function formatPerfSummary(aggregates: readonly PerfAggregate[]): string {
|
|
88
|
+
const parts = aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
89
|
+
return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
|
|
90
|
+
}
|
|
118
91
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
92
|
+
/** `observed` is never empty: a metric is recorded with its first value. */
|
|
93
|
+
function aggregate(name: string, observed: readonly number[]): PerfAggregate {
|
|
94
|
+
const values = [...observed].sort((a, b) => a - b);
|
|
95
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
count: values.length,
|
|
99
|
+
total,
|
|
100
|
+
min: values[0],
|
|
101
|
+
max: values[values.length - 1],
|
|
102
|
+
mean: total / values.length,
|
|
103
|
+
p50: percentile(values, 0.5),
|
|
104
|
+
p95: percentile(values, 0.95),
|
|
105
|
+
};
|
|
133
106
|
}
|
|
134
107
|
|
|
135
108
|
function percentile(sortedValues: readonly number[], p: number): number {
|
|
136
|
-
|
|
137
|
-
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * p) - 1));
|
|
138
|
-
return sortedValues[index] ?? 0;
|
|
109
|
+
return sortedValues[Math.ceil(sortedValues.length * p) - 1];
|
|
139
110
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
4
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
4
5
|
|
|
5
6
|
export interface BoundedProcessOptions {
|
|
@@ -102,6 +103,13 @@ export async function runBoundedProcess(
|
|
|
102
103
|
let stdout = "";
|
|
103
104
|
let stderr = "";
|
|
104
105
|
let bytes = 0;
|
|
106
|
+
// Pipe chunks can split a multi-byte UTF-8 sequence; streaming decoders carry the tail forward.
|
|
107
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
108
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
109
|
+
const flushDecoders = () => {
|
|
110
|
+
stdout += stdoutDecoder.end();
|
|
111
|
+
stderr += stderrDecoder.end();
|
|
112
|
+
};
|
|
105
113
|
let pendingFailure: BoundedProcessFailure | undefined;
|
|
106
114
|
const child = spawn(options.file, [...options.args], {
|
|
107
115
|
cwd: options.cwd,
|
|
@@ -127,6 +135,7 @@ export async function runBoundedProcess(
|
|
|
127
135
|
if (settled) return;
|
|
128
136
|
settled = true;
|
|
129
137
|
cleanup();
|
|
138
|
+
flushDecoders();
|
|
130
139
|
const base = { stdout, stderr, durationMs: performance.now() - start, bytes };
|
|
131
140
|
if (!failure) {
|
|
132
141
|
resolve({ ...base, ok: true });
|
|
@@ -216,7 +225,7 @@ export async function runBoundedProcess(
|
|
|
216
225
|
const onAbort = () => terminate({ kind: "abort", message: options.abortError });
|
|
217
226
|
const timeout = setTimeout(() => terminate({ kind: "timeout", message: options.timeoutError }), options.timeoutMs);
|
|
218
227
|
|
|
219
|
-
const captureChunk = (chunk: Buffer, append: (text: string) => void) => {
|
|
228
|
+
const captureChunk = (chunk: Buffer, decoder: StringDecoder, append: (text: string) => void) => {
|
|
220
229
|
if (pendingFailure) return;
|
|
221
230
|
bytes += chunk.length;
|
|
222
231
|
if (options.maxBufferBytes !== undefined && bytes > options.maxBufferBytes) {
|
|
@@ -226,10 +235,10 @@ export async function runBoundedProcess(
|
|
|
226
235
|
});
|
|
227
236
|
return;
|
|
228
237
|
}
|
|
229
|
-
append(
|
|
238
|
+
append(decoder.write(chunk));
|
|
230
239
|
};
|
|
231
|
-
child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stdout += text)));
|
|
232
|
-
child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, (text) => (stderr += text)));
|
|
240
|
+
child.stdout?.on("data", (chunk: Buffer) => captureChunk(chunk, stdoutDecoder, (text) => (stdout += text)));
|
|
241
|
+
child.stderr?.on("data", (chunk: Buffer) => captureChunk(chunk, stderrDecoder, (text) => (stderr += text)));
|
|
233
242
|
child.on("error", (spawnError) => {
|
|
234
243
|
if (!pendingFailure) finish({ kind: "spawn", message: spawnError.message });
|
|
235
244
|
});
|
|
@@ -242,6 +251,7 @@ export async function runBoundedProcess(
|
|
|
242
251
|
finish();
|
|
243
252
|
return;
|
|
244
253
|
}
|
|
254
|
+
flushDecoders();
|
|
245
255
|
const message = options.exitError(stderr, code, signal);
|
|
246
256
|
finish({ kind: "exit", message, code, signal });
|
|
247
257
|
});
|
|
@@ -40,6 +40,8 @@ export interface WorkflowProgressSnapshot {
|
|
|
40
40
|
readonly startedAt: number;
|
|
41
41
|
readonly doneAt?: number;
|
|
42
42
|
readonly currentPhase: string;
|
|
43
|
+
/** Phase titles the workflow declared in `meta.phases`; live surfaces list the ones not reached yet. */
|
|
44
|
+
readonly plannedPhases?: readonly string[];
|
|
43
45
|
readonly phases: readonly PhaseSnapshot[];
|
|
44
46
|
readonly counters: readonly WorkflowCounterSnapshot[];
|
|
45
47
|
readonly summary: readonly [string, string | number][];
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
2
3
|
import type { WorkflowProgressEvent } from "./types.ts";
|
|
3
4
|
import type { AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
4
|
-
import {
|
|
5
|
+
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
5
6
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
6
7
|
import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
WorkflowCounterSnapshot,
|
|
14
|
-
WorkflowLaneItemSnapshot,
|
|
15
|
-
WorkflowLaneItemStatus,
|
|
16
|
-
WorkflowProgressSnapshot,
|
|
17
|
-
} from "./progress-types.ts";
|
|
8
|
+
import {
|
|
9
|
+
renderBackgroundWorkflowLine,
|
|
10
|
+
renderWorkflowWidget,
|
|
11
|
+
STRING_WIDGET_WIDTH,
|
|
12
|
+
WidthAwareWidget,
|
|
13
|
+
} from "./ui/workflow-widget.ts";
|
|
18
14
|
|
|
19
15
|
interface AgentRow {
|
|
20
16
|
id: number;
|
|
@@ -51,6 +47,16 @@ const LOG_LIMIT = 24;
|
|
|
51
47
|
const WIDGET_REFRESH_INTERVAL_MS = 1_000;
|
|
52
48
|
export const DEFAULT_LANE_ITEM_LIMIT = 200;
|
|
53
49
|
|
|
50
|
+
export interface ProgressTrackerOptions {
|
|
51
|
+
/** Phase titles from the workflow's `meta.phases`, shown as upcoming until the run reaches them. */
|
|
52
|
+
readonly plannedPhases?: readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* `live` (default) draws the full progress widget and a footer status; `background` draws one
|
|
55
|
+
* summary line per run so a run the user sent to the background never takes over the screen.
|
|
56
|
+
*/
|
|
57
|
+
readonly display?: "live" | "background";
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
/**
|
|
55
61
|
* Tracks live workflow state for widgets, footer/status text, result renderers,
|
|
56
62
|
* and headless stderr breadcrumbs.
|
|
@@ -73,14 +79,24 @@ export class ProgressTracker {
|
|
|
73
79
|
private usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
74
80
|
private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
75
81
|
private readonly surfaceKey: string;
|
|
82
|
+
private readonly plannedPhases: readonly string[] | undefined;
|
|
83
|
+
private readonly display: "live" | "background";
|
|
84
|
+
/** Latest published snapshot; the TUI widget renders it at whatever width the terminal has. */
|
|
85
|
+
private latest: WorkflowProgressSnapshot | undefined;
|
|
86
|
+
/** Set once the TUI has instantiated this run's widget component. */
|
|
87
|
+
private tui: Pick<TUI, "requestRender"> | undefined;
|
|
88
|
+
private widgetRegistered = false;
|
|
76
89
|
|
|
77
90
|
constructor(
|
|
78
91
|
private readonly ctx: ExtensionContext,
|
|
79
92
|
private readonly title: string,
|
|
80
93
|
private readonly runId: string,
|
|
81
94
|
private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
|
|
95
|
+
options: ProgressTrackerOptions = {},
|
|
82
96
|
) {
|
|
83
97
|
this.surfaceKey = `workflow:${runId}`;
|
|
98
|
+
this.plannedPhases = options.plannedPhases?.length ? [...options.plannedPhases] : undefined;
|
|
99
|
+
this.display = options.display ?? "live";
|
|
84
100
|
this.ensurePhase(this.currentPhase);
|
|
85
101
|
}
|
|
86
102
|
|
|
@@ -152,29 +168,18 @@ export class ProgressTracker {
|
|
|
152
168
|
return id;
|
|
153
169
|
}
|
|
154
170
|
|
|
155
|
-
agentStart(
|
|
156
|
-
const row =
|
|
171
|
+
agentStart(id: number): void {
|
|
172
|
+
const row = this.rowsById.get(id);
|
|
157
173
|
if (row) {
|
|
158
174
|
this.transitionAgentStatus(row, "running");
|
|
159
175
|
row.startedAt = Date.now();
|
|
160
176
|
row.error = undefined;
|
|
161
|
-
} else {
|
|
162
|
-
const nextRow = {
|
|
163
|
-
label,
|
|
164
|
-
id: this.nextAgentId++,
|
|
165
|
-
status: "running" as const,
|
|
166
|
-
startedAt: Date.now(),
|
|
167
|
-
toolUses: 0,
|
|
168
|
-
};
|
|
169
|
-
this.ensurePhase(phase ?? this.currentPhase).agents.push(nextRow);
|
|
170
|
-
this.rowsById.set(nextRow.id, nextRow);
|
|
171
|
-
this.agentCounts.running++;
|
|
172
177
|
}
|
|
173
178
|
this.publish();
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
agentTool(
|
|
177
|
-
const row = this.
|
|
181
|
+
agentTool(id: number, tool: string): void {
|
|
182
|
+
const row = this.rowsById.get(id);
|
|
178
183
|
if (row) {
|
|
179
184
|
row.lastTool = tool;
|
|
180
185
|
row.toolUses += 1;
|
|
@@ -182,8 +187,8 @@ export class ProgressTracker {
|
|
|
182
187
|
this.publish();
|
|
183
188
|
}
|
|
184
189
|
|
|
185
|
-
agentDone(
|
|
186
|
-
const row = this.
|
|
190
|
+
agentDone(id: number): void {
|
|
191
|
+
const row = this.rowsById.get(id);
|
|
187
192
|
if (row && row.status !== "failed") {
|
|
188
193
|
this.transitionAgentStatus(row, "done");
|
|
189
194
|
row.doneAt = Date.now();
|
|
@@ -191,8 +196,8 @@ export class ProgressTracker {
|
|
|
191
196
|
this.publish();
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
agentFailed(
|
|
195
|
-
const row = this.
|
|
199
|
+
agentFailed(id: number, error: unknown): void {
|
|
200
|
+
const row = this.rowsById.get(id);
|
|
196
201
|
if (row) {
|
|
197
202
|
this.transitionAgentStatus(row, "failed");
|
|
198
203
|
row.doneAt = Date.now();
|
|
@@ -213,6 +218,7 @@ export class ProgressTracker {
|
|
|
213
218
|
startedAt: this.startedAt,
|
|
214
219
|
doneAt: this.doneAt,
|
|
215
220
|
currentPhase: this.currentPhase,
|
|
221
|
+
plannedPhases: this.plannedPhases,
|
|
216
222
|
phases: this.phases.map((phase) => ({
|
|
217
223
|
title: phase.title,
|
|
218
224
|
agents: phase.agents.map((agent) => ({ ...agent })),
|
|
@@ -227,10 +233,6 @@ export class ProgressTracker {
|
|
|
227
233
|
}
|
|
228
234
|
|
|
229
235
|
statusCounts(): WorkflowStatusCounts {
|
|
230
|
-
return this.statusCountsSnapshot();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private statusCountsSnapshot(): WorkflowStatusCounts {
|
|
234
236
|
return {
|
|
235
237
|
queued: this.agentCounts.queued,
|
|
236
238
|
running: this.agentCounts.running,
|
|
@@ -248,51 +250,59 @@ export class ProgressTracker {
|
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
private pruneLane(laneName: string, lane: WorkflowLaneItem[]): void {
|
|
251
|
-
if (this.laneItemLimit <= 0) return;
|
|
252
253
|
while (lane.length > this.laneItemLimit) {
|
|
253
254
|
lane.shift();
|
|
254
255
|
this.laneOverflow.set(laneName, (this.laneOverflow.get(laneName) ?? 0) + 1);
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
private findRow(label: string, id?: number): AgentRow | undefined {
|
|
259
|
-
if (id !== undefined) return this.findRowById(id);
|
|
260
|
-
for (let i = this.phases.length - 1; i >= 0; i--) {
|
|
261
|
-
const running = this.phases[i].agents.find(
|
|
262
|
-
(agent) => agent.label === label && (agent.status === "running" || agent.status === "queued"),
|
|
263
|
-
);
|
|
264
|
-
if (running) return running;
|
|
265
|
-
}
|
|
266
|
-
for (let i = this.phases.length - 1; i >= 0; i--) {
|
|
267
|
-
const matching = this.phases[i].agents.find((agent) => agent.label === label);
|
|
268
|
-
if (matching) return matching;
|
|
269
|
-
}
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private findRowById(id: number): AgentRow | undefined {
|
|
274
|
-
return this.rowsById.get(id);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
259
|
private publish(): void {
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
260
|
+
const snapshot = this.snapshot();
|
|
261
|
+
this.onSnapshot?.(snapshot);
|
|
262
|
+
// Agents that outlive a fatal drain still report after done(); record them without reviving live surfaces.
|
|
263
|
+
if (!this.ctx.hasUI || this.doneAt !== undefined) return;
|
|
264
|
+
this.latest = snapshot;
|
|
265
|
+
this.publishWidget(snapshot);
|
|
281
266
|
this.startWidgetRefresh();
|
|
282
|
-
this.publishStatus();
|
|
267
|
+
this.publishStatus(snapshot);
|
|
283
268
|
}
|
|
284
269
|
|
|
285
|
-
|
|
270
|
+
/**
|
|
271
|
+
* In the TUI the widget is a width-aware component registered once and redrawn on demand; surfaces
|
|
272
|
+
* that only take strings (RPC) get lines pre-rendered at a nominal width on every publish.
|
|
273
|
+
*/
|
|
274
|
+
private publishWidget(snapshot: WorkflowProgressSnapshot): void {
|
|
275
|
+
if (this.ctx.mode !== "tui") {
|
|
276
|
+
this.ctx.ui.setWidget(this.surfaceKey, this.renderWidget(snapshot, STRING_WIDGET_WIDTH, this.ctx.ui.theme), { placement: "aboveEditor" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (this.widgetRegistered) {
|
|
280
|
+
this.tui?.requestRender();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.widgetRegistered = true;
|
|
286
284
|
this.ctx.ui.setWidget(
|
|
287
285
|
this.surfaceKey,
|
|
288
|
-
|
|
286
|
+
(tui, theme) => {
|
|
287
|
+
this.tui = tui;
|
|
288
|
+
return new WidthAwareWidget((width) => (this.latest ? this.renderWidget(this.latest, width, theme) : []));
|
|
289
|
+
},
|
|
289
290
|
{ placement: "aboveEditor" },
|
|
290
291
|
);
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
private renderWidget(snapshot: WorkflowProgressSnapshot, width: number, theme: ExtensionContext["ui"]["theme"]): string[] {
|
|
295
|
+
return this.display === "background" ? renderBackgroundWorkflowLine(snapshot, width, theme) : renderWorkflowWidget(snapshot, width, theme);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Elapsed time ticks even when no agent reports, so redraw the widget and status once a second. */
|
|
293
299
|
private startWidgetRefresh(): void {
|
|
294
300
|
if (this.widgetRefreshInterval !== undefined) return;
|
|
295
|
-
this.widgetRefreshInterval = setInterval(() =>
|
|
301
|
+
this.widgetRefreshInterval = setInterval(() => {
|
|
302
|
+
if (!this.latest) return;
|
|
303
|
+
this.publishWidget(this.latest);
|
|
304
|
+
this.publishStatus(this.latest);
|
|
305
|
+
}, WIDGET_REFRESH_INTERVAL_MS);
|
|
296
306
|
}
|
|
297
307
|
|
|
298
308
|
private stopWidgetRefresh(): void {
|
|
@@ -301,19 +311,10 @@ export class ProgressTracker {
|
|
|
301
311
|
this.widgetRefreshInterval = undefined;
|
|
302
312
|
}
|
|
303
313
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
doneAt: this.doneAt,
|
|
309
|
-
currentPhase: this.currentPhase,
|
|
310
|
-
counters: [...this.counters.values()].map((counter) => ({ ...counter })),
|
|
311
|
-
},
|
|
312
|
-
this.statusCountsSnapshot(),
|
|
313
|
-
this.ctx.ui.theme,
|
|
314
|
-
);
|
|
315
|
-
const usage = formatWorkflowUsageLine(this.usageSnapshot);
|
|
316
|
-
const next = usage ? `${status} · ${usage}` : status;
|
|
314
|
+
/** Background runs are summarised by their widget line alone. */
|
|
315
|
+
private publishStatus(snapshot: WorkflowProgressSnapshot): void {
|
|
316
|
+
if (this.display === "background") return;
|
|
317
|
+
const next = statusTextFromCounts(snapshot, this.statusCounts(), this.ctx.ui.theme);
|
|
317
318
|
if (next === this.lastStatusText) return;
|
|
318
319
|
this.ctx.ui.setStatus(this.surfaceKey, next);
|
|
319
320
|
this.lastStatusText = next;
|
|
@@ -323,15 +324,13 @@ export class ProgressTracker {
|
|
|
323
324
|
done(): void {
|
|
324
325
|
this.doneAt = Date.now();
|
|
325
326
|
this.stopWidgetRefresh();
|
|
326
|
-
this.
|
|
327
|
+
this.onSnapshot?.(this.snapshot());
|
|
327
328
|
if (!this.ctx.hasUI) return;
|
|
328
329
|
this.ctx.ui.setWidget(this.surfaceKey, undefined);
|
|
329
330
|
this.ctx.ui.setStatus(this.surfaceKey, undefined);
|
|
330
331
|
this.lastStatusText = undefined;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
private publishSnapshot(): void {
|
|
334
|
-
this.onSnapshot?.(this.snapshot());
|
|
332
|
+
this.latest = undefined;
|
|
333
|
+
this.tui = undefined;
|
|
335
334
|
}
|
|
336
335
|
}
|
|
337
336
|
|