@outputai/cli 0.9.1-dev.000c5f3.0 → 0.9.1-dev.667788.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 (36) hide show
  1. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  2. package/dist/generated/framework_version.json +1 -1
  3. package/dist/views/dev/dev_app.js +3 -17
  4. package/dist/views/dev/modals/steps_modal.js +1 -1
  5. package/dist/views/dev/panels/runs_panel.js +2 -7
  6. package/dist/views/dev/panels/workflows_panel.js +1 -1
  7. package/dist/views/dev/state/ui_state.d.ts +0 -8
  8. package/dist/views/dev/state/ui_state.js +1 -5
  9. package/oclif.manifest.json +1 -81
  10. package/package.json +4 -4
  11. package/dist/commands/workflow/history.d.ts +0 -19
  12. package/dist/commands/workflow/history.js +0 -99
  13. package/dist/commands/workflow/history.spec.d.ts +0 -1
  14. package/dist/commands/workflow/history.spec.js +0 -26
  15. package/dist/services/workflow_history/correlator.d.ts +0 -26
  16. package/dist/services/workflow_history/correlator.js +0 -261
  17. package/dist/services/workflow_history/correlator.spec.d.ts +0 -1
  18. package/dist/services/workflow_history/correlator.spec.js +0 -116
  19. package/dist/services/workflow_history.d.ts +0 -23
  20. package/dist/services/workflow_history.js +0 -79
  21. package/dist/services/workflow_history.spec.d.ts +0 -1
  22. package/dist/services/workflow_history.spec.js +0 -87
  23. package/dist/utils/span_labels.d.ts +0 -11
  24. package/dist/utils/span_labels.js +0 -22
  25. package/dist/utils/span_labels.spec.d.ts +0 -1
  26. package/dist/utils/span_labels.spec.js +0 -36
  27. package/dist/utils/waterfall.d.ts +0 -31
  28. package/dist/utils/waterfall.js +0 -192
  29. package/dist/utils/waterfall.spec.d.ts +0 -1
  30. package/dist/utils/waterfall.spec.js +0 -100
  31. package/dist/views/dev/hooks/use_step_graph.d.ts +0 -22
  32. package/dist/views/dev/hooks/use_step_graph.js +0 -76
  33. package/dist/views/dev/modals/step_graph_modal.d.ts +0 -6
  34. package/dist/views/dev/modals/step_graph_modal.js +0 -148
  35. /package/dist/views/dev/utils/{ui_constants.d.ts → constants.d.ts} +0 -0
  36. /package/dist/views/dev/utils/{ui_constants.js → constants.js} +0 -0
@@ -1,31 +0,0 @@
1
- /**
2
- * Renders a workflow's correlated spans as a terminal waterfall/Gantt chart —
3
- * a text analog of the Agents HQ "Timeline" view. Each row is a step, drawn as
4
- * a colored bar positioned by its start offset and sized by its duration, on a
5
- * shared time axis.
6
- *
7
- * The tick math (TICK_STEPS_MS / pickTickStep / formatTickLabel) is a port of
8
- * Atlas's `StepGantt`; the bar geometry mirrors its leftPct/widthPct as integer
9
- * terminal columns.
10
- */
11
- import type { Span } from '#services/workflow_history/correlator.js';
12
- export interface WaterfallOptions {
13
- width: number;
14
- color: boolean;
15
- header?: string;
16
- labels?: Map<string, string>;
17
- }
18
- export interface BarGeometry {
19
- startCol: number;
20
- barLen: number;
21
- instantaneous: boolean;
22
- }
23
- export declare const FULL_BLOCK = "\u2588";
24
- export declare const THIN_BLOCK = "\u258F";
25
- export declare function pickTickStep(totalMs: number): number;
26
- export declare function buildTicks(totalMs: number): number[];
27
- export declare function formatTickLabel(ms: number): string;
28
- export declare function formatDurationLabel(ms: number): string;
29
- export declare function computeBar(startOffsetMs: number, endOffsetMs: number, totalMs: number, trackW: number): BarGeometry;
30
- export declare function buildRulerLine(totalMs: number, trackW: number): string;
31
- export default function renderWaterfall(spans: Span[], totalDurationMs: number, options: WaterfallOptions): string;
@@ -1,192 +0,0 @@
1
- const LABEL_MIN = 14;
2
- const LABEL_MAX = 28;
3
- const MIN_TRACK = 10;
4
- export const FULL_BLOCK = '█';
5
- export const THIN_BLOCK = '▏';
6
- // Human-friendly step sizes, smallest → largest. Pick the smallest step that
7
- // yields ~TARGET_TICKS or fewer labels.
8
- const TICK_STEPS_MS = [
9
- 100, 250, 500,
10
- 1_000, 2_000, 5_000, 10_000, 15_000, 30_000,
11
- 60_000, 2 * 60_000, 5 * 60_000, 10 * 60_000, 15 * 60_000, 30 * 60_000,
12
- 60 * 60_000, 2 * 60 * 60_000, 6 * 60 * 60_000, 12 * 60 * 60_000, 24 * 60 * 60_000
13
- ];
14
- const TARGET_TICKS = 8;
15
- const ANSI = {
16
- completed: '',
17
- running: '',
18
- failed: '',
19
- pending: '',
20
- dim: '',
21
- reset: ''
22
- };
23
- function clamp(value, lo, hi) {
24
- return Math.min(Math.max(value, lo), hi);
25
- }
26
- export function pickTickStep(totalMs) {
27
- if (totalMs <= 0) {
28
- return 1_000;
29
- }
30
- const ideal = totalMs / TARGET_TICKS;
31
- return TICK_STEPS_MS.find(s => s >= ideal) ?? TICK_STEPS_MS[TICK_STEPS_MS.length - 1];
32
- }
33
- export function buildTicks(totalMs) {
34
- const step = pickTickStep(totalMs);
35
- const count = Math.floor(totalMs / step);
36
- return Array.from({ length: count + 1 }, (_, i) => i * step);
37
- }
38
- // Round to whole units *before* splitting so a remainder that rounds up to a
39
- // full unit carries instead of rendering "1m60s" / "1h60m".
40
- function formatClock(ms) {
41
- const totalSec = Math.round(ms / 1_000);
42
- if (totalSec < 60) {
43
- return `${totalSec}s`;
44
- }
45
- if (totalSec < 3_600) {
46
- const m = Math.floor(totalSec / 60);
47
- const s = totalSec % 60;
48
- return s === 0 ? `${m}m` : `${m}m${s}s`;
49
- }
50
- const totalMin = Math.round(ms / 60_000);
51
- const h = Math.floor(totalMin / 60);
52
- const m = totalMin % 60;
53
- return m === 0 ? `${h}h` : `${h}h${m}m`;
54
- }
55
- export function formatTickLabel(ms) {
56
- if (ms === 0) {
57
- return '0';
58
- }
59
- if (ms < 1_000) {
60
- return `${ms}ms`;
61
- }
62
- return formatClock(ms);
63
- }
64
- export function formatDurationLabel(ms) {
65
- if (ms < 1_000) {
66
- return `${Math.max(0, Math.round(ms))}ms`;
67
- }
68
- return formatClock(ms);
69
- }
70
- // Map a span's [startOffset, endOffset] onto integer columns of a `trackW`-wide
71
- // lane. Both edges are positioned by their own offset and the length is the gap
72
- // between them — so the bar always spans start→end. (Rounding a separate width
73
- // off the start, as before, let the right edge drift past the true end time.)
74
- // A zero-width span still draws a 1-col marker, clamped to stay inside the lane.
75
- export function computeBar(startOffsetMs, endOffsetMs, totalMs, trackW) {
76
- const safeTotal = Math.max(totalMs, 1);
77
- const startCol = clamp(Math.round((startOffsetMs / safeTotal) * trackW), 0, Math.max(0, trackW - 1));
78
- const endCol = clamp(Math.round((endOffsetMs / safeTotal) * trackW), startCol, trackW);
79
- const span = endCol - startCol;
80
- const instantaneous = span <= 0;
81
- const barLen = Math.min(Math.max(span, 1), trackW - startCol);
82
- return { startCol, barLen, instantaneous };
83
- }
84
- function padOrTruncate(text, width) {
85
- if (text.length === width) {
86
- return text;
87
- }
88
- if (text.length < width) {
89
- return text.padEnd(width);
90
- }
91
- return `${text.slice(0, width - 1)}…`;
92
- }
93
- function truncate(text, width) {
94
- return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
95
- }
96
- // First tick aligns left, last tick aligns right, middle ticks center on their
97
- // position — so labels at 0 % and 100 % don't overflow the lane.
98
- function tickRawStart(idx, count, col, labelLen, trackW) {
99
- if (idx === 0) {
100
- return 0;
101
- }
102
- if (idx === count - 1) {
103
- return trackW - labelLen;
104
- }
105
- return col - Math.floor(labelLen / 2);
106
- }
107
- function tickStart(idx, count, col, labelLen, trackW) {
108
- return clamp(tickRawStart(idx, count, col, labelLen, trackW), 0, trackW - labelLen);
109
- }
110
- // The trackW-wide time-axis ruler (no label gutter, no colour). Exported so the
111
- // TUI overlay header can render the same ticks the CLI string renderer uses.
112
- export function buildRulerLine(totalMs, trackW) {
113
- const safeTotal = Math.max(totalMs, 1);
114
- const ticks = buildTicks(safeTotal);
115
- const placements = ticks.reduce((acc, t, idx) => {
116
- const label = formatTickLabel(t);
117
- const col = Math.min(Math.round((t / safeTotal) * trackW), trackW - 1);
118
- const start = tickStart(idx, ticks.length, col, label.length, trackW);
119
- if (start <= acc.writtenUntil) {
120
- return acc;
121
- }
122
- return { writtenUntil: start + label.length, items: [...acc.items, { start, label }] };
123
- }, { writtenUntil: -1, items: [] });
124
- const chars = Array.from({ length: trackW }, () => ' ');
125
- for (const { start, label } of placements.items) {
126
- [...label].forEach((ch, i) => {
127
- chars[start + i] = ch;
128
- });
129
- }
130
- return chars.join('');
131
- }
132
- function renderRuler(totalMs, labelW, trackW, tint) {
133
- return `${' '.repeat(labelW + 1)}${tint(buildRulerLine(totalMs, trackW), ANSI.dim)}`;
134
- }
135
- function renderLegend(tint) {
136
- const entry = (status, text) => `${tint(FULL_BLOCK, ANSI[status])} ${text}`;
137
- const entries = [
138
- entry('completed', 'completed'),
139
- entry('running', 'running'),
140
- entry('failed', 'failed'),
141
- entry('pending', 'pending')
142
- ];
143
- return tint(entries.join(' '), ANSI.dim);
144
- }
145
- // The bars only encode failure as a red lane; surface the reason underneath so a
146
- // failed run is actionable from the chart alone. `failureMessage` is only
147
- // populated when the history was fetched with payloads (the server strips it
148
- // otherwise), so spans without one are simply omitted here.
149
- function buildFailureLines(spans, labelFor, width, tint) {
150
- const failed = spans.filter(span => span.status === 'failed' && span.failureMessage);
151
- if (failed.length === 0) {
152
- return [];
153
- }
154
- const lines = failed.map(span => {
155
- const message = (span.failureMessage ?? '').replace(/\s+/g, ' ').trim();
156
- return tint(truncate(`✗ ${labelFor(span)}: ${message}`, width), ANSI.failed);
157
- });
158
- return ['', tint('Failures', ANSI.dim), ...lines];
159
- }
160
- export default function renderWaterfall(spans, totalDurationMs, options) {
161
- const { width, color, header, labels } = options;
162
- if (spans.length === 0) {
163
- return [header, 'No steps found for this run.'].filter(Boolean).join('\n\n');
164
- }
165
- const tint = (text, code) => (color ? `${code}${text}${ANSI.reset}` : text);
166
- const labelFor = (span) => labels?.get(span.id) ?? span.name;
167
- const durationFor = (span) => formatDurationLabel(Math.max(0, span.durationMs));
168
- const longestLabel = Math.max(...spans.map(s => labelFor(s).length));
169
- const labelW = clamp(longestLabel, LABEL_MIN, LABEL_MAX);
170
- const durationW = Math.max(...spans.map(s => durationFor(s).length));
171
- const trackW = Math.max(width - labelW - durationW - 2, MIN_TRACK);
172
- const rows = spans.map(span => {
173
- const label = padOrTruncate(labelFor(span), labelW);
174
- const { startCol, barLen, instantaneous } = computeBar(span.startOffsetMs, span.endOffsetMs, totalDurationMs, trackW);
175
- const glyph = instantaneous ? THIN_BLOCK : FULL_BLOCK;
176
- const leading = ' '.repeat(startCol);
177
- const trailing = ' '.repeat(Math.max(trackW - startCol - barLen, 0));
178
- const track = `${leading}${tint(glyph.repeat(barLen), ANSI[span.status])}${trailing}`;
179
- const duration = tint(durationFor(span).padStart(durationW), ANSI.dim);
180
- return `${label} ${track} ${duration}`;
181
- });
182
- const headerLines = header ? [header, ''] : [];
183
- const failureLines = buildFailureLines(spans, labelFor, width, tint);
184
- const legendLines = color ? ['', renderLegend(tint)] : [];
185
- return [
186
- ...headerLines,
187
- renderRuler(totalDurationMs, labelW, trackW, tint),
188
- ...rows,
189
- ...failureLines,
190
- ...legendLines
191
- ].join('\n');
192
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,100 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import renderWaterfall, { pickTickStep, formatTickLabel, formatDurationLabel, computeBar } from './waterfall.js';
3
- const span = (id, name, status, startOffsetMs, durationMs) => ({
4
- id,
5
- name,
6
- technicalName: name,
7
- description: null,
8
- status,
9
- kind: 'activity',
10
- attempt: 1,
11
- startedAt: null,
12
- scheduledAt: null,
13
- completedAt: null,
14
- startOffsetMs,
15
- endOffsetMs: startOffsetMs + durationMs,
16
- durationMs,
17
- failureMessage: null
18
- });
19
- describe('pickTickStep', () => {
20
- it('targets ~8 ticks by choosing the smallest sufficient step', () => {
21
- expect(pickTickStep(0)).toBe(1_000);
22
- expect(pickTickStep(8_000)).toBe(1_000);
23
- expect(pickTickStep(80_000)).toBe(10_000);
24
- expect(pickTickStep(160_000)).toBe(30_000);
25
- });
26
- });
27
- describe('formatTickLabel', () => {
28
- it('formats axis ticks like the UI', () => {
29
- expect(formatTickLabel(0)).toBe('0');
30
- expect(formatTickLabel(500)).toBe('500ms');
31
- expect(formatTickLabel(27_000)).toBe('27s');
32
- expect(formatTickLabel(60_000)).toBe('1m');
33
- expect(formatTickLabel(72_000)).toBe('1m12s');
34
- expect(formatTickLabel(3_600_000)).toBe('1h');
35
- expect(formatTickLabel(3_660_000)).toBe('1h1m');
36
- });
37
- });
38
- describe('formatDurationLabel', () => {
39
- it('always shows a unit, including zero', () => {
40
- expect(formatDurationLabel(0)).toBe('0ms');
41
- expect(formatDurationLabel(999)).toBe('999ms');
42
- expect(formatDurationLabel(1_000)).toBe('1s');
43
- expect(formatDurationLabel(27_000)).toBe('27s');
44
- });
45
- it('carries a rounded-up remainder instead of rendering 60s/60m', () => {
46
- expect(formatDurationLabel(59_500)).toBe('1m');
47
- expect(formatDurationLabel(119_500)).toBe('2m');
48
- expect(formatDurationLabel(3_599_500)).toBe('1h');
49
- });
50
- });
51
- describe('computeBar', () => {
52
- it('maps offsets to columns with a min width and lane clamp', () => {
53
- expect(computeBar(0, 27_000, 100_000, 50)).toEqual({ startCol: 0, barLen: 14, instantaneous: false });
54
- // Zero-duration step → a single thin marker
55
- expect(computeBar(50_000, 50_000, 100_000, 50)).toEqual({ startCol: 25, barLen: 1, instantaneous: true });
56
- // A short step at the very end is clamped so it stays inside the lane
57
- expect(computeBar(99_000, 100_000, 100_000, 50)).toEqual({ startCol: 49, barLen: 1, instantaneous: false });
58
- });
59
- it('sizes the bar to span start→end without right-edge drift', () => {
60
- // Full-span step fills the lane exactly.
61
- expect(computeBar(0, 100_000, 100_000, 50)).toEqual({ startCol: 0, barLen: 50, instantaneous: false });
62
- // End column is rounded from the true end offset (33), not start + a
63
- // separately-rounded width (which used to overshoot to col 34).
64
- expect(computeBar(33_000, 66_000, 100_000, 50)).toEqual({ startCol: 17, barLen: 16, instantaneous: false });
65
- });
66
- });
67
- describe('renderWaterfall', () => {
68
- const spans = [
69
- span('a', 'Compress Text', 'completed', 0, 27_000),
70
- span('b', 'Generate Brief', 'pending', 40_000, 0)
71
- ];
72
- it('renders labels, bars, durations and a header without color by default', () => {
73
- const out = renderWaterfall(spans, 40_000, { width: 70, color: false, header: 'wf-1 · run abcd1234 · running · 40s' });
74
- expect(out).toContain('Compress Text');
75
- expect(out).toContain('Generate Brief');
76
- expect(out).toContain('█');
77
- expect(out).toContain('27s');
78
- expect(out).toContain('wf-1 · run abcd1234');
79
- expect(out).not.toContain('\x1b[');
80
- });
81
- it('emits ANSI color when enabled', () => {
82
- const out = renderWaterfall(spans, 40_000, { width: 70, color: true });
83
- expect(out).toContain('\x1b[');
84
- expect(out).toContain('\x1b[92m'); // completed → green
85
- });
86
- it('shows a friendly message when there are no steps', () => {
87
- const out = renderWaterfall([], 1, { width: 70, color: false, header: 'wf-1' });
88
- expect(out).toContain('No steps found for this run.');
89
- });
90
- it('lists the reason under the chart for failed steps that carry a message', () => {
91
- const failed = { ...span('c', 'Scrape Serp Page', 'failed', 10_000, 2_000), failureMessage: 'connection reset' };
92
- const out = renderWaterfall([...spans, failed], 40_000, { width: 70, color: false });
93
- expect(out).toContain('Failures');
94
- expect(out).toContain('✗ Scrape Serp Page: connection reset');
95
- });
96
- it('omits the failures section when no failed step has a message', () => {
97
- const out = renderWaterfall(spans, 40_000, { width: 70, color: false });
98
- expect(out).not.toContain('Failures');
99
- });
100
- });
@@ -1,22 +0,0 @@
1
- import { type WorkflowMeta } from '#services/workflow_history.js';
2
- import type { Span } from '#services/workflow_history/correlator.js';
3
- export interface StepGraph {
4
- spans: Span[];
5
- totalDurationMs: number;
6
- workflow: WorkflowMeta | null;
7
- labels: Map<string, string>;
8
- loading: boolean;
9
- error: string | null;
10
- }
11
- /**
12
- * Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
13
- * reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
14
- * command. Driven by `usePoll`: while the run is still advancing it re-pulls
15
- * every tick so newly scheduled / finished steps appear, then stops and caches
16
- * once terminal. The modal ticks the time axis between polls so the right edge
17
- * tracks elapsed time.
18
- *
19
- * A hard fetch failure surfaces as `error` only when nothing has loaded yet — a
20
- * transient poll blip keeps the last good chart on screen.
21
- */
22
- export declare const useStepGraph: (workflowId: string | undefined, runId: string | undefined, status?: string) => StepGraph;
@@ -1,76 +0,0 @@
1
- import { useRef, useState } from 'react';
2
- import { fetchWorkflowHistory } from '#services/workflow_history.js';
3
- import buildSpanLabels from '#utils/span_labels.js';
4
- import { isTerminalRunStatus } from '#views/dev/hooks/use_run_detail.js';
5
- import { usePoll, POLL_INTERVAL_MS } from '#views/dev/hooks/use_poll.js';
6
- const EMPTY_GRAPH = {
7
- spans: [],
8
- totalDurationMs: 0,
9
- workflow: null,
10
- labels: new Map(),
11
- loading: false,
12
- error: null
13
- };
14
- const stepGraphCache = new Map();
15
- /**
16
- * Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
17
- * reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
18
- * command. Driven by `usePoll`: while the run is still advancing it re-pulls
19
- * every tick so newly scheduled / finished steps appear, then stops and caches
20
- * once terminal. The modal ticks the time axis between polls so the right edge
21
- * tracks elapsed time.
22
- *
23
- * A hard fetch failure surfaces as `error` only when nothing has loaded yet — a
24
- * transient poll blip keeps the last good chart on screen.
25
- */
26
- export const useStepGraph = (workflowId, runId, status) => {
27
- const [graph, setGraph] = useState(EMPTY_GRAPH);
28
- const terminal = isTerminalRunStatus(status);
29
- const requestKeyRef = useRef('');
30
- usePoll(Boolean(workflowId), POLL_INTERVAL_MS, async () => {
31
- if (!workflowId) {
32
- return 'done';
33
- }
34
- const key = `${workflowId}:${runId ?? 'latest'}`;
35
- requestKeyRef.current = key;
36
- const cached = stepGraphCache.get(key);
37
- if (cached) {
38
- setGraph(cached);
39
- return 'done';
40
- }
41
- setGraph(current => (current.spans.length === 0 ? { ...current, loading: true } : current));
42
- try {
43
- // Pull payloads so the detail pane can show each step's input/output and
44
- // a failed step's reason.
45
- const result = await fetchWorkflowHistory({ workflowId, runId, includePayloads: true });
46
- if (requestKeyRef.current !== key) {
47
- return 'done'; // a different run was selected mid-flight
48
- }
49
- const next = {
50
- spans: result.spans,
51
- totalDurationMs: result.totalDurationMs,
52
- workflow: result.workflow,
53
- labels: buildSpanLabels(result.spans),
54
- loading: false,
55
- error: null
56
- };
57
- // Cache only terminal runs — a running run's history is still growing.
58
- if (terminal) {
59
- stepGraphCache.set(key, next);
60
- }
61
- setGraph(next);
62
- }
63
- catch (err) {
64
- if (requestKeyRef.current !== key) {
65
- return 'done';
66
- }
67
- const message = err instanceof Error ? err.message : String(err);
68
- // Keep the last good chart if a poll blips; only surface a cold failure.
69
- setGraph(current => (current.spans.length > 0 ?
70
- { ...current, loading: false } :
71
- { ...EMPTY_GRAPH, error: message }));
72
- }
73
- return terminal ? 'done' : 'continue';
74
- });
75
- return graph;
76
- };
@@ -1,6 +0,0 @@
1
- import React from 'react';
2
- import type { WorkflowRun } from '#services/workflow_runs.js';
3
- export declare const StepGraphModal: React.FC<{
4
- run: WorkflowRun;
5
- height: number;
6
- }>;
@@ -1,148 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { Box, Text, useInput, useStdout } from 'ink';
4
- import { computeBar, buildRulerLine, formatDurationLabel, FULL_BLOCK, THIN_BLOCK } from '#utils/waterfall.js';
5
- import { useStepGraph } from '#views/dev/hooks/use_step_graph.js';
6
- import { isTerminalRunStatus } from '#views/dev/hooks/use_run_detail.js';
7
- import { LoadingSpinner } from '#views/dev/chrome/loading_spinner.js';
8
- import { SelectionIndicator } from '#views/dev/chrome/selection_indicator.js';
9
- import { ModalFrame, getHeight as getModalFrameHeight } from '#views/dev/modals/modal_frame.js';
10
- import { MasterDetailPanel } from '#views/dev/components/master_detail_panel.js';
11
- import { ContentTitle, getHeight as getContentTitleHeight } from '#views/dev/components/content_title.js';
12
- import { TabBar, getHeight as getTabBarHeight } from '#views/dev/chrome/tab_bar.js';
13
- import { JsonView } from '#views/dev/utils/json_render.js';
14
- import { workflowStatusColor } from '#views/dev/components/workflow_status.js';
15
- import { useUiState } from '#views/dev/state/ui_state.js';
16
- import { capitalize, cycleValue, formatContentTitle, formatStartedShort, truncate, useListSelection } from '#views/dev/utils/panel_helpers.js';
17
- const FRAME_WIDTH_OVERHEAD = 6; // outer paddingX (2) + round border (2) + inner paddingX (2)
18
- const META_ROWS = 2; // run status/started line + its bottom margin
19
- const FALLBACK_COLS = 100;
20
- const MIN_WIDTH = 40;
21
- const MIN_TRACK = 10;
22
- const VISIBLE_ROWS = 10;
23
- const TICK_MS = 1_000;
24
- const COL = { sel: 3, label: 22, duration: 8 };
25
- const PANE_ORDER = ['input', 'output', 'meta'];
26
- const PANE_TABS = [
27
- { id: 'input', label: 'Input' },
28
- { id: 'output', label: 'Output' },
29
- { id: 'meta', label: 'Meta' }
30
- ];
31
- const STEP_GRAPH_SHORTCUTS = [
32
- ['↑/↓', 'navigate'],
33
- ['←/→', 'tab'],
34
- ['e', 'expand'],
35
- ['esc', 'close']
36
- ];
37
- const parseMs = (iso) => {
38
- if (!iso) {
39
- return null;
40
- }
41
- const ms = Date.parse(iso);
42
- return Number.isNaN(ms) ? null : ms;
43
- };
44
- const spanColor = (status) => (status === 'pending' ? 'gray' : workflowStatusColor(status));
45
- // Stretch a still-running step's bar to the live edge so it visibly grows.
46
- const toLiveSpans = (spans, totalMs, terminal) => {
47
- if (terminal) {
48
- return spans;
49
- }
50
- return spans.map(span => (span.status === 'running' ?
51
- { ...span, endOffsetMs: Math.max(span.endOffsetMs, totalMs), durationMs: Math.max(0, totalMs - span.startOffsetMs) } :
52
- span));
53
- };
54
- const spanPaneValue = (span, tab) => {
55
- if (tab === 'input') {
56
- return span.input;
57
- }
58
- if (tab === 'output') {
59
- return span.failureMessage ?? span.output;
60
- }
61
- return {
62
- status: span.status,
63
- started: span.startedAt,
64
- ended: span.completedAt,
65
- duration: formatDurationLabel(Math.max(0, span.durationMs)),
66
- startOffset: formatDurationLabel(Math.max(0, span.startOffsetMs)),
67
- attempt: span.attempt,
68
- kind: span.kind,
69
- step: span.technicalName
70
- };
71
- };
72
- const RulerRow = ({ trackW, totalMs }) => (_jsxs(Box, { children: [_jsx(Box, { width: COL.sel + COL.label, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: trackW, children: _jsx(Text, { dimColor: true, children: buildRulerLine(totalMs, trackW) }) }), _jsx(Box, { width: COL.duration, children: _jsx(Text, { children: " " }) })] }));
73
- const SpanRow = ({ span, label, selected, trackW, totalMs }) => {
74
- const { startCol, barLen, instantaneous } = computeBar(span.startOffsetMs, span.endOffsetMs, totalMs, trackW);
75
- const glyph = instantaneous ? THIN_BLOCK : FULL_BLOCK;
76
- const bar = `${' '.repeat(startCol)}${glyph.repeat(barLen)}${' '.repeat(Math.max(0, trackW - startCol - barLen))}`;
77
- return (_jsxs(Box, { children: [_jsx(Box, { width: COL.sel, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.label, children: _jsx(Text, { bold: selected, children: truncate(label, COL.label - 1) }) }), _jsx(Box, { width: trackW, children: _jsx(Text, { color: spanColor(span.status), children: bar }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: formatDurationLabel(Math.max(0, span.durationMs)) }) })] }));
78
- };
79
- const SpanDetail = ({ span, activeTab, label, rows }) => {
80
- if (!span) {
81
- return _jsx(Text, { dimColor: true, children: "Select a step to see its input, output, and timing." });
82
- }
83
- const tabContentRows = Math.max(1, rows - getContentTitleHeight() - getTabBarHeight());
84
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(ContentTitle, { title: formatContentTitle([`Step "${label}"`, capitalize(activeTab)]) }), _jsx(TabBar, { active: activeTab, items: PANE_TABS }), _jsx(Box, { flexDirection: "column", children: _jsx(JsonView, { value: spanPaneValue(span, activeTab), maxLines: tabContentRows, truncateLine: true }) })] }));
85
- };
86
- export const StepGraphModal = ({ run, height }) => {
87
- const ui = useUiState();
88
- const { stdout } = useStdout();
89
- const { spans, totalDurationMs, workflow, labels, loading, error } = useStepGraph(run.workflowId, run.runId, run.status);
90
- const status = run.status ?? 'unknown';
91
- const terminal = isTerminalRunStatus(status);
92
- // Tick once a second while the run is live so the axis advances between polls.
93
- const [now, setNow] = useState(() => Date.now());
94
- useEffect(() => {
95
- const timer = terminal ? undefined : setInterval(() => setNow(Date.now()), TICK_MS);
96
- return () => {
97
- if (timer) {
98
- clearInterval(timer);
99
- }
100
- };
101
- }, [terminal]);
102
- // Recorded history only reaches the last event; for a running run grow the
103
- // axis to elapsed wall-clock so the right edge tracks "now", not open time.
104
- const startMs = parseMs(workflow?.startTime) ?? parseMs(run.startedAt);
105
- const liveTotalMs = terminal || startMs === null ? totalDurationMs : Math.max(totalDurationMs, now - startMs);
106
- const liveSpans = toLiveSpans(spans, liveTotalMs, terminal);
107
- const { selectedIndex, selectPrevious, selectNext } = useListSelection(liveSpans.length);
108
- const selectedSpan = liveSpans[selectedIndex];
109
- const labelFor = (span) => labels.get(span.id) ?? span.name;
110
- const activeTab = ui.runStepPaneTab;
111
- useInput((input, key) => {
112
- if (key.escape) {
113
- ui.closeStepGraph();
114
- return;
115
- }
116
- if (key.upArrow) {
117
- selectPrevious();
118
- return;
119
- }
120
- if (key.downArrow) {
121
- selectNext();
122
- return;
123
- }
124
- if (key.leftArrow || key.rightArrow) {
125
- ui.setRunStepPaneTab(cycleValue(PANE_ORDER, activeTab, key.rightArrow ? 1 : -1));
126
- return;
127
- }
128
- if (input === 'e' && selectedSpan) {
129
- ui.openExpandedJson(spanPaneValue(selectedSpan, activeTab), `step: ${labelFor(selectedSpan)} → ${activeTab}`);
130
- }
131
- }, { isActive: ui.stepGraph.open && !ui.expandedJson.open });
132
- const width = Math.max(MIN_WIDTH, (stdout?.columns ?? FALLBACK_COLS) - FRAME_WIDTH_OVERHEAD);
133
- const trackW = Math.max(MIN_TRACK, width - COL.sel - COL.label - COL.duration);
134
- const contentRows = Math.max(1, height - getModalFrameHeight() - META_ROWS);
135
- const renderContent = () => {
136
- if (error) {
137
- return _jsxs(Text, { color: "red", wrap: "truncate-end", children: ["Failed to load history: ", error] });
138
- }
139
- if (loading && liveSpans.length === 0) {
140
- return _jsx(LoadingSpinner, { label: "Loading step graph..." });
141
- }
142
- if (liveSpans.length === 0) {
143
- return _jsx(Text, { dimColor: true, children: "No steps recorded for this run." });
144
- }
145
- return (_jsx(MasterDetailPanel, { items: liveSpans, selectedIndex: selectedIndex, height: contentRows, visibleRows: VISIBLE_ROWS, renderHeader: () => _jsx(RulerRow, { trackW: trackW, totalMs: liveTotalMs }), renderRow: (span, selected) => (_jsx(SpanRow, { span: span, label: labelFor(span), selected: selected, trackW: trackW, totalMs: liveTotalMs })), rowKey: span => span.id, detail: ({ detailRows }) => (_jsx(SpanDetail, { span: selectedSpan, activeTab: activeTab, label: selectedSpan ? labelFor(selectedSpan) : '', rows: detailRows })) }));
146
- };
147
- return (_jsx(ModalFrame, { title: formatContentTitle([`Workflow "${run.workflowType}"`, 'Step graph']), titleRight: _jsx(Text, { dimColor: true, children: formatDurationLabel(liveTotalMs) }), shortcuts: STEP_GRAPH_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { columnGap: 2, marginBottom: 1, children: [_jsx(Text, { color: workflowStatusColor(status), bold: true, children: status }), _jsxs(Text, { dimColor: true, children: ["started ", formatStartedShort(run.startedAt)] })] }), renderContent()] }) }));
148
- };