@wichayutdew/pi-workflows 0.2.2 → 0.3.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/LICENSE +21 -201
- package/README.md +207 -106
- package/agents/step.md +15 -1
- package/dist/index.js +1964 -463
- package/examples/mr-comments.workflow.yaml +4 -4
- package/examples/prompts/mr-comments/implement.md +10 -5
- package/examples/prompts/mr-comments/plan.md +10 -5
- package/examples/prompts/mr-comments/verify.md +5 -4
- package/examples/settings.yaml +3 -1
- package/package.json +10 -6
- package/schemas/settings.schema.json +8 -0
- package/schemas/workflow.schema.json +10 -2
- package/src/command-names.ts +0 -1
- package/src/commands.ts +0 -6
- package/src/config/ceiling.ts +8 -0
- package/src/config/load.ts +3 -9
- package/src/config/types.ts +15 -3
- package/src/config/validate.ts +147 -22
- package/src/engine/state.ts +7 -0
- package/src/engine/transitions.ts +52 -7
- package/src/harness.ts +804 -74
- package/src/index.ts +6 -2
- package/src/integrations/prompt-gate.ts +14 -13
- package/src/integrations/subagents/child-runtime.ts +187 -69
- package/src/integrations/subagents/client.ts +4 -3
- package/src/integrations/subagents/diagnostics.ts +977 -0
- package/src/integrations/subagents/protocol.ts +86 -15
- package/src/policy/approved-commands.ts +212 -5
- package/src/policy/bash.ts +0 -9
- package/src/prompt.ts +115 -7
- package/src/runtime/serial-task-queue.ts +5 -1
- package/src/workflow-status.ts +244 -35
package/src/workflow-status.ts
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
2
|
+
ExtensionContext,
|
|
3
3
|
Theme,
|
|
4
4
|
ThemeColor,
|
|
5
5
|
} from '@earendil-works/pi-coding-agent';
|
|
6
6
|
import {
|
|
7
|
+
Key,
|
|
7
8
|
matchesKey,
|
|
8
9
|
truncateToWidth,
|
|
9
10
|
visibleWidth,
|
|
10
11
|
wrapTextWithAnsi,
|
|
11
12
|
type Component,
|
|
13
|
+
type KeyId,
|
|
12
14
|
type TUI,
|
|
13
15
|
} from '@earendil-works/pi-tui';
|
|
14
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_STATUS_SHORTCUT,
|
|
18
|
+
type LoadedWorkflow,
|
|
19
|
+
} from './config/types.ts';
|
|
15
20
|
import type {
|
|
16
21
|
StepHistoryEntry,
|
|
17
22
|
WorkflowRun,
|
|
18
23
|
WorkflowRunStatus,
|
|
19
24
|
} from './engine/state.ts';
|
|
20
25
|
|
|
21
|
-
const REFRESH_INTERVAL_MS =
|
|
26
|
+
const REFRESH_INTERVAL_MS = 250;
|
|
27
|
+
const WORKING_ICON_FRAME_MS = 250;
|
|
28
|
+
const WORKING_ICON_FRAMES = ['◐', '◓', '◑', '◒'] as const;
|
|
22
29
|
const WIDE_LAYOUT_MIN_COLUMNS = 92;
|
|
23
30
|
const MAX_PATH_ROWS = 16;
|
|
31
|
+
const MAX_REASON_ROWS = 5;
|
|
24
32
|
|
|
25
33
|
export type WorkflowStatusExecution =
|
|
26
34
|
| {
|
|
@@ -41,7 +49,10 @@ export interface WorkflowStatusSnapshot {
|
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
type SnapshotProvider = () => WorkflowStatusSnapshot | undefined;
|
|
44
|
-
type StepDisplayStatus = WorkflowRunStatus | 'completed';
|
|
52
|
+
type StepDisplayStatus = WorkflowRunStatus | 'completed' | 'failed';
|
|
53
|
+
type StatusViewTui = Pick<TUI, 'requestRender'> & {
|
|
54
|
+
terminal?: { rows: number };
|
|
55
|
+
};
|
|
45
56
|
|
|
46
57
|
interface PathEntry {
|
|
47
58
|
stepId: string;
|
|
@@ -78,27 +89,110 @@ export function formatWorkflowStatusText(
|
|
|
78
89
|
return lines.join('\n');
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
/** Format the full workflow status board. */
|
|
93
|
+
export function formatWorkflowStatusBoard(
|
|
94
|
+
snapshot: WorkflowStatusSnapshot,
|
|
95
|
+
width = 88,
|
|
96
|
+
theme: Theme = unstyledTheme,
|
|
97
|
+
): string[] {
|
|
98
|
+
return renderBoard(
|
|
99
|
+
theme,
|
|
100
|
+
snapshot,
|
|
101
|
+
Math.max(8, Math.floor(width)),
|
|
102
|
+
false,
|
|
103
|
+
formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const unstyledTheme = {
|
|
108
|
+
fg: (_color: string, value: string) => value,
|
|
109
|
+
bg: (_color: string, value: string) => value,
|
|
110
|
+
bold: (value: string) => value,
|
|
111
|
+
} as unknown as Theme;
|
|
112
|
+
|
|
113
|
+
const SHORTCUT_LABELS: Readonly<Record<string, string>> = {
|
|
114
|
+
ctrl: 'Ctrl',
|
|
115
|
+
shift: 'Shift',
|
|
116
|
+
alt: 'Alt',
|
|
117
|
+
super: 'Super',
|
|
118
|
+
escape: 'Esc',
|
|
119
|
+
esc: 'Esc',
|
|
120
|
+
enter: 'Enter',
|
|
121
|
+
return: 'Enter',
|
|
122
|
+
tab: 'Tab',
|
|
123
|
+
space: 'Space',
|
|
124
|
+
backspace: 'Backspace',
|
|
125
|
+
delete: 'Del',
|
|
126
|
+
insert: 'Ins',
|
|
127
|
+
clear: 'Clear',
|
|
128
|
+
home: 'Home',
|
|
129
|
+
end: 'End',
|
|
130
|
+
pageUp: 'PgUp',
|
|
131
|
+
pageDown: 'PgDn',
|
|
132
|
+
up: 'Up',
|
|
133
|
+
down: 'Down',
|
|
134
|
+
left: 'Left',
|
|
135
|
+
right: 'Right',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function formatShortcutLabel(shortcut: KeyId): string {
|
|
139
|
+
return shortcut
|
|
140
|
+
.split('+')
|
|
141
|
+
.map((part) => {
|
|
142
|
+
const label = SHORTCUT_LABELS[part];
|
|
143
|
+
if (label) return label;
|
|
144
|
+
if (/^f\d+$/.test(part)) return part.toUpperCase();
|
|
145
|
+
return part.length === 1 ? part.toUpperCase() : part;
|
|
146
|
+
})
|
|
147
|
+
.join('+');
|
|
148
|
+
}
|
|
149
|
+
|
|
81
150
|
export async function showWorkflowStatus(
|
|
82
|
-
ctx:
|
|
151
|
+
ctx: ExtensionContext,
|
|
83
152
|
getSnapshot: SnapshotProvider,
|
|
153
|
+
statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
|
|
84
154
|
): Promise<void> {
|
|
85
|
-
await ctx.ui.custom<void>(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
155
|
+
await ctx.ui.custom<void>(
|
|
156
|
+
(tui, theme, _keybindings, done) => {
|
|
157
|
+
const view = new WorkflowStatusView(
|
|
158
|
+
getSnapshot,
|
|
159
|
+
tui,
|
|
160
|
+
theme,
|
|
161
|
+
done,
|
|
162
|
+
statusShortcut,
|
|
163
|
+
);
|
|
164
|
+
view.start();
|
|
165
|
+
return view;
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
overlay: true,
|
|
169
|
+
overlayOptions: {
|
|
170
|
+
anchor: 'center',
|
|
171
|
+
width: '95%',
|
|
172
|
+
maxHeight: '95%',
|
|
173
|
+
margin: 1,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
);
|
|
90
177
|
}
|
|
91
178
|
|
|
92
179
|
export class WorkflowStatusView implements Component {
|
|
93
180
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
94
181
|
private closed = false;
|
|
182
|
+
private scrollOffset = 0;
|
|
183
|
+
private viewportRows = 0;
|
|
184
|
+
private contentRows = 0;
|
|
185
|
+
private readonly statusShortcutLabel: string;
|
|
95
186
|
|
|
96
187
|
constructor(
|
|
97
188
|
private readonly getSnapshot: SnapshotProvider,
|
|
98
|
-
private readonly tui:
|
|
189
|
+
private readonly tui: StatusViewTui,
|
|
99
190
|
private readonly theme: Theme,
|
|
100
191
|
private readonly done: () => void,
|
|
101
|
-
|
|
192
|
+
private readonly statusShortcut: KeyId = DEFAULT_STATUS_SHORTCUT,
|
|
193
|
+
) {
|
|
194
|
+
this.statusShortcutLabel = formatShortcutLabel(statusShortcut);
|
|
195
|
+
}
|
|
102
196
|
|
|
103
197
|
start(): void {
|
|
104
198
|
this.timer = setInterval(
|
|
@@ -121,9 +215,25 @@ export class WorkflowStatusView implements Component {
|
|
|
121
215
|
data === 'Q' ||
|
|
122
216
|
matchesKey(data, 'escape') ||
|
|
123
217
|
matchesKey(data, 'ctrl+c') ||
|
|
124
|
-
matchesKey(data, 'ctrl+d')
|
|
218
|
+
matchesKey(data, 'ctrl+d') ||
|
|
219
|
+
matchesKey(data, this.statusShortcut)
|
|
125
220
|
) {
|
|
126
221
|
this.close();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const pageSize = Math.max(1, this.viewportRows - 2);
|
|
225
|
+
if (matchesKey(data, Key.down) || data === 'j') {
|
|
226
|
+
this.scrollBy(1);
|
|
227
|
+
} else if (matchesKey(data, Key.up) || data === 'k') {
|
|
228
|
+
this.scrollBy(-1);
|
|
229
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
230
|
+
this.scrollBy(pageSize);
|
|
231
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
232
|
+
this.scrollBy(-pageSize);
|
|
233
|
+
} else if (matchesKey(data, Key.home)) {
|
|
234
|
+
this.setScrollOffset(0);
|
|
235
|
+
} else if (matchesKey(data, Key.end)) {
|
|
236
|
+
this.setScrollOffset(Number.MAX_SAFE_INTEGER);
|
|
127
237
|
}
|
|
128
238
|
}
|
|
129
239
|
|
|
@@ -132,18 +242,65 @@ export class WorkflowStatusView implements Component {
|
|
|
132
242
|
const snapshot = this.getSnapshot();
|
|
133
243
|
if (viewportWidth < 12) {
|
|
134
244
|
const label = snapshot
|
|
135
|
-
? `${statusGlyph(this.theme, snapshot.run.
|
|
245
|
+
? `${statusGlyph(this.theme, runDisplayStatus(snapshot.run), snapshot.now)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}`
|
|
136
246
|
: 'No workflow';
|
|
137
247
|
return [truncateToWidth(label, viewportWidth, '…', true)];
|
|
138
248
|
}
|
|
139
249
|
|
|
140
250
|
const contentWidth = viewportWidth - 2;
|
|
141
251
|
const lines = snapshot
|
|
142
|
-
? renderBoard(
|
|
252
|
+
? renderBoard(
|
|
253
|
+
this.theme,
|
|
254
|
+
snapshot,
|
|
255
|
+
contentWidth,
|
|
256
|
+
false,
|
|
257
|
+
this.statusShortcutLabel,
|
|
258
|
+
)
|
|
143
259
|
: renderEmptyBoard(this.theme, contentWidth);
|
|
144
|
-
|
|
260
|
+
const rendered = lines.map((line) =>
|
|
145
261
|
padAnsi(truncateToWidth(line, contentWidth, '…'), viewportWidth),
|
|
146
262
|
);
|
|
263
|
+
return this.paginate(rendered, viewportWidth);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private paginate(lines: string[], width: number): string[] {
|
|
267
|
+
this.contentRows = lines.length;
|
|
268
|
+
const terminalRows = this.tui.terminal?.rows;
|
|
269
|
+
const maximumRows =
|
|
270
|
+
terminalRows === undefined
|
|
271
|
+
? lines.length + 1
|
|
272
|
+
: Math.max(4, Math.floor(terminalRows * 0.95));
|
|
273
|
+
this.viewportRows = maximumRows;
|
|
274
|
+
const contentHeight = Math.max(1, maximumRows - 1);
|
|
275
|
+
const maximumOffset = Math.max(0, lines.length - contentHeight);
|
|
276
|
+
this.scrollOffset = Math.min(this.scrollOffset, maximumOffset);
|
|
277
|
+
const visible = lines.slice(
|
|
278
|
+
this.scrollOffset,
|
|
279
|
+
this.scrollOffset + contentHeight,
|
|
280
|
+
);
|
|
281
|
+
const first = lines.length === 0 ? 0 : this.scrollOffset + 1;
|
|
282
|
+
const last = Math.min(lines.length, this.scrollOffset + contentHeight);
|
|
283
|
+
const hint =
|
|
284
|
+
maximumOffset > 0
|
|
285
|
+
? `↑/↓ PgUp/PgDn Home/End · rows ${first}-${last}/${lines.length} · ${this.statusShortcutLabel} / q / Esc hide`
|
|
286
|
+
: `${this.statusShortcutLabel} / q / Esc hide · live refresh`;
|
|
287
|
+
return [
|
|
288
|
+
...visible,
|
|
289
|
+
padAnsi(truncateToWidth(this.theme.fg('dim', hint), width, '…'), width),
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private scrollBy(delta: number): void {
|
|
294
|
+
this.setScrollOffset(this.scrollOffset + delta);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private setScrollOffset(value: number): void {
|
|
298
|
+
const contentHeight = Math.max(1, this.viewportRows - 1);
|
|
299
|
+
const maximumOffset = Math.max(0, this.contentRows - contentHeight);
|
|
300
|
+
const next = Math.max(0, Math.min(value, maximumOffset));
|
|
301
|
+
if (next === this.scrollOffset) return;
|
|
302
|
+
this.scrollOffset = next;
|
|
303
|
+
this.tui.requestRender(true);
|
|
147
304
|
}
|
|
148
305
|
|
|
149
306
|
private close(): void {
|
|
@@ -159,6 +316,8 @@ function renderBoard(
|
|
|
159
316
|
theme: Theme,
|
|
160
317
|
snapshot: WorkflowStatusSnapshot,
|
|
161
318
|
width: number,
|
|
319
|
+
showCloseHint = true,
|
|
320
|
+
statusShortcutLabel = formatShortcutLabel(DEFAULT_STATUS_SHORTCUT),
|
|
162
321
|
): string[] {
|
|
163
322
|
const header = boxed(
|
|
164
323
|
theme,
|
|
@@ -206,13 +365,14 @@ function renderBoard(
|
|
|
206
365
|
];
|
|
207
366
|
}
|
|
208
367
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
368
|
+
const lines = [...header, '', ...body];
|
|
369
|
+
if (showCloseHint) {
|
|
370
|
+
lines.push(
|
|
371
|
+
'',
|
|
372
|
+
theme.fg('dim', `${statusShortcutLabel} / q / Esc hide · live refresh`),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return lines;
|
|
216
376
|
}
|
|
217
377
|
|
|
218
378
|
function renderEmptyBoard(theme: Theme, width: number): string[] {
|
|
@@ -242,7 +402,7 @@ function renderHeaderLines(
|
|
|
242
402
|
`${run.history.length} completed attempt${run.history.length === 1 ? '' : 's'}`,
|
|
243
403
|
);
|
|
244
404
|
const firstLine = [
|
|
245
|
-
statusGlyph(theme, run.
|
|
405
|
+
statusGlyph(theme, runDisplayStatus(run), snapshot.now),
|
|
246
406
|
theme.bold(workflowName),
|
|
247
407
|
status,
|
|
248
408
|
theme.fg('muted', '·'),
|
|
@@ -336,18 +496,38 @@ function renderSummaryLines(
|
|
|
336
496
|
}
|
|
337
497
|
if (run.pauseReason) {
|
|
338
498
|
lines.push(
|
|
339
|
-
...
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
499
|
+
...clampRows(
|
|
500
|
+
keyValueLines(
|
|
501
|
+
theme,
|
|
502
|
+
'reason',
|
|
503
|
+
run.pauseReason,
|
|
504
|
+
width,
|
|
505
|
+
run.status === 'aborted' ? 'error' : 'warning',
|
|
506
|
+
),
|
|
507
|
+
MAX_REASON_ROWS,
|
|
343
508
|
width,
|
|
344
|
-
|
|
509
|
+
theme,
|
|
345
510
|
),
|
|
346
511
|
);
|
|
347
512
|
}
|
|
348
513
|
return lines;
|
|
349
514
|
}
|
|
350
515
|
|
|
516
|
+
function clampRows(
|
|
517
|
+
lines: string[],
|
|
518
|
+
maximum: number,
|
|
519
|
+
width: number,
|
|
520
|
+
theme: Theme,
|
|
521
|
+
): string[] {
|
|
522
|
+
if (lines.length <= maximum) return lines;
|
|
523
|
+
const visible = lines.slice(0, maximum);
|
|
524
|
+
const last = visible.at(-1) ?? '';
|
|
525
|
+
visible[maximum - 1] =
|
|
526
|
+
truncateToWidth(last, Math.max(1, width - 1), '', true) +
|
|
527
|
+
theme.fg('dim', '…');
|
|
528
|
+
return visible;
|
|
529
|
+
}
|
|
530
|
+
|
|
351
531
|
function renderPathLines(
|
|
352
532
|
theme: Theme,
|
|
353
533
|
snapshot: WorkflowStatusSnapshot,
|
|
@@ -372,7 +552,7 @@ function renderPathLines(
|
|
|
372
552
|
for (const entry of visible) {
|
|
373
553
|
const visit =
|
|
374
554
|
entry.visit > 1 ? theme.fg('dim', ` · visit ${entry.visit}`) : '';
|
|
375
|
-
const left = `${statusGlyph(theme, entry.status)} ${theme.fg(
|
|
555
|
+
const left = `${statusGlyph(theme, entry.status, snapshot.now)} ${theme.fg(
|
|
376
556
|
entry.current ? 'text' : 'muted',
|
|
377
557
|
entry.title,
|
|
378
558
|
)}${visit}`;
|
|
@@ -407,7 +587,7 @@ function buildPathEntries(snapshot: WorkflowStatusSnapshot): PathEntry[] {
|
|
|
407
587
|
entries.push({
|
|
408
588
|
stepId: run.currentStepId,
|
|
409
589
|
title: stepTitle(workflow, run.currentStepId),
|
|
410
|
-
status: run
|
|
590
|
+
status: runDisplayStatus(run),
|
|
411
591
|
visit: Math.max(
|
|
412
592
|
visits.get(run.currentStepId) ?? 0,
|
|
413
593
|
run.visits[run.currentStepId] ?? 1,
|
|
@@ -525,13 +705,21 @@ function padAnsi(value: string, width: number): string {
|
|
|
525
705
|
return `${value}${' '.repeat(width - visible)}`;
|
|
526
706
|
}
|
|
527
707
|
|
|
528
|
-
function statusGlyph(
|
|
708
|
+
function statusGlyph(
|
|
709
|
+
theme: Theme,
|
|
710
|
+
status: StepDisplayStatus,
|
|
711
|
+
now = Date.now(),
|
|
712
|
+
): string {
|
|
529
713
|
if (status === 'completed') return theme.fg('success', '✓');
|
|
530
|
-
if (status === 'running')
|
|
714
|
+
if (status === 'running') {
|
|
715
|
+
return theme.fg('accent', workingIcon(now));
|
|
716
|
+
}
|
|
531
717
|
if (status === 'paused' || status === 'awaiting-gate') {
|
|
532
718
|
return theme.fg('warning', '◆');
|
|
533
719
|
}
|
|
534
|
-
if (status === '
|
|
720
|
+
if (status === 'failed' || status === 'aborted') {
|
|
721
|
+
return theme.fg('error', '✕');
|
|
722
|
+
}
|
|
535
723
|
return theme.fg('dim', '•');
|
|
536
724
|
}
|
|
537
725
|
|
|
@@ -539,7 +727,7 @@ function statusColor(status: StepDisplayStatus): ThemeColor {
|
|
|
539
727
|
if (status === 'completed') return 'success';
|
|
540
728
|
if (status === 'running') return 'accent';
|
|
541
729
|
if (status === 'paused' || status === 'awaiting-gate') return 'warning';
|
|
542
|
-
if (status === 'aborted') return 'error';
|
|
730
|
+
if (status === 'failed' || status === 'aborted') return 'error';
|
|
543
731
|
return 'dim';
|
|
544
732
|
}
|
|
545
733
|
|
|
@@ -553,6 +741,27 @@ function statusBadge(theme: Theme, status: StepDisplayStatus): string {
|
|
|
553
741
|
return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
|
|
554
742
|
}
|
|
555
743
|
|
|
744
|
+
function runDisplayStatus(run: WorkflowRun): StepDisplayStatus {
|
|
745
|
+
return run.status === 'paused' && run.failedStepId === run.currentStepId
|
|
746
|
+
? 'failed'
|
|
747
|
+
: run.status;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function workflowStatusIcon(run: WorkflowRun, now = Date.now()): string {
|
|
751
|
+
const status = runDisplayStatus(run);
|
|
752
|
+
if (status === 'completed') return '✓';
|
|
753
|
+
if (status === 'running') return workingIcon(now);
|
|
754
|
+
if (status === 'failed' || status === 'aborted') return '✕';
|
|
755
|
+
if (status === 'paused' || status === 'awaiting-gate') return '◆';
|
|
756
|
+
return '•';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function workingIcon(now: number): string {
|
|
760
|
+
return WORKING_ICON_FRAMES[
|
|
761
|
+
Math.floor(now / WORKING_ICON_FRAME_MS) % WORKING_ICON_FRAMES.length
|
|
762
|
+
]!;
|
|
763
|
+
}
|
|
764
|
+
|
|
556
765
|
function stepTitle(
|
|
557
766
|
workflow: LoadedWorkflow | undefined,
|
|
558
767
|
stepId: string,
|