@osolmaz/pi-workflows 0.5.2 → 0.6.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/README.md +13 -3
- package/dist/builtins/catalog.js +1 -1
- package/dist/builtins/monitor.workflow.d.ts +25 -69
- package/dist/builtins/monitor.workflow.js +194 -123
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/extension/executor.d.ts +7 -2
- package/dist/extension/executor.js +20 -14
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +56 -15
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/step-message.d.ts +24 -0
- package/dist/extension/step-message.js +106 -0
- package/dist/extension/step-message.js.map +1 -0
- package/dist/extension/widget.d.ts +4 -2
- package/dist/extension/widget.js +111 -17
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +9 -0
- package/dist/extension/workflow-tool.js +10 -0
- package/dist/extension/workflow-tool.js.map +1 -1
- package/dist/host/rpc-bridge.js +25 -11
- package/dist/host/rpc-bridge.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +2 -2
- package/dist/host/rpc-executor.js +23 -12
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/viewer/cli.js +1 -1
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +14 -0
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/tui.js +1 -1
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -1
- package/dist/workflows/engine.js +88 -6
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +4 -2
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +34 -0
- package/dist/workflows/progress.js +268 -0
- package/dist/workflows/progress.js.map +1 -0
- package/dist/workflows/schema.js +21 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/shell.d.ts +2 -2
- package/dist/workflows/shell.js +103 -25
- package/dist/workflows/shell.js.map +1 -1
- package/dist/workflows/store.d.ts +15 -2
- package/dist/workflows/store.js +44 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +52 -1
- package/dist/workflows/updates.d.ts +15 -0
- package/dist/workflows/updates.js +188 -0
- package/dist/workflows/updates.js.map +1 -0
- package/docs/DESIGN_PHILOSOPHY.md +51 -0
- package/docs/MONITOR.md +282 -0
- package/docs/WORKFLOW_STEP_MESSAGES.md +141 -0
- package/docs/WORKFLOW_UPDATES.md +416 -0
- package/docs/development.md +7 -3
- package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +11 -3
- package/docs/plans/2026-08-16-workflow-updates-plan.md +494 -0
- package/docs/run-bundles.md +10 -2
- package/docs/workflows.md +57 -17
- package/package.json +1 -1
- package/src/builtins/catalog.ts +1 -1
- package/src/builtins/monitor.workflow.ts +217 -148
- package/src/extension/executor.ts +36 -14
- package/src/extension/index.ts +89 -23
- package/src/extension/step-message.ts +145 -0
- package/src/extension/widget.ts +158 -14
- package/src/extension/workflow-tool.ts +22 -0
- package/src/host/rpc-bridge.ts +37 -14
- package/src/host/rpc-executor.ts +35 -14
- package/src/viewer/cli.ts +1 -1
- package/src/viewer/render.ts +27 -0
- package/src/viewer/tui.ts +1 -1
- package/src/workflows/engine.ts +117 -4
- package/src/workflows/index.ts +32 -0
- package/src/workflows/progress.ts +326 -0
- package/src/workflows/schema.ts +23 -1
- package/src/workflows/shell.ts +109 -26
- package/src/workflows/store.ts +67 -2
- package/src/workflows/types.ts +78 -1
- package/src/workflows/updates.ts +208 -0
package/src/extension/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { builtinWorkflowCatalog } from "../builtins/catalog.js";
|
|
5
5
|
import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
|
|
6
6
|
import type { JsonObject } from "../controllers/types.js";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../workflows/errors.js";
|
|
15
15
|
import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
|
|
16
16
|
import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
|
|
17
|
+
import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/progress.js";
|
|
17
18
|
import {
|
|
18
19
|
createRunId,
|
|
19
20
|
listRunBundles,
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
WorkflowDefinitionSnapshot,
|
|
29
30
|
WorkflowRunResult,
|
|
30
31
|
WorkflowRunState,
|
|
32
|
+
WorkflowUpdateRecord,
|
|
31
33
|
} from "../workflows/types.js";
|
|
32
34
|
import {
|
|
33
35
|
PiControllerHost,
|
|
@@ -36,6 +38,12 @@ import {
|
|
|
36
38
|
} from "./controller-host.js";
|
|
37
39
|
import { ConversationStepExecutor } from "./executor.js";
|
|
38
40
|
import { SessionRecorder } from "./recorder.js";
|
|
41
|
+
import {
|
|
42
|
+
registerWorkflowAgentStepMessageRenderer,
|
|
43
|
+
WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
|
|
44
|
+
WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
|
|
45
|
+
type WorkflowAgentStepMessageDetails,
|
|
46
|
+
} from "./step-message.js";
|
|
39
47
|
import { buildWidgetView } from "./widget.js";
|
|
40
48
|
import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
|
|
41
49
|
|
|
@@ -71,6 +79,7 @@ type ActiveRun = {
|
|
|
71
79
|
presentationPrompt: WorkflowDefinition["presentationPrompt"];
|
|
72
80
|
generation: number;
|
|
73
81
|
lastState: WorkflowRunState | null;
|
|
82
|
+
updateHistory: WorkflowUpdateRecord[];
|
|
74
83
|
childKey?: string;
|
|
75
84
|
onFinish?: (result: WorkflowSchedulerResult) => void;
|
|
76
85
|
completion?: Promise<void>;
|
|
@@ -191,6 +200,10 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
|
|
|
191
200
|
workflowName: state.workflowName,
|
|
192
201
|
status: state.status,
|
|
193
202
|
steps: state.steps.length,
|
|
203
|
+
updates: (state.updates ?? []).map(({ data, ...record }) => ({
|
|
204
|
+
...record,
|
|
205
|
+
data: data as JsonObject,
|
|
206
|
+
})),
|
|
194
207
|
...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
|
|
195
208
|
...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
|
|
196
209
|
...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
|
|
@@ -202,6 +215,7 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
|
|
|
202
215
|
type WidgetSource = {
|
|
203
216
|
state: WorkflowRunState;
|
|
204
217
|
snapshot: WorkflowDefinitionSnapshot;
|
|
218
|
+
updateHistory?: WorkflowUpdateRecord[];
|
|
205
219
|
};
|
|
206
220
|
|
|
207
221
|
type WorkflowWidgetComponent = {
|
|
@@ -209,10 +223,12 @@ type WorkflowWidgetComponent = {
|
|
|
209
223
|
invalidate: () => void;
|
|
210
224
|
};
|
|
211
225
|
|
|
212
|
-
type WorkflowWidgetFactory = () => WorkflowWidgetComponent;
|
|
226
|
+
type WorkflowWidgetFactory = (_tui: unknown, theme: Theme) => WorkflowWidgetComponent;
|
|
213
227
|
type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
|
|
214
228
|
|
|
215
229
|
export default function piWorkflows(pi: ExtensionAPI) {
|
|
230
|
+
registerWorkflowAgentStepMessageRenderer(pi);
|
|
231
|
+
|
|
216
232
|
// One runner identity per session; it names this session in run claims.
|
|
217
233
|
const runnerId = randomUUID();
|
|
218
234
|
let runQueueStore: SqliteControllerStore | null = null;
|
|
@@ -267,16 +283,19 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
267
283
|
leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
|
|
268
284
|
})) {
|
|
269
285
|
if (!alreadyDelivered.has(notification.notificationId)) {
|
|
270
|
-
pi.sendMessage(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
pi.sendMessage(
|
|
287
|
+
{
|
|
288
|
+
customType: "pi-workflows-notification",
|
|
289
|
+
content: notification.content,
|
|
290
|
+
display: true,
|
|
291
|
+
details: {
|
|
292
|
+
notificationId: notification.notificationId,
|
|
293
|
+
runId: notification.runId,
|
|
294
|
+
kind: notification.kind,
|
|
295
|
+
},
|
|
278
296
|
},
|
|
279
|
-
|
|
297
|
+
{ triggerTurn: false },
|
|
298
|
+
);
|
|
280
299
|
alreadyDelivered.add(notification.notificationId);
|
|
281
300
|
}
|
|
282
301
|
runQueueStore.markWorkflowNotificationDelivered({
|
|
@@ -377,7 +396,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
377
396
|
if (!widgetSource) {
|
|
378
397
|
return;
|
|
379
398
|
}
|
|
380
|
-
const render = (width = Number.POSITIVE_INFINITY): string[] => {
|
|
399
|
+
const render = (width = Number.POSITIVE_INFINITY, theme?: Theme): string[] => {
|
|
381
400
|
if (!widgetSource) return [];
|
|
382
401
|
const view = buildWidgetView(
|
|
383
402
|
widgetSource.state,
|
|
@@ -386,6 +405,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
386
405
|
widgetScroll,
|
|
387
406
|
runHeld(),
|
|
388
407
|
width,
|
|
408
|
+
theme,
|
|
409
|
+
widgetSource.updateHistory,
|
|
389
410
|
);
|
|
390
411
|
widgetShownScroll = view.scroll;
|
|
391
412
|
widgetMaxScroll = view.maxScroll;
|
|
@@ -395,7 +416,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
395
416
|
return view.lines;
|
|
396
417
|
};
|
|
397
418
|
if (ctx.mode === "tui") {
|
|
398
|
-
setWidget(ctx, () => ({
|
|
419
|
+
setWidget(ctx, (_tui, theme) => ({
|
|
420
|
+
render: (width) => render(width, theme),
|
|
421
|
+
invalidate() {},
|
|
422
|
+
}));
|
|
399
423
|
} else {
|
|
400
424
|
// RPC transports string widgets but cannot serialize TUI component factories.
|
|
401
425
|
setWidget(ctx, render());
|
|
@@ -407,13 +431,18 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
407
431
|
ctx: ExtensionContext,
|
|
408
432
|
state: WorkflowRunState,
|
|
409
433
|
snapshot: WorkflowDefinitionSnapshot,
|
|
434
|
+
updateHistory?: WorkflowUpdateRecord[],
|
|
410
435
|
) => {
|
|
411
436
|
if (state.steps.length !== widgetStepCount) {
|
|
412
437
|
widgetStepCount = state.steps.length;
|
|
413
438
|
// The workflow moved on; resume following the active node.
|
|
414
439
|
widgetScroll = null;
|
|
415
440
|
}
|
|
416
|
-
widgetSource = {
|
|
441
|
+
widgetSource = {
|
|
442
|
+
state,
|
|
443
|
+
snapshot,
|
|
444
|
+
...(updateHistory !== undefined ? { updateHistory: [...updateHistory] } : {}),
|
|
445
|
+
};
|
|
417
446
|
renderWidget(ctx);
|
|
418
447
|
};
|
|
419
448
|
|
|
@@ -592,7 +621,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
592
621
|
void run.recorder?.stop();
|
|
593
622
|
stopWidgetTicker();
|
|
594
623
|
const { state } = result;
|
|
595
|
-
updateWidget(ctx, state, run.snapshot);
|
|
624
|
+
updateWidget(ctx, state, run.snapshot, run.updateHistory);
|
|
596
625
|
clearWidgetTimer();
|
|
597
626
|
// A waiting run is parked at a checkpoint for a human; keep its widget up
|
|
598
627
|
// until a new workflow replaces it. Terminal runs fade after a grace TTL.
|
|
@@ -730,8 +759,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
730
759
|
}
|
|
731
760
|
|
|
732
761
|
const executor = new ConversationStepExecutor({
|
|
733
|
-
sendPrompt: ({ prompt, streaming }) => {
|
|
734
|
-
|
|
762
|
+
sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
|
|
763
|
+
const details: WorkflowAgentStepMessageDetails = {
|
|
764
|
+
schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
|
|
765
|
+
kind,
|
|
766
|
+
contract,
|
|
767
|
+
...(presentation !== undefined ? { presentation } : {}),
|
|
768
|
+
};
|
|
769
|
+
pi.sendMessage(
|
|
770
|
+
{
|
|
771
|
+
customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
|
|
772
|
+
content: prompt,
|
|
773
|
+
display: true,
|
|
774
|
+
details,
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
triggerTurn: true,
|
|
778
|
+
deliverAs: streaming ? "steer" : "followUp",
|
|
779
|
+
},
|
|
780
|
+
);
|
|
735
781
|
},
|
|
736
782
|
onAbort: (contract, reason) => {
|
|
737
783
|
lastExpiredAttempt = {
|
|
@@ -795,9 +841,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
795
841
|
onRunFinishing: async () => {
|
|
796
842
|
await run.recorder?.finish();
|
|
797
843
|
},
|
|
798
|
-
onEvent: (
|
|
844
|
+
onEvent: (event, state: WorkflowRunState) => {
|
|
799
845
|
run.lastState = state;
|
|
800
|
-
|
|
846
|
+
run.updateHistory = appendProgressHistory(
|
|
847
|
+
run.updateHistory,
|
|
848
|
+
progressRecordsFromTrace([event]),
|
|
849
|
+
);
|
|
850
|
+
updateWidget(ctx, state, snapshot, run.updateHistory);
|
|
801
851
|
},
|
|
802
852
|
});
|
|
803
853
|
const run: ActiveRun = {
|
|
@@ -810,6 +860,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
810
860
|
presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
|
|
811
861
|
generation,
|
|
812
862
|
lastState: null,
|
|
863
|
+
updateHistory: [],
|
|
813
864
|
...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
|
|
814
865
|
...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
|
|
815
866
|
...(claimToken !== undefined ? { claimToken } : {}),
|
|
@@ -1558,13 +1609,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1558
1609
|
name: "workflow",
|
|
1559
1610
|
label: "Workflow",
|
|
1560
1611
|
description: [
|
|
1561
|
-
"List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
|
|
1562
|
-
"When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes,
|
|
1563
|
-
"Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
|
|
1612
|
+
"List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
|
|
1613
|
+
"When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, stopWhen, and optional maxChecks.",
|
|
1614
|
+
"Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
|
|
1564
1615
|
"Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
|
|
1565
1616
|
].join(" "),
|
|
1566
1617
|
parameters: WorkflowToolParameters,
|
|
1567
|
-
async execute(
|
|
1618
|
+
async execute(toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
|
|
1568
1619
|
let control: WorkflowControlResult;
|
|
1569
1620
|
switch (params.action) {
|
|
1570
1621
|
case "list":
|
|
@@ -1592,6 +1643,21 @@ export default function piWorkflows(pi: ExtensionAPI) {
|
|
|
1592
1643
|
});
|
|
1593
1644
|
break;
|
|
1594
1645
|
}
|
|
1646
|
+
case "update": {
|
|
1647
|
+
if (!activeRun) throw new Error("No workflow step is active.");
|
|
1648
|
+
const receipt = await activeRun.engine.publishUpdate(
|
|
1649
|
+
params.step,
|
|
1650
|
+
params.attempt,
|
|
1651
|
+
params.update,
|
|
1652
|
+
toolCallId,
|
|
1653
|
+
);
|
|
1654
|
+
return {
|
|
1655
|
+
content: [
|
|
1656
|
+
{ type: "text", text: "Workflow update published; the step remains active." },
|
|
1657
|
+
],
|
|
1658
|
+
details: { action: "update", ...receipt },
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1595
1661
|
case "submit": {
|
|
1596
1662
|
if (!activeRun) {
|
|
1597
1663
|
if (
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeText } from "../workflows/text.js";
|
|
4
|
+
import type { AgentStepContract, AgentStepPresentation } from "../workflows/types.js";
|
|
5
|
+
import type { PromptDeliveryKind } from "./executor.js";
|
|
6
|
+
|
|
7
|
+
export const WORKFLOW_AGENT_STEP_MESSAGE_TYPE = "pi-workflows-agent-step";
|
|
8
|
+
export const WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA = "pi-workflows.agent-step-message.v1";
|
|
9
|
+
|
|
10
|
+
export type WorkflowAgentStepMessageDetails = {
|
|
11
|
+
schema: typeof WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA;
|
|
12
|
+
kind: PromptDeliveryKind;
|
|
13
|
+
contract: AgentStepContract;
|
|
14
|
+
presentation?: AgentStepPresentation;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type WorkflowAgentStepMessage = {
|
|
18
|
+
content: unknown;
|
|
19
|
+
details?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type WorkflowAgentStepView = {
|
|
23
|
+
title: string;
|
|
24
|
+
status?: string;
|
|
25
|
+
expandedText?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function registerWorkflowAgentStepMessageRenderer(pi: ExtensionAPI): void {
|
|
29
|
+
pi.registerMessageRenderer<WorkflowAgentStepMessageDetails>(
|
|
30
|
+
WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
|
|
31
|
+
(message, { expanded }, theme) => {
|
|
32
|
+
const view = buildWorkflowAgentStepView(message, expanded, theme);
|
|
33
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
34
|
+
box.addChild(new TruncatedText(view.title));
|
|
35
|
+
if (view.status !== undefined) {
|
|
36
|
+
box.addChild(new TruncatedText(view.status));
|
|
37
|
+
}
|
|
38
|
+
if (view.expandedText !== undefined) {
|
|
39
|
+
box.addChild(new Text(`\n${view.expandedText}`));
|
|
40
|
+
}
|
|
41
|
+
return box;
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build renderer text without depending on Pi's session or TUI state. */
|
|
47
|
+
export function buildWorkflowAgentStepView(
|
|
48
|
+
message: WorkflowAgentStepMessage,
|
|
49
|
+
expanded: boolean,
|
|
50
|
+
theme?: Pick<Theme, "fg">,
|
|
51
|
+
): WorkflowAgentStepView {
|
|
52
|
+
const details = parseDetails(message.details);
|
|
53
|
+
const contract = details?.contract;
|
|
54
|
+
const kind = details?.kind ?? "step";
|
|
55
|
+
const workflowName = cleanSingleLine(contract?.workflowName ?? "Workflow");
|
|
56
|
+
const nodeId = cleanSingleLine(contract?.nodeId ?? "step");
|
|
57
|
+
const runTitle = cleanOptionalSingleLine(details?.presentation?.runTitle);
|
|
58
|
+
const statusDetail = cleanOptionalSingleLine(details?.presentation?.statusDetail);
|
|
59
|
+
const label = runTitle ?? workflowName;
|
|
60
|
+
const suffix = kind === "step" ? "" : ` · ${kind}`;
|
|
61
|
+
const glyph = kind === "step" ? "▶" : "↻";
|
|
62
|
+
const title = paint(theme, "accent", `${glyph} ${label} › ${nodeId}${suffix}`);
|
|
63
|
+
|
|
64
|
+
if (!expanded) {
|
|
65
|
+
return {
|
|
66
|
+
title,
|
|
67
|
+
...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const metadata = [
|
|
72
|
+
`Workflow: ${workflowName}`,
|
|
73
|
+
...(runTitle !== undefined ? [`Run title: ${runTitle}`] : []),
|
|
74
|
+
`Run id: ${cleanSingleLine(contract?.runId ?? "unknown")}`,
|
|
75
|
+
`Node id: ${nodeId}`,
|
|
76
|
+
`Attempt id: ${cleanSingleLine(contract?.attemptId ?? "unknown")}`,
|
|
77
|
+
`Delivery: ${kind}`,
|
|
78
|
+
`Expected output: ${cleanSingleLine(contract?.expectedOutput ?? "a JSON object with your result")}`,
|
|
79
|
+
];
|
|
80
|
+
const prompt = contentText(message.content);
|
|
81
|
+
return {
|
|
82
|
+
title,
|
|
83
|
+
...(statusDetail !== undefined ? { status: paint(theme, "dim", statusDetail) } : {}),
|
|
84
|
+
expandedText: `${metadata.map((line) => paint(theme, "dim", line)).join("\n")}\n\n${prompt}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseDetails(value: unknown): WorkflowAgentStepMessageDetails | undefined {
|
|
89
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
90
|
+
const candidate = value as Partial<WorkflowAgentStepMessageDetails>;
|
|
91
|
+
if (candidate.schema !== WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA) return undefined;
|
|
92
|
+
if (candidate.kind !== "step" && candidate.kind !== "reminder" && candidate.kind !== "resume") {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const contract = candidate.contract;
|
|
96
|
+
if (
|
|
97
|
+
contract === null ||
|
|
98
|
+
typeof contract !== "object" ||
|
|
99
|
+
typeof contract.runId !== "string" ||
|
|
100
|
+
typeof contract.workflowName !== "string" ||
|
|
101
|
+
typeof contract.nodeId !== "string" ||
|
|
102
|
+
typeof contract.attemptId !== "string"
|
|
103
|
+
) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return candidate as WorkflowAgentStepMessageDetails;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function contentText(content: unknown): string {
|
|
110
|
+
if (typeof content === "string") return cleanMultiline(content);
|
|
111
|
+
if (!Array.isArray(content)) return "";
|
|
112
|
+
return content
|
|
113
|
+
.map((part) => {
|
|
114
|
+
if (part === null || typeof part !== "object" || !("text" in part)) return "";
|
|
115
|
+
const text = (part as { text?: unknown }).text;
|
|
116
|
+
return typeof text === "string" ? cleanMultiline(text) : "";
|
|
117
|
+
})
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cleanOptionalSingleLine(value: unknown): string | undefined {
|
|
123
|
+
return typeof value === "string" && value.length > 0 ? cleanSingleLine(value) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cleanSingleLine(value: string): string {
|
|
127
|
+
return sanitizeText(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cleanMultiline(value: string): string {
|
|
131
|
+
return value
|
|
132
|
+
.replaceAll("\r\n", "\n")
|
|
133
|
+
.replaceAll("\r", "\n")
|
|
134
|
+
.split("\n")
|
|
135
|
+
.map((line) => sanitizeText(line))
|
|
136
|
+
.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function paint(
|
|
140
|
+
theme: Pick<Theme, "fg"> | undefined,
|
|
141
|
+
color: "accent" | "dim",
|
|
142
|
+
text: string,
|
|
143
|
+
): string {
|
|
144
|
+
return theme?.fg(color, text) ?? text;
|
|
145
|
+
}
|
package/src/extension/widget.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
-
import { ansi } from "../render/ansi.js";
|
|
3
3
|
import { formatDuration } from "../render/format.js";
|
|
4
4
|
import { nodeTypeGlyph } from "../render/node-type.js";
|
|
5
|
+
import {
|
|
6
|
+
estimateProgress,
|
|
7
|
+
formatProgressLine,
|
|
8
|
+
formatRemaining,
|
|
9
|
+
prioritizeProgressEstimates,
|
|
10
|
+
progressTracksFromRecords,
|
|
11
|
+
type ProgressEstimate,
|
|
12
|
+
} from "../workflows/progress.js";
|
|
5
13
|
import { sanitizeText } from "../workflows/text.js";
|
|
6
14
|
import type {
|
|
7
15
|
WorkflowDefinitionSnapshot,
|
|
8
16
|
WorkflowRunState,
|
|
9
17
|
WorkflowRunStatus,
|
|
18
|
+
WorkflowUpdateRecord,
|
|
10
19
|
} from "../workflows/types.js";
|
|
11
20
|
|
|
12
21
|
const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
|
|
@@ -45,6 +54,8 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
|
|
|
45
54
|
return Object.keys(snapshot.nodes);
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
export type WidgetTheme = Pick<Theme, "bold" | "fg">;
|
|
58
|
+
|
|
48
59
|
export type WidgetView = {
|
|
49
60
|
lines: string[];
|
|
50
61
|
/** The clamped first visible node row. */
|
|
@@ -65,6 +76,8 @@ export function buildWidgetView(
|
|
|
65
76
|
scroll: number | null = null,
|
|
66
77
|
held = false,
|
|
67
78
|
width = Number.POSITIVE_INFINITY,
|
|
79
|
+
theme?: WidgetTheme,
|
|
80
|
+
updateHistory?: WorkflowUpdateRecord[],
|
|
68
81
|
): WidgetView {
|
|
69
82
|
const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
|
|
70
83
|
if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
|
|
@@ -77,34 +90,46 @@ export function buildWidgetView(
|
|
|
77
90
|
// Titles, status details, and errors can carry model- or shell-controlled
|
|
78
91
|
// text; never let escape sequences or newlines reach the terminal.
|
|
79
92
|
const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
|
|
80
|
-
const
|
|
93
|
+
const headerTone = statusTone(paused ? "waiting" : state.status);
|
|
94
|
+
const header = `${paint(theme, headerTone, glyph)} workflow ${sanitizeText(state.workflowName)}${title} ${paint(theme, headerTone, `[${statusText}]`)}`;
|
|
81
95
|
|
|
82
96
|
const footer: string[] = [];
|
|
83
97
|
if (state.error) {
|
|
84
|
-
footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
|
|
98
|
+
footer.push(paint(theme, "error", ` error: ${truncate(sanitizeText(state.error), 120)}`));
|
|
85
99
|
}
|
|
86
100
|
if (state.status === "waiting" && state.waitingOn) {
|
|
87
|
-
footer.push(
|
|
101
|
+
footer.push(
|
|
102
|
+
paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
|
|
103
|
+
);
|
|
88
104
|
}
|
|
89
105
|
|
|
90
|
-
const
|
|
106
|
+
const progress = progressLines(state, now, updateHistory).slice(0, 4);
|
|
107
|
+
const budget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
|
|
91
108
|
const nodes = displayNodeIds(snapshot).map((nodeId) =>
|
|
92
|
-
compactNodeLine(state, snapshot, nodeId, now),
|
|
109
|
+
compactNodeLine(state, snapshot, nodeId, now, paused, theme),
|
|
93
110
|
);
|
|
94
|
-
if (nodes.length === 0) {
|
|
111
|
+
if (nodes.length === 0 || budget <= 0) {
|
|
95
112
|
return {
|
|
96
|
-
lines: fitLines(
|
|
113
|
+
lines: fitLines(
|
|
114
|
+
[header, ...progress, ...footer].slice(0, PI_MAX_WIDGET_LINES),
|
|
115
|
+
availableWidth,
|
|
116
|
+
),
|
|
97
117
|
scroll: 0,
|
|
98
118
|
maxScroll: 0,
|
|
99
119
|
};
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
const anchor = scroll ?? compactFocusIndex(state, snapshot);
|
|
103
|
-
const windowed = windowLines(nodes, budget, anchor, scroll !== null);
|
|
123
|
+
const windowed = windowLines(nodes, budget, anchor, scroll !== null, theme);
|
|
104
124
|
const indentation = availableWidth >= 3 ? " " : "";
|
|
105
125
|
return {
|
|
106
126
|
lines: fitLines(
|
|
107
|
-
[
|
|
127
|
+
[
|
|
128
|
+
header,
|
|
129
|
+
...windowed.lines.map((line) => `${indentation}${line}`),
|
|
130
|
+
...progress.map((line) => `${indentation}${line}`),
|
|
131
|
+
...footer,
|
|
132
|
+
],
|
|
108
133
|
availableWidth,
|
|
109
134
|
),
|
|
110
135
|
scroll: windowed.scroll,
|
|
@@ -112,6 +137,76 @@ export function buildWidgetView(
|
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
|
|
140
|
+
function progressLines(
|
|
141
|
+
state: WorkflowRunState,
|
|
142
|
+
now: Date,
|
|
143
|
+
updateHistory?: WorkflowUpdateRecord[],
|
|
144
|
+
): string[] {
|
|
145
|
+
const measured =
|
|
146
|
+
updateHistory === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: progressTracksFromRecords(updateHistory, now).map((track) => track.estimate);
|
|
149
|
+
const projected = mergeProgressEstimates(latestProgressEstimates(state, now), measured ?? []);
|
|
150
|
+
const estimates = mergeProgressEstimates(projected, monitorEstimates(state) ?? []);
|
|
151
|
+
const lines = prioritizeProgressEstimates(estimates).map((estimate) =>
|
|
152
|
+
formatProgressLine(estimate, now),
|
|
153
|
+
);
|
|
154
|
+
const schedule = (state.updates ?? []).find(
|
|
155
|
+
(record) => record.type === "monitor.schedule" && record.key === "next-check",
|
|
156
|
+
);
|
|
157
|
+
if (schedule !== undefined && typeof schedule.data.nextCheckAt === "string") {
|
|
158
|
+
const next = Date.parse(schedule.data.nextCheckAt);
|
|
159
|
+
if (Number.isFinite(next)) {
|
|
160
|
+
const age = Math.max(0, now.getTime() - Date.parse(schedule.at));
|
|
161
|
+
lines.push(
|
|
162
|
+
`Last update ${formatRemaining(age)} ago next check ${formatRemaining(next - now.getTime())}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function monitorEstimates(state: WorkflowRunState): ProgressEstimate[] | undefined {
|
|
170
|
+
const output = state.outputs.estimate;
|
|
171
|
+
if (output === null || typeof output !== "object" || Array.isArray(output)) return undefined;
|
|
172
|
+
const tracks = (output as { tracks?: unknown }).tracks;
|
|
173
|
+
if (!Array.isArray(tracks)) return undefined;
|
|
174
|
+
const estimates = tracks
|
|
175
|
+
.map((track) =>
|
|
176
|
+
track !== null && typeof track === "object" && !Array.isArray(track)
|
|
177
|
+
? (track as { estimate?: ProgressEstimate }).estimate
|
|
178
|
+
: undefined,
|
|
179
|
+
)
|
|
180
|
+
.filter((estimate): estimate is ProgressEstimate => estimate !== undefined);
|
|
181
|
+
return estimates.length > 0 ? estimates : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeProgressEstimates(
|
|
185
|
+
latest: ProgressEstimate[],
|
|
186
|
+
measured: ProgressEstimate[],
|
|
187
|
+
): ProgressEstimate[] {
|
|
188
|
+
const measuredByKey = new Map(measured.map((estimate) => [estimate.key, estimate]));
|
|
189
|
+
const merged = latest.map((estimate) => measuredByKey.get(estimate.key) ?? estimate);
|
|
190
|
+
const latestKeys = new Set(latest.map((estimate) => estimate.key));
|
|
191
|
+
merged.push(...measured.filter((estimate) => !latestKeys.has(estimate.key)));
|
|
192
|
+
return merged;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function latestProgressEstimates(state: WorkflowRunState, now: Date): ProgressEstimate[] {
|
|
196
|
+
const estimates: ProgressEstimate[] = [];
|
|
197
|
+
for (const record of state.updates ?? []) {
|
|
198
|
+
if (record.type !== "progress") continue;
|
|
199
|
+
try {
|
|
200
|
+
estimates.push(
|
|
201
|
+
estimateProgress(record.key, [{ at: record.at, data: record.data as never }], now),
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
// A malformed historical update must not break the workflow widget.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return estimates;
|
|
208
|
+
}
|
|
209
|
+
|
|
115
210
|
function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
|
|
116
211
|
const nodeIds = displayNodeIds(snapshot);
|
|
117
212
|
const focused = state.currentNode ?? state.waitingOn;
|
|
@@ -125,12 +220,41 @@ function compactNodeLine(
|
|
|
125
220
|
snapshot: WorkflowDefinitionSnapshot,
|
|
126
221
|
nodeId: string,
|
|
127
222
|
now: Date,
|
|
223
|
+
paused: boolean,
|
|
224
|
+
theme?: WidgetTheme,
|
|
128
225
|
): string {
|
|
129
226
|
const node = snapshot.nodes[nodeId];
|
|
130
|
-
const
|
|
227
|
+
const glyph = nodeGlyph(state, nodeId);
|
|
228
|
+
const typeGlyph = node ? nodeTypeGlyph(node.nodeType, node.actionExecution) : "?";
|
|
229
|
+
const name = sanitizeText(nodeId);
|
|
131
230
|
const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
|
|
132
|
-
const
|
|
133
|
-
|
|
231
|
+
const isCurrent = state.currentNode === nodeId;
|
|
232
|
+
const isWaiting = state.waitingOn === nodeId;
|
|
233
|
+
|
|
234
|
+
if (isCurrent || isWaiting) {
|
|
235
|
+
const tone = paused || isWaiting ? "warning" : "accent";
|
|
236
|
+
const focusedName = theme ? theme.bold(name) : name;
|
|
237
|
+
const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
|
|
238
|
+
return paint(theme, tone, `${glyph} ${typeGlyph} ${focusedName}${detail}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result = state.results[nodeId];
|
|
242
|
+
if (!result) {
|
|
243
|
+
const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
|
|
244
|
+
return paint(theme, "dim", `${glyph} ${typeGlyph} ${name}${detail}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const styledGlyph = paint(theme, result.outcome === "ok" ? "success" : "error", glyph);
|
|
248
|
+
const styledType = paint(theme, "dim", typeGlyph);
|
|
249
|
+
const styledSegments = segments.map((segment, index) =>
|
|
250
|
+
paint(
|
|
251
|
+
theme,
|
|
252
|
+
result.outcome !== "ok" && index === segments.length - 1 ? "error" : "dim",
|
|
253
|
+
segment,
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
const detail = styledSegments.length > 0 ? ` · ${styledSegments.join(" · ")}` : "";
|
|
257
|
+
return `${styledGlyph} ${styledType} ${name}${detail}`;
|
|
134
258
|
}
|
|
135
259
|
|
|
136
260
|
function nodeRuntimeSegments(
|
|
@@ -188,6 +312,25 @@ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
|
|
|
188
312
|
return formatDuration(Math.max(0, now.getTime() - started));
|
|
189
313
|
}
|
|
190
314
|
|
|
315
|
+
function statusTone(status: WorkflowRunStatus): ThemeColor {
|
|
316
|
+
switch (status) {
|
|
317
|
+
case "completed":
|
|
318
|
+
return "success";
|
|
319
|
+
case "failed":
|
|
320
|
+
case "timed_out":
|
|
321
|
+
case "cancelled":
|
|
322
|
+
return "error";
|
|
323
|
+
case "waiting":
|
|
324
|
+
return "warning";
|
|
325
|
+
case "running":
|
|
326
|
+
return "accent";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function paint(theme: WidgetTheme | undefined, color: ThemeColor, text: string): string {
|
|
331
|
+
return theme ? theme.fg(color, text) : text;
|
|
332
|
+
}
|
|
333
|
+
|
|
191
334
|
function fitLines(lines: string[], width: number): string[] {
|
|
192
335
|
if (!Number.isFinite(width)) return lines;
|
|
193
336
|
return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
|
|
@@ -212,6 +355,7 @@ function windowLines(
|
|
|
212
355
|
budget: number,
|
|
213
356
|
anchor: number,
|
|
214
357
|
anchorIsStart: boolean,
|
|
358
|
+
theme?: WidgetTheme,
|
|
215
359
|
): { lines: string[]; scroll: number; maxScroll: number } {
|
|
216
360
|
if (lines.length <= budget) {
|
|
217
361
|
return { lines, scroll: 0, maxScroll: 0 };
|
|
@@ -225,7 +369,7 @@ function windowLines(
|
|
|
225
369
|
const directions = [above > 0 ? `↑ ${above}` : "", below > 0 ? `↓ ${below}` : ""]
|
|
226
370
|
.filter(Boolean)
|
|
227
371
|
.join(" · ");
|
|
228
|
-
out.push(
|
|
372
|
+
out.push(paint(theme, "dim", `${directions} more · shift+↑/↓ scroll`));
|
|
229
373
|
return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
|
|
230
374
|
}
|
|
231
375
|
|
|
@@ -37,6 +37,22 @@ export const WorkflowToolParameters: TSchema = Type.Union([
|
|
|
37
37
|
},
|
|
38
38
|
noExtraProperties,
|
|
39
39
|
),
|
|
40
|
+
Type.Object(
|
|
41
|
+
{
|
|
42
|
+
action: StringEnum(["update"] as const),
|
|
43
|
+
step: Type.String({ description: "Active step id" }),
|
|
44
|
+
attempt: Type.String({ description: "Active attempt id" }),
|
|
45
|
+
update: Type.Object(
|
|
46
|
+
{
|
|
47
|
+
type: Type.String(),
|
|
48
|
+
key: Type.String(),
|
|
49
|
+
data: Type.Record(Type.String(), Type.Unknown()),
|
|
50
|
+
},
|
|
51
|
+
noExtraProperties,
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
noExtraProperties,
|
|
55
|
+
),
|
|
40
56
|
Type.Object(
|
|
41
57
|
{
|
|
42
58
|
action: StringEnum(["submit"] as const),
|
|
@@ -56,4 +72,10 @@ export type WorkflowToolInput =
|
|
|
56
72
|
| { action: "resume" }
|
|
57
73
|
| { action: "cancel" }
|
|
58
74
|
| { action: "answer"; input: unknown; runId?: string }
|
|
75
|
+
| {
|
|
76
|
+
action: "update";
|
|
77
|
+
step: string;
|
|
78
|
+
attempt: string;
|
|
79
|
+
update: { type: string; key: string; data: Record<string, unknown> };
|
|
80
|
+
}
|
|
59
81
|
| { action: "submit"; step: string; attempt: string; output: unknown };
|