@osolmaz/pi-workflows 0.1.0 → 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.
Files changed (62) hide show
  1. package/README.md +36 -21
  2. package/dist/extension/executor.d.ts +14 -1
  3. package/dist/extension/executor.js +11 -1
  4. package/dist/extension/executor.js.map +1 -1
  5. package/dist/extension/index.js +79 -7
  6. package/dist/extension/index.js.map +1 -1
  7. package/dist/extension/recorder.d.ts +85 -0
  8. package/dist/extension/recorder.js +525 -0
  9. package/dist/extension/recorder.js.map +1 -0
  10. package/dist/extension/session-events.d.ts +134 -0
  11. package/dist/extension/session-events.js +60 -0
  12. package/dist/extension/session-events.js.map +1 -0
  13. package/dist/extension/widget.js +25 -24
  14. package/dist/extension/widget.js.map +1 -1
  15. package/dist/render/canvas.d.ts +1 -1
  16. package/dist/render/canvas.js +5 -0
  17. package/dist/render/canvas.js.map +1 -1
  18. package/dist/render/graph-render.d.ts +5 -0
  19. package/dist/render/graph-render.js +211 -48
  20. package/dist/render/graph-render.js.map +1 -1
  21. package/dist/viewer/render.js +19 -3
  22. package/dist/viewer/render.js.map +1 -1
  23. package/dist/viewer/session-reducer.d.ts +45 -0
  24. package/dist/viewer/session-reducer.js +266 -0
  25. package/dist/viewer/session-reducer.js.map +1 -0
  26. package/dist/workflows/artifacts.d.ts +40 -0
  27. package/dist/workflows/artifacts.js +155 -0
  28. package/dist/workflows/artifacts.js.map +1 -0
  29. package/dist/workflows/engine.d.ts +2 -0
  30. package/dist/workflows/engine.js +38 -7
  31. package/dist/workflows/engine.js.map +1 -1
  32. package/dist/workflows/index.d.ts +3 -2
  33. package/dist/workflows/index.js +2 -1
  34. package/dist/workflows/index.js.map +1 -1
  35. package/dist/workflows/store.d.ts +53 -9
  36. package/dist/workflows/store.js +523 -43
  37. package/dist/workflows/store.js.map +1 -1
  38. package/dist/workflows/types.d.ts +126 -3
  39. package/docs/development.md +43 -19
  40. package/docs/live-replay-protocol.md +155 -0
  41. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  42. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  43. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  44. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  45. package/docs/run-bundles.md +320 -55
  46. package/docs/session-event-journal.md +470 -0
  47. package/docs/tui-viewer.md +218 -0
  48. package/package.json +2 -1
  49. package/src/extension/executor.ts +28 -1
  50. package/src/extension/index.ts +87 -7
  51. package/src/extension/recorder.ts +633 -0
  52. package/src/extension/session-events.ts +119 -0
  53. package/src/extension/widget.ts +26 -24
  54. package/src/render/canvas.ts +19 -1
  55. package/src/render/graph-render.ts +277 -44
  56. package/src/viewer/render.ts +21 -3
  57. package/src/viewer/session-reducer.ts +347 -0
  58. package/src/workflows/artifacts.ts +188 -0
  59. package/src/workflows/engine.ts +39 -7
  60. package/src/workflows/index.ts +15 -0
  61. package/src/workflows/store.ts +649 -49
  62. package/src/workflows/types.ts +141 -3
@@ -27,28 +27,89 @@ export type GraphView = {
27
27
  snapshot: WorkflowDefinitionSnapshot | null;
28
28
  };
29
29
 
30
- type NodeStatus = "completed" | "failed" | "active" | "waiting" | "queued" | "cancelled";
30
+ type NodeStatus =
31
+ | "completed"
32
+ | "failed"
33
+ | "timed_out"
34
+ | "active"
35
+ | "replay_focus"
36
+ | "waiting"
37
+ | "queued"
38
+ | "cancelled";
31
39
 
32
40
  const STATUS_GLYPHS: Record<NodeStatus, string> = {
33
41
  completed: "✓",
34
42
  failed: "✗",
43
+ timed_out: "×",
35
44
  active: "◐",
45
+ replay_focus: "◆",
36
46
  waiting: "⏸",
37
47
  cancelled: "~",
38
48
  queued: "·",
39
49
  };
40
50
 
51
+ const STATUS_LABELS: Record<NodeStatus, string> = {
52
+ completed: "completed",
53
+ failed: "failed",
54
+ timed_out: "timed out",
55
+ active: "running",
56
+ replay_focus: "replay focus",
57
+ waiting: "waiting",
58
+ cancelled: "cancelled",
59
+ queued: "queued",
60
+ };
61
+
41
62
  const STATUS_STYLES: Record<NodeStatus, CanvasStyle> = {
42
63
  completed: "ok",
43
64
  failed: "fail",
65
+ timed_out: "fail",
44
66
  active: "active",
67
+ replay_focus: "active",
45
68
  waiting: "warn",
46
69
  cancelled: "warn",
47
70
  queued: "dim",
48
71
  };
49
72
 
50
- const CELL_GAP = 4;
73
+ const CELL_GAP = 6;
51
74
  const GUTTER_GAP = 2;
75
+ const GRAPH_SIDE_MARGIN = 2;
76
+ const CARD_MIN_CONTENT_WIDTH = 28;
77
+ const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
78
+
79
+ const NODE_TYPE_GLYPHS: Record<string, string> = {
80
+ agent: "●",
81
+ compute: "ƒ",
82
+ action: "⚙",
83
+ checkpoint: "◆",
84
+ };
85
+
86
+ function nodeTypeStyle(nodeType: string): CanvasStyle {
87
+ switch (nodeType) {
88
+ case "agent":
89
+ case "compute":
90
+ case "action":
91
+ case "checkpoint":
92
+ return nodeType;
93
+ default:
94
+ return "dim";
95
+ }
96
+ }
97
+
98
+ function nodeTypeBadge(nodeType: string): string {
99
+ return `${NODE_TYPE_GLYPHS[nodeType] ?? "?"} ${nodeType}`;
100
+ }
101
+
102
+ function fitText(text: string, width: number): string {
103
+ const chars = [...text];
104
+ if (chars.length <= width) return text;
105
+ return width <= 1 ? chars.slice(0, width).join("") : `${chars.slice(0, width - 1).join("")}…`;
106
+ }
107
+
108
+ function centeredText(text: string, width: number): string {
109
+ const fitted = fitText(text, width);
110
+ const left = Math.max(0, Math.floor((width - [...fitted].length) / 2));
111
+ return `${" ".repeat(left)}${fitted}`;
112
+ }
52
113
 
53
114
  /** How node cells are drawn: single text lines or bordered boxes. */
54
115
  export type GraphNodeStyle = "line" | "box";
@@ -57,9 +118,15 @@ export type GraphRenderOptions = {
57
118
  nodeStyle?: GraphNodeStyle;
58
119
  };
59
120
 
60
- /** Rows a node cell occupies: boxes add a border row above and below. */
61
- function cellHeight(nodeStyle: GraphNodeStyle): number {
62
- return nodeStyle === "box" ? 3 : 1;
121
+ type CardMetrics = {
122
+ width: number;
123
+ height: number;
124
+ contentWidth: number;
125
+ branchRows: number;
126
+ };
127
+
128
+ function cellHeight(nodeStyle: GraphNodeStyle, boxHeight: number): number {
129
+ return nodeStyle === "box" ? boxHeight : 1;
63
130
  }
64
131
 
65
132
  function paint(text: string, style: CanvasStyle): string {
@@ -71,9 +138,18 @@ function paint(text: string, style: CanvasStyle): string {
71
138
  return ansi.cyan(text);
72
139
  case "back":
73
140
  case "warn":
141
+ case "action":
74
142
  return ansi.yellow(text);
75
143
  case "fail":
76
144
  return ansi.red(text);
145
+ case "agent":
146
+ return ansi.green(text);
147
+ case "compute":
148
+ return ansi.blue(text);
149
+ case "checkpoint":
150
+ return ansi.magenta(text);
151
+ case "branch":
152
+ return ansi.cyan(text);
77
153
  case "dim":
78
154
  return ansi.dim(text);
79
155
  default:
@@ -110,13 +186,14 @@ function deriveNodeStatus(
110
186
  if (!attempt) {
111
187
  return "queued";
112
188
  }
113
- // While scrubbing, the selected step's node reads as the active position.
114
189
  if (!atLatestStep && visibleSteps.at(-1)?.nodeId === nodeId) {
115
- return "active";
190
+ return "replay_focus";
116
191
  }
117
192
  switch (attempt.outcome) {
118
193
  case "ok":
119
194
  return "completed";
195
+ case "timed_out":
196
+ return "timed_out";
120
197
  case "cancelled":
121
198
  return "cancelled";
122
199
  default:
@@ -124,10 +201,61 @@ function deriveNodeStatus(
124
201
  }
125
202
  }
126
203
 
204
+ function nodeBranchLabels(view: GraphView, nodeId: string): string[] {
205
+ return (
206
+ view.snapshot?.edges.flatMap((edge) =>
207
+ edge.from === nodeId && "switch" in edge ? Object.keys(edge.switch.cases) : [],
208
+ ) ?? []
209
+ ).map(sanitizeText);
210
+ }
211
+
212
+ function cardMetrics(view: GraphView): CardMetrics {
213
+ const snapshot = view.snapshot;
214
+ if (!snapshot) {
215
+ return { width: CARD_MIN_CONTENT_WIDTH + 4, height: 7, contentWidth: 24, branchRows: 0 };
216
+ }
217
+ let contentWidth = CARD_MIN_CONTENT_WIDTH;
218
+ let branchRows = 0;
219
+ const measure = (text: string) => {
220
+ contentWidth = Math.max(contentWidth, text.length);
221
+ };
222
+ measure(CARD_DYNAMIC_RESERVE);
223
+ for (const status of Object.keys(STATUS_LABELS) as NodeStatus[]) {
224
+ measure(`${nodeTypeBadge("checkpoint")} ${STATUS_GLYPHS[status]} ${STATUS_LABELS[status]}`);
225
+ }
226
+ for (const [nodeId, node] of Object.entries(snapshot.nodes)) {
227
+ measure(sanitizeText(nodeId));
228
+ measure(nodeTypeBadge(node.nodeType));
229
+ const labels = nodeBranchLabels(view, nodeId);
230
+ branchRows = Math.max(branchRows, labels.length);
231
+ for (const label of labels) measure(`◇ ${label}`);
232
+ }
233
+ return {
234
+ width: contentWidth + 4,
235
+ height: 7 + branchRows,
236
+ contentWidth,
237
+ branchRows,
238
+ };
239
+ }
240
+
241
+ /** Canonical outer dimensions shared by every full card in this graph. */
242
+ export function graphCardSize(view: GraphView): { width: number; height: number } {
243
+ const { width, height } = cardMetrics(view);
244
+ return { width, height };
245
+ }
246
+
127
247
  type RenderedCell = {
128
248
  cell: GraphCell;
129
249
  text: string;
250
+ nodeId: string;
251
+ nodeType: string;
130
252
  status: NodeStatus | null;
253
+ attempts: number;
254
+ elapsed: string;
255
+ detail: string;
256
+ branchLines: string[];
257
+ isStart: boolean;
258
+ isEnd: boolean;
131
259
  width: number;
132
260
  };
133
261
 
@@ -138,38 +266,80 @@ function renderCellText(
138
266
  atLatestStep: boolean,
139
267
  now: Date,
140
268
  nodeStyle: GraphNodeStyle,
269
+ metrics: CardMetrics,
141
270
  ): RenderedCell {
142
271
  if (cell.kind === "virtual") {
143
- return { cell, text: "", status: null, width: 1 };
272
+ return {
273
+ cell,
274
+ text: "",
275
+ nodeId: "",
276
+ nodeType: "",
277
+ status: null,
278
+ attempts: 0,
279
+ elapsed: "",
280
+ detail: "",
281
+ branchLines: [],
282
+ isStart: false,
283
+ isEnd: false,
284
+ width: 1,
285
+ };
144
286
  }
145
287
  const state = view.state;
146
288
  const nodeId = cell.nodeId;
147
289
  const status = deriveNodeStatus(view, nodeId, visibleSteps, atLatestStep);
148
- const nodeType = view.snapshot?.nodes[nodeId]?.nodeType ?? "?";
290
+ const node = view.snapshot?.nodes[nodeId];
291
+ const nodeType = node?.nodeType ?? "?";
149
292
  const attempt = latestVisibleAttempt(visibleSteps, nodeId);
150
293
  const attempts = visibleSteps.filter((step) => step.nodeId === nodeId).length;
151
- const parts = [`${nodeId} [${nodeType}]`];
294
+ const labels = nodeBranchLabels(view, nodeId);
295
+ const outgoing =
296
+ view.snapshot?.edges
297
+ .filter((edge) => edge.from === nodeId)
298
+ .reduce(
299
+ (count, edge) => count + ("to" in edge ? 1 : Object.keys(edge.switch.cases).length),
300
+ 0,
301
+ ) ?? 0;
302
+ const isStart = view.snapshot?.startAt === nodeId;
303
+ const isEnd = outgoing === 0;
304
+ let elapsed = "—";
152
305
  if (atLatestStep && state.currentNode === nodeId) {
153
306
  const startedAt = state.currentNodeStartedAt
154
307
  ? Date.parse(state.currentNodeStartedAt)
155
308
  : now.getTime();
156
- parts.push(`running ${formatDuration(now.getTime() - startedAt)}`);
157
- if (state.statusDetail) {
158
- // statusDetail can be set by workflow authors; keep terminal-safe.
159
- parts.push(`· ${sanitizeText(state.statusDetail)}`);
160
- }
309
+ elapsed = formatDuration(now.getTime() - startedAt);
161
310
  } else if (attempt) {
162
311
  const durationMs = Date.parse(attempt.finishedAt) - Date.parse(attempt.startedAt);
163
- parts.push(formatDuration(durationMs));
164
- }
165
- if (attempts > 1) {
166
- parts.push(`×${attempts}`);
312
+ elapsed = formatDuration(durationMs);
167
313
  }
168
- const text = parts.join(" ");
169
- // Width includes the status glyph and the space after it; boxes add a
170
- // border and one padding column on each side.
171
- const contentWidth = text.length + 2;
172
- return { cell, text, status, width: nodeStyle === "box" ? contentWidth + 4 : contentWidth };
314
+ const detail =
315
+ atLatestStep && state.currentNode === nodeId && state.statusDetail
316
+ ? sanitizeText(state.statusDetail)
317
+ : node?.statusDetail
318
+ ? sanitizeText(node.statusDetail)
319
+ : node?.summary
320
+ ? sanitizeText(node.summary)
321
+ : "";
322
+ const branchLines = Array.from({ length: metrics.branchRows }, (_, index) =>
323
+ labels[index] ? `◇ ${labels[index]}` : "",
324
+ );
325
+ const count = atLatestStep && state.currentNode === nodeId ? Math.max(attempts, 1) : attempts;
326
+ const timing =
327
+ attempt || count > 0 ? `${count} attempt${count === 1 ? "" : "s"} · ${elapsed}` : "not visited";
328
+ const text = `${nodeId} [${nodeType}] ${timing}`;
329
+ return {
330
+ cell,
331
+ text,
332
+ nodeId: sanitizeText(nodeId),
333
+ nodeType,
334
+ status,
335
+ attempts: count,
336
+ elapsed,
337
+ detail,
338
+ branchLines,
339
+ isStart,
340
+ isEnd,
341
+ width: nodeStyle === "box" ? metrics.width : text.length + 2,
342
+ };
173
343
  }
174
344
 
175
345
  type RankGeometry = {
@@ -221,6 +391,7 @@ export function renderGraphLines(
221
391
  return [];
222
392
  }
223
393
  const nodeStyle = options.nodeStyle ?? "line";
394
+ const metrics = cardMetrics(view);
224
395
  const layout = layoutGraph(snapshot);
225
396
  const steps = view.state.steps;
226
397
  const boundedIndex = Math.min(Math.max(selectedStepIndex, -1), steps.length - 1);
@@ -230,7 +401,9 @@ export function renderGraphLines(
230
401
  const activePair = derivePairInFlight(view, visibleSteps, atLatestStep);
231
402
 
232
403
  const rendered = layout.ranks.map((rank) =>
233
- rank.map((cell) => renderCellText(view, cell, visibleSteps, atLatestStep, now, nodeStyle)),
404
+ rank.map((cell) =>
405
+ renderCellText(view, cell, visibleSteps, atLatestStep, now, nodeStyle, metrics),
406
+ ),
234
407
  );
235
408
 
236
409
  // Column positions: pack cells left to right per rank, then center every
@@ -239,7 +412,7 @@ export function renderGraphLines(
239
412
  (cells) =>
240
413
  cells.reduce((sum, cell) => sum + cell.width, 0) + Math.max(0, cells.length - 1) * CELL_GAP,
241
414
  );
242
- const graphWidth = Math.max(0, ...rankWidths);
415
+ const graphWidth = Math.max(0, ...rankWidths) + GRAPH_SIDE_MARGIN * 2;
243
416
  const geometry: RankGeometry[] = rendered.map((cells, rankIndex) => {
244
417
  const centers: number[] = [];
245
418
  let x = Math.floor((graphWidth - (rankWidths[rankIndex] ?? 0)) / 2);
@@ -268,14 +441,14 @@ export function renderGraphLines(
268
441
  for (const [rankIndex, rank] of geometry.entries()) {
269
442
  placed.push({ ...rank, y });
270
443
  y +=
271
- cellHeight(nodeStyle) +
444
+ cellHeight(nodeStyle, metrics.height) +
272
445
  lanes.below(rankIndex).length +
273
446
  gapRows(strips[rankIndex] as StripGeometry, rankIndex, layout.ranks.length) +
274
447
  lanes.above(rankIndex + 1).length;
275
448
  }
276
449
 
277
450
  const canvas = new CharCanvas();
278
- drawNodes(canvas, placed, layout, transitions, nodeStyle);
451
+ drawNodes(canvas, placed, layout, transitions, nodeStyle, metrics.height);
279
452
  const labels = drawSegments(
280
453
  canvas,
281
454
  placed,
@@ -285,9 +458,10 @@ export function renderGraphLines(
285
458
  activePair,
286
459
  graphWidth,
287
460
  nodeStyle,
461
+ metrics.height,
288
462
  lanes,
289
463
  );
290
- drawBackEdges(canvas, placed, layout, transitions, graphWidth, nodeStyle, lanes);
464
+ drawBackEdges(canvas, placed, layout, transitions, graphWidth, nodeStyle, metrics.height, lanes);
291
465
  // Labels go on last, once every line is on the canvas: placement can then
292
466
  // guarantee no later stroke crosses through a label.
293
467
  for (const label of labels) {
@@ -463,8 +637,8 @@ function fanOffsets(
463
637
  }
464
638
 
465
639
  const BOX_CHARS = {
466
- light: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
467
- heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
640
+ light: { tl: "┌", tr: "┐", ml: "├", mr: "┤", bl: "└", br: "┘", h: "─", v: "│" },
641
+ heavy: { tl: "┏", tr: "┓", ml: "┣", mr: "┫", bl: "┗", br: "┛", h: "━", v: "┃" },
468
642
  } as const;
469
643
 
470
644
  function drawNodes(
@@ -473,6 +647,7 @@ function drawNodes(
473
647
  layout: GraphLayout,
474
648
  transitions: Set<string>,
475
649
  nodeStyle: GraphNodeStyle,
650
+ boxHeight: number,
476
651
  ): void {
477
652
  for (const rank of placed) {
478
653
  for (const [index, rendered] of rank.cells.entries()) {
@@ -483,7 +658,12 @@ function drawNodes(
483
658
  const taken = edge ? transitions.has(`${edge.from}->${edge.to}`) : false;
484
659
  // Pass-through cells span the full cell height so the edge stays
485
660
  // visually continuous across the rank row(s).
486
- canvas.vline(center, rank.y, rank.y + cellHeight(nodeStyle) - 1, taken ? "taken" : "dim");
661
+ canvas.vline(
662
+ center,
663
+ rank.y,
664
+ rank.y + cellHeight(nodeStyle, boxHeight) - 1,
665
+ taken ? "taken" : "dim",
666
+ );
487
667
  continue;
488
668
  }
489
669
  const status = rendered.status ?? "queued";
@@ -491,8 +671,12 @@ function drawNodes(
491
671
  if (nodeStyle === "box") {
492
672
  drawNodeBox(canvas, startX, rank.y, rendered, status);
493
673
  } else {
674
+ if (rendered.isStart) canvas.put(startX - 2, rank.y, "▶", STATUS_STYLES[status]);
494
675
  canvas.put(startX, rank.y, STATUS_GLYPHS[status], STATUS_STYLES[status]);
495
676
  canvas.text(startX + 2, rank.y, rendered.text, status === "queued" ? "dim" : "plain");
677
+ if (rendered.isEnd) {
678
+ canvas.put(startX + rendered.width + 1, rank.y, "■", STATUS_STYLES[status]);
679
+ }
496
680
  }
497
681
  }
498
682
  }
@@ -510,15 +694,62 @@ function drawNodeBox(
510
694
  rendered: RenderedCell,
511
695
  status: NodeStatus,
512
696
  ): void {
513
- const chars = status === "active" ? BOX_CHARS.heavy : BOX_CHARS.light;
514
- const style = STATUS_STYLES[status];
697
+ const chars =
698
+ status === "active" || status === "replay_focus" ? BOX_CHARS.heavy : BOX_CHARS.light;
699
+ const borderStyle = STATUS_STYLES[status];
700
+ const typeStyle = nodeTypeStyle(rendered.nodeType);
701
+ const contentStyle = status === "queued" ? "dim" : "plain";
515
702
  const innerWidth = rendered.width - 2;
516
- canvas.text(startX, y, `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`, style);
517
- canvas.text(startX, y + 1, chars.v, style);
518
- canvas.put(startX + 2, y + 1, STATUS_GLYPHS[status], style);
519
- canvas.text(startX + 4, y + 1, rendered.text, status === "queued" ? "dim" : "plain");
520
- canvas.text(startX + rendered.width - 1, y + 1, chars.v, style);
521
- canvas.text(startX, y + 2, `${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`, style);
703
+ const height = 7 + rendered.branchLines.length;
704
+ const rightX = startX + rendered.width - 1;
705
+ const horizontal = chars.h.repeat(innerWidth);
706
+ const rowBorder = (row: number) => {
707
+ canvas.text(startX, row, chars.v, borderStyle);
708
+ canvas.text(rightX, row, chars.v, borderStyle);
709
+ };
710
+ const pairedRow = (
711
+ row: number,
712
+ left: string,
713
+ leftStyle: CanvasStyle,
714
+ right: string,
715
+ rightStyle: CanvasStyle,
716
+ ) => {
717
+ rowBorder(row);
718
+ canvas.text(startX + 2, row, fitText(left, innerWidth - 2), leftStyle);
719
+ const rightText = fitText(right, innerWidth - 2);
720
+ canvas.text(rightX - 1 - [...rightText].length, row, rightText, rightStyle);
721
+ };
722
+
723
+ canvas.text(startX, y, `${chars.tl}${horizontal}${chars.tr}`, borderStyle);
724
+ rowBorder(y + 1);
725
+ canvas.text(startX + 1, y + 1, centeredText(rendered.nodeId, innerWidth), contentStyle);
726
+ canvas.text(startX, y + 2, `${chars.ml}${horizontal}${chars.mr}`, borderStyle);
727
+ pairedRow(
728
+ y + 3,
729
+ nodeTypeBadge(rendered.nodeType),
730
+ typeStyle,
731
+ `${STATUS_GLYPHS[status]} ${STATUS_LABELS[status]}`,
732
+ borderStyle,
733
+ );
734
+ pairedRow(y + 4, `↻ ${rendered.attempts}`, contentStyle, `◷ ${rendered.elapsed}`, contentStyle);
735
+ for (const [index, branch] of rendered.branchLines.entries()) {
736
+ const row = y + 5 + index;
737
+ rowBorder(row);
738
+ canvas.text(startX + 2, row, fitText(branch, innerWidth - 2), "branch");
739
+ }
740
+ const detailRow = y + 5 + rendered.branchLines.length;
741
+ rowBorder(detailRow);
742
+ if (rendered.detail) {
743
+ canvas.text(
744
+ startX + 2,
745
+ detailRow,
746
+ fitText(`… ${rendered.detail}`, innerWidth - 2),
747
+ contentStyle,
748
+ );
749
+ }
750
+ canvas.text(startX, y + height - 1, `${chars.bl}${horizontal}${chars.br}`, borderStyle);
751
+ if (rendered.isStart) canvas.put(startX - 2, y + 1, "▶", borderStyle);
752
+ if (rendered.isEnd) canvas.put(startX + rendered.width + 1, y + 1, "■", borderStyle);
522
753
  }
523
754
 
524
755
  function edgeStyle(
@@ -544,6 +775,7 @@ function drawSegments(
544
775
  activePair: string | null,
545
776
  graphWidth: number,
546
777
  nodeStyle: GraphNodeStyle,
778
+ boxHeight: number,
547
779
  lanes: BackEdgeLanes,
548
780
  ): PendingLabel[] {
549
781
  const labels: PendingLabel[] = [];
@@ -557,7 +789,7 @@ function drawSegments(
557
789
  // Forward lines start right below the source cell, cross any back-edge
558
790
  // lane rows (as ┼ crossings), run their strip tracks, then cross the
559
791
  // entry lanes to the arrow row directly above the target cell.
560
- const stubTop = top.y + cellHeight(nodeStyle);
792
+ const stubTop = top.y + cellHeight(nodeStyle, boxHeight);
561
793
  const stripTop = stubTop + lanes.below(rank).length;
562
794
  const arrowY = bottom.y - 1;
563
795
  const stripBottom = arrowY - 1 - lanes.above(rank + 1).length;
@@ -665,6 +897,7 @@ function drawBackEdges(
665
897
  transitions: Set<string>,
666
898
  graphWidth: number,
667
899
  nodeStyle: GraphNodeStyle,
900
+ boxHeight: number,
668
901
  lanes: BackEdgeLanes,
669
902
  ): void {
670
903
  let gutterX = graphWidth + GUTTER_GAP;
@@ -682,14 +915,14 @@ function drawBackEdges(
682
915
  continue;
683
916
  }
684
917
  const style: CanvasStyle = transitions.has(`${edge.from}->${edge.to}`) ? "taken" : "back";
685
- const exitLaneY = from.y + cellHeight(nodeStyle) + exit.lane;
918
+ const exitLaneY = from.y + cellHeight(nodeStyle, boxHeight) + exit.lane;
686
919
  const aboveCount = lanes.above(toRank).length;
687
920
  const arrowY = to.y - 1;
688
921
  const entryLaneY = arrowY - aboveCount + entry.lane;
689
922
 
690
923
  // Downward stub out of the source cell, then right along the exit lane.
691
- if (exitLaneY > from.y + cellHeight(nodeStyle)) {
692
- canvas.vline(exit.x, from.y + cellHeight(nodeStyle), exitLaneY - 1, style);
924
+ if (exitLaneY > from.y + cellHeight(nodeStyle, boxHeight)) {
925
+ canvas.vline(exit.x, from.y + cellHeight(nodeStyle, boxHeight), exitLaneY - 1, style);
693
926
  }
694
927
  canvas.put(exit.x, exitLaneY, "└", style);
695
928
  canvas.hline(exitLaneY, exit.x + 1, gutterX - 1, style);
@@ -1,11 +1,28 @@
1
1
  import { ansi, fitWidth, sanitizeText } from "../render/ansi.js";
2
2
  import { formatDuration, runElapsedMs } from "../render/format.js";
3
3
  import { renderGraphLines } from "../render/graph-render.js";
4
+ import { decodeValueWith } from "../workflows/artifacts.js";
4
5
  import type { LoadedRunBundle } from "../workflows/store.js";
5
6
  import type { WorkflowRunStatus, WorkflowStepRecord } from "../workflows/types.js";
6
7
 
7
8
  export { formatDuration, runElapsedMs };
8
9
 
10
+ /**
11
+ * Replace `$artifact` references with a compact placeholder for display. The
12
+ * terminal viewer shows summaries; full artifact contents are for replay
13
+ * tooling.
14
+ */
15
+ function withArtifactPlaceholders(value: unknown): unknown {
16
+ return decodeValueWith(value, (ref) => `«artifact ${formatBytes(ref.bytes)} ${ref.path}»`);
17
+ }
18
+
19
+ function formatBytes(bytes: number): string {
20
+ if (bytes < 1024) {
21
+ return `${bytes}B`;
22
+ }
23
+ return `${(bytes / 1024).toFixed(1)}KB`;
24
+ }
25
+
9
26
  export type ViewportSize = {
10
27
  width: number;
11
28
  height: number;
@@ -24,10 +41,11 @@ export function statusLabel(status: WorkflowRunStatus): string {
24
41
  return STATUS_COLORS[status](status);
25
42
  }
26
43
 
27
- function previewValue(value: unknown, maxLength: number): string {
28
- if (value === undefined) {
44
+ function previewValue(rawValue: unknown, maxLength: number): string {
45
+ if (rawValue === undefined) {
29
46
  return "";
30
47
  }
48
+ const value = withArtifactPlaceholders(rawValue);
31
49
  const text = typeof value === "string" ? value : JSON.stringify(value);
32
50
  // Model-controlled values must not carry escape sequences into the terminal.
33
51
  const singleLine = sanitizeText(text ?? "")
@@ -120,7 +138,7 @@ function nodeStatusLine(bundle: LoadedRunBundle, nodeId: string, width: number,
120
138
  /** Pretty-printed JSON body of the selected step for the inspector pane. */
121
139
  function inspectorLines(step: WorkflowStepRecord, width: number): string[] {
122
140
  const lines: string[] = [];
123
- const body = step.error !== undefined ? step.error : step.output;
141
+ const body = step.error !== undefined ? step.error : withArtifactPlaceholders(step.output);
124
142
  const rendered =
125
143
  typeof body === "string" && step.error !== undefined ? body : JSON.stringify(body, null, 2);
126
144
  for (const raw of (rendered ?? "null").split("\n")) {