@signalridge/pi-workflows 1.8.0 → 1.10.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/CHANGELOG.md +15 -0
- package/README.md +31 -1
- package/package.json +2 -2
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/engine.ts +1088 -395
- package/src/index.ts +286 -53
- package/src/inspection.ts +544 -0
- package/src/journal.ts +33 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +387 -148
- package/src/status-actions.ts +143 -0
- package/src/ui-bus.ts +243 -0
- package/src/ui-command-center.ts +1032 -0
- package/src/ui-inspection.ts +206 -0
- package/src/ui-presentation.ts +196 -0
- package/src/ui.ts +43 -265
package/src/ui.ts
CHANGED
|
@@ -1,276 +1,54 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Key, matchesKey, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
2
|
import type { WorkflowEngine } from "./engine.js";
|
|
5
|
-
import type { ScriptRun } from "./journal.js";
|
|
6
3
|
import { sanitizeDisplayText } from "./safe-text.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Live-widget lines for all non-terminal runs (A10). */
|
|
25
|
-
export function liveWidgetLines(
|
|
26
|
-
runs: readonly ScriptRun[],
|
|
27
|
-
mode: "compact" | "detailed" = "compact",
|
|
28
|
-
maxAgentsShown = 6,
|
|
29
|
-
): string[] {
|
|
30
|
-
const active = runs.filter((run) => !isTerminal(run.status));
|
|
31
|
-
if (active.length === 0) return [];
|
|
32
|
-
const boundedMax = Math.max(1, Math.min(32, Math.floor(maxAgentsShown)));
|
|
33
|
-
const lines = ["Workflows"];
|
|
34
|
-
for (const run of active.slice(0, boundedMax)) {
|
|
35
|
-
lines.push(` ${snapshotRunLine(run)}`);
|
|
36
|
-
if (mode === "detailed") {
|
|
37
|
-
for (const [nodeId, status] of Object.entries(run.callStatus).slice(0, boundedMax)) {
|
|
38
|
-
lines.push(` ${nodeId}: ${status}`);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return lines.slice(0, 1 + boundedMax * (mode === "detailed" ? boundedMax + 1 : 1));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type RunStatus = ScriptRun["status"];
|
|
46
|
-
|
|
47
|
-
function elapsed(ms: number): string {
|
|
48
|
-
const seconds = Math.floor(ms / 1_000);
|
|
49
|
-
if (seconds < 60) return `${seconds}s`;
|
|
50
|
-
const minutes = Math.floor(seconds / 60);
|
|
51
|
-
return `${minutes}m ${seconds % 60}s`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isTerminal(status: RunStatus): boolean {
|
|
55
|
-
return status === "completed" || status === "failed" || status === "stopped";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function formatWorkflowRunLabel(run: ScriptRun): string {
|
|
59
|
-
const phases = run.meta.phases && run.meta.phases.length > 0 ? ` · phases ${run.meta.phases.length}` : "";
|
|
60
|
-
const calls = Object.keys(run.callResults).length;
|
|
61
|
-
const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
|
|
62
|
-
return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function resultText(value: unknown): string {
|
|
66
|
-
if (typeof value === "string") return value;
|
|
67
|
-
try {
|
|
68
|
-
return JSON.stringify(value) ?? "(no preview)";
|
|
69
|
-
} catch {
|
|
70
|
-
return "(unavailable preview)";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
|
|
75
|
-
const state = engine.getState(runId);
|
|
76
|
-
if (!state) return "Workflow run is no longer available.";
|
|
77
|
-
const run = state.run;
|
|
78
|
-
const tokenValues = Object.values(run.callResults)
|
|
79
|
-
.map((result) => result.tokenCount)
|
|
80
|
-
.filter((value): value is number => value !== undefined);
|
|
81
|
-
const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
|
|
82
|
-
const lines = [
|
|
83
|
-
`${sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS)} [${run.status}]`,
|
|
84
|
-
`run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
|
|
85
|
-
`elapsed: ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
|
|
86
|
-
"",
|
|
87
|
-
...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
|
|
88
|
-
];
|
|
89
|
-
for (const phase of run.meta.phases ?? []) {
|
|
90
|
-
lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
|
|
91
|
-
}
|
|
92
|
-
const calls = Object.entries(run.callStatus)
|
|
93
|
-
.sort(([a], [b]) => Number(a) - Number(b))
|
|
94
|
-
.map(([nodeId, status]) => {
|
|
95
|
-
const result = run.callResults[nodeId];
|
|
96
|
-
const agent = run.agentIds[nodeId] ? ` agent=${sanitizeDisplayText(run.agentIds[nodeId], MAX_ID_CHARS)}` : "";
|
|
97
|
-
const compact = run.compactions[nodeId] ? ` compactions=${run.compactions[nodeId]}` : "";
|
|
98
|
-
const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
|
|
99
|
-
if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
|
|
100
|
-
if (result?.result !== undefined && result.status === "completed") {
|
|
101
|
-
parts.push(` result: ${sanitizeDisplayText(resultText(result.result), MAX_RESULT_CHARS)}`);
|
|
102
|
-
}
|
|
103
|
-
return parts.join("\n");
|
|
104
|
-
});
|
|
105
|
-
if (calls.length > 0) {
|
|
106
|
-
lines.push("Calls:");
|
|
107
|
-
lines.push(...calls);
|
|
108
|
-
}
|
|
109
|
-
lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
|
|
110
|
-
if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
|
|
111
|
-
return lines.join("\n");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Actions the `/workflows` detail menu can offer, in display order. */
|
|
115
|
-
export type WorkflowAction = "refresh" | "pause" | "resume" | "stop" | "back";
|
|
116
|
-
|
|
117
|
-
export interface WorkflowActionItem {
|
|
118
|
-
value: WorkflowAction;
|
|
119
|
-
label: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const REFRESH: WorkflowActionItem = { value: "refresh", label: "Refresh" };
|
|
123
|
-
const PAUSE: WorkflowActionItem = { value: "pause", label: "Pause" };
|
|
124
|
-
const RESUME: WorkflowActionItem = { value: "resume", label: "Resume" };
|
|
125
|
-
const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
|
|
126
|
-
const BACK: WorkflowActionItem = { value: "back", label: "Back" };
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Menu actions legal for a run in `status`. Exhaustive by construction.
|
|
130
|
-
*/
|
|
131
|
-
export function workflowActionsFor(status: RunStatus): readonly WorkflowActionItem[] {
|
|
132
|
-
switch (status) {
|
|
133
|
-
case "pending":
|
|
134
|
-
case "pausing":
|
|
135
|
-
case "stopping":
|
|
136
|
-
return [REFRESH, STOP, BACK];
|
|
137
|
-
case "running":
|
|
138
|
-
return [REFRESH, PAUSE, STOP, BACK];
|
|
139
|
-
case "paused":
|
|
140
|
-
case "interrupted":
|
|
141
|
-
return [REFRESH, RESUME, STOP, BACK];
|
|
142
|
-
case "completed":
|
|
143
|
-
case "failed":
|
|
144
|
-
case "stopped":
|
|
145
|
-
return [REFRESH, BACK];
|
|
146
|
-
}
|
|
147
|
-
const unreachable: never = status;
|
|
148
|
-
return unreachable;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function selectList<T>(
|
|
152
|
-
ctx: ExtensionCommandContext,
|
|
153
|
-
title: string,
|
|
154
|
-
items: SelectItem[],
|
|
155
|
-
renderExtra: (theme: Pick<Theme, "fg">) => string,
|
|
156
|
-
): Promise<T | undefined> {
|
|
157
|
-
if (ctx.mode !== "tui" || !ctx.hasUI) return undefined;
|
|
158
|
-
return ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
|
|
159
|
-
const border = new DynamicBorder((s: string) => theme.fg("borderAccent", s));
|
|
160
|
-
const heading = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
|
|
161
|
-
const detail = new Text(theme.fg("dim", renderExtra(theme)), 1, 0);
|
|
162
|
-
let offset = 0;
|
|
163
|
-
let pageSize = 1;
|
|
164
|
-
const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), {
|
|
165
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
166
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
167
|
-
description: (text) => theme.fg("muted", text),
|
|
168
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
169
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
170
|
-
});
|
|
171
|
-
list.onSelect = (item) => done(item.value as T);
|
|
172
|
-
list.onCancel = () => done(undefined);
|
|
173
|
-
const hint = new Text(theme.fg("dim", "↑↓ select · enter confirm · esc back · PgUp/PgDn details"), 1, 0);
|
|
174
|
-
return {
|
|
175
|
-
render: (width) => {
|
|
176
|
-
const top = [...border.render(width), ...heading.render(width).slice(0, 2)];
|
|
177
|
-
const bottom = [...list.render(width), ...hint.render(width), ...border.render(width)];
|
|
178
|
-
pageSize = Math.max(1, tui.terminal.rows - top.length - bottom.length - 1);
|
|
179
|
-
const lines = detail.render(width);
|
|
180
|
-
offset = Math.max(0, Math.min(offset, lines.length - pageSize));
|
|
181
|
-
return [...top, ...lines.slice(offset, offset + pageSize), ...bottom];
|
|
182
|
-
},
|
|
183
|
-
invalidate: () => {
|
|
184
|
-
heading.invalidate();
|
|
185
|
-
detail.invalidate();
|
|
186
|
-
list.invalidate();
|
|
187
|
-
},
|
|
188
|
-
handleInput: (data) => {
|
|
189
|
-
if (matchesKey(data, Key.pageDown)) offset += pageSize;
|
|
190
|
-
else if (matchesKey(data, Key.pageUp)) offset = Math.max(0, offset - pageSize);
|
|
191
|
-
else list.handleInput(data);
|
|
192
|
-
tui.requestRender();
|
|
193
|
-
},
|
|
194
|
-
};
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Interactive `/workflows` navigator. TUI-only by design. */
|
|
4
|
+
import { WorkflowCommandCenter, type WorkflowNavigatorOptions } from "./ui-command-center.js";
|
|
5
|
+
|
|
6
|
+
export type { WorkflowNavigatorOptions } from "./ui-command-center.js";
|
|
7
|
+
export {
|
|
8
|
+
formatWorkflowDetail,
|
|
9
|
+
formatWorkflowRunLabel,
|
|
10
|
+
liveWidgetLines,
|
|
11
|
+
type RunStatus,
|
|
12
|
+
snapshotRunLine,
|
|
13
|
+
type WorkflowAction,
|
|
14
|
+
type WorkflowActionItem,
|
|
15
|
+
workflowActionsFor,
|
|
16
|
+
} from "./ui-presentation.js";
|
|
17
|
+
|
|
18
|
+
/** Live, explicitly opened `/workflows` command center. No headless TUI mounts. */
|
|
199
19
|
export async function showWorkflowNavigator(
|
|
200
|
-
ctx:
|
|
20
|
+
ctx: ExtensionContext,
|
|
201
21
|
engineSource: WorkflowEngine | (() => WorkflowEngine),
|
|
22
|
+
options: WorkflowNavigatorOptions = {},
|
|
202
23
|
): Promise<void> {
|
|
203
|
-
if (ctx.mode !== "tui") {
|
|
24
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
204
25
|
ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
|
|
205
26
|
return;
|
|
206
27
|
}
|
|
207
|
-
const
|
|
208
|
-
let
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.filter((run): run is ScriptRun => run !== undefined)
|
|
227
|
-
.sort((a, b) => b.startedAt - a.startedAt);
|
|
228
|
-
const selected = await selectList<string>(
|
|
229
|
-
ctx,
|
|
230
|
-
"Workflow runs",
|
|
231
|
-
states.map((run) => ({
|
|
232
|
-
value: run.runId,
|
|
233
|
-
label: formatWorkflowRunLabel(run),
|
|
234
|
-
description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
|
|
235
|
-
})),
|
|
236
|
-
(theme) =>
|
|
237
|
-
states.length > 0
|
|
238
|
-
? theme.fg("dim", "Select a run to inspect.")
|
|
239
|
-
: theme.fg("muted", "No workflow runs in this session."),
|
|
28
|
+
const source = typeof engineSource === "function" ? engineSource : () => engineSource;
|
|
29
|
+
let component: WorkflowCommandCenter | undefined;
|
|
30
|
+
try {
|
|
31
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
32
|
+
await ctx.ui.custom<void>(
|
|
33
|
+
(tui, theme, keys, done) => {
|
|
34
|
+
component = new WorkflowCommandCenter(
|
|
35
|
+
tui,
|
|
36
|
+
theme,
|
|
37
|
+
keys,
|
|
38
|
+
source,
|
|
39
|
+
() => ctx.sessionManager.getSessionId() === sessionId,
|
|
40
|
+
done,
|
|
41
|
+
(message) => ctx.ui.notify(message, "error"),
|
|
42
|
+
options,
|
|
43
|
+
);
|
|
44
|
+
return component;
|
|
45
|
+
},
|
|
46
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } },
|
|
240
47
|
);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!run) break;
|
|
247
|
-
const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
|
|
248
|
-
const action = await selectList<WorkflowAction>(
|
|
249
|
-
ctx,
|
|
250
|
-
`${name} · ${run.status}`,
|
|
251
|
-
workflowActionsFor(run.status).map((item) => ({
|
|
252
|
-
value: item.value,
|
|
253
|
-
label: item.label,
|
|
254
|
-
})),
|
|
255
|
-
() => {
|
|
256
|
-
const current = currentEngine();
|
|
257
|
-
return current ? formatWorkflowDetail(current, selected) : "Workflow surface is no longer active.";
|
|
258
|
-
},
|
|
259
|
-
);
|
|
260
|
-
if (action === undefined || action === "back") break;
|
|
261
|
-
if (action === "refresh") continue;
|
|
262
|
-
if (action === "stop") {
|
|
263
|
-
const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${name}? The run will be non-resumable.`);
|
|
264
|
-
if (!confirmed) continue;
|
|
265
|
-
}
|
|
266
|
-
try {
|
|
267
|
-
const current = currentEngine();
|
|
268
|
-
if (!current) return;
|
|
269
|
-
await current.control(action, selected);
|
|
270
|
-
ctx.ui.notify(`Workflow ${action} requested.`, "info");
|
|
271
|
-
} catch (error: unknown) {
|
|
272
|
-
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
273
|
-
}
|
|
274
|
-
}
|
|
48
|
+
} catch (error: unknown) {
|
|
49
|
+
ctx.ui.notify(sanitizeDisplayText(error instanceof Error ? error.message : String(error), 500), "error");
|
|
50
|
+
} finally {
|
|
51
|
+
// Also covers external cancellation or custom() rejection before TUI disposal.
|
|
52
|
+
component?.dispose();
|
|
275
53
|
}
|
|
276
54
|
}
|