@signalridge/pi-workflows 0.2.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.
@@ -0,0 +1,95 @@
1
+ import type { WorkflowRun, WorkflowTaskResult } from "./journal.js";
2
+ import type { WorkflowDefinition, WorkflowTask } from "./schema.js";
3
+
4
+ export const MAX_TASK_RESULT_CHARS = 6_000;
5
+ export const MAX_SYNTHESIS_INSTRUCTION_CHARS = 8_000;
6
+ export const MAX_SYNTHESIS_INPUT_CHARS = 48_000;
7
+
8
+ function cap(value: string, limit: number): { text: string; truncated: boolean } {
9
+ if (value.length <= limit) return { text: value, truncated: false };
10
+ const marker = "\n…[truncated]";
11
+ const contentLimit = Math.max(0, limit - marker.length);
12
+ return { text: `${value.slice(0, contentLimit)}${marker}`, truncated: true };
13
+ }
14
+
15
+ /** Build bounded, labeled synthesis context without concatenating full transcripts. */
16
+ export function buildSynthesisPrompt(
17
+ definition: WorkflowDefinition,
18
+ run: WorkflowRun,
19
+ tasks: readonly WorkflowTask[] = definition.tasks,
20
+ ): string {
21
+ if (!definition.synthesis) throw new Error("workflow has no synthesis definition");
22
+
23
+ const instruction = cap(definition.synthesis.prompt, MAX_SYNTHESIS_INSTRUCTION_CHARS);
24
+ const chunks: string[] = [instruction.text, "", "Workflow task results:"];
25
+ let total = chunks.join("\n").length;
26
+ let aggregateTruncated = false;
27
+
28
+ for (const task of tasks) {
29
+ const result = run.taskResults[task.id];
30
+ const text = result?.text ?? "(no result)";
31
+ const bounded = cap(text, MAX_TASK_RESULT_CHARS);
32
+ const pointers = [
33
+ result?.agentId ? `agent_id=${result.agentId}` : "agent_id=unknown",
34
+ result?.outputFile ? `transcript=${result.outputFile}` : undefined,
35
+ result?.tokenCount !== undefined ? `token_count=${result.tokenCount}` : undefined,
36
+ ]
37
+ .filter((value): value is string => value !== undefined)
38
+ .join(" ");
39
+ const block = [
40
+ `### ${task.id} — ${task.description}`,
41
+ `status=${result?.status ?? run.taskStatus[task.id]} ${pointers}`,
42
+ bounded.truncated ? "result_truncated=true" : "result_truncated=false",
43
+ bounded.text,
44
+ ].join("\n");
45
+ if (total + block.length + 2 > MAX_SYNTHESIS_INPUT_CHARS) {
46
+ aggregateTruncated = true;
47
+ break;
48
+ }
49
+ chunks.push("", block);
50
+ total += block.length + 2;
51
+ }
52
+
53
+ let output = chunks.join("\n");
54
+ if (aggregateTruncated) {
55
+ const marker = "\n\n[aggregate synthesis input truncated: additional task results omitted]";
56
+ if (output.length + marker.length <= MAX_SYNTHESIS_INPUT_CHARS) {
57
+ output += marker;
58
+ } else {
59
+ output = `${output.slice(0, MAX_SYNTHESIS_INPUT_CHARS - marker.length)}${marker}`;
60
+ }
61
+ }
62
+ return cap(output, MAX_SYNTHESIS_INPUT_CHARS).text;
63
+ }
64
+
65
+ export function resultFromLifecycle(
66
+ status: "completed" | "failed" | "stopped",
67
+ agentId: string | undefined,
68
+ result: unknown,
69
+ error: unknown,
70
+ compactionCount: number,
71
+ updatedAt = Date.now(),
72
+ outputFile: unknown = undefined,
73
+ tokenCount: unknown = undefined,
74
+ ): WorkflowTaskResult {
75
+ const raw = typeof result === "string" ? result : "";
76
+ const bounded = cap(raw, MAX_TASK_RESULT_CHARS);
77
+ const errorText = typeof error === "string" && error.length > 0 ? error.slice(0, 2_000) : undefined;
78
+ const boundedOutputFile =
79
+ typeof outputFile === "string" && outputFile.length > 0 ? outputFile.slice(0, 2_000) : undefined;
80
+ const boundedTokenCount =
81
+ typeof tokenCount === "number" && Number.isFinite(tokenCount) && tokenCount > 0
82
+ ? Math.floor(tokenCount)
83
+ : undefined;
84
+ return {
85
+ status,
86
+ ...(agentId ? { agentId } : {}),
87
+ ...(bounded.text ? { text: bounded.text } : {}),
88
+ ...(errorText ? { error: errorText } : {}),
89
+ ...(boundedOutputFile ? { outputFile: boundedOutputFile } : {}),
90
+ ...(boundedTokenCount === undefined ? {} : { tokenCount: boundedTokenCount }),
91
+ compactionCount,
92
+ ...(bounded.truncated ? { truncated: true } : {}),
93
+ updatedAt,
94
+ };
95
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
+ import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
4
+ import type { WorkflowEngine, WorkflowSummary } from "./engine.js";
5
+
6
+ function elapsed(ms: number): string {
7
+ const seconds = Math.floor(ms / 1_000);
8
+ if (seconds < 60) return `${seconds}s`;
9
+ const minutes = Math.floor(seconds / 60);
10
+ return `${minutes}m ${seconds % 60}s`;
11
+ }
12
+
13
+ export function formatWorkflowRunLabel(run: WorkflowSummary): string {
14
+ const phases = run.phaseCount > 0 ? ` · phases ${run.completedPhases}/${run.phaseCount}` : "";
15
+ const tokens = run.tokenCount === undefined ? "" : ` · ${run.tokenCount} tokens`;
16
+ return `${run.name} · ${run.status}${phases} · ${run.completedTasks}/${run.taskCount} tasks · ${elapsed(run.elapsedMs)}${tokens}`;
17
+ }
18
+
19
+ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
20
+ const run = engine.getRun(runId);
21
+ if (!run) return "Workflow run is no longer available.";
22
+ const tokenValues = [...Object.values(run.taskResults), ...(run.synthesisResult ? [run.synthesisResult] : [])]
23
+ .map((result) => result.tokenCount)
24
+ .filter((value): value is number => value !== undefined);
25
+ const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
26
+ const lines = [
27
+ `${run.definition.name} [${run.status}]`,
28
+ `run: ${run.runId}`,
29
+ `elapsed: ${elapsed(Math.max(0, (run.status === "completed" || run.status === "failed" || run.status === "stopped" ? run.updatedAt : Date.now()) - run.startedAt))}`,
30
+ "",
31
+ ...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
32
+ ];
33
+ for (const phase of run.definition.phases) {
34
+ lines.push(`Phase: ${phase.title}`);
35
+ for (const task of run.definition.tasks.filter((candidate) => candidate.phase === phase.id)) {
36
+ const status = run.taskStatus[task.id] ?? "pending";
37
+ const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
38
+ const compact = run.compactions[task.id] ? ` compactions=${run.compactions[task.id]}` : "";
39
+ lines.push(` ${status.padEnd(11)} ${task.id}${agent}${compact}`);
40
+ const result = run.taskResults[task.id];
41
+ if (result?.error) lines.push(` error: ${result.error.slice(0, 300)}`);
42
+ if (result?.text) lines.push(` result: ${result.text.replaceAll("\n", " ").slice(0, 300)}`);
43
+ }
44
+ }
45
+ const unphased = run.definition.tasks.filter((task) => task.phase === undefined);
46
+ if (unphased.length > 0) {
47
+ lines.push("Tasks:");
48
+ for (const task of unphased) {
49
+ const status = run.taskStatus[task.id] ?? "pending";
50
+ const agent = run.agentIds[task.id] ? ` agent=${run.agentIds[task.id]}` : "";
51
+ lines.push(` ${status.padEnd(11)} ${task.id}${agent}`);
52
+ }
53
+ }
54
+ lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
55
+ if (run.error) lines.push(`Error: ${run.error.slice(0, 500)}`);
56
+ return lines.join("\n");
57
+ }
58
+
59
+ async function selectList<T>(
60
+ ctx: ExtensionCommandContext,
61
+ title: string,
62
+ items: SelectItem[],
63
+ renderExtra: (theme: Pick<Theme, "fg">) => string,
64
+ ): Promise<T | undefined> {
65
+ if (ctx.mode !== "tui") return undefined;
66
+ return ctx.ui.custom<T | undefined>((_tui, theme, _keybindings, done) => {
67
+ const container = new Container();
68
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
69
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
70
+ container.addChild(new Text(theme.fg("dim", renderExtra(theme)), 1, 0));
71
+ const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), {
72
+ selectedPrefix: (text) => theme.fg("accent", text),
73
+ selectedText: (text) => theme.fg("accent", text),
74
+ description: (text) => theme.fg("muted", text),
75
+ scrollInfo: (text) => theme.fg("dim", text),
76
+ noMatch: (text) => theme.fg("warning", text),
77
+ });
78
+ list.onSelect = (item) => done(item.value as T);
79
+ list.onCancel = () => done(undefined);
80
+ container.addChild(list);
81
+ container.addChild(new Text(theme.fg("dim", "↑↓ select · enter confirm · esc back"), 1, 0));
82
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
83
+ return {
84
+ render: (width) => container.render(width),
85
+ invalidate: () => container.invalidate(),
86
+ handleInput: (data) => list.handleInput(data),
87
+ };
88
+ });
89
+ }
90
+
91
+ /** Interactive `/workflows` navigator. TUI-only by design. */
92
+ export async function showWorkflowNavigator(ctx: ExtensionCommandContext, engine: WorkflowEngine): Promise<void> {
93
+ if (ctx.mode !== "tui") {
94
+ ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
95
+ return;
96
+ }
97
+ while (true) {
98
+ const runs = engine.list();
99
+ const selected = await selectList<string>(
100
+ ctx,
101
+ "Workflow runs",
102
+ runs.map((run) => ({
103
+ value: run.runId,
104
+ label: formatWorkflowRunLabel(run),
105
+ description: run.error?.slice(0, 120),
106
+ })),
107
+ (theme) =>
108
+ runs.length > 0
109
+ ? theme.fg("dim", "Select a run to inspect.")
110
+ : theme.fg("muted", "No workflow runs in this session."),
111
+ );
112
+ if (!selected) return;
113
+ while (true) {
114
+ const run = engine.get(selected);
115
+ if (!run) break;
116
+ const action = await selectList<string>(
117
+ ctx,
118
+ `${run.name} · ${run.status}`,
119
+ [
120
+ { value: "refresh", label: "Refresh" },
121
+ ...(run.status === "running" ? [{ value: "pause", label: "Pause" }] : []),
122
+ ...(run.status === "paused" || run.status === "interrupted" ? [{ value: "resume", label: "Resume" }] : []),
123
+ ...(!["completed", "failed", "stopped"].includes(run.status) ? [{ value: "stop", label: "Stop" }] : []),
124
+ { value: "back", label: "Back" },
125
+ ],
126
+ () => formatWorkflowDetail(engine, selected),
127
+ );
128
+ if (!action || action === "back") break;
129
+ if (action === "refresh") continue;
130
+ if (action === "stop") {
131
+ const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${run.name}? The run will be non-resumable.`);
132
+ if (!confirmed) continue;
133
+ }
134
+ try {
135
+ await engine.control(action as "pause" | "resume" | "stop", selected);
136
+ ctx.ui.notify(`Workflow ${action} requested.`, "info");
137
+ } catch (error: unknown) {
138
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
139
+ }
140
+ }
141
+ }
142
+ }