@outputai/cli 0.10.1-next.37650c5.0 → 0.10.1-next.52bedcf.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/api/generated/api.d.ts +12 -0
  2. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  3. package/dist/commands/dev/index.js +57 -6
  4. package/dist/commands/dev/index.spec.js +55 -0
  5. package/dist/commands/workflow/history.js +3 -3
  6. package/dist/commands/workflow/history.spec.js +31 -2
  7. package/dist/commands/workflow/monitor.d.ts +49 -0
  8. package/dist/commands/workflow/monitor.js +230 -0
  9. package/dist/commands/workflow/monitor.spec.d.ts +1 -0
  10. package/dist/commands/workflow/monitor.spec.js +243 -0
  11. package/dist/generated/framework_version.json +1 -1
  12. package/dist/services/workflow_history/correlator.d.ts +2 -0
  13. package/dist/services/workflow_history/correlator.js +2 -2
  14. package/dist/services/workflow_history.d.ts +28 -0
  15. package/dist/services/workflow_history.js +95 -12
  16. package/dist/services/workflow_history.spec.js +183 -1
  17. package/dist/utils/color.d.ts +7 -0
  18. package/dist/utils/color.js +12 -0
  19. package/dist/utils/color.spec.d.ts +1 -0
  20. package/dist/utils/color.spec.js +43 -0
  21. package/dist/utils/format_workflow_result.d.ts +1 -0
  22. package/dist/utils/format_workflow_result.js +4 -0
  23. package/dist/utils/monitor_log.d.ts +20 -0
  24. package/dist/utils/monitor_log.js +48 -0
  25. package/dist/utils/monitor_log.spec.d.ts +1 -0
  26. package/dist/utils/monitor_log.spec.js +71 -0
  27. package/dist/utils/waterfall.d.ts +3 -1
  28. package/dist/utils/waterfall.js +8 -2
  29. package/dist/views/dev/hooks/use_run_detail.js +7 -8
  30. package/dist/views/dev/hooks/use_step_graph.js +3 -1
  31. package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
  32. package/dist/views/dev/utils/bounded_cache.js +42 -0
  33. package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
  34. package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
  35. package/oclif.manifest.json +75 -1
  36. package/package.json +4 -4
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { getWorkflowIdHistory } from '#api/generated/api.js';
3
- import { fetchWorkflowHistory } from '#services/workflow_history.js';
3
+ import { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } from '#services/workflow_history.js';
4
4
  vi.mock('#api/generated/api.js', () => ({ getWorkflowIdHistory: vi.fn() }));
5
5
  // The real correlator runs (only the API client is mocked), so spans are derived
6
6
  // from the events below — letting us assert duration/offset behaviour end to end.
@@ -48,6 +48,39 @@ describe('fetchWorkflowHistory', () => {
48
48
  expect(result.totalDurationMs).toBe(300_000); // closeTime - startTime
49
49
  expect(result.spans).toHaveLength(1);
50
50
  expect(result.spans[0].durationMs).toBe(27_000);
51
+ // Even though the server's own nextPageToken for the final page is empty (nothing more
52
+ // buffered), the cursor keeps the *request* token that reached it — a genuinely resumable
53
+ // position for a follow-up fetchWorkflowHistoryUpdates call. See fetchPages.
54
+ expect(result.cursor.pageToken).toBe('token-2');
55
+ });
56
+ it('a follow-up fetchWorkflowHistoryUpdates call resumes from fetchWorkflowHistory\'s cursor instead of re-paging from page 1', async () => {
57
+ mockGet
58
+ .mockResolvedValueOnce(page({
59
+ workflow: { workflowId: 'wf-123', runId: 'run-456', status: 'running', startTime: at(0) },
60
+ runId: 'run-456',
61
+ events: [workflowStarted(0), scheduled('2', 'wf#step', 0)],
62
+ nextPageToken: 'token-2'
63
+ }))
64
+ .mockResolvedValueOnce(page({
65
+ workflow: null,
66
+ runId: 'run-456',
67
+ events: [started('3', '2', 0)],
68
+ nextPageToken: null
69
+ }));
70
+ const first = await fetchWorkflowHistory({ workflowId: 'wf-123' });
71
+ expect(mockGet).toHaveBeenCalledTimes(2);
72
+ // The next tick replays the last page (deduped) then finds nothing new and times out —
73
+ // one call, not a full re-walk from page 1 through both prior pages again.
74
+ mockGet.mockResolvedValueOnce(page({
75
+ workflow: null, runId: 'run-456', events: [], nextPageToken: 'token-2'
76
+ }));
77
+ const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-123', runId: 'run-456', longPollTimeoutMs: 2500 }, first.cursor);
78
+ expect(mockGet).toHaveBeenCalledTimes(3);
79
+ expect(mockGet).toHaveBeenNthCalledWith(3, 'wf-123', {
80
+ runId: 'run-456', pageSize: 50, pageToken: 'token-2', includePayloads: false, longPollTimeoutMs: 2500
81
+ });
82
+ expect(result.events).toHaveLength(3);
83
+ expect(cursor.pageToken).toBe('token-2');
51
84
  });
52
85
  it('forwards an explicit runId and includePayloads, stopping after a single page', async () => {
53
86
  mockGet.mockResolvedValueOnce(page({
@@ -84,4 +117,153 @@ describe('fetchWorkflowHistory', () => {
84
117
  mockGet.mockResolvedValueOnce({ status: 200 });
85
118
  await expect(fetchWorkflowHistory({ workflowId: 'wf-x' })).rejects.toThrow(/invalid response/);
86
119
  });
120
+ it('extracts the chained run id from a WORKFLOW_EXECUTION_CONTINUED_AS_NEW event', async () => {
121
+ mockGet.mockResolvedValueOnce(page({
122
+ workflow: { workflowId: 'wf-4', runId: 'run-4', status: 'continued_as_new', startTime: at(0) },
123
+ runId: 'run-4',
124
+ events: [
125
+ workflowStarted(0),
126
+ {
127
+ eventId: '9', eventTypeName: 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW', eventTime: at(60),
128
+ workflowExecutionContinuedAsNewEventAttributes: { newExecutionRunId: 'run-5' }
129
+ }
130
+ ],
131
+ nextPageToken: null
132
+ }));
133
+ const result = await fetchWorkflowHistory({ workflowId: 'wf-4' });
134
+ expect(result.continuedAsNewRunId).toBe('run-5');
135
+ });
136
+ it('returns null continuedAsNewRunId when there is no continue-as-new event', async () => {
137
+ mockGet.mockResolvedValueOnce(page({
138
+ workflow: { workflowId: 'wf-5', runId: 'run-5', status: 'completed', startTime: at(0), closeTime: at(1) },
139
+ runId: 'run-5', events: [workflowStarted(0)], nextPageToken: null
140
+ }));
141
+ const result = await fetchWorkflowHistory({ workflowId: 'wf-5' });
142
+ expect(result.continuedAsNewRunId).toBeNull();
143
+ });
144
+ });
145
+ describe('fetchWorkflowHistoryUpdates', () => {
146
+ it('stops as soon as the first hop finds new events, without draining further buffered pages', async () => {
147
+ // Two pages already exist beyond the resume point; the old behavior kept paging until a
148
+ // timeout, which could silently merge several transitions (even a terminal one) into a
149
+ // single delayed render. A poller needs each batch back as soon as it has something new.
150
+ mockGet.mockResolvedValueOnce(page({
151
+ workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
152
+ runId: 'run-7',
153
+ events: [workflowStarted(0)],
154
+ nextPageToken: 'page-2'
155
+ }));
156
+ const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7', longPollTimeoutMs: 2500 });
157
+ expect(mockGet).toHaveBeenCalledTimes(1);
158
+ expect(mockGet).toHaveBeenCalledWith('wf-7', {
159
+ runId: 'run-7', pageSize: 50, pageToken: undefined, includePayloads: false, longPollTimeoutMs: 2500
160
+ });
161
+ expect(result.events).toHaveLength(1);
162
+ expect(result.workflow?.status).toBe('running');
163
+ // Resumes from the still-unfetched page 2 next time, not from the start.
164
+ expect(cursor.pageToken).toBe('page-2');
165
+ expect(cursor.lastEventId).toBe(1);
166
+ });
167
+ it('drains buffered pages when the describe already reports the run closed, so the terminal events are not stranded', async () => {
168
+ // >1 page of events accumulated between polls, then the run completed: the fresh
169
+ // describe says 'completed' before the closing events have been paged in. Stopping
170
+ // after the first batch would make the poller act on the terminal status with the
171
+ // final steps' events unfetched.
172
+ mockGet
173
+ .mockResolvedValueOnce(page({
174
+ workflow: { workflowId: 'wf-8', runId: 'run-8', status: 'completed', startTime: at(0), closeTime: at(30) },
175
+ runId: 'run-8',
176
+ events: [workflowStarted(0), scheduled('2', 'wf#step', 0)],
177
+ nextPageToken: 'page-2'
178
+ }))
179
+ .mockResolvedValueOnce(page({
180
+ workflow: null,
181
+ runId: 'run-8',
182
+ events: [started('3', '2', 1), completed('4', '2', 30)],
183
+ nextPageToken: null
184
+ }));
185
+ const { result } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-8', runId: 'run-8' });
186
+ expect(mockGet).toHaveBeenCalledTimes(2);
187
+ expect(result.events).toHaveLength(4);
188
+ expect(result.spans).toHaveLength(1);
189
+ expect(result.spans[0].status).toBe('completed');
190
+ });
191
+ it('drains to the CONTINUED_AS_NEW event on a later page when the describe already reports continued_as_new', async () => {
192
+ mockGet
193
+ .mockResolvedValueOnce(page({
194
+ workflow: { workflowId: 'wf-9', runId: 'run-9', status: 'continued_as_new', startTime: at(0) },
195
+ runId: 'run-9',
196
+ events: [workflowStarted(0)],
197
+ nextPageToken: 'page-2'
198
+ }))
199
+ .mockResolvedValueOnce(page({
200
+ workflow: null,
201
+ runId: 'run-9',
202
+ events: [{
203
+ eventId: '2', eventTypeName: 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW', eventTime: at(60),
204
+ workflowExecutionContinuedAsNewEventAttributes: { newExecutionRunId: 'run-10' }
205
+ }],
206
+ nextPageToken: null
207
+ }));
208
+ const { result } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-9', runId: 'run-9' });
209
+ expect(mockGet).toHaveBeenCalledTimes(2);
210
+ expect(result.continuedAsNewRunId).toBe('run-10');
211
+ });
212
+ it('forwards longPollTimeoutMs to the API on a resumed poll', async () => {
213
+ mockGet.mockResolvedValueOnce(page({
214
+ workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
215
+ runId: 'run-7', events: [], nextPageToken: null
216
+ }));
217
+ await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7', longPollTimeoutMs: 2500 });
218
+ expect(mockGet).toHaveBeenCalledWith('wf-7', {
219
+ runId: 'run-7', pageSize: 50, pageToken: undefined, includePayloads: false, longPollTimeoutMs: 2500
220
+ });
221
+ });
222
+ it('sends no long-poll param when longPollTimeoutMs is not provided', async () => {
223
+ mockGet.mockResolvedValueOnce(page({
224
+ workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
225
+ runId: 'run-7', events: [], nextPageToken: null
226
+ }));
227
+ await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7' });
228
+ expect(mockGet).toHaveBeenCalledWith('wf-7', expect.not.objectContaining({ longPollTimeoutMs: expect.anything() }));
229
+ });
230
+ it('times out with the sent token echoed back when there is genuinely nothing new', async () => {
231
+ mockGet.mockResolvedValueOnce(page({
232
+ workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
233
+ runId: 'run-7',
234
+ events: [],
235
+ nextPageToken: null
236
+ }));
237
+ const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7' });
238
+ expect(mockGet).toHaveBeenCalledTimes(1);
239
+ expect(result.events).toHaveLength(0);
240
+ expect(cursor.pageToken).toBeUndefined();
241
+ });
242
+ it('resumes from a prior cursor and picks up the workflow status finishing, not the status frozen on the cursor', async () => {
243
+ const cursor = {
244
+ pageToken: 'token-2',
245
+ lastEventId: 2,
246
+ meta: { workflowId: 'wf-6', runId: 'run-6', status: 'running', startTime: at(0) },
247
+ runId: 'run-6',
248
+ events: [workflowStarted(0), scheduled('2', 'wf#step', 0)]
249
+ };
250
+ mockGet.mockResolvedValueOnce(page({
251
+ // The server re-describes on every `wait` call specifically so a resumed poll can see
252
+ // status changes — this must win over the (now-stale) status carried on the cursor.
253
+ workflow: { workflowId: 'wf-6', runId: 'run-6', status: 'completed', startTime: at(0), closeTime: at(5) },
254
+ runId: 'run-6',
255
+ events: [started('3', '2', 1), completed('4', '2', 5)],
256
+ nextPageToken: null
257
+ }));
258
+ const { result, cursor: nextCursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-6', runId: 'run-6', longPollTimeoutMs: 2500 }, cursor);
259
+ expect(mockGet).toHaveBeenCalledTimes(1);
260
+ expect(mockGet).toHaveBeenCalledWith('wf-6', {
261
+ runId: 'run-6', pageSize: 50, pageToken: 'token-2', includePayloads: false, longPollTimeoutMs: 2500
262
+ });
263
+ expect(result.workflow?.status).toBe('completed');
264
+ // The events already carried on the cursor (2) plus the genuinely new ones (2).
265
+ expect(result.events).toHaveLength(4);
266
+ expect(nextCursor.pageToken).toBe('token-2');
267
+ expect(nextCursor.lastEventId).toBe(4);
268
+ });
87
269
  });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Standard TTY/env precedence for whether a command should colorize its
3
+ * output: the command's own --color/--no-color flag wins first, then
4
+ * NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
5
+ * fall back to whether stdout is an interactive terminal.
6
+ */
7
+ export declare function shouldColorize(flag: boolean): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Standard TTY/env precedence for whether a command should colorize its
3
+ * output: the command's own --color/--no-color flag wins first, then
4
+ * NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
5
+ * fall back to whether stdout is an interactive terminal.
6
+ */
7
+ export function shouldColorize(flag) {
8
+ // Per the NO_COLOR convention (https://no-color.org/), presence disables color
9
+ // regardless of value — `NO_COLOR=` must opt out just like `NO_COLOR=1`.
10
+ return flag && process.env.NO_COLOR === undefined &&
11
+ (!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { shouldColorize } from '#utils/color.js';
3
+ describe('shouldColorize', () => {
4
+ const originalEnv = { ...process.env };
5
+ const originalTTY = process.stdout.isTTY;
6
+ beforeEach(() => {
7
+ delete process.env.NO_COLOR;
8
+ delete process.env.FORCE_COLOR;
9
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
10
+ });
11
+ afterEach(() => {
12
+ process.env = { ...originalEnv };
13
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalTTY, configurable: true });
14
+ });
15
+ it('returns false when the flag itself is false', () => {
16
+ expect(shouldColorize(false)).toBe(false);
17
+ });
18
+ it('returns true on a TTY with no overrides', () => {
19
+ expect(shouldColorize(true)).toBe(true);
20
+ });
21
+ it('disables color when NO_COLOR is set to a non-empty value', () => {
22
+ process.env.NO_COLOR = '1';
23
+ expect(shouldColorize(true)).toBe(false);
24
+ });
25
+ it('disables color when NO_COLOR is present but empty, per the NO_COLOR convention', () => {
26
+ process.env.NO_COLOR = '';
27
+ expect(shouldColorize(true)).toBe(false);
28
+ });
29
+ it('enables color off a TTY when FORCE_COLOR is set', () => {
30
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
31
+ process.env.FORCE_COLOR = '1';
32
+ expect(shouldColorize(true)).toBe(true);
33
+ });
34
+ it('disables color off a TTY with no FORCE_COLOR', () => {
35
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
36
+ expect(shouldColorize(true)).toBe(false);
37
+ });
38
+ it('NO_COLOR wins even when FORCE_COLOR is also set', () => {
39
+ process.env.FORCE_COLOR = '1';
40
+ process.env.NO_COLOR = '1';
41
+ expect(shouldColorize(true)).toBe(false);
42
+ });
43
+ });
@@ -1,5 +1,6 @@
1
1
  import type { WorkflowResultResponse, WorkflowResultResponseStatus } from '../api/generated/api.js';
2
2
  type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
3
3
  export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultResponseStatus | undefined>;
4
+ export declare const TERMINAL_STATUSES: ReadonlySet<string>;
4
5
  export declare function formatWorkflowResult(result: WorkflowResult): string;
5
6
  export {};
@@ -1,5 +1,9 @@
1
1
  import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
2
2
  export const ERROR_STATUSES = new Set(['failed', 'canceled', 'terminated', 'timed_out']);
3
+ // Every error status plus the one success status — derived so the two sets can't
4
+ // silently drift apart as error statuses evolve. Shared by `workflow monitor` and
5
+ // the dev TUI's `useRunDetail`/`useStepGraph` so both agree on what "done" means.
6
+ export const TERMINAL_STATUSES = new Set(['completed', ...ERROR_STATUSES]);
3
7
  export function formatWorkflowResult(result) {
4
8
  const status = normalizeWorkflowStatus(result.status);
5
9
  const lines = [
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Turns newly-correlated spans into append-only status lines for `workflow
3
+ * monitor`. Unlike the waterfall (which needs the full span set up front to
4
+ * lay out a time axis), a live monitor just reports each span's status
5
+ * transitions as they're observed on each poll.
6
+ */
7
+ import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
8
+ export interface SpanUpdate {
9
+ span: Span;
10
+ label: string;
11
+ }
12
+ /**
13
+ * Returns spans whose status changed since the last call. `seen` is mutated
14
+ * in place so callers can carry it across polls. Pending spans are skipped —
15
+ * nothing worth reporting until a step starts.
16
+ */
17
+ export declare function diffSpanUpdates(spans: Span[], labels: Map<string, string>, seen: Map<string, SpanStatus>): SpanUpdate[];
18
+ /** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
19
+ export declare function formatContinuedAsNew(runId: string): string;
20
+ export declare function formatSpanUpdate(update: SpanUpdate, color: boolean): string;
@@ -0,0 +1,48 @@
1
+ import { ANSI, formatDurationLabel, makeTint } from '#utils/waterfall.js';
2
+ const GLYPH = {
3
+ pending: '·',
4
+ running: '●',
5
+ completed: '✓',
6
+ failed: '✗'
7
+ };
8
+ // Continue-as-new is a workflow-level transition, not a span status, so it gets its own glyph
9
+ // here in the formatting layer rather than being concatenated into the message at the call site.
10
+ const CONTINUED_AS_NEW_GLYPH = '↻';
11
+ /**
12
+ * Returns spans whose status changed since the last call. `seen` is mutated
13
+ * in place so callers can carry it across polls. Pending spans are skipped —
14
+ * nothing worth reporting until a step starts.
15
+ */
16
+ export function diffSpanUpdates(spans, labels, seen) {
17
+ const updates = [];
18
+ for (const span of spans) {
19
+ if (span.status === 'pending' || seen.get(span.id) === span.status) {
20
+ continue;
21
+ }
22
+ seen.set(span.id, span.status);
23
+ updates.push({ span, label: labels.get(span.id) ?? span.name });
24
+ }
25
+ return updates;
26
+ }
27
+ /** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
28
+ export function formatContinuedAsNew(runId) {
29
+ return `${CONTINUED_AS_NEW_GLYPH} continued as new run ${runId}`;
30
+ }
31
+ export function formatSpanUpdate(update, color) {
32
+ const { span, label } = update;
33
+ const glyph = GLYPH[span.status];
34
+ const tint = makeTint(color);
35
+ const tintStatus = (text) => tint(text, ANSI[span.status]);
36
+ switch (span.status) {
37
+ case 'running':
38
+ return `${tintStatus(glyph)} ${label} running…`;
39
+ case 'completed':
40
+ return `${tintStatus(glyph)} ${label} ${formatDurationLabel(Math.max(0, span.durationMs))}`;
41
+ case 'failed': {
42
+ const reason = span.failureMessage ? `: ${span.failureMessage}` : '';
43
+ return `${tintStatus(glyph)} ${label} failed${reason}`;
44
+ }
45
+ default:
46
+ return `${glyph} ${label} ${span.status}`;
47
+ }
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { diffSpanUpdates, formatSpanUpdate } from '#utils/monitor_log.js';
3
+ const span = (overrides) => ({
4
+ name: 'Step',
5
+ technicalName: 'wf#step',
6
+ description: null,
7
+ kind: 'activity',
8
+ attempt: 1,
9
+ startedAt: null,
10
+ scheduledAt: null,
11
+ completedAt: null,
12
+ startOffsetMs: 0,
13
+ endOffsetMs: 0,
14
+ durationMs: 0,
15
+ failureMessage: null,
16
+ ...overrides
17
+ });
18
+ describe('diffSpanUpdates', () => {
19
+ it('skips pending spans', () => {
20
+ const seen = new Map();
21
+ const updates = diffSpanUpdates([span({ id: '1', status: 'pending' })], new Map(), seen);
22
+ expect(updates).toHaveLength(0);
23
+ expect(seen.size).toBe(0);
24
+ });
25
+ it('reports a span the first time it is seen in a non-pending status', () => {
26
+ const seen = new Map();
27
+ const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map([['1', 'Fetch page']]), seen);
28
+ expect(updates).toHaveLength(1);
29
+ expect(updates[0].label).toBe('Fetch page');
30
+ expect(seen.get('1')).toBe('running');
31
+ });
32
+ it('does not re-report a span whose status is unchanged since the last call', () => {
33
+ const seen = new Map([['1', 'running']]);
34
+ const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map(), seen);
35
+ expect(updates).toHaveLength(0);
36
+ });
37
+ it('reports a span again once its status transitions (running -> completed)', () => {
38
+ const seen = new Map([['1', 'running']]);
39
+ const updates = diffSpanUpdates([span({ id: '1', status: 'completed' })], new Map(), seen);
40
+ expect(updates).toHaveLength(1);
41
+ expect(seen.get('1')).toBe('completed');
42
+ });
43
+ it('falls back to the span name when no label is provided', () => {
44
+ const seen = new Map();
45
+ const updates = diffSpanUpdates([span({ id: '1', status: 'running', name: 'Unlabeled' })], new Map(), seen);
46
+ expect(updates[0].label).toBe('Unlabeled');
47
+ });
48
+ });
49
+ describe('formatSpanUpdate', () => {
50
+ it('formats a running span', () => {
51
+ const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, false);
52
+ expect(line).toBe('● Fetch page running…');
53
+ });
54
+ it('formats a completed span with its duration', () => {
55
+ const line = formatSpanUpdate({ span: span({ id: '1', status: 'completed', durationMs: 1234 }), label: 'Fetch page' }, false);
56
+ expect(line).toBe('✓ Fetch page 1s');
57
+ });
58
+ it('formats a failed span with its failure message', () => {
59
+ const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed', failureMessage: 'boom' }), label: 'Fetch page' }, false);
60
+ expect(line).toBe('✗ Fetch page failed: boom');
61
+ });
62
+ it('formats a failed span without a failure message', () => {
63
+ const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed' }), label: 'Fetch page' }, false);
64
+ expect(line).toBe('✗ Fetch page failed');
65
+ });
66
+ it('wraps the glyph in ANSI codes when color is enabled', () => {
67
+ const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, true);
68
+ expect(line).toContain('●');
69
+ expect(line).not.toBe('● Fetch page running…'); // color codes present
70
+ });
71
+ });
@@ -8,7 +8,7 @@
8
8
  * Atlas's `StepGantt`; the bar geometry mirrors its leftPct/widthPct as integer
9
9
  * terminal columns.
10
10
  */
11
- import type { Span } from '#services/workflow_history/correlator.js';
11
+ import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
12
12
  export interface WaterfallOptions {
13
13
  width: number;
14
14
  color: boolean;
@@ -22,6 +22,8 @@ export interface BarGeometry {
22
22
  }
23
23
  export declare const FULL_BLOCK = "\u2588";
24
24
  export declare const THIN_BLOCK = "\u258F";
25
+ export declare const ANSI: Record<SpanStatus | 'dim' | 'reset', string>;
26
+ export declare function makeTint(color: boolean): (text: string, code: string) => string;
25
27
  export declare function pickTickStep(totalMs: number): number;
26
28
  export declare function buildTicks(totalMs: number): number[];
27
29
  export declare function formatTickLabel(ms: number): string;
@@ -12,7 +12,7 @@ const TICK_STEPS_MS = [
12
12
  60 * 60_000, 2 * 60 * 60_000, 6 * 60 * 60_000, 12 * 60 * 60_000, 24 * 60 * 60_000
13
13
  ];
14
14
  const TARGET_TICKS = 8;
15
- const ANSI = {
15
+ export const ANSI = {
16
16
  completed: '',
17
17
  running: '',
18
18
  failed: '',
@@ -20,6 +20,12 @@ const ANSI = {
20
20
  dim: '',
21
21
  reset: ''
22
22
  };
23
+ // Shared by renderWaterfall and monitor_log's live status lines so there's one
24
+ // place that knows how to wrap text in an ANSI code (and reset it) -- or not,
25
+ // when color is disabled.
26
+ export function makeTint(color) {
27
+ return (text, code) => (color ? `${code}${text}${ANSI.reset}` : text);
28
+ }
23
29
  function clamp(value, lo, hi) {
24
30
  return Math.min(Math.max(value, lo), hi);
25
31
  }
@@ -162,7 +168,7 @@ export default function renderWaterfall(spans, totalDurationMs, options) {
162
168
  if (spans.length === 0) {
163
169
  return [header, 'No steps found for this run.'].filter(Boolean).join('\n\n');
164
170
  }
165
- const tint = (text, code) => (color ? `${code}${text}${ANSI.reset}` : text);
171
+ const tint = makeTint(color);
166
172
  const labelFor = (span) => labels?.get(span.id) ?? span.name;
167
173
  const durationFor = (span) => formatDurationLabel(Math.max(0, span.durationMs));
168
174
  const longestLabel = Math.max(...spans.map(s => labelFor(s).length));
@@ -2,13 +2,16 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { getWorkflowIdResult, getWorkflowIdRunsRidResult, getWorkflowIdTraceLog, getWorkflowIdRunsRidTraceLog } from '#api/generated/api.js';
4
4
  import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
5
+ import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
6
+ import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
5
7
  const EMPTY_DETAIL = {
6
8
  result: null,
7
9
  trace: null,
8
10
  steps: [],
9
11
  loading: false
10
12
  };
11
- const runDetailCache = new Map();
13
+ const RUN_DETAIL_CACHE_MAX = 50;
14
+ const runDetailCache = createBoundedCache(RUN_DETAIL_CACHE_MAX);
12
15
  const stepNameOf = (node) => {
13
16
  if (node.name) {
14
17
  return node.name;
@@ -99,13 +102,9 @@ const fetchResult = async (workflowId, runId) => {
99
102
  return null;
100
103
  }
101
104
  };
102
- /**
103
- * Statuses that mean the workflow has stopped advancing. The cache is
104
- * intentionally only populated for these — partial results from a still-
105
- * running workflow would otherwise stick and stall the UI when the run
106
- * eventually finishes.
107
- */
108
- const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'terminated', 'timed_out']);
105
+ // The cache is intentionally only populated for terminal statuses — partial results
106
+ // from a still-running workflow would otherwise stick and stall the UI when the run
107
+ // eventually finishes. `TERMINAL_STATUSES` is shared with `workflow monitor`.
109
108
  export const isTerminalRunStatus = (status) => Boolean(status && TERMINAL_STATUSES.has(status));
110
109
  export const useRunDetail = (workflowId, runId, status) => {
111
110
  const [detail, setDetail] = useState(EMPTY_DETAIL);
@@ -3,6 +3,7 @@ import { fetchWorkflowHistory } from '#services/workflow_history.js';
3
3
  import buildSpanLabels from '#utils/span_labels.js';
4
4
  import { isTerminalRunStatus } from '#views/dev/hooks/use_run_detail.js';
5
5
  import { usePoll, POLL_INTERVAL_MS } from '#views/dev/hooks/use_poll.js';
6
+ import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
6
7
  const EMPTY_GRAPH = {
7
8
  spans: [],
8
9
  totalDurationMs: 0,
@@ -11,7 +12,8 @@ const EMPTY_GRAPH = {
11
12
  loading: false,
12
13
  error: null
13
14
  };
14
- const stepGraphCache = new Map();
15
+ const STEP_GRAPH_CACHE_MAX = 50;
16
+ const stepGraphCache = createBoundedCache(STEP_GRAPH_CACHE_MAX);
15
17
  /**
16
18
  * Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
17
19
  * reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
@@ -0,0 +1,14 @@
1
+ export interface BoundedCache<K, V extends {}> {
2
+ get(key: K): V | undefined;
3
+ set(key: K, value: V): void;
4
+ has(key: K): boolean;
5
+ clear(): void;
6
+ size(): number;
7
+ }
8
+ /**
9
+ * A small LRU cache backed by an insertion-ordered `Map`, capped at `maxSize`
10
+ * entries. Reading a key refreshes its recency; once the cap is exceeded the
11
+ * least-recently-used entries are evicted. Drop-in compatible with the
12
+ * `Map.get` / `Map.set` calls it replaces.
13
+ */
14
+ export declare const createBoundedCache: <K, V extends {}>(maxSize: number) => BoundedCache<K, V>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A small LRU cache backed by an insertion-ordered `Map`, capped at `maxSize`
3
+ * entries. Reading a key refreshes its recency; once the cap is exceeded the
4
+ * least-recently-used entries are evicted. Drop-in compatible with the
5
+ * `Map.get` / `Map.set` calls it replaces.
6
+ */
7
+ export const createBoundedCache = (maxSize) => {
8
+ if (maxSize < 1) {
9
+ throw new Error('createBoundedCache: maxSize must be >= 1');
10
+ }
11
+ const entries = new Map();
12
+ return {
13
+ get(key) {
14
+ const value = entries.get(key);
15
+ if (value === undefined) {
16
+ return value;
17
+ }
18
+ entries.delete(key);
19
+ entries.set(key, value);
20
+ return value;
21
+ },
22
+ set(key, value) {
23
+ entries.delete(key);
24
+ entries.set(key, value);
25
+ if (entries.size > maxSize) {
26
+ const oldest = entries.keys().next();
27
+ if (!oldest.done) {
28
+ entries.delete(oldest.value);
29
+ }
30
+ }
31
+ },
32
+ has(key) {
33
+ return entries.has(key);
34
+ },
35
+ clear() {
36
+ entries.clear();
37
+ },
38
+ size() {
39
+ return entries.size;
40
+ }
41
+ };
42
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createBoundedCache } from './bounded_cache.js';
3
+ describe('createBoundedCache', () => {
4
+ it('round-trips set and get', () => {
5
+ const cache = createBoundedCache(3);
6
+ cache.set('a', 1);
7
+ expect(cache.get('a')).toBe(1);
8
+ expect(cache.get('missing')).toBeUndefined();
9
+ });
10
+ it('evicts the oldest entry once maxSize is exceeded', () => {
11
+ const cache = createBoundedCache(2);
12
+ cache.set('a', 1);
13
+ cache.set('b', 2);
14
+ cache.set('c', 3);
15
+ expect(cache.has('a')).toBe(false);
16
+ expect(cache.get('b')).toBe(2);
17
+ expect(cache.get('c')).toBe(3);
18
+ });
19
+ it('refreshes recency on get so the touched entry survives eviction', () => {
20
+ const cache = createBoundedCache(2);
21
+ cache.set('a', 1);
22
+ cache.set('b', 2);
23
+ // Touch 'a' so 'b' becomes the least-recently-used entry.
24
+ expect(cache.get('a')).toBe(1);
25
+ cache.set('c', 3);
26
+ expect(cache.has('a')).toBe(true);
27
+ expect(cache.has('b')).toBe(false);
28
+ expect(cache.has('c')).toBe(true);
29
+ });
30
+ it('updates an existing key in place without growing', () => {
31
+ const cache = createBoundedCache(2);
32
+ cache.set('a', 1);
33
+ cache.set('a', 2);
34
+ expect(cache.get('a')).toBe(2);
35
+ expect(cache.size()).toBe(1);
36
+ });
37
+ it('supports has and clear', () => {
38
+ const cache = createBoundedCache(2);
39
+ cache.set('a', 1);
40
+ expect(cache.has('a')).toBe(true);
41
+ cache.clear();
42
+ expect(cache.has('a')).toBe(false);
43
+ expect(cache.size()).toBe(0);
44
+ });
45
+ it('never exceeds maxSize', () => {
46
+ const cache = createBoundedCache(3);
47
+ Array.from({ length: 100 }, (_, i) => i).forEach(i => {
48
+ cache.set(`key-${i}`, i);
49
+ expect(cache.size()).toBeLessThanOrEqual(3);
50
+ });
51
+ expect(cache.size()).toBe(3);
52
+ });
53
+ });