@wichayutdew/pi-workflows 0.1.1
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/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface StepResultPolicy {
|
|
2
|
+
policyDigest: string;
|
|
3
|
+
outcomes: string[];
|
|
4
|
+
summaryMaxChars: number;
|
|
5
|
+
gateSubmitOutcome?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorkflowStepResult {
|
|
9
|
+
version: 1;
|
|
10
|
+
policyDigest: string;
|
|
11
|
+
outcome: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
artifact?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function parseWorkflowStepResult(
|
|
21
|
+
value: unknown,
|
|
22
|
+
policy: StepResultPolicy,
|
|
23
|
+
): WorkflowStepResult {
|
|
24
|
+
if (!isObject(value))
|
|
25
|
+
throw new Error('workflow step result must be an object');
|
|
26
|
+
const allowedKeys = new Set([
|
|
27
|
+
'version',
|
|
28
|
+
'policyDigest',
|
|
29
|
+
'outcome',
|
|
30
|
+
'summary',
|
|
31
|
+
'artifact',
|
|
32
|
+
]);
|
|
33
|
+
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
34
|
+
if (unknownKey) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`workflow step result has unknown property "${unknownKey}"`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (value.version !== 1)
|
|
40
|
+
throw new Error('unsupported workflow step result version');
|
|
41
|
+
if (value.policyDigest !== policy.policyDigest) {
|
|
42
|
+
throw new Error('workflow step result does not match the active policy');
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
typeof value.outcome !== 'string' ||
|
|
46
|
+
!policy.outcomes.includes(value.outcome)
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`workflow step returned invalid outcome "${String(value.outcome)}"`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (typeof value.summary !== 'string') {
|
|
53
|
+
throw new Error('workflow step summary must be a string');
|
|
54
|
+
}
|
|
55
|
+
const summary = value.summary.trim();
|
|
56
|
+
if (!summary) {
|
|
57
|
+
throw new Error('workflow step summary must not be empty');
|
|
58
|
+
}
|
|
59
|
+
if (summary.length > policy.summaryMaxChars) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`workflow step summary exceeds ${policy.summaryMaxChars} characters`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (value.artifact !== undefined && typeof value.artifact !== 'string') {
|
|
65
|
+
throw new Error('workflow step artifact must be a string');
|
|
66
|
+
}
|
|
67
|
+
const artifact =
|
|
68
|
+
typeof value.artifact === 'string' ? value.artifact : undefined;
|
|
69
|
+
if (artifact !== undefined && artifact.length > 200_000) {
|
|
70
|
+
throw new Error('workflow step artifact exceeds 200000 characters');
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
value.outcome === policy.gateSubmitOutcome &&
|
|
74
|
+
(!artifact || !artifact.trim())
|
|
75
|
+
) {
|
|
76
|
+
throw new Error('workflow gate outcome requires a non-empty artifact');
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
version: 1,
|
|
80
|
+
policyDigest: policy.policyDigest,
|
|
81
|
+
outcome: value.outcome,
|
|
82
|
+
summary,
|
|
83
|
+
...(artifact !== undefined ? { artifact } : {}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface WorkflowListItem {
|
|
2
|
+
id: string;
|
|
3
|
+
command: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function escapeMarkdownTableCell(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replaceAll('\\', '\\\\')
|
|
10
|
+
.replaceAll('|', '\\|')
|
|
11
|
+
.replace(/\r\n|\r|\n/g, ' ');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatWorkflowList(
|
|
15
|
+
workflows: readonly WorkflowListItem[],
|
|
16
|
+
): string {
|
|
17
|
+
return [
|
|
18
|
+
'| Workflow | Command | Description |',
|
|
19
|
+
'| --- | --- | --- |',
|
|
20
|
+
...workflows.map(
|
|
21
|
+
(workflow) =>
|
|
22
|
+
`| \`${workflow.id}\` | \`/${workflow.command}\` | ${escapeMarkdownTableCell(workflow.description)} |`,
|
|
23
|
+
),
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
Theme,
|
|
4
|
+
ThemeColor,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import {
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
type Component,
|
|
12
|
+
type TUI,
|
|
13
|
+
} from '@earendil-works/pi-tui';
|
|
14
|
+
import type { LoadedWorkflow } from './config/types.ts';
|
|
15
|
+
import type {
|
|
16
|
+
StepHistoryEntry,
|
|
17
|
+
WorkflowRun,
|
|
18
|
+
WorkflowRunStatus,
|
|
19
|
+
} from './engine/state.ts';
|
|
20
|
+
|
|
21
|
+
const REFRESH_INTERVAL_MS = 1_000;
|
|
22
|
+
const WIDE_LAYOUT_MIN_COLUMNS = 92;
|
|
23
|
+
const MAX_PATH_ROWS = 16;
|
|
24
|
+
|
|
25
|
+
export type WorkflowStatusExecution =
|
|
26
|
+
| {
|
|
27
|
+
kind: 'main';
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
kind: 'subagent';
|
|
31
|
+
agent: string;
|
|
32
|
+
requestId: string;
|
|
33
|
+
progress: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface WorkflowStatusSnapshot {
|
|
37
|
+
run: WorkflowRun;
|
|
38
|
+
workflow?: LoadedWorkflow;
|
|
39
|
+
execution?: WorkflowStatusExecution;
|
|
40
|
+
now: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type SnapshotProvider = () => WorkflowStatusSnapshot | undefined;
|
|
44
|
+
type StepDisplayStatus = WorkflowRunStatus | 'completed';
|
|
45
|
+
|
|
46
|
+
interface PathEntry {
|
|
47
|
+
stepId: string;
|
|
48
|
+
title: string;
|
|
49
|
+
status: StepDisplayStatus;
|
|
50
|
+
visit: number;
|
|
51
|
+
outcome?: string;
|
|
52
|
+
current: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatWorkflowStatusText(
|
|
56
|
+
snapshot: WorkflowStatusSnapshot,
|
|
57
|
+
): string {
|
|
58
|
+
const { run } = snapshot;
|
|
59
|
+
const lines = [
|
|
60
|
+
`Workflow: ${run.workflowId}`,
|
|
61
|
+
`Run: ${run.runId}`,
|
|
62
|
+
`Status: ${run.status}`,
|
|
63
|
+
`Step: ${run.currentStepId}`,
|
|
64
|
+
`Completed steps: ${run.history.length}`,
|
|
65
|
+
];
|
|
66
|
+
if (run.pendingGate?.reviewId) {
|
|
67
|
+
lines.push(`Review: ${run.pendingGate.reviewId}`);
|
|
68
|
+
}
|
|
69
|
+
if (snapshot.execution?.kind === 'subagent') {
|
|
70
|
+
lines.push(
|
|
71
|
+
`Subagent: ${snapshot.execution.agent} (${snapshot.execution.requestId})`,
|
|
72
|
+
`Progress: ${snapshot.execution.progress}`,
|
|
73
|
+
);
|
|
74
|
+
} else if (snapshot.execution?.kind === 'main') {
|
|
75
|
+
lines.push('Execution: main agent');
|
|
76
|
+
}
|
|
77
|
+
if (run.pauseReason) lines.push(`Reason: ${run.pauseReason}`);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function showWorkflowStatus(
|
|
82
|
+
ctx: ExtensionCommandContext,
|
|
83
|
+
getSnapshot: SnapshotProvider,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
86
|
+
const view = new WorkflowStatusView(getSnapshot, tui, theme, done);
|
|
87
|
+
view.start();
|
|
88
|
+
return view;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class WorkflowStatusView implements Component {
|
|
93
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
94
|
+
private closed = false;
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
private readonly getSnapshot: SnapshotProvider,
|
|
98
|
+
private readonly tui: Pick<TUI, 'requestRender'>,
|
|
99
|
+
private readonly theme: Theme,
|
|
100
|
+
private readonly done: () => void,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
start(): void {
|
|
104
|
+
this.timer = setInterval(
|
|
105
|
+
() => this.tui.requestRender(),
|
|
106
|
+
REFRESH_INTERVAL_MS,
|
|
107
|
+
);
|
|
108
|
+
this.timer.unref?.();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
dispose(): void {
|
|
112
|
+
if (this.timer) clearInterval(this.timer);
|
|
113
|
+
this.timer = undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
invalidate(): void {}
|
|
117
|
+
|
|
118
|
+
handleInput(data: string): void {
|
|
119
|
+
if (
|
|
120
|
+
data === 'q' ||
|
|
121
|
+
data === 'Q' ||
|
|
122
|
+
matchesKey(data, 'escape') ||
|
|
123
|
+
matchesKey(data, 'ctrl+c') ||
|
|
124
|
+
matchesKey(data, 'ctrl+d')
|
|
125
|
+
) {
|
|
126
|
+
this.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const viewportWidth = Math.max(1, Math.floor(width || 1));
|
|
132
|
+
const snapshot = this.getSnapshot();
|
|
133
|
+
if (viewportWidth < 12) {
|
|
134
|
+
const label = snapshot
|
|
135
|
+
? `${statusGlyph(this.theme, snapshot.run.status)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}`
|
|
136
|
+
: 'No workflow';
|
|
137
|
+
return [truncateToWidth(label, viewportWidth, '…', true)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const contentWidth = viewportWidth - 2;
|
|
141
|
+
const lines = snapshot
|
|
142
|
+
? renderBoard(this.theme, snapshot, contentWidth)
|
|
143
|
+
: renderEmptyBoard(this.theme, contentWidth);
|
|
144
|
+
return lines.map((line) =>
|
|
145
|
+
padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private close(): void {
|
|
150
|
+
if (this.closed) return;
|
|
151
|
+
this.closed = true;
|
|
152
|
+
this.dispose();
|
|
153
|
+
this.done();
|
|
154
|
+
this.tui.requestRender(true);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderBoard(
|
|
159
|
+
theme: Theme,
|
|
160
|
+
snapshot: WorkflowStatusSnapshot,
|
|
161
|
+
width: number,
|
|
162
|
+
): string[] {
|
|
163
|
+
const header = boxed(
|
|
164
|
+
theme,
|
|
165
|
+
'✦ Workflow Status',
|
|
166
|
+
width,
|
|
167
|
+
renderHeaderLines(theme, snapshot, width - 4),
|
|
168
|
+
'borderAccent',
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
let body: string[];
|
|
172
|
+
if (width >= WIDE_LAYOUT_MIN_COLUMNS) {
|
|
173
|
+
const gap = 2;
|
|
174
|
+
const summaryWidth = Math.min(42, Math.max(36, Math.floor(width * 0.36)));
|
|
175
|
+
const pathWidth = width - summaryWidth - gap;
|
|
176
|
+
const summary = boxed(
|
|
177
|
+
theme,
|
|
178
|
+
'Run Summary',
|
|
179
|
+
summaryWidth,
|
|
180
|
+
renderSummaryLines(theme, snapshot, summaryWidth - 4),
|
|
181
|
+
);
|
|
182
|
+
const path = boxed(
|
|
183
|
+
theme,
|
|
184
|
+
'Execution Path',
|
|
185
|
+
pathWidth,
|
|
186
|
+
renderPathLines(theme, snapshot, pathWidth - 4),
|
|
187
|
+
'borderAccent',
|
|
188
|
+
);
|
|
189
|
+
body = joinPanels(summary, summaryWidth, path, pathWidth, gap);
|
|
190
|
+
} else {
|
|
191
|
+
body = [
|
|
192
|
+
...boxed(
|
|
193
|
+
theme,
|
|
194
|
+
'Run Summary',
|
|
195
|
+
width,
|
|
196
|
+
renderSummaryLines(theme, snapshot, width - 4),
|
|
197
|
+
),
|
|
198
|
+
'',
|
|
199
|
+
...boxed(
|
|
200
|
+
theme,
|
|
201
|
+
'Execution Path',
|
|
202
|
+
width,
|
|
203
|
+
renderPathLines(theme, snapshot, width - 4),
|
|
204
|
+
'borderAccent',
|
|
205
|
+
),
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return [
|
|
210
|
+
...header,
|
|
211
|
+
'',
|
|
212
|
+
...body,
|
|
213
|
+
'',
|
|
214
|
+
theme.fg('dim', 'q / Esc close · live refresh'),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderEmptyBoard(theme: Theme, width: number): string[] {
|
|
219
|
+
return [
|
|
220
|
+
...boxed(
|
|
221
|
+
theme,
|
|
222
|
+
'✦ Workflow Status',
|
|
223
|
+
width,
|
|
224
|
+
[theme.fg('muted', 'No workflow checkpoint in this session')],
|
|
225
|
+
'borderAccent',
|
|
226
|
+
),
|
|
227
|
+
'',
|
|
228
|
+
theme.fg('dim', 'q / Esc close'),
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderHeaderLines(
|
|
233
|
+
theme: Theme,
|
|
234
|
+
snapshot: WorkflowStatusSnapshot,
|
|
235
|
+
width: number,
|
|
236
|
+
): string[] {
|
|
237
|
+
const { run } = snapshot;
|
|
238
|
+
const workflowName = inline(run.workflowId);
|
|
239
|
+
const status = statusBadge(theme, run.status);
|
|
240
|
+
const completed = theme.fg(
|
|
241
|
+
'success',
|
|
242
|
+
`${run.history.length} completed attempt${run.history.length === 1 ? '' : 's'}`,
|
|
243
|
+
);
|
|
244
|
+
const firstLine = [
|
|
245
|
+
statusGlyph(theme, run.status),
|
|
246
|
+
theme.bold(workflowName),
|
|
247
|
+
status,
|
|
248
|
+
theme.fg('muted', '·'),
|
|
249
|
+
completed,
|
|
250
|
+
].join(' ');
|
|
251
|
+
|
|
252
|
+
const currentTitle = stepTitle(snapshot.workflow, run.currentStepId);
|
|
253
|
+
const visit = Math.max(1, run.visits[run.currentStepId] ?? 1);
|
|
254
|
+
const elapsed = formatElapsed(elapsedMs(snapshot));
|
|
255
|
+
const secondLine = [
|
|
256
|
+
theme.fg('muted', 'step'),
|
|
257
|
+
theme.fg('text', formatStepName(currentTitle, run.currentStepId)),
|
|
258
|
+
theme.fg('muted', `· visit ${visit} · elapsed ${elapsed}`),
|
|
259
|
+
].join(' ');
|
|
260
|
+
|
|
261
|
+
return [
|
|
262
|
+
truncateToWidth(firstLine, width),
|
|
263
|
+
truncateToWidth(secondLine, width),
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderSummaryLines(
|
|
268
|
+
theme: Theme,
|
|
269
|
+
snapshot: WorkflowStatusSnapshot,
|
|
270
|
+
width: number,
|
|
271
|
+
): string[] {
|
|
272
|
+
const { run, workflow } = snapshot;
|
|
273
|
+
const lines = [
|
|
274
|
+
...keyValueLines(theme, 'workflow', run.workflowId, width),
|
|
275
|
+
...keyValueLines(theme, 'run', run.runId, width),
|
|
276
|
+
...keyValueLines(
|
|
277
|
+
theme,
|
|
278
|
+
'status',
|
|
279
|
+
statusLabel(run.status),
|
|
280
|
+
width,
|
|
281
|
+
statusColor(run.status),
|
|
282
|
+
),
|
|
283
|
+
...keyValueLines(
|
|
284
|
+
theme,
|
|
285
|
+
'current',
|
|
286
|
+
formatStepName(stepTitle(workflow, run.currentStepId), run.currentStepId),
|
|
287
|
+
width,
|
|
288
|
+
),
|
|
289
|
+
...keyValueLines(
|
|
290
|
+
theme,
|
|
291
|
+
'visit',
|
|
292
|
+
String(Math.max(1, run.visits[run.currentStepId] ?? 1)),
|
|
293
|
+
width,
|
|
294
|
+
),
|
|
295
|
+
...keyValueLines(theme, 'started', formatTimestamp(run.startedAt), width),
|
|
296
|
+
...keyValueLines(
|
|
297
|
+
theme,
|
|
298
|
+
'updated',
|
|
299
|
+
`${formatTimestamp(run.updatedAt)} · ${formatElapsed(elapsedMs(snapshot))}`,
|
|
300
|
+
width,
|
|
301
|
+
),
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const execution = formatExecution(snapshot.execution);
|
|
305
|
+
if (execution) {
|
|
306
|
+
lines.push(
|
|
307
|
+
...keyValueLines(theme, 'execution', execution, width, 'accent'),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (run.pendingGate) {
|
|
311
|
+
const review = run.pendingGate.reviewId
|
|
312
|
+
? `${run.pendingGate.provider} · ${run.pendingGate.reviewId}`
|
|
313
|
+
: `${run.pendingGate.provider} · opening`;
|
|
314
|
+
lines.push(...keyValueLines(theme, 'review', review, width, 'warning'));
|
|
315
|
+
}
|
|
316
|
+
if (!workflow) {
|
|
317
|
+
lines.push(
|
|
318
|
+
...keyValueLines(
|
|
319
|
+
theme,
|
|
320
|
+
'config',
|
|
321
|
+
'workflow definition is not loaded',
|
|
322
|
+
width,
|
|
323
|
+
'warning',
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
} else if (workflow.digest !== run.workflowDigest) {
|
|
327
|
+
lines.push(
|
|
328
|
+
...keyValueLines(
|
|
329
|
+
theme,
|
|
330
|
+
'config',
|
|
331
|
+
'definition changed since this checkpoint',
|
|
332
|
+
width,
|
|
333
|
+
'warning',
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (run.pauseReason) {
|
|
338
|
+
lines.push(
|
|
339
|
+
...keyValueLines(
|
|
340
|
+
theme,
|
|
341
|
+
'reason',
|
|
342
|
+
run.pauseReason,
|
|
343
|
+
width,
|
|
344
|
+
run.status === 'aborted' ? 'error' : 'warning',
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return lines;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderPathLines(
|
|
352
|
+
theme: Theme,
|
|
353
|
+
snapshot: WorkflowStatusSnapshot,
|
|
354
|
+
width: number,
|
|
355
|
+
): string[] {
|
|
356
|
+
const entries = buildPathEntries(snapshot);
|
|
357
|
+
if (entries.length === 0) {
|
|
358
|
+
return [theme.fg('muted', 'No step attempts recorded')];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const hidden = Math.max(0, entries.length - MAX_PATH_ROWS);
|
|
362
|
+
const visible = entries.slice(hidden);
|
|
363
|
+
const lines =
|
|
364
|
+
hidden > 0
|
|
365
|
+
? [
|
|
366
|
+
theme.fg(
|
|
367
|
+
'dim',
|
|
368
|
+
`… ${hidden} earlier attempt${hidden === 1 ? '' : 's'}`,
|
|
369
|
+
),
|
|
370
|
+
]
|
|
371
|
+
: [];
|
|
372
|
+
for (const entry of visible) {
|
|
373
|
+
const visit =
|
|
374
|
+
entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
|
|
375
|
+
const left = `${statusGlyph(theme, entry.status)} ${theme.fg(
|
|
376
|
+
entry.current ? 'text' : 'muted',
|
|
377
|
+
entry.title,
|
|
378
|
+
)}${visit}`;
|
|
379
|
+
const right = entry.outcome
|
|
380
|
+
? `${statusLabel(entry.status)} · ${inline(entry.outcome)}`
|
|
381
|
+
: statusLabel(entry.status);
|
|
382
|
+
const row = joinColumns(
|
|
383
|
+
left,
|
|
384
|
+
theme.fg(statusColor(entry.status), right),
|
|
385
|
+
width,
|
|
386
|
+
Math.max(12, Math.floor(width * 0.58)),
|
|
387
|
+
);
|
|
388
|
+
lines.push(
|
|
389
|
+
entry.current
|
|
390
|
+
? theme.bg('selectedBg', padAnsi(row, width))
|
|
391
|
+
: truncateToWidth(row, width),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return lines;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildPathEntries(snapshot: WorkflowStatusSnapshot): PathEntry[] {
|
|
398
|
+
const { run, workflow } = snapshot;
|
|
399
|
+
const visits = new Map<string, number>();
|
|
400
|
+
const entries = run.history.map((entry) => {
|
|
401
|
+
const visit = (visits.get(entry.stepId) ?? 0) + 1;
|
|
402
|
+
visits.set(entry.stepId, visit);
|
|
403
|
+
return historyPathEntry(workflow, entry, visit);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
if (run.status !== 'completed') {
|
|
407
|
+
entries.push({
|
|
408
|
+
stepId: run.currentStepId,
|
|
409
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
410
|
+
status: run.status,
|
|
411
|
+
visit: Math.max(
|
|
412
|
+
visits.get(run.currentStepId) ?? 0,
|
|
413
|
+
run.visits[run.currentStepId] ?? 1,
|
|
414
|
+
),
|
|
415
|
+
current: true,
|
|
416
|
+
});
|
|
417
|
+
} else if (
|
|
418
|
+
entries.length === 0 ||
|
|
419
|
+
entries.at(-1)?.stepId !== run.currentStepId
|
|
420
|
+
) {
|
|
421
|
+
entries.push({
|
|
422
|
+
stepId: run.currentStepId,
|
|
423
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
424
|
+
status: 'completed',
|
|
425
|
+
visit: Math.max(1, run.visits[run.currentStepId] ?? 1),
|
|
426
|
+
current: true,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return entries;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function historyPathEntry(
|
|
433
|
+
workflow: LoadedWorkflow | undefined,
|
|
434
|
+
entry: StepHistoryEntry,
|
|
435
|
+
visit: number,
|
|
436
|
+
): PathEntry {
|
|
437
|
+
return {
|
|
438
|
+
stepId: entry.stepId,
|
|
439
|
+
title: stepTitle(workflow, entry.stepId),
|
|
440
|
+
status: 'completed',
|
|
441
|
+
visit,
|
|
442
|
+
outcome: entry.outcome,
|
|
443
|
+
current: false,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function keyValueLines(
|
|
448
|
+
theme: Theme,
|
|
449
|
+
label: string,
|
|
450
|
+
rawValue: string,
|
|
451
|
+
width: number,
|
|
452
|
+
valueColor: ThemeColor = 'text',
|
|
453
|
+
): string[] {
|
|
454
|
+
const safeWidth = Math.max(1, width);
|
|
455
|
+
const labelWidth = Math.min(10, Math.max(7, label.length + 1));
|
|
456
|
+
const valueWidth = Math.max(1, safeWidth - labelWidth);
|
|
457
|
+
const value = theme.fg(valueColor, inline(rawValue));
|
|
458
|
+
const wrapped = wrapTextWithAnsi(value, valueWidth);
|
|
459
|
+
const prefix = theme.fg('muted', label.padEnd(labelWidth));
|
|
460
|
+
return (wrapped.length > 0 ? wrapped : ['']).map((line, index) =>
|
|
461
|
+
index === 0 ? `${prefix}${line}` : `${' '.repeat(labelWidth)}${line}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function boxed(
|
|
466
|
+
theme: Theme,
|
|
467
|
+
title: string,
|
|
468
|
+
width: number,
|
|
469
|
+
content: string[],
|
|
470
|
+
color: ThemeColor = 'borderMuted',
|
|
471
|
+
): string[] {
|
|
472
|
+
const safeWidth = Math.max(8, Math.floor(width));
|
|
473
|
+
const bodyWidth = Math.max(1, safeWidth - 4);
|
|
474
|
+
const topLabel = `╭─ ${title} `;
|
|
475
|
+
const top = `${topLabel}${'─'.repeat(
|
|
476
|
+
Math.max(0, safeWidth - visibleWidth(topLabel) - 1),
|
|
477
|
+
)}╮`;
|
|
478
|
+
const bottom = `╰${'─'.repeat(Math.max(0, safeWidth - 2))}╯`;
|
|
479
|
+
const body = content.length > 0 ? content : [''];
|
|
480
|
+
return [
|
|
481
|
+
theme.fg(color, top),
|
|
482
|
+
...body.map(
|
|
483
|
+
(line) =>
|
|
484
|
+
`${theme.fg(color, '│')} ${padAnsi(
|
|
485
|
+
truncateToWidth(line, bodyWidth),
|
|
486
|
+
bodyWidth,
|
|
487
|
+
)} ${theme.fg(color, '│')}`,
|
|
488
|
+
),
|
|
489
|
+
theme.fg(color, bottom),
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function joinPanels(
|
|
494
|
+
left: string[],
|
|
495
|
+
leftWidth: number,
|
|
496
|
+
right: string[],
|
|
497
|
+
rightWidth: number,
|
|
498
|
+
gap: number,
|
|
499
|
+
): string[] {
|
|
500
|
+
const height = Math.max(left.length, right.length);
|
|
501
|
+
return Array.from({ length: height }, (_, index) => {
|
|
502
|
+
const leftLine = padAnsi(left[index] ?? '', leftWidth);
|
|
503
|
+
const rightLine = padAnsi(right[index] ?? '', rightWidth);
|
|
504
|
+
return `${leftLine}${' '.repeat(gap)}${rightLine}`;
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function joinColumns(
|
|
509
|
+
left: string,
|
|
510
|
+
right: string,
|
|
511
|
+
width: number,
|
|
512
|
+
leftWidth: number,
|
|
513
|
+
): string {
|
|
514
|
+
const safeLeftWidth = Math.max(1, Math.min(leftWidth, width - 2));
|
|
515
|
+
const rightWidth = Math.max(1, width - safeLeftWidth - 1);
|
|
516
|
+
return `${padAnsi(
|
|
517
|
+
truncateToWidth(left, safeLeftWidth),
|
|
518
|
+
safeLeftWidth,
|
|
519
|
+
)} ${truncateToWidth(right, rightWidth)}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function padAnsi(value: string, width: number): string {
|
|
523
|
+
const visible = visibleWidth(value);
|
|
524
|
+
if (visible >= width) return value;
|
|
525
|
+
return `${value}${' '.repeat(width - visible)}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function statusGlyph(theme: Theme, status: StepDisplayStatus): string {
|
|
529
|
+
if (status === 'completed') return theme.fg('success', '✓');
|
|
530
|
+
if (status === 'running') return theme.fg('accent', '↻');
|
|
531
|
+
if (status === 'paused' || status === 'awaiting-gate') {
|
|
532
|
+
return theme.fg('warning', '◆');
|
|
533
|
+
}
|
|
534
|
+
if (status === 'aborted') return theme.fg('error', '✕');
|
|
535
|
+
return theme.fg('dim', '•');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function statusColor(status: StepDisplayStatus): ThemeColor {
|
|
539
|
+
if (status === 'completed') return 'success';
|
|
540
|
+
if (status === 'running') return 'accent';
|
|
541
|
+
if (status === 'paused' || status === 'awaiting-gate') return 'warning';
|
|
542
|
+
if (status === 'aborted') return 'error';
|
|
543
|
+
return 'dim';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function statusLabel(status: StepDisplayStatus): string {
|
|
547
|
+
return status === 'awaiting-gate'
|
|
548
|
+
? 'AWAITING REVIEW'
|
|
549
|
+
: status.toUpperCase().replaceAll('-', ' ');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function statusBadge(theme: Theme, status: StepDisplayStatus): string {
|
|
553
|
+
return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function stepTitle(
|
|
557
|
+
workflow: LoadedWorkflow | undefined,
|
|
558
|
+
stepId: string,
|
|
559
|
+
): string {
|
|
560
|
+
return inline(workflow?.definition.steps[stepId]?.title ?? stepId);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function formatStepName(title: string, stepId: string): string {
|
|
564
|
+
const safeStepId = inline(stepId);
|
|
565
|
+
return title === safeStepId ? title : `${title} (${safeStepId})`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function formatExecution(
|
|
569
|
+
execution: WorkflowStatusExecution | undefined,
|
|
570
|
+
): string | undefined {
|
|
571
|
+
if (!execution) return undefined;
|
|
572
|
+
if (execution.kind === 'main') return 'main agent';
|
|
573
|
+
return `${execution.agent} · ${execution.progress} · ${execution.requestId}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function inline(value: string): string {
|
|
577
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function elapsedMs(snapshot: WorkflowStatusSnapshot): number {
|
|
581
|
+
const { run } = snapshot;
|
|
582
|
+
const end =
|
|
583
|
+
run.status === 'running' || run.status === 'awaiting-gate'
|
|
584
|
+
? snapshot.now
|
|
585
|
+
: run.updatedAt;
|
|
586
|
+
return Math.max(0, end - run.startedAt);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function formatElapsed(milliseconds: number): string {
|
|
590
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
591
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
592
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
593
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
594
|
+
const seconds = totalSeconds % 60;
|
|
595
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
596
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
597
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
598
|
+
return `${seconds}s`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function formatTimestamp(milliseconds: number): string {
|
|
602
|
+
const value = new Date(milliseconds);
|
|
603
|
+
if (!Number.isFinite(value.getTime())) return 'unknown';
|
|
604
|
+
const year = value.getFullYear();
|
|
605
|
+
const month = String(value.getMonth() + 1).padStart(2, '0');
|
|
606
|
+
const day = String(value.getDate()).padStart(2, '0');
|
|
607
|
+
const hours = String(value.getHours()).padStart(2, '0');
|
|
608
|
+
const minutes = String(value.getMinutes()).padStart(2, '0');
|
|
609
|
+
const seconds = String(value.getSeconds()).padStart(2, '0');
|
|
610
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
611
|
+
}
|