@outputai/cli 0.8.2-next.edf06bb.0 → 0.9.1-dev.000c5f3.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/dist/api/generated/api.d.ts +79 -0
- package/dist/api/generated/api.js +32 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/history.d.ts +19 -0
- package/dist/commands/workflow/history.js +99 -0
- package/dist/commands/workflow/history.spec.d.ts +1 -0
- package/dist/commands/workflow/history.spec.js +26 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/workflow_history/correlator.d.ts +26 -0
- package/dist/services/workflow_history/correlator.js +261 -0
- package/dist/services/workflow_history/correlator.spec.d.ts +1 -0
- package/dist/services/workflow_history/correlator.spec.js +116 -0
- package/dist/services/workflow_history.d.ts +23 -0
- package/dist/services/workflow_history.js +79 -0
- package/dist/services/workflow_history.spec.d.ts +1 -0
- package/dist/services/workflow_history.spec.js +87 -0
- package/dist/utils/span_labels.d.ts +11 -0
- package/dist/utils/span_labels.js +22 -0
- package/dist/utils/span_labels.spec.d.ts +1 -0
- package/dist/utils/span_labels.spec.js +36 -0
- package/dist/utils/waterfall.d.ts +31 -0
- package/dist/utils/waterfall.js +192 -0
- package/dist/utils/waterfall.spec.d.ts +1 -0
- package/dist/utils/waterfall.spec.js +100 -0
- package/dist/views/dev/dev_app.js +17 -3
- package/dist/views/dev/hooks/use_step_graph.d.ts +22 -0
- package/dist/views/dev/hooks/use_step_graph.js +76 -0
- package/dist/views/dev/modals/run_modal.d.ts +9 -0
- package/dist/views/dev/modals/run_modal.js +56 -56
- package/dist/views/dev/modals/run_modal.spec.d.ts +1 -0
- package/dist/views/dev/modals/run_modal.spec.js +34 -0
- package/dist/views/dev/modals/step_graph_modal.d.ts +6 -0
- package/dist/views/dev/modals/step_graph_modal.js +148 -0
- package/dist/views/dev/modals/steps_modal.js +1 -1
- package/dist/views/dev/panels/help_panel.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +7 -2
- package/dist/views/dev/panels/workflows_panel.js +1 -1
- package/dist/views/dev/state/ui_state.d.ts +8 -0
- package/dist/views/dev/state/ui_state.js +5 -1
- package/dist/views/dev/utils/json_editor.d.ts +2 -0
- package/dist/views/dev/utils/json_editor.js +28 -15
- package/dist/views/dev/utils/json_editor.spec.js +16 -1
- package/oclif.manifest.json +81 -1
- package/package.json +4 -4
- /package/dist/views/dev/utils/{constants.d.ts → ui_constants.d.ts} +0 -0
- /package/dist/views/dev/utils/{constants.js → ui_constants.js} +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { correlate } from './correlator.js';
|
|
3
|
+
const T0 = Date.parse('2026-06-22T12:00:00.000Z');
|
|
4
|
+
const at = (seconds) => new Date(T0 + (seconds * 1000)).toISOString();
|
|
5
|
+
// Minimal Temporal-shaped event builders.
|
|
6
|
+
const scheduled = (id, name, sec, input) => ({
|
|
7
|
+
eventId: id,
|
|
8
|
+
eventTypeName: 'ACTIVITY_TASK_SCHEDULED',
|
|
9
|
+
eventTime: at(sec),
|
|
10
|
+
activityTaskScheduledEventAttributes: { activityType: { name }, activityId: `act-${id}`, input }
|
|
11
|
+
});
|
|
12
|
+
const started = (id, scheduledEventId, sec) => ({
|
|
13
|
+
eventId: id,
|
|
14
|
+
eventTypeName: 'ACTIVITY_TASK_STARTED',
|
|
15
|
+
eventTime: at(sec),
|
|
16
|
+
activityTaskStartedEventAttributes: { scheduledEventId, attempt: 1 }
|
|
17
|
+
});
|
|
18
|
+
const completed = (id, scheduledEventId, sec) => ({
|
|
19
|
+
eventId: id,
|
|
20
|
+
eventTypeName: 'ACTIVITY_TASK_COMPLETED',
|
|
21
|
+
eventTime: at(sec),
|
|
22
|
+
activityTaskCompletedEventAttributes: { scheduledEventId, result: { ok: true } }
|
|
23
|
+
});
|
|
24
|
+
const failed = (id, scheduledEventId, sec, message) => ({
|
|
25
|
+
eventId: id,
|
|
26
|
+
eventTypeName: 'ACTIVITY_TASK_FAILED',
|
|
27
|
+
eventTime: at(sec),
|
|
28
|
+
activityTaskFailedEventAttributes: { scheduledEventId, failure: { message } }
|
|
29
|
+
});
|
|
30
|
+
function buildEvents() {
|
|
31
|
+
return [
|
|
32
|
+
{ eventId: '1', eventTypeName: 'WORKFLOW_EXECUTION_STARTED', eventTime: at(0), workflowExecutionStartedEventAttributes: {} },
|
|
33
|
+
// Sequential completed activity (0s → 27s)
|
|
34
|
+
scheduled('2', 'contentBrief#compressText', 0, { text: 'x' }),
|
|
35
|
+
started('3', '2', 0),
|
|
36
|
+
completed('4', '2', 27),
|
|
37
|
+
// Parallel fan-out of the same step: one completes (1s), one fails (8s)
|
|
38
|
+
scheduled('5', 'contentBrief#scrapeSerpPage', 30),
|
|
39
|
+
scheduled('6', 'contentBrief#scrapeSerpPage', 30),
|
|
40
|
+
started('7', '5', 30),
|
|
41
|
+
started('8', '6', 30),
|
|
42
|
+
completed('9', '5', 31),
|
|
43
|
+
failed('10', '6', 38, 'boom'),
|
|
44
|
+
// Noise steps — must be filtered out
|
|
45
|
+
scheduled('11', '$shared#callWebhook', 35),
|
|
46
|
+
started('11s', '11', 35),
|
|
47
|
+
scheduled('12', '__internal#resolveTrace', 35),
|
|
48
|
+
// Pending activity (scheduled, never started)
|
|
49
|
+
scheduled('13', 'contentBrief#generateBrief', 40),
|
|
50
|
+
// Child workflow (initiatedEventId arrives as a protobuf Long struct)
|
|
51
|
+
{
|
|
52
|
+
eventId: '20', eventTypeName: 'START_CHILD_WORKFLOW_EXECUTION_INITIATED', eventTime: at(50),
|
|
53
|
+
startChildWorkflowExecutionInitiatedEventAttributes: { workflowType: { name: 'subWorkflow' } }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
eventId: '21', eventTypeName: 'CHILD_WORKFLOW_EXECUTION_STARTED', eventTime: at(50),
|
|
57
|
+
childWorkflowExecutionStartedEventAttributes: { initiatedEventId: { low: 20, high: 0, unsigned: false } }
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
eventId: '22', eventTypeName: 'CHILD_WORKFLOW_EXECUTION_COMPLETED', eventTime: at(60),
|
|
61
|
+
childWorkflowExecutionCompletedEventAttributes: { initiatedEventId: { low: 20, high: 0, unsigned: false }, result: { done: true } }
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
describe('correlate', () => {
|
|
66
|
+
const spans = correlate(buildEvents(), T0);
|
|
67
|
+
const byId = (id) => spans.find(s => s.id === id);
|
|
68
|
+
it('filters framework noise steps', () => {
|
|
69
|
+
expect(spans.every(s => !s.technicalName.startsWith('__internal#'))).toBe(true);
|
|
70
|
+
expect(spans.some(s => s.technicalName === '$shared#callWebhook')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('produces one span per real activity plus the child workflow', () => {
|
|
73
|
+
expect(spans).toHaveLength(5);
|
|
74
|
+
});
|
|
75
|
+
it('returns spans sorted by start offset', () => {
|
|
76
|
+
const offsets = spans.map(s => s.startOffsetMs);
|
|
77
|
+
expect(offsets).toEqual([...offsets].sort((a, b) => a - b));
|
|
78
|
+
});
|
|
79
|
+
it('correlates a completed activity with humanized name, offset, and duration', () => {
|
|
80
|
+
const compress = byId('2');
|
|
81
|
+
expect(compress.name).toBe('Compress Text');
|
|
82
|
+
expect(compress.status).toBe('completed');
|
|
83
|
+
expect(compress.startOffsetMs).toBe(0);
|
|
84
|
+
expect(compress.durationMs).toBe(27_000);
|
|
85
|
+
expect(compress.attempt).toBe(1);
|
|
86
|
+
expect(compress.kind).toBe('activity');
|
|
87
|
+
});
|
|
88
|
+
it('distinguishes parallel instances of the same step', () => {
|
|
89
|
+
const ok = byId('5');
|
|
90
|
+
const bad = byId('6');
|
|
91
|
+
expect(ok.name).toBe('Scrape Serp Page');
|
|
92
|
+
expect(ok.status).toBe('completed');
|
|
93
|
+
expect(ok.startOffsetMs).toBe(30_000);
|
|
94
|
+
expect(ok.durationMs).toBe(1_000);
|
|
95
|
+
expect(bad.status).toBe('failed');
|
|
96
|
+
expect(bad.failureMessage).toBe('boom');
|
|
97
|
+
expect(bad.durationMs).toBe(8_000);
|
|
98
|
+
});
|
|
99
|
+
it('marks a scheduled-only activity pending with a zero-duration span at its scheduled offset', () => {
|
|
100
|
+
const pending = byId('13');
|
|
101
|
+
expect(pending.status).toBe('pending');
|
|
102
|
+
expect(pending.startOffsetMs).toBe(40_000);
|
|
103
|
+
expect(pending.durationMs).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
it('correlates a child workflow via a Long-struct initiatedEventId', () => {
|
|
106
|
+
const child = byId('child-20');
|
|
107
|
+
expect(child.name).toBe('Sub Workflow');
|
|
108
|
+
expect(child.kind).toBe('child_workflow');
|
|
109
|
+
expect(child.status).toBe('completed');
|
|
110
|
+
expect(child.startOffsetMs).toBe(50_000);
|
|
111
|
+
expect(child.durationMs).toBe(10_000);
|
|
112
|
+
});
|
|
113
|
+
it('is robust to an empty event list', () => {
|
|
114
|
+
expect(correlate([], T0)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type HistoryEvent, type Span } from '#services/workflow_history/correlator.js';
|
|
2
|
+
export interface WorkflowMeta {
|
|
3
|
+
workflowId?: string;
|
|
4
|
+
runId?: string;
|
|
5
|
+
status?: string;
|
|
6
|
+
startTime?: string;
|
|
7
|
+
closeTime?: string | null;
|
|
8
|
+
historyLength?: number;
|
|
9
|
+
taskQueue?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface FetchWorkflowHistoryOptions {
|
|
12
|
+
workflowId: string;
|
|
13
|
+
runId?: string;
|
|
14
|
+
includePayloads?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface WorkflowHistoryResult {
|
|
17
|
+
workflow: WorkflowMeta | null;
|
|
18
|
+
runId: string | null;
|
|
19
|
+
events: HistoryEvent[];
|
|
20
|
+
spans: Span[];
|
|
21
|
+
totalDurationMs: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function fetchWorkflowHistory(options: FetchWorkflowHistoryOptions): Promise<WorkflowHistoryResult>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches a workflow run's full Temporal history from the API and correlates the
|
|
3
|
+
* flat event stream into spans (one per step) the CLI can render as a waterfall.
|
|
4
|
+
*
|
|
5
|
+
* Pages through `GET /workflow/{id}/history` (mirroring Atlas's
|
|
6
|
+
* OutputWorkflows::WorkflowHistory): the first page carries the workflow
|
|
7
|
+
* metadata and resolves the run ID; subsequent pages echo that run ID alongside
|
|
8
|
+
* the `nextPageToken` (the endpoint requires runId once a pageToken is used).
|
|
9
|
+
*/
|
|
10
|
+
import { getWorkflowIdHistory } from '#api/generated/api.js';
|
|
11
|
+
import { correlate } from '#services/workflow_history/correlator.js';
|
|
12
|
+
const PAGE_SIZE = 50;
|
|
13
|
+
function toMs(value) {
|
|
14
|
+
if (!value) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const ms = Date.parse(String(value));
|
|
18
|
+
return Number.isNaN(ms) ? null : ms;
|
|
19
|
+
}
|
|
20
|
+
function earliestEventMs(events) {
|
|
21
|
+
const times = events
|
|
22
|
+
.map(e => toMs(e.eventTime))
|
|
23
|
+
.filter((ms) => ms !== null);
|
|
24
|
+
return times.length > 0 ? Math.min(...times) : null;
|
|
25
|
+
}
|
|
26
|
+
// Timeline origin (0 offset): the workflow's start time, falling back to the
|
|
27
|
+
// WORKFLOW_EXECUTION_STARTED event, then the earliest event seen.
|
|
28
|
+
function workflowStartMs(meta, events) {
|
|
29
|
+
const fromMeta = toMs(meta?.startTime);
|
|
30
|
+
if (fromMeta !== null) {
|
|
31
|
+
return fromMeta;
|
|
32
|
+
}
|
|
33
|
+
const started = events.find(e => e.eventTypeName === 'WORKFLOW_EXECUTION_STARTED');
|
|
34
|
+
const fromStarted = toMs(started?.eventTime);
|
|
35
|
+
if (fromStarted !== null) {
|
|
36
|
+
return fromStarted;
|
|
37
|
+
}
|
|
38
|
+
return earliestEventMs(events);
|
|
39
|
+
}
|
|
40
|
+
function totalDuration(meta, spans, startMs) {
|
|
41
|
+
const closeMs = toMs(meta?.closeTime ?? undefined);
|
|
42
|
+
if (closeMs !== null && startMs !== null && (closeMs - startMs) > 0) {
|
|
43
|
+
return closeMs - startMs;
|
|
44
|
+
}
|
|
45
|
+
const maxEnd = spans.reduce((max, span) => Math.max(max, span.endOffsetMs), 0);
|
|
46
|
+
return Math.max(maxEnd, 1);
|
|
47
|
+
}
|
|
48
|
+
async function fetchAllPages(workflowId, includePayloads, runId, pageToken, acc) {
|
|
49
|
+
const response = await getWorkflowIdHistory(workflowId, { runId, pageSize: PAGE_SIZE, pageToken, includePayloads });
|
|
50
|
+
if (!response.data) {
|
|
51
|
+
throw new Error('API returned invalid response (missing data)');
|
|
52
|
+
}
|
|
53
|
+
const data = response.data;
|
|
54
|
+
// The generated `data.workflow` is an opaque `{ [key: string]: unknown }`, so
|
|
55
|
+
// narrow it to WorkflowMeta via `unknown` (its real fields are validated by
|
|
56
|
+
// the server, mirroring Atlas's metadata shape).
|
|
57
|
+
const meta = acc.meta ?? data.workflow ?? null;
|
|
58
|
+
const resolvedRunId = runId ?? data.runId ?? acc.runId;
|
|
59
|
+
const events = [...acc.events, ...(data.events ?? [])];
|
|
60
|
+
const nextToken = data.nextPageToken ?? undefined;
|
|
61
|
+
const nextAcc = { meta, runId: resolvedRunId, events };
|
|
62
|
+
if (nextToken) {
|
|
63
|
+
return fetchAllPages(workflowId, includePayloads, resolvedRunId, nextToken, nextAcc);
|
|
64
|
+
}
|
|
65
|
+
return nextAcc;
|
|
66
|
+
}
|
|
67
|
+
export async function fetchWorkflowHistory(options) {
|
|
68
|
+
const { workflowId, runId, includePayloads = false } = options;
|
|
69
|
+
const { meta, runId: resolvedRunId, events } = await fetchAllPages(workflowId, includePayloads, runId, undefined, { meta: null, runId, events: [] });
|
|
70
|
+
const startMs = workflowStartMs(meta, events);
|
|
71
|
+
const spans = correlate(events, startMs);
|
|
72
|
+
return {
|
|
73
|
+
workflow: meta,
|
|
74
|
+
runId: resolvedRunId ?? meta?.runId ?? null,
|
|
75
|
+
events,
|
|
76
|
+
spans,
|
|
77
|
+
totalDurationMs: totalDuration(meta, spans, startMs)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { getWorkflowIdHistory } from '#api/generated/api.js';
|
|
3
|
+
import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
4
|
+
vi.mock('#api/generated/api.js', () => ({ getWorkflowIdHistory: vi.fn() }));
|
|
5
|
+
// The real correlator runs (only the API client is mocked), so spans are derived
|
|
6
|
+
// from the events below — letting us assert duration/offset behaviour end to end.
|
|
7
|
+
const mockGet = getWorkflowIdHistory;
|
|
8
|
+
const T0 = Date.parse('2026-06-22T12:00:00.000Z');
|
|
9
|
+
const at = (seconds) => new Date(T0 + (seconds * 1000)).toISOString();
|
|
10
|
+
const page = (data) => ({ status: 200, data });
|
|
11
|
+
const started = (id, scheduledEventId, sec) => ({
|
|
12
|
+
eventId: id, eventTypeName: 'ACTIVITY_TASK_STARTED', eventTime: at(sec),
|
|
13
|
+
activityTaskStartedEventAttributes: { scheduledEventId, attempt: 1 }
|
|
14
|
+
});
|
|
15
|
+
const completed = (id, scheduledEventId, sec) => ({
|
|
16
|
+
eventId: id, eventTypeName: 'ACTIVITY_TASK_COMPLETED', eventTime: at(sec),
|
|
17
|
+
activityTaskCompletedEventAttributes: { scheduledEventId }
|
|
18
|
+
});
|
|
19
|
+
const scheduled = (id, name, sec) => ({
|
|
20
|
+
eventId: id, eventTypeName: 'ACTIVITY_TASK_SCHEDULED', eventTime: at(sec),
|
|
21
|
+
activityTaskScheduledEventAttributes: { activityType: { name }, activityId: `act-${id}` }
|
|
22
|
+
});
|
|
23
|
+
const workflowStarted = (sec) => ({ eventId: '1', eventTypeName: 'WORKFLOW_EXECUTION_STARTED', eventTime: at(sec) });
|
|
24
|
+
beforeEach(() => mockGet.mockReset());
|
|
25
|
+
describe('fetchWorkflowHistory', () => {
|
|
26
|
+
it('pages through results, pinning the resolved runId after the first page', async () => {
|
|
27
|
+
mockGet
|
|
28
|
+
.mockResolvedValueOnce(page({
|
|
29
|
+
workflow: { workflowId: 'wf-123', runId: 'run-456', status: 'completed', startTime: at(0), closeTime: at(300) },
|
|
30
|
+
runId: 'run-456',
|
|
31
|
+
events: [workflowStarted(0), scheduled('2', 'contentBrief#compressText', 0)],
|
|
32
|
+
nextPageToken: 'token-2'
|
|
33
|
+
}))
|
|
34
|
+
.mockResolvedValueOnce(page({
|
|
35
|
+
workflow: null,
|
|
36
|
+
runId: 'run-456',
|
|
37
|
+
events: [started('3', '2', 0), completed('4', '2', 27)],
|
|
38
|
+
nextPageToken: null
|
|
39
|
+
}));
|
|
40
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-123' });
|
|
41
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
42
|
+
expect(mockGet).toHaveBeenNthCalledWith(1, 'wf-123', { runId: undefined, pageSize: 50, pageToken: undefined, includePayloads: false });
|
|
43
|
+
// Page 2 echoes the runId the first page resolved (the endpoint requires it once a pageToken is used).
|
|
44
|
+
expect(mockGet).toHaveBeenNthCalledWith(2, 'wf-123', { runId: 'run-456', pageSize: 50, pageToken: 'token-2', includePayloads: false });
|
|
45
|
+
expect(result.runId).toBe('run-456');
|
|
46
|
+
expect(result.workflow?.workflowId).toBe('wf-123'); // metadata is taken from the first page only
|
|
47
|
+
expect(result.events).toHaveLength(4);
|
|
48
|
+
expect(result.totalDurationMs).toBe(300_000); // closeTime - startTime
|
|
49
|
+
expect(result.spans).toHaveLength(1);
|
|
50
|
+
expect(result.spans[0].durationMs).toBe(27_000);
|
|
51
|
+
});
|
|
52
|
+
it('forwards an explicit runId and includePayloads, stopping after a single page', async () => {
|
|
53
|
+
mockGet.mockResolvedValueOnce(page({
|
|
54
|
+
workflow: { workflowId: 'wf-9', runId: 'run-1', status: 'running', startTime: at(0) },
|
|
55
|
+
runId: 'run-1', events: [], nextPageToken: null
|
|
56
|
+
}));
|
|
57
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-9', runId: 'run-1', includePayloads: true });
|
|
58
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(mockGet).toHaveBeenCalledWith('wf-9', { runId: 'run-1', pageSize: 50, pageToken: undefined, includePayloads: true });
|
|
60
|
+
expect(result.runId).toBe('run-1');
|
|
61
|
+
});
|
|
62
|
+
it('falls back to the last span end for total duration when there is no closeTime', async () => {
|
|
63
|
+
mockGet.mockResolvedValueOnce(page({
|
|
64
|
+
workflow: { workflowId: 'wf-2', runId: 'run-2', status: 'running', startTime: at(0) },
|
|
65
|
+
runId: 'run-2',
|
|
66
|
+
events: [scheduled('2', 'wf#step', 0), started('3', '2', 0), completed('4', '2', 12)],
|
|
67
|
+
nextPageToken: null
|
|
68
|
+
}));
|
|
69
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-2' });
|
|
70
|
+
expect(result.totalDurationMs).toBe(12_000);
|
|
71
|
+
});
|
|
72
|
+
it('derives the timeline origin from WORKFLOW_EXECUTION_STARTED when metadata lacks a startTime', async () => {
|
|
73
|
+
mockGet.mockResolvedValueOnce(page({
|
|
74
|
+
workflow: { workflowId: 'wf-3', runId: 'run-3', status: 'completed' }, // no startTime / closeTime
|
|
75
|
+
runId: 'run-3',
|
|
76
|
+
events: [workflowStarted(0), scheduled('2', 'wf#step', 5), started('3', '2', 5), completed('4', '2', 10)],
|
|
77
|
+
nextPageToken: null
|
|
78
|
+
}));
|
|
79
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-3' });
|
|
80
|
+
expect(result.spans[0].startOffsetMs).toBe(5_000);
|
|
81
|
+
expect(result.totalDurationMs).toBe(10_000);
|
|
82
|
+
});
|
|
83
|
+
it('throws when the response has no data', async () => {
|
|
84
|
+
mockGet.mockResolvedValueOnce({ status: 200 });
|
|
85
|
+
await expect(fetchWorkflowHistory({ workflowId: 'wf-x' })).rejects.toThrow(/invalid response/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A step's name is per step-TYPE, so a fan-out of N identical activities (e.g.
|
|
3
|
+
* 9× "Scrape Serp Page") all share one label and become indistinguishable.
|
|
4
|
+
* Number the repeats in chronological order so each instance is addressable —
|
|
5
|
+
* "Scrape Serp Page #1", "#2", …. Labels that occur once are left untouched.
|
|
6
|
+
*
|
|
7
|
+
* Pass spans in their stored (chronological) order so #1 is the earliest. A
|
|
8
|
+
* TypeScript port of Atlas's `buildSpanLabels`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Span } from '#services/workflow_history/correlator.js';
|
|
11
|
+
export default function buildSpanLabels(spans: Span[]): Map<string, string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const baseLabel = (span) => span.description ?? span.name;
|
|
2
|
+
export default function buildSpanLabels(spans) {
|
|
3
|
+
const totals = new Map();
|
|
4
|
+
for (const span of spans) {
|
|
5
|
+
const label = baseLabel(span);
|
|
6
|
+
totals.set(label, (totals.get(label) ?? 0) + 1);
|
|
7
|
+
}
|
|
8
|
+
const seen = new Map();
|
|
9
|
+
const labels = new Map();
|
|
10
|
+
for (const span of spans) {
|
|
11
|
+
const label = baseLabel(span);
|
|
12
|
+
if ((totals.get(label) ?? 0) > 1) {
|
|
13
|
+
const n = (seen.get(label) ?? 0) + 1;
|
|
14
|
+
seen.set(label, n);
|
|
15
|
+
labels.set(span.id, `${label} #${n}`);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
labels.set(span.id, label);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return labels;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import buildSpanLabels from './span_labels.js';
|
|
3
|
+
const span = (id, name) => ({
|
|
4
|
+
id,
|
|
5
|
+
name,
|
|
6
|
+
technicalName: name,
|
|
7
|
+
description: null,
|
|
8
|
+
status: 'completed',
|
|
9
|
+
kind: 'activity',
|
|
10
|
+
attempt: 1,
|
|
11
|
+
startedAt: null,
|
|
12
|
+
scheduledAt: null,
|
|
13
|
+
completedAt: null,
|
|
14
|
+
startOffsetMs: 0,
|
|
15
|
+
endOffsetMs: 0,
|
|
16
|
+
durationMs: 0,
|
|
17
|
+
failureMessage: null
|
|
18
|
+
});
|
|
19
|
+
describe('buildSpanLabels', () => {
|
|
20
|
+
it('numbers repeated labels chronologically and leaves unique labels untouched', () => {
|
|
21
|
+
const spans = [
|
|
22
|
+
span('a', 'Compress Text'),
|
|
23
|
+
span('b', 'Scrape Serp Page'),
|
|
24
|
+
span('c', 'Scrape Serp Page'),
|
|
25
|
+
span('d', 'Scrape Serp Page')
|
|
26
|
+
];
|
|
27
|
+
const labels = buildSpanLabels(spans);
|
|
28
|
+
expect(labels.get('a')).toBe('Compress Text');
|
|
29
|
+
expect(labels.get('b')).toBe('Scrape Serp Page #1');
|
|
30
|
+
expect(labels.get('c')).toBe('Scrape Serp Page #2');
|
|
31
|
+
expect(labels.get('d')).toBe('Scrape Serp Page #3');
|
|
32
|
+
});
|
|
33
|
+
it('returns an empty map for no spans', () => {
|
|
34
|
+
expect(buildSpanLabels([]).size).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
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;
|
|
@@ -0,0 +1,192 @@
|
|
|
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: '[92m',
|
|
17
|
+
running: '[33m',
|
|
18
|
+
failed: '[31m',
|
|
19
|
+
pending: '[90m',
|
|
20
|
+
dim: '[2m',
|
|
21
|
+
reset: '[0m'
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|