@outputai/cli 0.10.1-next.6deba0f.0 → 0.10.1-next.74e6a3d.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 +81 -26
- package/dist/api/generated/api.js +7 -4
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/workflow/monitor.d.ts +5 -20
- package/dist/commands/workflow/monitor.js +20 -182
- package/dist/commands/workflow/monitor.spec.js +82 -3
- package/dist/commands/workflow/result.js +2 -2
- package/dist/commands/workflow/result.spec.js +65 -1
- package/dist/commands/workflow/run.js +2 -2
- package/dist/commands/workflow/run.spec.js +30 -3
- package/dist/commands/workflow/start.d.ts +4 -0
- package/dist/commands/workflow/start.js +95 -10
- package/dist/commands/workflow/start.spec.js +252 -0
- package/dist/commands/workflow/status.spec.js +1 -1
- package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
- package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
- package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/monitor_stream.d.ts +62 -0
- package/dist/services/monitor_stream.js +285 -0
- package/dist/services/monitor_stream.spec.d.ts +1 -0
- package/dist/services/monitor_stream.spec.js +285 -0
- package/dist/services/workflow_history.js +2 -2
- package/dist/templates/agent_instructions/CLAUDE.md.template +4 -2
- package/dist/templates/project/README.md.template +3 -1
- package/dist/templates/project/package.json.template +2 -2
- package/dist/utils/error_handler.d.ts +10 -0
- package/dist/utils/error_handler.js +14 -0
- package/dist/utils/error_handler.spec.d.ts +1 -0
- package/dist/utils/error_handler.spec.js +62 -0
- package/dist/utils/format_workflow_result.d.ts +15 -3
- package/dist/utils/format_workflow_result.js +39 -6
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/monitor_flags.d.ts +35 -0
- package/dist/utils/monitor_flags.js +76 -0
- package/dist/utils/normalize_workflow_status.d.ts +4 -3
- package/dist/utils/normalize_workflow_status.js +12 -3
- package/dist/utils/normalize_workflow_status.spec.js +3 -0
- package/dist/views/dev/components/workflow_status.js +1 -1
- package/dist/views/dev/hooks/use_run_detail.js +4 -4
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/oclif.manifest.json +44 -7
- package/package.json +5 -5
- /package/dist/commands/workflow/{test_eval.spec.d.ts → test.spec.d.ts} +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { HttpError } from '#api/http_client.js';
|
|
4
|
+
import { commandStreamIo, monitorErrorOverrides, streamWorkflowUpdates } from './monitor_stream.js';
|
|
5
|
+
vi.mock('#services/workflow_history.js', () => ({ fetchWorkflowHistory: vi.fn(), fetchWorkflowHistoryUpdates: vi.fn() }));
|
|
6
|
+
vi.mock('#utils/sleep.js', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }));
|
|
7
|
+
/**
|
|
8
|
+
* Direct coverage of the loop now that two commands share it. `monitor.spec.ts`
|
|
9
|
+
* exercises it end-to-end through `workflow monitor`, and `start.spec.ts` stubs
|
|
10
|
+
* it out entirely — so what's here is the behavior neither reaches: the return
|
|
11
|
+
* value both callers branch on, the pre-cursor retry cap, the detach guards, and
|
|
12
|
+
* the error classification at its edges.
|
|
13
|
+
*/
|
|
14
|
+
const cursor = { pageToken: 'token', lastEventId: 1, meta: null, runId: 'run-1', events: [] };
|
|
15
|
+
const history = (status, overrides = {}) => ({
|
|
16
|
+
workflow: { status },
|
|
17
|
+
runId: 'run-1',
|
|
18
|
+
events: [],
|
|
19
|
+
spans: [],
|
|
20
|
+
totalDurationMs: 0,
|
|
21
|
+
continuedAsNewRunId: null,
|
|
22
|
+
cursor,
|
|
23
|
+
...overrides
|
|
24
|
+
});
|
|
25
|
+
const update = (status, overrides = {}) => ({ result: history(status, overrides), cursor });
|
|
26
|
+
const createIo = (errorReturns = false) => ({
|
|
27
|
+
log: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
// Typed `never` in the interface but nothing enforces that at runtime, which is
|
|
30
|
+
// exactly what the continue-as-new guard defends against — so both shapes are
|
|
31
|
+
// constructible here.
|
|
32
|
+
error: (errorReturns ?
|
|
33
|
+
vi.fn() :
|
|
34
|
+
vi.fn((message) => {
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}))
|
|
37
|
+
});
|
|
38
|
+
const options = (overrides = {}) => ({
|
|
39
|
+
workflowId: 'wf-1',
|
|
40
|
+
runId: undefined,
|
|
41
|
+
includePayloads: false,
|
|
42
|
+
interval: 5000,
|
|
43
|
+
json: false,
|
|
44
|
+
color: false,
|
|
45
|
+
...overrides
|
|
46
|
+
});
|
|
47
|
+
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
48
|
+
describe('monitor_stream service', () => {
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
process.exitCode = undefined;
|
|
52
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
53
|
+
vi.mocked(sleep).mockResolvedValue(undefined);
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
process.exitCode = undefined;
|
|
57
|
+
});
|
|
58
|
+
const histories = async () => {
|
|
59
|
+
const { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await import('#services/workflow_history.js');
|
|
60
|
+
return {
|
|
61
|
+
fetchWorkflowHistory: vi.mocked(fetchWorkflowHistory),
|
|
62
|
+
fetchWorkflowHistoryUpdates: vi.mocked(fetchWorkflowHistoryUpdates)
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
describe('streamWorkflowUpdates() return value', () => {
|
|
66
|
+
it('returns the terminal status it stopped on so the caller can name a follow-up', async () => {
|
|
67
|
+
const { fetchWorkflowHistory } = await histories();
|
|
68
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
69
|
+
// `start --monitor` branches on this to print "workflow result" vs
|
|
70
|
+
// "workflow debug"; a boolean or a throw wouldn't carry enough.
|
|
71
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBe('completed');
|
|
72
|
+
expect(process.exitCode).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it('returns a failed status and records exit 1 without throwing', async () => {
|
|
75
|
+
const { fetchWorkflowHistory } = await histories();
|
|
76
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('failed'));
|
|
77
|
+
// Not thrown: the caller's own output stays the command's primary result.
|
|
78
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBe('failed');
|
|
79
|
+
expect(process.exitCode).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
it('returns undefined when the user detached, so no follow-up is printed', async () => {
|
|
82
|
+
const { fetchWorkflowHistory } = await histories();
|
|
83
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
84
|
+
fetchWorkflowHistory.mockResolvedValue(history('running'));
|
|
85
|
+
const onSpy = vi.spyOn(process, 'on');
|
|
86
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
87
|
+
vi.mocked(sleep).mockImplementation(async () => {
|
|
88
|
+
onSpy.mock.calls.find(([event]) => event === 'SIGINT')[1]();
|
|
89
|
+
});
|
|
90
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBeUndefined();
|
|
91
|
+
await flush();
|
|
92
|
+
onSpy.mockRestore();
|
|
93
|
+
exitSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('retry pacing', () => {
|
|
97
|
+
it('caps the retry sleep while no cursor is established, ignoring a long --interval', async () => {
|
|
98
|
+
const { fetchWorkflowHistory } = await histories();
|
|
99
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
100
|
+
fetchWorkflowHistory
|
|
101
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
102
|
+
.mockResolvedValueOnce(history('completed'));
|
|
103
|
+
await streamWorkflowUpdates(options({ interval: 5000 }), createIo());
|
|
104
|
+
// Otherwise "the API is down" takes interval x budget to surface — minutes,
|
|
105
|
+
// for a `workflow monitor` against a server that was never reachable.
|
|
106
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(1000);
|
|
107
|
+
});
|
|
108
|
+
it('honors an --interval shorter than the cap rather than slowing the retry down', async () => {
|
|
109
|
+
const { fetchWorkflowHistory } = await histories();
|
|
110
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
111
|
+
fetchWorkflowHistory
|
|
112
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
113
|
+
.mockResolvedValueOnce(history('completed'));
|
|
114
|
+
await streamWorkflowUpdates(options({ interval: 250 }), createIo());
|
|
115
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(250);
|
|
116
|
+
});
|
|
117
|
+
it('sleeps the full --interval on a retry once a cursor exists', async () => {
|
|
118
|
+
const { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await histories();
|
|
119
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
120
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
|
|
121
|
+
fetchWorkflowHistoryUpdates
|
|
122
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
123
|
+
.mockResolvedValueOnce(update('completed'));
|
|
124
|
+
await streamWorkflowUpdates(options({ interval: 5000 }), createIo());
|
|
125
|
+
expect(vi.mocked(sleep)).not.toHaveBeenCalledWith(1000);
|
|
126
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(5000);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('transient error classification', () => {
|
|
130
|
+
const retried = async (error) => {
|
|
131
|
+
const { fetchWorkflowHistory } = await histories();
|
|
132
|
+
fetchWorkflowHistory.mockRejectedValueOnce(error).mockResolvedValueOnce(history('completed'));
|
|
133
|
+
const io = createIo();
|
|
134
|
+
await streamWorkflowUpdates(options(), io);
|
|
135
|
+
return { calls: fetchWorkflowHistory.mock.calls.length, io };
|
|
136
|
+
};
|
|
137
|
+
it.each([
|
|
138
|
+
['a 503', new HttpError('Service unavailable', { status: 503 })],
|
|
139
|
+
['a 429 rate limit', new HttpError('Too many requests', { status: 429 })],
|
|
140
|
+
['a 408 request timeout', new HttpError('Request timeout', { status: 408 })],
|
|
141
|
+
['a client-side TimeoutError', Object.assign(new Error('timed out'), { name: 'TimeoutError' })],
|
|
142
|
+
['a nested ECONNREFUSED cause', Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })],
|
|
143
|
+
['a top-level EAI_AGAIN', Object.assign(new Error('dns'), { code: 'EAI_AGAIN' })]
|
|
144
|
+
])('retries %s', async (_label, error) => {
|
|
145
|
+
const { calls, io } = await retried(error);
|
|
146
|
+
expect(calls).toBe(2);
|
|
147
|
+
expect(io.warn).toHaveBeenCalledWith(expect.stringContaining('(1/5)'));
|
|
148
|
+
});
|
|
149
|
+
it.each([
|
|
150
|
+
['a 404 for a mistyped workflow id', new HttpError('Not found', { status: 404 })],
|
|
151
|
+
['a 400 stale resume cursor', new HttpError('Invalid page token', { status: 400 })],
|
|
152
|
+
['a bug in the parsing pipeline', new TypeError('spans is not iterable')]
|
|
153
|
+
])('surfaces %s immediately instead of burning the retry budget', async (_label, error) => {
|
|
154
|
+
const { fetchWorkflowHistory } = await histories();
|
|
155
|
+
fetchWorkflowHistory.mockRejectedValue(error);
|
|
156
|
+
const io = createIo();
|
|
157
|
+
await expect(streamWorkflowUpdates(options(), io)).rejects.toThrow();
|
|
158
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(io.warn).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('detaching', () => {
|
|
163
|
+
const detachDuring = async () => {
|
|
164
|
+
const onSpy = vi.spyOn(process, 'on');
|
|
165
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
166
|
+
const sigint = () => onSpy.mock.calls.find(([event]) => event === 'SIGINT')[1]();
|
|
167
|
+
return { onSpy, exitSpy, sigint };
|
|
168
|
+
};
|
|
169
|
+
it('swallows a poll failure raised after the user detached instead of racing the exit code', async () => {
|
|
170
|
+
const { fetchWorkflowHistory } = await histories();
|
|
171
|
+
const { onSpy, exitSpy, sigint } = await detachDuring();
|
|
172
|
+
// The in-flight poll dies because the process is on its way out. Rethrowing
|
|
173
|
+
// would unwind into `start`'s "monitoring stopped" handler and race exit 3
|
|
174
|
+
// against the 130 the detach already recorded.
|
|
175
|
+
fetchWorkflowHistory.mockImplementation(async () => {
|
|
176
|
+
sigint();
|
|
177
|
+
throw new Error('socket hang up');
|
|
178
|
+
});
|
|
179
|
+
const io = createIo();
|
|
180
|
+
expect(await streamWorkflowUpdates(options(), io)).toBeUndefined();
|
|
181
|
+
expect(process.exitCode).toBe(130);
|
|
182
|
+
expect(io.warn).not.toHaveBeenCalled();
|
|
183
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
184
|
+
await flush();
|
|
185
|
+
onSpy.mockRestore();
|
|
186
|
+
exitSpy.mockRestore();
|
|
187
|
+
});
|
|
188
|
+
it('ignores a second Ctrl+C while the first is still draining stdout', async () => {
|
|
189
|
+
const { fetchWorkflowHistory } = await histories();
|
|
190
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
191
|
+
const { onSpy, exitSpy, sigint } = await detachDuring();
|
|
192
|
+
const io = createIo();
|
|
193
|
+
await streamWorkflowUpdates(options(), io);
|
|
194
|
+
sigint();
|
|
195
|
+
// The exit is deferred behind a flush and the listener is still registered,
|
|
196
|
+
// so an impatient user lands here again before the process is gone.
|
|
197
|
+
sigint();
|
|
198
|
+
await flush();
|
|
199
|
+
const detached = io.log.mock.calls.filter((call) => String(call[0]).includes('Detached'));
|
|
200
|
+
expect(detached).toHaveLength(1);
|
|
201
|
+
expect(exitSpy).toHaveBeenCalledTimes(1);
|
|
202
|
+
onSpy.mockRestore();
|
|
203
|
+
exitSpy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
it('removes its SIGINT listener once the loop ends', async () => {
|
|
206
|
+
const { fetchWorkflowHistory } = await histories();
|
|
207
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
208
|
+
const before = process.listenerCount('SIGINT');
|
|
209
|
+
await streamWorkflowUpdates(options(), createIo());
|
|
210
|
+
expect(process.listenerCount('SIGINT')).toBe(before);
|
|
211
|
+
});
|
|
212
|
+
it('removes its SIGINT listener even when the loop unwinds on an error', async () => {
|
|
213
|
+
const { fetchWorkflowHistory } = await histories();
|
|
214
|
+
fetchWorkflowHistory.mockRejectedValue(new HttpError('Not found', { status: 404 }));
|
|
215
|
+
const before = process.listenerCount('SIGINT');
|
|
216
|
+
await expect(streamWorkflowUpdates(options(), createIo())).rejects.toThrow();
|
|
217
|
+
expect(process.listenerCount('SIGINT')).toBe(before);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('continue-as-new', () => {
|
|
221
|
+
it('breaks instead of re-polling forever when the new run id is missing and io.error returns', async () => {
|
|
222
|
+
const { fetchWorkflowHistory } = await histories();
|
|
223
|
+
fetchWorkflowHistory.mockResolvedValue(history('continued_as_new'));
|
|
224
|
+
// `io.error` is typed `never`, but an io whose error returns would otherwise
|
|
225
|
+
// fall through and replay the whole history every interval, forever, with a
|
|
226
|
+
// zero exit code.
|
|
227
|
+
const io = createIo(true);
|
|
228
|
+
expect(await streamWorkflowUpdates(options(), io)).toBeUndefined();
|
|
229
|
+
expect(io.error).toHaveBeenCalledWith(expect.stringContaining('new run ID could not be determined'));
|
|
230
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
231
|
+
});
|
|
232
|
+
it('reports the chained run under the new run id in json mode', async () => {
|
|
233
|
+
const { fetchWorkflowHistory } = await histories();
|
|
234
|
+
fetchWorkflowHistory
|
|
235
|
+
.mockResolvedValueOnce(history('continued_as_new', { continuedAsNewRunId: 'run-2' }))
|
|
236
|
+
.mockResolvedValueOnce(history('completed', { runId: 'run-2' }));
|
|
237
|
+
const io = createIo();
|
|
238
|
+
await streamWorkflowUpdates(options({ json: true }), io);
|
|
239
|
+
const lines = io.log.mock.calls.map((call) => JSON.parse(call[0]));
|
|
240
|
+
expect(lines.some((line) => line.continuedAsNewRunId === 'run-2')).toBe(true);
|
|
241
|
+
// Every subsequent line carries the run actually being polled, not the one
|
|
242
|
+
// the stream attached to.
|
|
243
|
+
expect(lines.at(-1)).toMatchObject({ workflowId: 'wf-1', runId: 'run-2', status: 'completed' });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('commandStreamIo()', () => {
|
|
247
|
+
it('resolves log/warn at call time so a replaced command method still receives output', async () => {
|
|
248
|
+
const command = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
249
|
+
const io = commandStreamIo(command);
|
|
250
|
+
// oclif (and the command specs) swap these in as own properties after the
|
|
251
|
+
// adapter exists, which a `.bind()` at construction time would miss.
|
|
252
|
+
const replacement = vi.fn();
|
|
253
|
+
command.log = replacement;
|
|
254
|
+
io.log('hello');
|
|
255
|
+
expect(replacement).toHaveBeenCalledWith('hello');
|
|
256
|
+
});
|
|
257
|
+
it('raises command errors with an explicit exit 1', () => {
|
|
258
|
+
const command = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
259
|
+
commandStreamIo(command).error('boom');
|
|
260
|
+
expect(command.error).toHaveBeenCalledWith('boom', { exit: 1 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe('monitorErrorOverrides()', () => {
|
|
264
|
+
const withResponse = (status, data) => Object.assign(new Error('failed'), { response: { status, data } });
|
|
265
|
+
it('overrides a 400 the server identifies as a stale resume cursor', () => {
|
|
266
|
+
expect(monitorErrorOverrides(withResponse(400, { error: 'InvalidPageTokenError' }))).toEqual({
|
|
267
|
+
400: expect.stringContaining('Resume cursor is no longer valid')
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
it('leaves an unrelated 400 alone rather than misdiagnosing it as a stale cursor', () => {
|
|
271
|
+
// 400 also covers a missing runId and an out-of-range longPollTimeoutMs, whose
|
|
272
|
+
// real validation messages are more useful than a cursor guess.
|
|
273
|
+
expect(monitorErrorOverrides(withResponse(400, { error: 'ValidationError' }))).toEqual({});
|
|
274
|
+
expect(monitorErrorOverrides(withResponse(400))).toEqual({});
|
|
275
|
+
});
|
|
276
|
+
it('does not override a 404, which only reads correctly where the user typed the id', () => {
|
|
277
|
+
// `workflow monitor` adds its own; under `start --monitor` the id came back
|
|
278
|
+
// from the API, so "check the workflow ID" would misdirect the user.
|
|
279
|
+
expect(monitorErrorOverrides(withResponse(404))).toEqual({});
|
|
280
|
+
});
|
|
281
|
+
it('tolerates an error with no response at all', () => {
|
|
282
|
+
expect(monitorErrorOverrides(new Error('fetch failed'))).toEqual({});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
import { getWorkflowIdHistory } from '#api/generated/api.js';
|
|
11
11
|
import { correlate, eventAttributes, eventTypeName } from '#services/workflow_history/correlator.js';
|
|
12
12
|
import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isTerminalStatus } from '#utils/format_workflow_result.js';
|
|
14
14
|
const PAGE_SIZE = 50;
|
|
15
15
|
// The status here comes from the request's own describe (fresh whenever `wait` is
|
|
16
16
|
// set), so it can report the run closed before the closing event has been paged in.
|
|
17
17
|
function isRunClosed(meta) {
|
|
18
18
|
const status = normalizeWorkflowStatus(meta?.status);
|
|
19
|
-
return status === 'continued_as_new' || (status !== undefined
|
|
19
|
+
return status === 'continued_as_new' || isTerminalStatus(status) !== undefined;
|
|
20
20
|
}
|
|
21
21
|
function numericEventId(event) {
|
|
22
22
|
const id = Number(event.eventId);
|
|
@@ -17,10 +17,12 @@ claude plugin install outputai@outputai --scope project
|
|
|
17
17
|
npm run output:dev # Start dev environment (worker + Temporal)
|
|
18
18
|
npm run output:worker:build # Build TypeScript to dist/
|
|
19
19
|
npm run output:worker:check # Optional: bundle-check workflows for bad imports (node: built-ins)
|
|
20
|
-
npm run output:worker:watch # Build + restart on file changes
|
|
21
|
-
npm run output:worker #
|
|
20
|
+
npm run output:worker:watch # Build + restart on src/ file changes
|
|
21
|
+
npm run output:worker # Build and start worker
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Hot-reload watches `src/` only. After changing dependencies (`package.json` / lockfile), run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
25
|
+
|
|
24
26
|
## Project Structure
|
|
25
27
|
|
|
26
28
|
```
|
|
@@ -79,7 +79,9 @@ This starts:
|
|
|
79
79
|
- Temporal server and UI (http://localhost:8080)
|
|
80
80
|
- PostgreSQL and Redis databases
|
|
81
81
|
- Output.ai API server (http://localhost:3001)
|
|
82
|
-
- Worker process for executing workflows
|
|
82
|
+
- Worker process for executing workflows (auto-reloads on `src/` changes)
|
|
83
|
+
|
|
84
|
+
Dependency changes (`package.json` / lockfile) are not picked up by hot-reload. Run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
83
85
|
|
|
84
86
|
### 4. Run a workflow
|
|
85
87
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"output:worker:build": "rm -rf dist/* && tsc -p ./ && output-copy-assets",
|
|
10
10
|
"output:worker:start": "output-worker",
|
|
11
11
|
"output:worker:check": "output-worker --check",
|
|
12
|
-
"output:worker": "npm run output:worker:
|
|
13
|
-
"output:worker:watch": "npx nodemon --watch src --
|
|
12
|
+
"output:worker": "npm run output:worker:build && npm run output:worker:start",
|
|
13
|
+
"output:worker:watch": "npx nodemon --watch src --ext ts,js,json,prompt,md --ignore 'dist/**' --ignore '**/*.spec.*' --ignore '**/*.test.*' --exec 'npm run output:worker'",
|
|
14
14
|
"output:dev": "output dev"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
@@ -5,4 +5,14 @@ type ErrorOverrides = {
|
|
|
5
5
|
export declare function handleApiError(error: unknown, errorFn: (...args: [message: string, options: {
|
|
6
6
|
exit: number;
|
|
7
7
|
}]) => never, overrides?: ErrorOverrides): never;
|
|
8
|
+
/**
|
|
9
|
+
* `catch()` handling for a command that raises oclif errors of its own. Flag
|
|
10
|
+
* relationship failures and every `this.error( ..., { exit } )` already carry an
|
|
11
|
+
* exit code and formatted output, and `handleApiError` would flatten all of it
|
|
12
|
+
* to a bare exit 1 — so pass those straight through and only map what actually
|
|
13
|
+
* came back from the API.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleCommandError(error: Error, errorFn: (...args: [message: string, options: {
|
|
16
|
+
exit: number;
|
|
17
|
+
}]) => never, overrides?: ErrorOverrides): never;
|
|
8
18
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLIError } from '@oclif/core/errors';
|
|
1
2
|
import { config } from '#config.js';
|
|
2
3
|
function getDefaultMessages() {
|
|
3
4
|
return {
|
|
@@ -79,3 +80,16 @@ export function handleApiError(error, errorFn, overrides = {}) {
|
|
|
79
80
|
const detailedMessage = getDetailedErrorMessage(error);
|
|
80
81
|
errorFn(detailedMessage, { exit: 1 });
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* `catch()` handling for a command that raises oclif errors of its own. Flag
|
|
85
|
+
* relationship failures and every `this.error( ..., { exit } )` already carry an
|
|
86
|
+
* exit code and formatted output, and `handleApiError` would flatten all of it
|
|
87
|
+
* to a bare exit 1 — so pass those straight through and only map what actually
|
|
88
|
+
* came back from the API.
|
|
89
|
+
*/
|
|
90
|
+
export function handleCommandError(error, errorFn, overrides = {}) {
|
|
91
|
+
if (error instanceof CLIError) {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
return handleApiError(error, errorFn, overrides);
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { CLIError } from '@oclif/core/errors';
|
|
4
|
+
import { handleApiError, handleCommandError } from './error_handler.js';
|
|
5
|
+
const errorFn = () => vi.fn((message) => {
|
|
6
|
+
throw new Error(message);
|
|
7
|
+
});
|
|
8
|
+
const apiError = (status, data) => Object.assign(new Error('request failed'), { response: { status, data } });
|
|
9
|
+
describe('handleApiError()', () => {
|
|
10
|
+
it('prefers a caller override over the server body for the same status', () => {
|
|
11
|
+
const fn = errorFn();
|
|
12
|
+
expect(() => handleApiError(apiError(404, { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' }), fn, { 404: 'Workflow not found. Check the workflow ID.' })).toThrow('Workflow not found. Check the workflow ID.');
|
|
13
|
+
});
|
|
14
|
+
it('surfaces the server body when no override covers the status', () => {
|
|
15
|
+
const fn = errorFn();
|
|
16
|
+
// What `start --monitor` relies on now that it passes no 404 override: the
|
|
17
|
+
// API's own words, rather than advice about an id the user never typed.
|
|
18
|
+
expect(() => handleApiError(apiError(404, { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' }), fn)).toThrow('WorkflowNotFoundError: Workflow "wf-1" not found.');
|
|
19
|
+
});
|
|
20
|
+
it('appends a root cause to the server message when the API reports one', () => {
|
|
21
|
+
const fn = errorFn();
|
|
22
|
+
expect(() => handleApiError(apiError(500, { error: 'StepFailure', message: 'Step failed', rootCause: { error: 'TypeError', message: 'x is not a function' } }), fn)).toThrow(/TypeError: x is not a function/);
|
|
23
|
+
});
|
|
24
|
+
it('falls back to the status default when the body carries no detail', () => {
|
|
25
|
+
const fn = errorFn();
|
|
26
|
+
expect(() => handleApiError(apiError(401), fn)).toThrow(/OUTPUT_API_AUTH_TOKEN/);
|
|
27
|
+
});
|
|
28
|
+
it.each([
|
|
29
|
+
['a top-level code', Object.assign(new Error('connect'), { code: 'ECONNREFUSED' })],
|
|
30
|
+
['a nested cause', Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })]
|
|
31
|
+
])('reports a refused connection from %s before looking at any status', (_label, error) => {
|
|
32
|
+
const fn = errorFn();
|
|
33
|
+
expect(() => handleApiError(error, fn)).toThrow(/Is the API server running\?/);
|
|
34
|
+
});
|
|
35
|
+
it('assembles a detailed message when there is no response at all', () => {
|
|
36
|
+
const fn = errorFn();
|
|
37
|
+
const error = Object.assign(new Error('fetch failed'), {
|
|
38
|
+
cause: Object.assign(new Error('getaddrinfo EAI_AGAIN api'), { code: 'EAI_AGAIN', hostname: 'api', port: 3001 })
|
|
39
|
+
});
|
|
40
|
+
expect(() => handleApiError(error, fn)).toThrow(/fetch failed \| Cause: getaddrinfo EAI_AGAIN api \| Code: EAI_AGAIN \| Host: api:3001/);
|
|
41
|
+
});
|
|
42
|
+
it('always exits 1, leaving a command\'s own exit codes to the command', () => {
|
|
43
|
+
const fn = errorFn();
|
|
44
|
+
expect(() => handleApiError(apiError(500), fn)).toThrow();
|
|
45
|
+
expect(fn).toHaveBeenCalledWith(expect.any(String), { exit: 1 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('handleCommandError()', () => {
|
|
49
|
+
it('rethrows an oclif error untouched so its exit code and formatting survive', () => {
|
|
50
|
+
const fn = errorFn();
|
|
51
|
+
// `workflow start --monitor` raises exit 2 (bad flag combination) and exit 3
|
|
52
|
+
// (started but unmonitorable); flattening those to exit 1 would let a CI job
|
|
53
|
+
// retrying on a failed workflow re-submit one that is already running.
|
|
54
|
+
const cliError = new CLIError('Cannot combine --monitor with --json', { exit: 2 });
|
|
55
|
+
expect(() => handleCommandError(cliError, fn)).toThrow(cliError);
|
|
56
|
+
expect(fn).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('maps anything that came back from the API the same way handleApiError does', () => {
|
|
59
|
+
const fn = errorFn();
|
|
60
|
+
expect(() => handleCommandError(apiError(404), fn, { 404: 'Workflow not found. Check the workflow name.' })).toThrow('Workflow not found. Check the workflow name.');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import type { WorkflowResultResponse
|
|
1
|
+
import type { WorkflowResultResponse } from '../api/generated/api.js';
|
|
2
2
|
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
-
|
|
4
|
-
export
|
|
3
|
+
declare const ERROR_STATUS_VALUES: readonly ["failed", "cancelled", "terminated", "timed_out"];
|
|
4
|
+
export type ErrorStatus = typeof ERROR_STATUS_VALUES[number];
|
|
5
|
+
export type TerminalStatus = 'completed' | ErrorStatus;
|
|
6
|
+
/**
|
|
7
|
+
* Maps a raw status to a canonical error status, or `undefined` if it is not one.
|
|
8
|
+
* Legacy spellings (`canceled`) are normalized so callers only ever see the
|
|
9
|
+
* current API vocabulary — until `normalizeWorkflowStatus` is removed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isErrorStatus(status: string | null | undefined): ErrorStatus | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Maps a raw status to a canonical terminal status, or `undefined` if it is not
|
|
14
|
+
* one. Same normalization contract as `isErrorStatus`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isTerminalStatus(status: string | null | undefined): TerminalStatus | undefined;
|
|
5
17
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
6
18
|
export {};
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// `satisfies` rather than a plain annotation: it pins these to the generated API
|
|
3
|
+
// union without widening them, so regenerating the API with a renamed status
|
|
4
|
+
// breaks the build instead of silently making that status non-terminal.
|
|
5
|
+
const ERROR_STATUS_VALUES = [
|
|
6
|
+
'failed', 'cancelled', 'terminated', 'timed_out'
|
|
7
|
+
];
|
|
8
|
+
const ERROR_STATUSES = new Set(ERROR_STATUS_VALUES);
|
|
9
|
+
const TERMINAL_STATUSES = new Set(['completed', ...ERROR_STATUS_VALUES]);
|
|
10
|
+
/**
|
|
11
|
+
* Maps a raw status to a canonical error status, or `undefined` if it is not one.
|
|
12
|
+
* Legacy spellings (`canceled`) are normalized so callers only ever see the
|
|
13
|
+
* current API vocabulary — until `normalizeWorkflowStatus` is removed.
|
|
14
|
+
*/
|
|
15
|
+
/* eslint-disable consistent-return -- returns ErrorStatus | undefined via early exit */
|
|
16
|
+
export function isErrorStatus(status) {
|
|
17
|
+
if (typeof status !== 'string') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const normalized = normalizeWorkflowStatus(status);
|
|
21
|
+
return ERROR_STATUSES.has(normalized) ? normalized : undefined;
|
|
22
|
+
}
|
|
23
|
+
/* eslint-enable consistent-return */
|
|
24
|
+
/**
|
|
25
|
+
* Maps a raw status to a canonical terminal status, or `undefined` if it is not
|
|
26
|
+
* one. Same normalization contract as `isErrorStatus`.
|
|
27
|
+
*/
|
|
28
|
+
/* eslint-disable consistent-return -- returns TerminalStatus | undefined via early exit */
|
|
29
|
+
export function isTerminalStatus(status) {
|
|
30
|
+
if (typeof status !== 'string') {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const normalized = normalizeWorkflowStatus(status);
|
|
34
|
+
return TERMINAL_STATUSES.has(normalized) ? normalized : undefined;
|
|
35
|
+
}
|
|
36
|
+
/* eslint-enable consistent-return */
|
|
7
37
|
export function formatWorkflowResult(result) {
|
|
8
38
|
const status = normalizeWorkflowStatus(result.status);
|
|
9
39
|
const lines = [
|
|
@@ -17,7 +47,10 @@ export function formatWorkflowResult(result) {
|
|
|
17
47
|
else {
|
|
18
48
|
lines.push(`Status: ${status || 'unknown'}`);
|
|
19
49
|
if (result.error) {
|
|
20
|
-
|
|
50
|
+
const error = typeof result.error === 'string' ?
|
|
51
|
+
result.error :
|
|
52
|
+
result.error.message ?? JSON.stringify(result.error, null, 2);
|
|
53
|
+
lines.push(`Error: ${error}`);
|
|
21
54
|
}
|
|
22
55
|
}
|
|
23
56
|
return lines.join('\n');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatWorkflowResult } from './format_workflow_result.js';
|
|
2
|
+
import { formatWorkflowResult, isErrorStatus } from './format_workflow_result.js';
|
|
3
3
|
describe('formatWorkflowResult', () => {
|
|
4
4
|
it('should display output for completed workflows', () => {
|
|
5
5
|
const result = formatWorkflowResult({
|
|
@@ -13,7 +13,7 @@ describe('formatWorkflowResult', () => {
|
|
|
13
13
|
expect(result).toContain('"values"');
|
|
14
14
|
expect(result).not.toContain('Status:');
|
|
15
15
|
});
|
|
16
|
-
it('should display
|
|
16
|
+
it('should display legacy string errors for failed workflows', () => {
|
|
17
17
|
const result = formatWorkflowResult({
|
|
18
18
|
workflowId: 'wf-456',
|
|
19
19
|
status: 'failed',
|
|
@@ -25,6 +25,29 @@ describe('formatWorkflowResult', () => {
|
|
|
25
25
|
expect(result).toContain('Error: Activity task failed');
|
|
26
26
|
expect(result).not.toContain('Output:');
|
|
27
27
|
});
|
|
28
|
+
it('should display the message from structured errors', () => {
|
|
29
|
+
const result = formatWorkflowResult({
|
|
30
|
+
workflowId: 'wf-v2',
|
|
31
|
+
status: 'failed',
|
|
32
|
+
output: null,
|
|
33
|
+
error: {
|
|
34
|
+
name: 'ValidationError',
|
|
35
|
+
message: 'Input is invalid',
|
|
36
|
+
code: 'INVALID_INPUT'
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
expect(result).toContain('Error: Input is invalid');
|
|
40
|
+
expect(result).not.toContain('[object Object]');
|
|
41
|
+
});
|
|
42
|
+
it('should serialize structured errors without a message', () => {
|
|
43
|
+
const result = formatWorkflowResult({
|
|
44
|
+
workflowId: 'wf-v2',
|
|
45
|
+
status: 'failed',
|
|
46
|
+
output: null,
|
|
47
|
+
error: { code: 'UNKNOWN' }
|
|
48
|
+
});
|
|
49
|
+
expect(result).toContain('"code": "UNKNOWN"');
|
|
50
|
+
});
|
|
28
51
|
it('should display status for terminated workflows', () => {
|
|
29
52
|
const result = formatWorkflowResult({
|
|
30
53
|
workflowId: 'wf-term',
|
|
@@ -35,15 +58,25 @@ describe('formatWorkflowResult', () => {
|
|
|
35
58
|
expect(result).toContain('Status: terminated');
|
|
36
59
|
expect(result).toContain('Error: Workflow terminated by user');
|
|
37
60
|
});
|
|
38
|
-
it('should display status for
|
|
61
|
+
it('should display status for cancelled workflows', () => {
|
|
39
62
|
const result = formatWorkflowResult({
|
|
63
|
+
workflowId: 'wf-cancel',
|
|
64
|
+
status: 'cancelled',
|
|
65
|
+
output: null,
|
|
66
|
+
error: 'Workflow was cancelled'
|
|
67
|
+
});
|
|
68
|
+
expect(result).toContain('Status: cancelled');
|
|
69
|
+
expect(result).toContain('Error: Workflow was cancelled');
|
|
70
|
+
});
|
|
71
|
+
it('normalizes canceled responses without changing the current status type', () => {
|
|
72
|
+
const legacyResult = {
|
|
40
73
|
workflowId: 'wf-cancel',
|
|
41
74
|
status: 'canceled',
|
|
42
75
|
output: null,
|
|
43
76
|
error: 'Workflow was canceled'
|
|
44
|
-
}
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
77
|
+
};
|
|
78
|
+
expect(formatWorkflowResult(legacyResult)).toContain('Status: cancelled');
|
|
79
|
+
expect(isErrorStatus('canceled')).toBe('cancelled');
|
|
47
80
|
});
|
|
48
81
|
it('should display status without error line for continued_as_new workflows', () => {
|
|
49
82
|
const result = formatWorkflowResult({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream's defaults when the tuning flags are not given. Caller-side, not a
|
|
3
|
+
* property of the loop: `streamWorkflowUpdates` takes all three as required
|
|
4
|
+
* options and never falls back. Single-sourced because only `workflow monitor`
|
|
5
|
+
* can hand them to oclif — `workflow start --monitor` has to apply them itself
|
|
6
|
+
* (see `gatedMonitorStreamFlags`), and a literal repeated on that side would
|
|
7
|
+
* drift the moment one of these changes.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MONITOR_DEFAULTS: {
|
|
10
|
+
readonly interval: 2500;
|
|
11
|
+
readonly color: true;
|
|
12
|
+
readonly includePayloads: false;
|
|
13
|
+
};
|
|
14
|
+
/** For a command that always monitors, where oclif can apply the defaults itself. */
|
|
15
|
+
export declare function monitorStreamFlags(): {
|
|
16
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* For a command where monitoring is opt-in (`workflow start --monitor`).
|
|
22
|
+
*
|
|
23
|
+
* These carry no oclif `default` on purpose: a defaulted flag counts as present
|
|
24
|
+
* (`validateFlags` runs a relationship check whenever the parsed value is not
|
|
25
|
+
* `undefined`, and `validateDependsOn` has no `setFromDefault` exemption the way
|
|
26
|
+
* `validateExclusive` does), which triggers its own `dependsOn` check and fails
|
|
27
|
+
* every invocation that omits `--monitor` — including a plain `workflow start`.
|
|
28
|
+
* The caller applies the defaults in `run()`, so the interval's default is
|
|
29
|
+
* spelled out in the help text rather than rendered by oclif.
|
|
30
|
+
*/
|
|
31
|
+
export declare function gatedMonitorStreamFlags(gatedBy: string): {
|
|
32
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
33
|
+
interval: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
34
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
35
|
+
};
|