@outputai/cli 0.10.0 → 0.10.1-next.09ed166.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.
@@ -24,13 +24,13 @@ const CHILD_TERMINAL_TYPES = [
24
24
  'CHILD_WORKFLOW_EXECUTION_TIMED_OUT', 'CHILD_WORKFLOW_EXECUTION_CANCELED',
25
25
  'CHILD_WORKFLOW_EXECUTION_TERMINATED', 'START_CHILD_WORKFLOW_EXECUTION_FAILED'
26
26
  ];
27
- function eventTypeName(event) {
27
+ export function eventTypeName(event) {
28
28
  return event.eventTypeName ?? '';
29
29
  }
30
30
  function eventId(event) {
31
31
  return String(event.eventId);
32
32
  }
33
- function eventAttributes(event) {
33
+ export function eventAttributes(event) {
34
34
  const key = event && Object.keys(event).find(k => k.endsWith('EventAttributes'));
35
35
  return key ? event[key] : undefined;
36
36
  }
@@ -12,12 +12,40 @@ export interface FetchWorkflowHistoryOptions {
12
12
  workflowId: string;
13
13
  runId?: string;
14
14
  includePayloads?: boolean;
15
+ longPollTimeoutMs?: number;
15
16
  }
16
17
  export interface WorkflowHistoryResult {
17
18
  workflow: WorkflowMeta | null;
19
+ rawWorkflow: WorkflowMeta | null;
18
20
  runId: string | null;
19
21
  events: HistoryEvent[];
20
22
  spans: Span[];
21
23
  totalDurationMs: number;
24
+ continuedAsNewRunId: string | null;
25
+ cursor: WorkflowHistoryCursor;
26
+ }
27
+ /**
28
+ * Resume state for `fetchWorkflowHistoryUpdates`: the accumulated events (so spans/duration
29
+ * are always computed over the full history, not just the latest delta), `lastEventId` for
30
+ * de-duping a replayed page, and `pageToken` — the position that fetched the *current* end of
31
+ * history, which is itself always a valid resume point (see `fetchPages`) even though the
32
+ * server's own `nextPageToken` for that position is empty.
33
+ */
34
+ export interface WorkflowHistoryCursor {
35
+ pageToken: string | undefined;
36
+ lastEventId: number;
37
+ meta: WorkflowMeta | null;
38
+ runId: string | undefined;
39
+ events: HistoryEvent[];
22
40
  }
23
41
  export declare function fetchWorkflowHistory(options: FetchWorkflowHistoryOptions): Promise<WorkflowHistoryResult>;
42
+ /**
43
+ * Incremental counterpart to `fetchWorkflowHistory`, for a poller (`workflow monitor`) that
44
+ * calls repeatedly while a workflow is still running. Pass the previous call's `cursor`
45
+ * (from either function's result) to resume from where it left off instead of re-paging the
46
+ * whole history; omit it only to start a completely fresh walk.
47
+ */
48
+ export declare function fetchWorkflowHistoryUpdates(options: FetchWorkflowHistoryOptions, cursor?: WorkflowHistoryCursor): Promise<{
49
+ result: WorkflowHistoryResult;
50
+ cursor: WorkflowHistoryCursor;
51
+ }>;
@@ -8,8 +8,20 @@
8
8
  * the `nextPageToken` (the endpoint requires runId once a pageToken is used).
9
9
  */
10
10
  import { getWorkflowIdHistory } from '#api/generated/api.js';
11
- import { correlate } from '#services/workflow_history/correlator.js';
11
+ import { correlate, eventAttributes, eventTypeName } from '#services/workflow_history/correlator.js';
12
+ import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
13
+ import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
12
14
  const PAGE_SIZE = 50;
15
+ // The status here comes from the request's own describe (fresh whenever `wait` is
16
+ // set), so it can report the run closed before the closing event has been paged in.
17
+ function isRunClosed(meta) {
18
+ const status = normalizeWorkflowStatus(meta?.status);
19
+ return status === 'continued_as_new' || (status !== undefined && TERMINAL_STATUSES.has(status));
20
+ }
21
+ function numericEventId(event) {
22
+ const id = Number(event.eventId);
23
+ return Number.isFinite(id) ? id : 0;
24
+ }
13
25
  function toMs(value) {
14
26
  if (!value) {
15
27
  return null;
@@ -37,6 +49,13 @@ function workflowStartMs(meta, events) {
37
49
  }
38
50
  return earliestEventMs(events);
39
51
  }
52
+ // The paginated history endpoint doesn't surface a resolved `newRunId` the way
53
+ // the SSE stream endpoint does (see `stream_history.js`'s `doneChunk`), so pull
54
+ // it directly off the WORKFLOW_EXECUTION_CONTINUED_AS_NEW event when present.
55
+ function continuedAsNewRunId(events) {
56
+ const terminal = events.find(e => eventTypeName(e) === 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW');
57
+ return eventAttributes(terminal)?.newExecutionRunId ?? null;
58
+ }
40
59
  function totalDuration(meta, spans, startMs) {
41
60
  const closeMs = toMs(meta?.closeTime ?? undefined);
42
61
  if (closeMs !== null && startMs !== null && (closeMs - startMs) > 0) {
@@ -45,35 +64,99 @@ function totalDuration(meta, spans, startMs) {
45
64
  const maxEnd = spans.reduce((max, span) => Math.max(max, span.endOffsetMs), 0);
46
65
  return Math.max(maxEnd, 1);
47
66
  }
48
- async function fetchAllPages(workflowId, includePayloads, runId, pageToken, acc) {
49
- const response = await getWorkflowIdHistory(workflowId, { runId, pageSize: PAGE_SIZE, pageToken, includePayloads });
67
+ /**
68
+ * Pages through history starting from `acc` (its `pageToken`/`lastEventId`/`events` carry
69
+ * the resume position — pass a zeroed cursor for a fresh walk). When `longPollTimeoutMs` is
70
+ * set, every request asks the server to long-poll (`waitNewEvent`) rather than return
71
+ * immediately, so the final hop blocks — up to that many milliseconds (clamped to the server's
72
+ * ceiling) — until either a new event exists or the deadline elapses. `lastEventId` de-dupes:
73
+ * resuming from a previously-seen page token replays that page's events, which are filtered out
74
+ * here rather than appended twice.
75
+ */
76
+ async function fetchPages(workflowId, includePayloads, acc, longPollTimeoutMs) {
77
+ const wait = longPollTimeoutMs !== undefined && longPollTimeoutMs > 0;
78
+ const { pageToken, runId } = acc;
79
+ const response = await getWorkflowIdHistory(workflowId, {
80
+ runId, pageSize: PAGE_SIZE, pageToken, includePayloads,
81
+ ...(wait ? { longPollTimeoutMs } : {})
82
+ });
50
83
  if (!response.data) {
51
84
  throw new Error('API returned invalid response (missing data)');
52
85
  }
53
86
  const data = response.data;
54
87
  // The generated `data.workflow` is an opaque `{ [key: string]: unknown }`, so
55
88
  // 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;
89
+ // the server, mirroring Atlas's metadata shape). Prefer the *fresh* value when the
90
+ // server sent one it re-describes on every `wait` call specifically so status
91
+ // updates (e.g. running -> completed) are seen; falling back to `acc.meta` only
92
+ // covers the pages within a walk where the server didn't re-describe.
93
+ const meta = data.workflow ?? acc.meta;
58
94
  const resolvedRunId = runId ?? data.runId ?? acc.runId;
59
- const events = [...acc.events, ...(data.events ?? [])];
95
+ const pageEvents = data.events ?? [];
96
+ const newEvents = pageEvents.filter(event => numericEventId(event) > acc.lastEventId);
97
+ const events = [...acc.events, ...newEvents];
98
+ // Events arrive in increasing eventId order, so the last new one is the max — no scan needed.
99
+ const lastEventId = newEvents.length > 0 ? numericEventId(newEvents[newEvents.length - 1]) : acc.lastEventId;
60
100
  const nextToken = data.nextPageToken ?? undefined;
61
- const nextAcc = { meta, runId: resolvedRunId, events };
101
+ const nextAcc = { meta, runId: resolvedRunId, events, lastEventId, pageToken: nextToken ?? pageToken };
102
+ // While long-polling an open run, stop and hand back the first batch of new events
103
+ // instead of draining further pages — a poller needs each transition rendered as it
104
+ // arrives, and the buffered remainder will surface on subsequent ticks. Once the run
105
+ // is closed, though, no further ticks are coming: the poller acts on the closed
106
+ // status immediately, so stopping early would strand the trailing pages — including
107
+ // the terminal or CONTINUED_AS_NEW event — unfetched. Drain to the tip instead.
108
+ if (wait && newEvents.length > 0 && !isRunClosed(meta)) {
109
+ return nextAcc;
110
+ }
111
+ // The server echoes `pageToken` back unchanged (see `get_history.js`) when a waitNewEvent
112
+ // call's deadline elapses with nothing new — that's the tip, stop for this tick.
113
+ const timedOut = wait && nextToken === pageToken;
114
+ if (timedOut) {
115
+ return nextAcc;
116
+ }
62
117
  if (nextToken) {
63
- return fetchAllPages(workflowId, includePayloads, resolvedRunId, nextToken, nextAcc);
118
+ return fetchPages(workflowId, includePayloads, nextAcc, longPollTimeoutMs);
64
119
  }
120
+ // Drained: the server has nothing more buffered (`nextToken` is empty), but unlike
121
+ // `nextToken`, `pageToken` — the position that fetched this now-empty page — is still a
122
+ // valid resume point: a future waitNewEvent call from here replays this page (de-duped by
123
+ // `lastEventId`) and then genuinely waits at the tip, instead of restarting from page 1.
65
124
  return nextAcc;
66
125
  }
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: [] });
126
+ function buildResult(pages) {
127
+ const { meta: rawMeta, runId: resolvedRunId, events } = pages;
128
+ // Normalize once here so every consumer (monitor, history, etc.) sees the
129
+ // same status vocabulary, matching status.ts/workflow_runs.ts/etc.
130
+ const status = normalizeWorkflowStatus(rawMeta?.status);
131
+ const meta = rawMeta ? { ...rawMeta, status } : rawMeta;
70
132
  const startMs = workflowStartMs(meta, events);
71
133
  const spans = correlate(events, startMs);
72
134
  return {
73
135
  workflow: meta,
136
+ rawWorkflow: rawMeta,
74
137
  runId: resolvedRunId ?? meta?.runId ?? null,
75
138
  events,
76
139
  spans,
77
- totalDurationMs: totalDuration(meta, spans, startMs)
140
+ totalDurationMs: totalDuration(meta, spans, startMs),
141
+ continuedAsNewRunId: continuedAsNewRunId(events),
142
+ cursor: pages
78
143
  };
79
144
  }
145
+ export async function fetchWorkflowHistory(options) {
146
+ const { workflowId, runId, includePayloads = false } = options;
147
+ const pages = await fetchPages(workflowId, includePayloads, { meta: null, runId, events: [], lastEventId: 0, pageToken: undefined });
148
+ return buildResult(pages);
149
+ }
150
+ /**
151
+ * Incremental counterpart to `fetchWorkflowHistory`, for a poller (`workflow monitor`) that
152
+ * calls repeatedly while a workflow is still running. Pass the previous call's `cursor`
153
+ * (from either function's result) to resume from where it left off instead of re-paging the
154
+ * whole history; omit it only to start a completely fresh walk.
155
+ */
156
+ export async function fetchWorkflowHistoryUpdates(options, cursor) {
157
+ const { workflowId, includePayloads = false, longPollTimeoutMs } = options;
158
+ const seed = cursor ??
159
+ { meta: null, runId: options.runId, events: [], lastEventId: 0, pageToken: undefined };
160
+ const pages = await fetchPages(workflowId, includePayloads, seed, longPollTimeoutMs);
161
+ return { result: buildResult(pages), cursor: pages };
162
+ }
@@ -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
+ });