@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.
- package/dist/api/generated/api.d.ts +12 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/index.js +57 -6
- package/dist/commands/dev/index.spec.js +55 -0
- package/dist/commands/workflow/history.js +3 -3
- package/dist/commands/workflow/history.spec.js +31 -2
- package/dist/commands/workflow/monitor.d.ts +49 -0
- package/dist/commands/workflow/monitor.js +230 -0
- package/dist/commands/workflow/monitor.spec.d.ts +1 -0
- package/dist/commands/workflow/monitor.spec.js +243 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/workflow_history/correlator.d.ts +2 -0
- package/dist/services/workflow_history/correlator.js +2 -2
- package/dist/services/workflow_history.d.ts +28 -0
- package/dist/services/workflow_history.js +95 -12
- package/dist/services/workflow_history.spec.js +183 -1
- package/dist/utils/color.d.ts +7 -0
- package/dist/utils/color.js +12 -0
- package/dist/utils/color.spec.d.ts +1 -0
- package/dist/utils/color.spec.js +43 -0
- package/dist/utils/format_workflow_result.d.ts +1 -0
- package/dist/utils/format_workflow_result.js +4 -0
- package/dist/utils/monitor_log.d.ts +20 -0
- package/dist/utils/monitor_log.js +48 -0
- package/dist/utils/monitor_log.spec.d.ts +1 -0
- package/dist/utils/monitor_log.spec.js +71 -0
- package/dist/utils/waterfall.d.ts +3 -1
- package/dist/utils/waterfall.js +8 -2
- package/dist/views/dev/hooks/use_run_detail.js +7 -8
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
- package/dist/views/dev/utils/bounded_cache.js +42 -0
- package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
- package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
- package/oclif.manifest.json +75 -1
- package/package.json +4 -4
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { HttpError } from '#api/http_client.js';
|
|
4
|
+
vi.mock('#services/workflow_history.js', () => ({ fetchWorkflowHistory: vi.fn(), fetchWorkflowHistoryUpdates: vi.fn() }));
|
|
5
|
+
vi.mock('#utils/sleep.js', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }));
|
|
6
|
+
const span = (id, status, overrides = {}) => ({
|
|
7
|
+
id,
|
|
8
|
+
name: `Step ${id}`,
|
|
9
|
+
technicalName: `wf#step${id}`,
|
|
10
|
+
description: null,
|
|
11
|
+
status,
|
|
12
|
+
kind: 'activity',
|
|
13
|
+
attempt: 1,
|
|
14
|
+
startedAt: null,
|
|
15
|
+
scheduledAt: null,
|
|
16
|
+
completedAt: null,
|
|
17
|
+
startOffsetMs: 0,
|
|
18
|
+
endOffsetMs: 0,
|
|
19
|
+
durationMs: 1000,
|
|
20
|
+
failureMessage: null,
|
|
21
|
+
...overrides
|
|
22
|
+
});
|
|
23
|
+
const history = (status, overrides = {}) => ({
|
|
24
|
+
workflow: { status },
|
|
25
|
+
runId: 'run-1',
|
|
26
|
+
events: [],
|
|
27
|
+
spans: [],
|
|
28
|
+
totalDurationMs: 0,
|
|
29
|
+
continuedAsNewRunId: null,
|
|
30
|
+
// Every result carries a cursor now (see workflow_history.ts) — `poll()` uses its
|
|
31
|
+
// presence to decide fetchWorkflowHistory vs. fetchWorkflowHistoryUpdates on the next tick.
|
|
32
|
+
cursor: { pageToken: 'token', lastEventId: 1, meta: null, runId: 'run-1', events: [] },
|
|
33
|
+
...overrides
|
|
34
|
+
});
|
|
35
|
+
// `fetchWorkflowHistoryUpdates` (used from the second poll onward) wraps the same result
|
|
36
|
+
// shape with a resume cursor; tests don't care about cursor contents, only that it's threaded.
|
|
37
|
+
const update = (status, overrides = {}) => ({
|
|
38
|
+
result: history(status, overrides),
|
|
39
|
+
cursor: { pageToken: 'token', lastEventId: 1, meta: null, runId: 'run-1', events: [] }
|
|
40
|
+
});
|
|
41
|
+
describe('workflow monitor command', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
process.exitCode = undefined;
|
|
45
|
+
});
|
|
46
|
+
describe('command definition', () => {
|
|
47
|
+
it('exports a valid OCLIF command with a required workflowId arg', async () => {
|
|
48
|
+
const WorkflowMonitor = (await import('./monitor.js')).default;
|
|
49
|
+
expect(WorkflowMonitor).toBeDefined();
|
|
50
|
+
expect(WorkflowMonitor.args).toHaveProperty('workflowId');
|
|
51
|
+
expect(WorkflowMonitor.args.workflowId.required).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it('declares the expected flags and defaults', async () => {
|
|
54
|
+
const WorkflowMonitor = (await import('./monitor.js')).default;
|
|
55
|
+
const flags = WorkflowMonitor.flags;
|
|
56
|
+
expect(flags).toHaveProperty('run-id');
|
|
57
|
+
expect(flags).toHaveProperty('include-payloads');
|
|
58
|
+
expect(flags).toHaveProperty('interval');
|
|
59
|
+
expect(flags).toHaveProperty('color');
|
|
60
|
+
expect(flags.format.options).toEqual(['text', 'json']);
|
|
61
|
+
expect(flags.format.default).toBe('text');
|
|
62
|
+
expect(flags.interval.default).toBe(2500);
|
|
63
|
+
expect(flags.color.default).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('run()', () => {
|
|
67
|
+
const createCommand = async (flagOverrides = {}) => {
|
|
68
|
+
const WorkflowMonitor = (await import('./monitor.js')).default;
|
|
69
|
+
const { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await import('#services/workflow_history.js');
|
|
70
|
+
const cmd = new WorkflowMonitor(['wf-1'], {});
|
|
71
|
+
cmd.log = vi.fn();
|
|
72
|
+
cmd.warn = vi.fn();
|
|
73
|
+
cmd.error = vi.fn((message) => {
|
|
74
|
+
throw new Error(message);
|
|
75
|
+
});
|
|
76
|
+
cmd.parse = vi.fn().mockResolvedValue({
|
|
77
|
+
args: { workflowId: 'wf-1' },
|
|
78
|
+
flags: {
|
|
79
|
+
'run-id': undefined,
|
|
80
|
+
format: 'text',
|
|
81
|
+
'include-payloads': false,
|
|
82
|
+
interval: 1,
|
|
83
|
+
color: false,
|
|
84
|
+
...flagOverrides
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
cmd,
|
|
89
|
+
fetchWorkflowHistory: vi.mocked(fetchWorkflowHistory),
|
|
90
|
+
fetchWorkflowHistoryUpdates: vi.mocked(fetchWorkflowHistoryUpdates)
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
it('prints span updates and exits cleanly when the workflow is already completed on the first poll', async () => {
|
|
94
|
+
const { cmd, fetchWorkflowHistory } = await createCommand();
|
|
95
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed', { spans: [span('1', 'completed')], totalDurationMs: 5000 }));
|
|
96
|
+
await cmd.run();
|
|
97
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Step 1'));
|
|
99
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('workflow completed'));
|
|
100
|
+
expect(process.exitCode).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
it('sets exit code 1 when the workflow ends in a failed status', async () => {
|
|
103
|
+
const { cmd, fetchWorkflowHistory } = await createCommand();
|
|
104
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('failed', {
|
|
105
|
+
spans: [span('1', 'failed', { failureMessage: 'boom' })], totalDurationMs: 1000
|
|
106
|
+
}));
|
|
107
|
+
await cmd.run();
|
|
108
|
+
expect(process.exitCode).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
it('polls again while the workflow is running, then stops once it completes', async () => {
|
|
111
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
112
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running', { spans: [span('1', 'running')] }));
|
|
113
|
+
fetchWorkflowHistoryUpdates.mockResolvedValueOnce(update('completed', {
|
|
114
|
+
spans: [span('1', 'completed')], totalDurationMs: 2000
|
|
115
|
+
}));
|
|
116
|
+
await cmd.run();
|
|
117
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(fetchWorkflowHistoryUpdates).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('running'));
|
|
120
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('1s'));
|
|
121
|
+
expect(process.exitCode).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
it('keeps a span label stable once printed, even when a same-named span appears on a later poll', async () => {
|
|
124
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
125
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running', { spans: [span('1', 'running', { name: 'Scrape Page' })] }));
|
|
126
|
+
// Span 2 shares span 1's name, only showing up on the second poll — buildSpanLabels
|
|
127
|
+
// would number both "#1"/"#2" if recomputed fresh, retroactively relabeling span 1's
|
|
128
|
+
// already-printed "running" line.
|
|
129
|
+
fetchWorkflowHistoryUpdates.mockResolvedValueOnce(update('completed', {
|
|
130
|
+
spans: [
|
|
131
|
+
span('1', 'completed', { name: 'Scrape Page' }),
|
|
132
|
+
span('2', 'completed', { name: 'Scrape Page' })
|
|
133
|
+
]
|
|
134
|
+
}));
|
|
135
|
+
await cmd.run();
|
|
136
|
+
const lines = cmd.log.mock.calls.map(([line]) => line);
|
|
137
|
+
expect(lines.some((line) => line.includes('Scrape Page running'))).toBe(true);
|
|
138
|
+
// Span 1's completion line keeps its original unnumbered label...
|
|
139
|
+
expect(lines.some((line) => line.includes('Scrape Page '))).toBe(true);
|
|
140
|
+
expect(lines.some((line) => line.includes('Scrape Page #1'))).toBe(false);
|
|
141
|
+
// ...while span 2, new this tick, is free to be numbered.
|
|
142
|
+
expect(lines.some((line) => line.includes('Scrape Page #2'))).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
it('does not re-print a span whose status has not changed between polls', async () => {
|
|
145
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
146
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running', { spans: [span('1', 'running')] }));
|
|
147
|
+
fetchWorkflowHistoryUpdates.mockResolvedValueOnce(update('completed', {
|
|
148
|
+
spans: [span('1', 'running'), span('2', 'completed')], totalDurationMs: 1000
|
|
149
|
+
}));
|
|
150
|
+
await cmd.run();
|
|
151
|
+
const runningLines = cmd.log.mock.calls.filter(([line]) => line.includes('Step 1'));
|
|
152
|
+
expect(runningLines).toHaveLength(1); // span 1 only reported once, not again on the second poll
|
|
153
|
+
});
|
|
154
|
+
it('follows a continue-as-new chain by re-polling with the new run id', async () => {
|
|
155
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
156
|
+
fetchWorkflowHistory
|
|
157
|
+
.mockResolvedValueOnce(history('continued_as_new', { continuedAsNewRunId: 'run-2' }))
|
|
158
|
+
// The new run's cursor was reset, so its first poll takes the same fast,
|
|
159
|
+
// non-waiting path as the very first poll of the command — not a resumed one.
|
|
160
|
+
.mockResolvedValueOnce(history('completed', { runId: 'run-2', totalDurationMs: 500 }));
|
|
161
|
+
await cmd.run();
|
|
162
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(2);
|
|
163
|
+
expect(fetchWorkflowHistory).toHaveBeenNthCalledWith(2, expect.objectContaining({ runId: 'run-2' }));
|
|
164
|
+
expect(fetchWorkflowHistoryUpdates).not.toHaveBeenCalled();
|
|
165
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('continued as new run run-2'));
|
|
166
|
+
expect(process.exitCode).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
it('errors when the workflow continues as new but no new run id can be determined', async () => {
|
|
169
|
+
const { cmd, fetchWorkflowHistory } = await createCommand();
|
|
170
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('continued_as_new'));
|
|
171
|
+
await expect(cmd.run()).rejects.toThrow(/new run ID could not be determined/);
|
|
172
|
+
});
|
|
173
|
+
it('propagates a failure on the very first poll', async () => {
|
|
174
|
+
const { cmd, fetchWorkflowHistory } = await createCommand();
|
|
175
|
+
fetchWorkflowHistory.mockRejectedValue(new Error('network down'));
|
|
176
|
+
await expect(cmd.run()).rejects.toThrow('network down');
|
|
177
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
it('retries a transient network failure after the first successful poll instead of crashing', async () => {
|
|
180
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
181
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
|
|
182
|
+
fetchWorkflowHistoryUpdates
|
|
183
|
+
.mockRejectedValueOnce(Object.assign(new Error('blip'), { code: 'ECONNRESET' }))
|
|
184
|
+
.mockResolvedValueOnce(update('completed', { totalDurationMs: 1000 }));
|
|
185
|
+
await cmd.run();
|
|
186
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(fetchWorkflowHistoryUpdates).toHaveBeenCalledTimes(2);
|
|
188
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('(1/5)'));
|
|
189
|
+
expect(process.exitCode).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
it('retries a transient 503 HttpError after the first successful poll', async () => {
|
|
192
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
193
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
|
|
194
|
+
fetchWorkflowHistoryUpdates
|
|
195
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
196
|
+
.mockResolvedValueOnce(update('completed', { totalDurationMs: 1000 }));
|
|
197
|
+
await cmd.run();
|
|
198
|
+
expect(fetchWorkflowHistoryUpdates).toHaveBeenCalledTimes(2);
|
|
199
|
+
expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('(1/5)'));
|
|
200
|
+
expect(process.exitCode).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
it('immediately rethrows a non-transient error (e.g. a stale resume cursor) without retrying', async () => {
|
|
203
|
+
const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
|
|
204
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
|
|
205
|
+
fetchWorkflowHistoryUpdates.mockRejectedValueOnce(new HttpError('Invalid page token', { status: 400 }));
|
|
206
|
+
await expect(cmd.run()).rejects.toThrow('Invalid page token');
|
|
207
|
+
expect(fetchWorkflowHistoryUpdates).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(cmd.warn).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
it('registers a SIGINT handler that detaches and exits 130 without affecting the workflow', async () => {
|
|
211
|
+
const { cmd, fetchWorkflowHistory } = await createCommand();
|
|
212
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
213
|
+
const onSpy = vi.spyOn(process, 'on');
|
|
214
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
215
|
+
await cmd.run();
|
|
216
|
+
const sigintCall = onSpy.mock.calls.find(([event]) => event === 'SIGINT');
|
|
217
|
+
expect(sigintCall).toBeDefined();
|
|
218
|
+
const handler = sigintCall[1];
|
|
219
|
+
handler();
|
|
220
|
+
expect(exitSpy).toHaveBeenCalledWith(130);
|
|
221
|
+
expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Detached'));
|
|
222
|
+
onSpy.mockRestore();
|
|
223
|
+
exitSpy.mockRestore();
|
|
224
|
+
});
|
|
225
|
+
it('emits NDJSON lines under --format json', async () => {
|
|
226
|
+
const { cmd, fetchWorkflowHistory } = await createCommand({ format: 'json' });
|
|
227
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed', { spans: [span('1', 'completed')], totalDurationMs: 1000 }));
|
|
228
|
+
await cmd.run();
|
|
229
|
+
const lines = cmd.log.mock.calls.map(([line]) => line);
|
|
230
|
+
expect(lines.every((line) => {
|
|
231
|
+
try {
|
|
232
|
+
JSON.parse(line);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
})).toBe(true);
|
|
239
|
+
expect(lines.some((line) => JSON.parse(line).span?.id === '1')).toBe(true);
|
|
240
|
+
expect(lines.some((line) => JSON.parse(line).monitoring === true)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -19,6 +19,8 @@ export interface Span {
|
|
|
19
19
|
output?: unknown;
|
|
20
20
|
}
|
|
21
21
|
export type HistoryEvent = Record<string, unknown>;
|
|
22
|
+
export declare function eventTypeName(event: HistoryEvent): string;
|
|
23
|
+
export declare function eventAttributes(event?: HistoryEvent): Record<string, unknown> | undefined;
|
|
22
24
|
/**
|
|
23
25
|
* @param events - flat Temporal history events, in chronological order
|
|
24
26
|
* @param workflowStartTimeMs - epoch ms used as the timeline origin (0 offset)
|
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
const {
|
|
69
|
-
|
|
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
|
+
}
|