@osolmaz/pi-workflows 0.5.1 → 0.5.2

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.
@@ -1,6 +1,7 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { ansi, stripAnsi } from "../render/ansi.js";
3
- import { renderGraphLines } from "../render/graph-render.js";
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { ansi } from "../render/ansi.js";
3
+ import { formatDuration } from "../render/format.js";
4
+ import { nodeTypeGlyph } from "../render/node-type.js";
4
5
  import { sanitizeText } from "../workflows/text.js";
5
6
  import type {
6
7
  WorkflowDefinitionSnapshot,
@@ -20,9 +21,10 @@ const STATUS_GLYPHS: Record<WorkflowRunStatus, string> = {
20
21
  /**
21
22
  * pi renders at most this many widget lines (InteractiveMode.MAX_WIDGET_LINES)
22
23
  * and appends its own "(widget truncated)" marker beyond it. Stay inside the
23
- * budget and choose which graph rows to show instead of losing the bottom.
24
+ * budget and choose which node rows to show instead of losing the bottom.
24
25
  */
25
26
  const PI_MAX_WIDGET_LINES = 10;
27
+ const MAX_NODE_ERROR_CHARS = 120;
26
28
 
27
29
  export function nodeGlyph(state: WorkflowRunState, nodeId: string): string {
28
30
  if (state.currentNode === nodeId) {
@@ -43,36 +45,29 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
43
45
  return Object.keys(snapshot.nodes);
44
46
  }
45
47
 
46
- export type WidgetLayout = "graph" | "compact";
47
-
48
- export type WidgetScrollState = Record<WidgetLayout, number | null>;
49
-
50
48
  export type WidgetView = {
51
49
  lines: string[];
52
- layout: WidgetLayout;
53
- /** The clamped first visible row for the selected layout. */
50
+ /** The clamped first visible node row. */
54
51
  scroll: number;
55
- /** Largest useful scroll value; 0 when the whole graph fits. */
52
+ /** Largest useful scroll value; 0 when the whole list fits. */
56
53
  maxScroll: number;
57
54
  };
58
55
 
59
56
  /**
60
- * Live-progress view for the in-pi widget: a header plus the same boxed
61
- * graph the standalone viewer draws. When the graph is taller than pi's
62
- * widget budget, a window is shown with ↑/↓ overflow markers — centered on
63
- * the active node by default, or at `scroll` when the user scrolled
64
- * manually. Pure so it can be tested without a TUI.
57
+ * Compact live-progress view for the in-pi widget. It uses one line per node,
58
+ * follows the active node by default, and never returns a line wider than the
59
+ * width supplied by Pi's component renderer.
65
60
  */
66
61
  export function buildWidgetView(
67
62
  state: WorkflowRunState,
68
63
  snapshot: WorkflowDefinitionSnapshot,
69
64
  now: Date = new Date(),
70
- scroll: WidgetScrollState = { graph: null, compact: null },
65
+ scroll: number | null = null,
71
66
  held = false,
72
67
  width = Number.POSITIVE_INFINITY,
73
68
  ): WidgetView {
74
69
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
75
- if (availableWidth === 0) return { lines: [], layout: "compact", scroll: 0, maxScroll: 0 };
70
+ if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
76
71
 
77
72
  // `held` covers pauses the state cannot see yet: an escape-interrupted
78
73
  // step or a pause requested while the current node is still finishing.
@@ -93,58 +88,25 @@ export function buildWidgetView(
93
88
  }
94
89
 
95
90
  const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
96
- const graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
97
- nodeStyle: "box",
98
- });
99
- if (graph.length > 0) {
100
- const graphScroll = scroll.graph;
101
- const windowed = windowLines(
102
- graph,
103
- budget,
104
- graphScroll ?? focusLine(graph, state),
105
- graphScroll !== null,
106
- );
107
- const graphLines = windowed.lines.map((line) => ` ${line}`);
108
- if (graphLines.every((line) => visibleWidth(line) <= availableWidth)) {
109
- return {
110
- lines: fitLines([header, ...graphLines, ...footer], availableWidth),
111
- layout: "graph",
112
- scroll: windowed.scroll,
113
- maxScroll: windowed.maxScroll,
114
- };
115
- }
116
- }
117
-
118
- return compactWidgetView(state, snapshot, header, footer, budget, availableWidth, scroll.compact);
119
- }
120
-
121
- function compactWidgetView(
122
- state: WorkflowRunState,
123
- snapshot: WorkflowDefinitionSnapshot,
124
- header: string,
125
- footer: string[],
126
- budget: number,
127
- width: number,
128
- scroll: number | null,
129
- ): WidgetView {
130
- const nodes = displayNodeIds(snapshot).map((nodeId) => compactNodeLine(state, snapshot, nodeId));
91
+ const nodes = displayNodeIds(snapshot).map((nodeId) =>
92
+ compactNodeLine(state, snapshot, nodeId, now),
93
+ );
131
94
  if (nodes.length === 0) {
132
95
  return {
133
- lines: fitLines([header, ...footer], width),
134
- layout: "compact",
96
+ lines: fitLines([header, ...footer], availableWidth),
135
97
  scroll: 0,
136
98
  maxScroll: 0,
137
99
  };
138
100
  }
101
+
139
102
  const anchor = scroll ?? compactFocusIndex(state, snapshot);
140
103
  const windowed = windowLines(nodes, budget, anchor, scroll !== null);
141
- const indentation = width >= 3 ? " " : "";
104
+ const indentation = availableWidth >= 3 ? " " : "";
142
105
  return {
143
106
  lines: fitLines(
144
107
  [header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
145
- width,
108
+ availableWidth,
146
109
  ),
147
- layout: "compact",
148
110
  scroll: windowed.scroll,
149
111
  maxScroll: windowed.maxScroll,
150
112
  };
@@ -162,14 +124,68 @@ function compactNodeLine(
162
124
  state: WorkflowRunState,
163
125
  snapshot: WorkflowDefinitionSnapshot,
164
126
  nodeId: string,
127
+ now: Date,
165
128
  ): string {
166
129
  const node = snapshot.nodes[nodeId];
167
- const type = node?.nodeType === undefined ? "" : ` · ${node.nodeType}`;
168
- const detail =
169
- state.currentNode === nodeId && state.statusDetail
170
- ? ` · ${sanitizeText(state.statusDetail)}`
171
- : "";
172
- return `${nodeGlyph(state, nodeId)} ${sanitizeText(nodeId)}${type}${detail}`;
130
+ const type = node ? ansi.dim(nodeTypeGlyph(node.nodeType, node.actionExecution)) : "?";
131
+ const segments = nodeRuntimeSegments(state, snapshot, nodeId, now);
132
+ const detail = segments.length > 0 ? ` · ${segments.join(" · ")}` : "";
133
+ return `${nodeGlyph(state, nodeId)} ${type} ${sanitizeText(nodeId)}${detail}`;
134
+ }
135
+
136
+ function nodeRuntimeSegments(
137
+ state: WorkflowRunState,
138
+ snapshot: WorkflowDefinitionSnapshot,
139
+ nodeId: string,
140
+ now: Date,
141
+ ): string[] {
142
+ const segments: string[] = [];
143
+ const completedAttempts = state.steps.filter((step) => step.nodeId === nodeId).length;
144
+ const attempts = completedAttempts + (state.currentNode === nodeId ? 1 : 0);
145
+ if (attempts > 1) {
146
+ segments.push(`↻${attempts}`);
147
+ }
148
+
149
+ const result = state.results[nodeId];
150
+ if (state.currentNode === nodeId) {
151
+ if (state.statusDetail) {
152
+ segments.push(sanitizeText(state.statusDetail));
153
+ }
154
+ const elapsed = elapsedSince(state.currentNodeStartedAt, now);
155
+ if (elapsed !== null) {
156
+ segments.push(elapsed);
157
+ }
158
+ return segments;
159
+ }
160
+
161
+ if (state.waitingOn === nodeId) {
162
+ const summary = snapshot.nodes[nodeId]?.summary;
163
+ segments.push(summary ? sanitizeText(summary) : "waiting");
164
+ return segments;
165
+ }
166
+
167
+ if (!result) {
168
+ return segments;
169
+ }
170
+ if (result.outcome !== "ok") {
171
+ segments.push(
172
+ result.error
173
+ ? truncate(sanitizeText(result.error), MAX_NODE_ERROR_CHARS)
174
+ : result.outcome.replaceAll("_", " "),
175
+ );
176
+ return segments;
177
+ }
178
+ if (Number.isFinite(result.durationMs)) {
179
+ segments.push(formatDuration(result.durationMs));
180
+ }
181
+ return segments;
182
+ }
183
+
184
+ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
185
+ if (!startedAt) return null;
186
+ const started = Date.parse(startedAt);
187
+ if (!Number.isFinite(started)) return null;
188
+ return formatDuration(Math.max(0, now.getTime() - started));
173
189
  }
174
190
 
175
191
  function fitLines(lines: string[], width: number): string[] {
@@ -177,7 +193,7 @@ function fitLines(lines: string[], width: number): string[] {
177
193
  return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
178
194
  }
179
195
 
180
- /** Back-compatible line view following the active node. */
196
+ /** Compact line view following the active node. */
181
197
  export function buildWidgetLines(
182
198
  state: WorkflowRunState,
183
199
  snapshot: WorkflowDefinitionSnapshot,
@@ -186,29 +202,6 @@ export function buildWidgetLines(
186
202
  return buildWidgetView(state, snapshot, now).lines;
187
203
  }
188
204
 
189
- /** The graph row the window should center on: the active or waiting node. */
190
- function focusLine(graph: string[], state: WorkflowRunState): number {
191
- const active = graph.findIndex((line) => stripAnsi(line).includes("◐"));
192
- const focus =
193
- active !== -1
194
- ? active
195
- : state.waitingOn
196
- ? graph.findIndex((line) => stripAnsi(line).includes(state.waitingOn as string))
197
- : -1;
198
- if (focus === -1) {
199
- return graph.length - 1;
200
- }
201
- let top = focus;
202
- while (top > 0 && !/[┌┏]/u.test(stripAnsi(graph[top] ?? ""))) {
203
- top -= 1;
204
- }
205
- let bottom = focus;
206
- while (bottom + 1 < graph.length && !/[└┗]/u.test(stripAnsi(graph[bottom] ?? ""))) {
207
- bottom += 1;
208
- }
209
- return Math.floor((top + bottom) / 2);
210
- }
211
-
212
205
  /**
213
206
  * Slice `lines` to at most `budget` rows, marking hidden rows at either end.
214
207
  * Markers count against the budget. `anchor` is a row to center on (follow
@@ -223,8 +216,6 @@ function windowLines(
223
216
  if (lines.length <= budget) {
224
217
  return { lines, scroll: 0, maxScroll: 0 };
225
218
  }
226
- // Reserve one combined overflow row, leaving seven rows for a complete
227
- // full card even when the widget also has an error footer.
228
219
  const inner = Math.max(1, budget - 1);
229
220
  const start = clampStart(anchor, inner, lines.length, anchorIsStart);
230
221
  const end = start + inner;
@@ -13,6 +13,7 @@ import {
13
13
  type GraphLayout,
14
14
  type GraphSegment,
15
15
  } from "./graph.js";
16
+ import { nodeTypeBadge } from "./node-type.js";
16
17
 
17
18
  /**
18
19
  * Renders the workflow DAG as text, mirroring the acpx replay viewer's graph
@@ -76,14 +77,6 @@ const GRAPH_SIDE_MARGIN = 2;
76
77
  const CARD_MIN_CONTENT_WIDTH = 28;
77
78
  const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
78
79
 
79
- const NODE_TYPE_GLYPHS: Record<string, string> = {
80
- agent: "●",
81
- compute: "ƒ",
82
- notify: "✉",
83
- action: "⚙",
84
- checkpoint: "◆",
85
- };
86
-
87
80
  function nodeTypeStyle(nodeType: string): CanvasStyle {
88
81
  switch (nodeType) {
89
82
  case "agent":
@@ -98,10 +91,6 @@ function nodeTypeStyle(nodeType: string): CanvasStyle {
98
91
  }
99
92
  }
100
93
 
101
- function nodeTypeBadge(nodeType: string): string {
102
- return `${NODE_TYPE_GLYPHS[nodeType] ?? "?"} ${nodeType}`;
103
- }
104
-
105
94
  function fitText(text: string, width: number): string {
106
95
  const chars = [...text];
107
96
  if (chars.length <= width) return text;
@@ -228,7 +217,7 @@ function cardMetrics(view: GraphView): CardMetrics {
228
217
  }
229
218
  for (const [nodeId, node] of Object.entries(snapshot.nodes)) {
230
219
  measure(sanitizeText(nodeId));
231
- measure(nodeTypeBadge(node.nodeType));
220
+ measure(nodeTypeBadge(node.nodeType, node.actionExecution));
232
221
  const labels = nodeBranchLabels(view, nodeId);
233
222
  branchRows = Math.max(branchRows, labels.length);
234
223
  for (const label of labels) measure(`◇ ${label}`);
@@ -252,6 +241,7 @@ type RenderedCell = {
252
241
  text: string;
253
242
  nodeId: string;
254
243
  nodeType: string;
244
+ typeBadge: string;
255
245
  status: NodeStatus | null;
256
246
  attempts: number;
257
247
  elapsed: string;
@@ -277,6 +267,7 @@ function renderCellText(
277
267
  text: "",
278
268
  nodeId: "",
279
269
  nodeType: "",
270
+ typeBadge: "",
280
271
  status: null,
281
272
  attempts: 0,
282
273
  elapsed: "",
@@ -334,6 +325,7 @@ function renderCellText(
334
325
  text,
335
326
  nodeId: sanitizeText(nodeId),
336
327
  nodeType,
328
+ typeBadge: node ? nodeTypeBadge(node.nodeType, node.actionExecution) : "? unknown",
337
329
  status,
338
330
  attempts: count,
339
331
  elapsed,
@@ -729,7 +721,7 @@ function drawNodeBox(
729
721
  canvas.text(startX, y + 2, `${chars.ml}${horizontal}${chars.mr}`, borderStyle);
730
722
  pairedRow(
731
723
  y + 3,
732
- nodeTypeBadge(rendered.nodeType),
724
+ rendered.typeBadge,
733
725
  typeStyle,
734
726
  `${STATUS_GLYPHS[status]} ${STATUS_LABELS[status]}`,
735
727
  borderStyle,
@@ -0,0 +1,28 @@
1
+ import type { WorkflowNodeSnapshot } from "../workflows/types.js";
2
+
3
+ const NODE_TYPE_GLYPHS: Readonly<Record<WorkflowNodeSnapshot["nodeType"], string>> = {
4
+ agent: "●",
5
+ compute: "ƒ",
6
+ notify: "!",
7
+ action: "*",
8
+ checkpoint: "◆",
9
+ };
10
+
11
+ /** A stable one-column glyph for a workflow node or action subtype. */
12
+ export function nodeTypeGlyph(
13
+ nodeType: WorkflowNodeSnapshot["nodeType"],
14
+ actionExecution?: WorkflowNodeSnapshot["actionExecution"],
15
+ ): string {
16
+ if (nodeType === "action" && actionExecution === "shell") {
17
+ return "$";
18
+ }
19
+ return NODE_TYPE_GLYPHS[nodeType];
20
+ }
21
+
22
+ /** The graph viewer badge keeps the readable type name beside its glyph. */
23
+ export function nodeTypeBadge(
24
+ nodeType: WorkflowNodeSnapshot["nodeType"],
25
+ actionExecution?: WorkflowNodeSnapshot["actionExecution"],
26
+ ): string {
27
+ return `${nodeTypeGlyph(nodeType, actionExecution)} ${nodeType}`;
28
+ }