@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.
- package/README.md +36 -21
- package/dist/extension/executor.d.ts +14 -1
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +79 -7
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +85 -0
- package/dist/extension/recorder.js +525 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/widget.js +25 -24
- package/dist/extension/widget.js.map +1 -1
- package/dist/render/canvas.d.ts +1 -1
- package/dist/render/canvas.js +5 -0
- package/dist/render/canvas.js.map +1 -1
- package/dist/render/graph-render.d.ts +5 -0
- package/dist/render/graph-render.js +211 -48
- package/dist/render/graph-render.js.map +1 -1
- package/dist/viewer/render.js +19 -3
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +45 -0
- package/dist/viewer/session-reducer.js +266 -0
- package/dist/viewer/session-reducer.js.map +1 -0
- package/dist/workflows/artifacts.d.ts +40 -0
- package/dist/workflows/artifacts.js +155 -0
- package/dist/workflows/artifacts.js.map +1 -0
- package/dist/workflows/engine.d.ts +2 -0
- package/dist/workflows/engine.js +38 -7
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/store.d.ts +53 -9
- package/dist/workflows/store.js +523 -43
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +126 -3
- package/docs/development.md +43 -19
- package/docs/live-replay-protocol.md +155 -0
- package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
- package/docs/plans/session-event-replay-implementation-plan.md +494 -0
- package/docs/plans/tui-viewer-implementation-plan.md +64 -0
- package/docs/run-bundles.md +320 -55
- package/docs/session-event-journal.md +470 -0
- package/docs/tui-viewer.md +218 -0
- package/package.json +2 -1
- package/src/extension/executor.ts +28 -1
- package/src/extension/index.ts +87 -7
- package/src/extension/recorder.ts +633 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/widget.ts +26 -24
- package/src/render/canvas.ts +19 -1
- package/src/render/graph-render.ts +277 -44
- package/src/viewer/render.ts +21 -3
- package/src/viewer/session-reducer.ts +347 -0
- package/src/workflows/artifacts.ts +188 -0
- package/src/workflows/engine.ts +39 -7
- package/src/workflows/index.ts +15 -0
- package/src/workflows/store.ts +649 -49
- 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 =
|
|
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 =
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 "
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
}
|
|
165
|
-
if (attempts > 1) {
|
|
166
|
-
parts.push(`×${attempts}`);
|
|
312
|
+
elapsed = formatDuration(durationMs);
|
|
167
313
|
}
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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) =>
|
|
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(
|
|
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 =
|
|
514
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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);
|
package/src/viewer/render.ts
CHANGED
|
@@ -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(
|
|
28
|
-
if (
|
|
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")) {
|