@outputai/cli 0.10.1-next.f6a7c1a.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/api/generated/api.d.ts +81 -26
  2. package/dist/api/generated/api.js +7 -4
  3. package/dist/api/http_client.js +2 -2
  4. package/dist/assets/docker/docker-compose-dev.yml +2 -2
  5. package/dist/commands/dev/down.d.ts +10 -0
  6. package/dist/commands/dev/down.js +34 -0
  7. package/dist/commands/dev/down.spec.js +71 -0
  8. package/dist/commands/dev/index.d.ts +4 -0
  9. package/dist/commands/dev/index.js +200 -53
  10. package/dist/commands/dev/index.spec.js +390 -42
  11. package/dist/commands/workflow/monitor.d.ts +5 -20
  12. package/dist/commands/workflow/monitor.js +20 -182
  13. package/dist/commands/workflow/monitor.spec.js +82 -3
  14. package/dist/commands/workflow/result.js +2 -2
  15. package/dist/commands/workflow/result.spec.js +65 -1
  16. package/dist/commands/workflow/run.js +10 -3
  17. package/dist/commands/workflow/run.spec.js +42 -5
  18. package/dist/commands/workflow/start.d.ts +7 -1
  19. package/dist/commands/workflow/start.js +107 -12
  20. package/dist/commands/workflow/start.spec.js +282 -5
  21. package/dist/commands/workflow/status.spec.js +1 -1
  22. package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
  23. package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
  24. package/dist/commands/workflow/test.spec.d.ts +1 -0
  25. package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
  26. package/dist/generated/framework_version.json +1 -1
  27. package/dist/services/docker.d.ts +28 -1
  28. package/dist/services/docker.js +106 -12
  29. package/dist/services/docker.spec.js +144 -14
  30. package/dist/services/monitor_stream.d.ts +62 -0
  31. package/dist/services/monitor_stream.js +285 -0
  32. package/dist/services/monitor_stream.spec.d.ts +1 -0
  33. package/dist/services/monitor_stream.spec.js +285 -0
  34. package/dist/services/workflow_history.js +2 -2
  35. package/dist/templates/agent_instructions/CLAUDE.md.template +5 -3
  36. package/dist/templates/project/README.md.template +3 -1
  37. package/dist/templates/project/package.json.template +2 -2
  38. package/dist/templates/project/src/clients/jina.ts.template +4 -4
  39. package/dist/utils/env_loader.js +6 -2
  40. package/dist/utils/env_loader.spec.js +61 -32
  41. package/dist/utils/error_handler.d.ts +10 -0
  42. package/dist/utils/error_handler.js +14 -0
  43. package/dist/utils/error_handler.spec.d.ts +1 -0
  44. package/dist/utils/error_handler.spec.js +62 -0
  45. package/dist/utils/format_workflow_result.d.ts +15 -3
  46. package/dist/utils/format_workflow_result.js +39 -6
  47. package/dist/utils/format_workflow_result.spec.js +39 -6
  48. package/dist/utils/monitor_flags.d.ts +35 -0
  49. package/dist/utils/monitor_flags.js +76 -0
  50. package/dist/utils/normalize_workflow_status.d.ts +4 -3
  51. package/dist/utils/normalize_workflow_status.js +12 -3
  52. package/dist/utils/normalize_workflow_status.spec.js +3 -0
  53. package/dist/utils/port_collision.d.ts +22 -7
  54. package/dist/utils/port_collision.js +39 -14
  55. package/dist/utils/port_collision.spec.js +40 -1
  56. package/dist/utils/resolve_input.d.ts +9 -1
  57. package/dist/utils/resolve_input.js +8 -2
  58. package/dist/utils/resolve_input.spec.d.ts +1 -0
  59. package/dist/utils/resolve_input.spec.js +75 -0
  60. package/dist/views/dev/chrome/footer.d.ts +2 -0
  61. package/dist/views/dev/chrome/footer.js +4 -4
  62. package/dist/views/dev/components/workflow_status.js +1 -1
  63. package/dist/views/dev/dev_app.d.ts +1 -0
  64. package/dist/views/dev/dev_app.js +13 -4
  65. package/dist/views/dev/hooks/use_run_detail.js +4 -4
  66. package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
  67. package/dist/views/dev/panels/runs_panel.js +2 -2
  68. package/oclif.manifest.json +91 -10
  69. package/package.json +7 -9
  70. /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
@@ -1,39 +1,8 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
- import { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } from '#services/workflow_history.js';
3
- import buildSpanLabels from '#utils/span_labels.js';
4
- import { formatDurationLabel } from '#utils/waterfall.js';
5
- import { diffSpanUpdates, formatContinuedAsNew, formatSpanUpdate } from '#utils/monitor_log.js';
6
- import { ERROR_STATUSES, TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
7
- import { handleApiError } from '#utils/error_handler.js';
8
- import { getErrorMessage } from '#utils/error_utils.js';
9
- import { sleep } from '#utils/sleep.js';
10
- import { shouldColorize } from '#utils/color.js';
11
- import { HttpError } from '#api/http_client.js';
12
- const DEFAULT_INTERVAL_MS = 2500;
13
- const MAX_CONSECUTIVE_ERRORS = 5;
2
+ import { commandStreamIo, monitorErrorOverrides, streamWorkflowUpdates } from '#services/monitor_stream.js';
3
+ import { handleCommandError } from '#utils/error_handler.js';
4
+ import { monitorStreamFlags } from '#utils/monitor_flags.js';
14
5
  const OUTPUT_FORMAT = { JSON: 'json', TEXT: 'text' };
15
- const SIGINT_EXIT_CODE = 130;
16
- const TRANSIENT_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
17
- /**
18
- * Distinguishes blips worth retrying from errors that will fail identically on
19
- * every attempt: network hiccups, a client-side request timeout, and 5xx/408/429
20
- * responses are transient. Everything else — a 4xx like a stale/invalid resume
21
- * cursor (`InvalidPageTokenError`, surfaced as 400), or a bug in correlate()/
22
- * buildResult() re-throwing the same exception — can't self-resolve by waiting,
23
- * so it should surface immediately instead of burning the retry budget.
24
- */
25
- function isTransientPollError(error) {
26
- if (error instanceof HttpError) {
27
- const status = error.response.status;
28
- return status >= 500 || status === 408 || status === 429;
29
- }
30
- const err = error;
31
- if (err.name === 'TimeoutError') {
32
- return true;
33
- }
34
- return Boolean((err.code && TRANSIENT_ERROR_CODES.has(err.code)) ||
35
- (err.cause?.code && TRANSIENT_ERROR_CODES.has(err.cause.code)));
36
- }
37
6
  /**
38
7
  * Unlike `run`/`status`/`result` (migrated to oclif's native `--json` in
39
8
  * OUT-419, #281), this command deliberately keeps a custom `--format json`
@@ -47,6 +16,9 @@ function isTransientPollError(error) {
47
16
  * can tail and parse the stream incrementally, which native `--json`'s
48
17
  * "one object at the end" model can't do. See docs/guides/packages/cli.mdx
49
18
  * ("output workflow monitor") for the same rationale written up for users.
19
+ *
20
+ * The polling loop itself lives in `#services/monitor_stream.js` so
21
+ * `workflow start --monitor` (OUT-537) streams through the same code path.
50
22
  */
51
23
  export default class WorkflowMonitor extends Command {
52
24
  static description = 'Attach to a workflow run and stream status updates until it ends';
@@ -72,159 +44,25 @@ export default class WorkflowMonitor extends Command {
72
44
  options: [OUTPUT_FORMAT.TEXT, OUTPUT_FORMAT.JSON],
73
45
  default: OUTPUT_FORMAT.TEXT
74
46
  }),
75
- 'include-payloads': Flags.boolean({
76
- description: 'Include decoded step input/output payloads',
77
- default: false
78
- }),
79
- interval: Flags.integer({
80
- description: 'Poll interval in milliseconds. Once a resumed poll is long-polling ' +
81
- 'server-side for new events, this also bounds how long that block may last (capped ' +
82
- 'at the server\'s configured max), so an idle workflow\'s update cadence is roughly ' +
83
- 'twice this value (the long-poll bound, then this sleep) rather than a much longer, ' +
84
- 'separate server default.',
85
- default: DEFAULT_INTERVAL_MS,
86
- min: 1
87
- }),
88
- color: Flags.boolean({
89
- description: 'Colorize status output (use --no-color to disable)',
90
- default: true,
91
- allowNo: true
92
- })
47
+ ...monitorStreamFlags()
93
48
  };
94
49
  async run() {
95
50
  const { args, flags } = await this.parse(WorkflowMonitor);
96
- const color = shouldColorize(flags.color);
97
- const json = flags.format === OUTPUT_FORMAT.JSON;
98
- // Threaded via mutable properties (not `let` reassignment) so state
99
- // persists across polls without local variable reassignment.
100
- const state = {
51
+ await streamWorkflowUpdates({
52
+ workflowId: args.workflowId,
101
53
  runId: flags['run-id'],
102
- consecutiveErrors: 0,
103
- firstTick: true,
104
- // Undefined until a resumable cursor is established (see `poll` and
105
- // `fetchWorkflowHistoryUpdates`); reset on continue-as-new since a new run's
106
- // cursor position is meaningless carried over from the old one.
107
- cursor: undefined
108
- };
109
- const seen = new Map();
110
- // Assigned once per span id and never overwritten: `buildSpanLabels` numbers
111
- // same-named spans by how many are in the array *at call time*, so recomputing
112
- // it fresh every poll could retroactively change a label already printed to
113
- // the user (e.g. an unnumbered "Scrape Page" becoming "Scrape Page #1" once a
114
- // second instance appears). Freezing on first sight keeps printed labels stable.
115
- const labels = new Map();
116
- // One emit point for both output formats: json mode wraps `fields` (plus
117
- // the ambient workflow/run id) as a line of NDJSON, text mode prints `text`.
118
- const emit = (fields, text) => {
119
- this.log(json ?
120
- JSON.stringify({ workflowId: args.workflowId, runId: state.runId, ...fields }) :
121
- text);
122
- };
123
- emit({ monitoring: true }, `Monitoring ${args.workflowId}${state.runId ? ` (run ${state.runId})` : ''}... (Ctrl+C to detach)`);
124
- const sigintHandler = () => {
125
- emit({ detached: true }, '\nDetached (the workflow keeps running).');
126
- process.exit(SIGINT_EXIT_CODE);
127
- };
128
- process.on('SIGINT', sigintHandler);
129
- try {
130
- while (true) {
131
- const outcome = await this.poll(args.workflowId, flags, state);
132
- if (outcome === null) {
133
- state.consecutiveErrors += 1;
134
- await sleep(flags.interval);
135
- continue;
136
- }
137
- state.consecutiveErrors = 0;
138
- state.firstTick = false;
139
- state.cursor = outcome.cursor;
140
- const result = outcome.result;
141
- state.runId = result.runId ?? state.runId;
142
- for (const [id, label] of buildSpanLabels(result.spans)) {
143
- if (!labels.has(id)) {
144
- labels.set(id, label);
145
- }
146
- }
147
- for (const update of diffSpanUpdates(result.spans, labels, seen)) {
148
- emit({ span: update.span }, formatSpanUpdate(update, color));
149
- }
150
- const status = result.workflow?.status;
151
- if (status === 'continued_as_new') {
152
- if (!result.continuedAsNewRunId) {
153
- this.error('Workflow continued as a new run, but the new run ID could not be determined.', { exit: 1 });
154
- }
155
- emit({ continuedAsNewRunId: result.continuedAsNewRunId }, formatContinuedAsNew(result.continuedAsNewRunId));
156
- state.runId = result.continuedAsNewRunId;
157
- state.cursor = undefined;
158
- seen.clear();
159
- labels.clear();
160
- await sleep(flags.interval);
161
- continue;
162
- }
163
- if (status && TERMINAL_STATUSES.has(status)) {
164
- const summary = `${status === 'completed' ? '✓' : '✗'} workflow ${status} · ${formatDurationLabel(result.totalDurationMs)}`;
165
- emit({ status }, summary);
166
- if (ERROR_STATUSES.has(status)) {
167
- process.exitCode = 1;
168
- }
169
- return;
170
- }
171
- await sleep(flags.interval);
172
- }
173
- }
174
- finally {
175
- process.removeListener('SIGINT', sigintHandler);
176
- }
177
- }
178
- /**
179
- * Wraps a single poll: a failure on the very first tick propagates (there's
180
- * nothing to fall back on), but a transient blip (see `isTransientPollError`)
181
- * after we've already been monitoring successfully just returns `null` so the
182
- * loop can retry — matching the dev TUI's `useStepGraph` behavior of keeping
183
- * the last good state on a poll hiccup. A non-transient error (e.g. a stale
184
- * resume cursor, or a bug in the parsing pipeline) rethrows immediately since
185
- * retrying it cannot succeed. `MAX_CONSECUTIVE_ERRORS` bounds how long we'll
186
- * retry transient failures before giving up.
187
- *
188
- * Fetch strategy is driven by `state.cursor`, not tick count: no cursor yet
189
- * (the very first poll, or the first poll of a run chained via continue-as-new)
190
- * uses `fetchWorkflowHistory` (fast, no long-poll) so that render isn't delayed;
191
- * once a cursor exists, every poll resumes via `fetchWorkflowHistoryUpdates`
192
- * instead of re-paging the whole history — see `plan_workflow_monitor_history.md`
193
- * for why a full re-fetch every tick is expensive for long-running workflows.
194
- */
195
- async poll(workflowId, flags, state) {
196
- try {
197
- // Keeps the resumed long-poll's server-side block roughly aligned with `--interval`
198
- // instead of always blocking for the server's full configured deadline regardless of
199
- // it (see `plan_workflow_monitor_history.md`'s "Known tradeoff" note). Only takes
200
- // effect once resuming (i.e. from the second tick onward); `fetchWorkflowHistory`
201
- // ignores it on the initial full walk.
202
- const options = { workflowId, runId: state.runId, includePayloads: flags['include-payloads'], longPollTimeoutMs: flags.interval };
203
- if (!state.cursor) {
204
- const result = await fetchWorkflowHistory(options);
205
- return { result, cursor: result.cursor };
206
- }
207
- return await fetchWorkflowHistoryUpdates(options, state.cursor);
208
- }
209
- catch (error) {
210
- if (state.firstTick || !isTransientPollError(error) || state.consecutiveErrors + 1 >= MAX_CONSECUTIVE_ERRORS) {
211
- throw error;
212
- }
213
- this.warn(`Poll failed (${state.consecutiveErrors + 1}/${MAX_CONSECUTIVE_ERRORS}), retrying: ${getErrorMessage(error)}`);
214
- return null;
215
- }
54
+ includePayloads: flags['include-payloads'],
55
+ interval: flags.interval,
56
+ json: flags.format === OUTPUT_FORMAT.JSON,
57
+ color: flags.color
58
+ }, commandStreamIo(this));
216
59
  }
217
60
  async catch(error) {
218
- // A 400 is the generic status for several distinct causes (invalid pageToken, a
219
- // missing runId, an out-of-range longPollTimeoutMs) — only override it with the stale-cursor
220
- // message when the server actually identifies that specific cause; otherwise let
221
- // the real validation error surface instead of misdiagnosing an unrelated 400.
222
- const response = error.response;
223
- const isStaleCursor = response?.status === 400 && response.data?.error === 'InvalidPageTokenError';
224
- const overrides = { 404: 'Workflow not found. Check the workflow ID.' };
225
- if (isStaleCursor) {
226
- overrides[400] = 'Resume cursor is no longer valid for this workflow; restart the monitor.';
227
- }
228
- return handleApiError(error, (...args) => this.error(...args), overrides);
61
+ return handleCommandError(error, (...args) => this.error(...args), {
62
+ ...monitorErrorOverrides(error),
63
+ // Owned by this command rather than the shared overrides: here the id is the
64
+ // argument the user typed, so pointing at it is actionable advice.
65
+ 404: 'Workflow not found. Check the workflow ID.'
66
+ });
229
67
  }
230
68
  }
@@ -39,9 +39,11 @@ const update = (status, overrides = {}) => ({
39
39
  cursor: { pageToken: 'token', lastEventId: 1, meta: null, runId: 'run-1', events: [] }
40
40
  });
41
41
  describe('workflow monitor command', () => {
42
- beforeEach(() => {
42
+ beforeEach(async () => {
43
43
  vi.clearAllMocks();
44
44
  process.exitCode = undefined;
45
+ const { sleep } = await import('#utils/sleep.js');
46
+ vi.mocked(sleep).mockResolvedValue(undefined);
45
47
  });
46
48
  describe('command definition', () => {
47
49
  it('exports a valid OCLIF command with a required workflowId arg', async () => {
@@ -99,6 +101,14 @@ describe('workflow monitor command', () => {
99
101
  expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('workflow completed'));
100
102
  expect(process.exitCode).toBeUndefined();
101
103
  });
104
+ it('pins the fetch to an explicit run id instead of resolving the latest run', async () => {
105
+ const { cmd, fetchWorkflowHistory } = await createCommand({ 'run-id': 'run-9' });
106
+ fetchWorkflowHistory.mockResolvedValueOnce(history('completed', { runId: 'run-9' }));
107
+ await cmd.run();
108
+ // The whole point of `start --monitor` pinning the run it just started: the
109
+ // id has to survive options -> state -> the first fetch, not just be accepted.
110
+ expect(fetchWorkflowHistory).toHaveBeenCalledWith(expect.objectContaining({ runId: 'run-9' }));
111
+ });
102
112
  it('sets exit code 1 when the workflow ends in a failed status', async () => {
103
113
  const { cmd, fetchWorkflowHistory } = await createCommand();
104
114
  fetchWorkflowHistory.mockResolvedValueOnce(history('failed', {
@@ -170,12 +180,31 @@ describe('workflow monitor command', () => {
170
180
  fetchWorkflowHistory.mockResolvedValueOnce(history('continued_as_new'));
171
181
  await expect(cmd.run()).rejects.toThrow(/new run ID could not be determined/);
172
182
  });
173
- it('propagates a failure on the very first poll', async () => {
183
+ it('propagates a non-transient failure on the very first poll', async () => {
174
184
  const { cmd, fetchWorkflowHistory } = await createCommand();
175
185
  fetchWorkflowHistory.mockRejectedValue(new Error('network down'));
176
186
  await expect(cmd.run()).rejects.toThrow('network down');
177
187
  expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
178
188
  });
189
+ it('retries a transient failure on the very first poll instead of aborting', async () => {
190
+ const { cmd, fetchWorkflowHistory } = await createCommand();
191
+ // `start --monitor` polls milliseconds after the API accepted the start, so
192
+ // the first poll is the one most likely to catch a restart — giving up there
193
+ // would abandon a workflow that is already running.
194
+ fetchWorkflowHistory
195
+ .mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
196
+ .mockResolvedValueOnce(history('completed', { totalDurationMs: 1000 }));
197
+ await cmd.run();
198
+ expect(fetchWorkflowHistory).toHaveBeenCalledTimes(2);
199
+ expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('(1/5)'));
200
+ expect(process.exitCode).toBeUndefined();
201
+ });
202
+ it('gives up on the first poll once the retry budget is exhausted', async () => {
203
+ const { cmd, fetchWorkflowHistory } = await createCommand();
204
+ fetchWorkflowHistory.mockRejectedValue(new HttpError('Service unavailable', { status: 503 }));
205
+ await expect(cmd.run()).rejects.toThrow('Service unavailable');
206
+ expect(fetchWorkflowHistory).toHaveBeenCalledTimes(5);
207
+ });
179
208
  it('retries a transient network failure after the first successful poll instead of crashing', async () => {
180
209
  const { cmd, fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await createCommand();
181
210
  fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
@@ -217,11 +246,61 @@ describe('workflow monitor command', () => {
217
246
  expect(sigintCall).toBeDefined();
218
247
  const handler = sigintCall[1];
219
248
  handler();
249
+ // The exit is deferred until stdout drains, so it can't truncate the
250
+ // workflow ID `start --monitor` printed moments earlier.
251
+ expect(exitSpy).not.toHaveBeenCalled();
252
+ await new Promise(resolve => setImmediate(resolve));
220
253
  expect(exitSpy).toHaveBeenCalledWith(130);
221
- expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Detached'));
254
+ // The detach message names the follow-up commands, since the workflow is
255
+ // still running and the user has nothing else on screen to act on.
256
+ const detached = cmd.log.mock.calls
257
+ .map(([line]) => line)
258
+ .find((line) => line.includes('Detached'));
259
+ expect(detached).toContain('workflow status wf-1');
260
+ expect(detached).toContain('workflow result wf-1');
222
261
  onSpy.mockRestore();
223
262
  exitSpy.mockRestore();
224
263
  });
264
+ it('records exit 130 on detach even when the command unwinds first', async () => {
265
+ const { cmd, fetchWorkflowHistory } = await createCommand();
266
+ const { sleep } = await import('#utils/sleep.js');
267
+ // Still running, so the loop sleeps — that's where Ctrl+C lands in practice.
268
+ fetchWorkflowHistory.mockResolvedValue(history('running'));
269
+ const onSpy = vi.spyOn(process, 'on');
270
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
271
+ vi.mocked(sleep).mockImplementation(async () => {
272
+ const sigint = onSpy.mock.calls.find(([event]) => event === 'SIGINT');
273
+ sigint[1]();
274
+ });
275
+ await cmd.run();
276
+ // The deferred exit races the command returning normally; if the natural
277
+ // unwind wins, only the recorded code decides what the shell sees.
278
+ expect(process.exitCode).toBe(130);
279
+ // Let the deferred flush land while process.exit is still stubbed.
280
+ await new Promise(resolve => setImmediate(resolve));
281
+ expect(exitSpy).toHaveBeenCalledWith(130);
282
+ onSpy.mockRestore();
283
+ exitSpy.mockRestore();
284
+ });
285
+ it('blames the workflow id for a 404, which is the argument the user typed', async () => {
286
+ const { cmd } = await createCommand();
287
+ const notFound = Object.assign(new Error('not found'), {
288
+ response: { status: 404, data: { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' } }
289
+ });
290
+ // The override lives on this command rather than in the shared
291
+ // `monitorErrorOverrides`: under `start --monitor` the id came back from the
292
+ // API, so the same advice would misdirect the user.
293
+ await expect(cmd.catch(notFound)).rejects.toThrow();
294
+ expect(cmd.error).toHaveBeenCalledWith('Workflow not found. Check the workflow ID.', expect.objectContaining({ exit: 1 }));
295
+ });
296
+ it('explains a stale resume cursor behind the API\'s generic 400', async () => {
297
+ const { cmd } = await createCommand();
298
+ const staleCursor = Object.assign(new Error('bad request'), {
299
+ response: { status: 400, data: { error: 'InvalidPageTokenError' } }
300
+ });
301
+ await expect(cmd.catch(staleCursor)).rejects.toThrow();
302
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Resume cursor is no longer valid'), expect.objectContaining({ exit: 1 }));
303
+ });
225
304
  it('emits NDJSON lines under --format json', async () => {
226
305
  const { cmd, fetchWorkflowHistory } = await createCommand({ format: 'json' });
227
306
  fetchWorkflowHistory.mockResolvedValueOnce(history('completed', { spans: [span('1', 'completed')], totalDurationMs: 1000 }));
@@ -1,6 +1,6 @@
1
1
  import { Args, Command } from '@oclif/core';
2
2
  import { getWorkflowIdResult } from '#api/generated/api.js';
3
- import { formatWorkflowResult, ERROR_STATUSES } from '#utils/format_workflow_result.js';
3
+ import { formatWorkflowResult, isErrorStatus } from '#utils/format_workflow_result.js';
4
4
  import { handleApiError } from '#utils/error_handler.js';
5
5
  export default class WorkflowResult extends Command {
6
6
  static description = 'Get workflow execution result';
@@ -24,7 +24,7 @@ export default class WorkflowResult extends Command {
24
24
  }
25
25
  const data = response.data;
26
26
  this.log(`\n${formatWorkflowResult(data)}`);
27
- if (ERROR_STATUSES.has(data.status)) {
27
+ if (isErrorStatus(data.status)) {
28
28
  process.exitCode = 1;
29
29
  }
30
30
  return data;
@@ -1,10 +1,12 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- vi.mock('../../api/generated/api.js', () => ({
3
+ vi.mock('#api/generated/api.js', () => ({
3
4
  getWorkflowIdResult: vi.fn()
4
5
  }));
5
6
  describe('workflow result command', () => {
6
7
  beforeEach(() => {
7
8
  vi.clearAllMocks();
9
+ process.exitCode = undefined;
8
10
  });
9
11
  describe('command definition', () => {
10
12
  it('should export a valid OCLIF command', async () => {
@@ -18,4 +20,66 @@ describe('workflow result command', () => {
18
20
  expect(WorkflowResult.enableJsonFlag).toBe(true);
19
21
  });
20
22
  });
23
+ describe('run()', () => {
24
+ const runCommand = async (data) => {
25
+ const WorkflowResult = (await import('./result.js')).default;
26
+ const { getWorkflowIdResult } = await import('#api/generated/api.js');
27
+ const cmd = new WorkflowResult(['wf-1'], {});
28
+ cmd.log = vi.fn();
29
+ cmd.parse = vi.fn().mockResolvedValue({ args: { workflowId: 'wf-1' } });
30
+ vi.mocked(getWorkflowIdResult).mockResolvedValue({
31
+ data,
32
+ status: 200,
33
+ headers: new Headers()
34
+ });
35
+ const result = await cmd.run();
36
+ return { cmd, result, getWorkflowIdResult };
37
+ };
38
+ it('returns and formats a legacy result', async () => {
39
+ const data = {
40
+ workflowId: 'wf-1',
41
+ runId: 'run-1',
42
+ status: 'failed',
43
+ input: {},
44
+ output: null,
45
+ trace: null,
46
+ error: 'Legacy failure',
47
+ errorDetails: null
48
+ };
49
+ const { cmd, result, getWorkflowIdResult } = await runCommand(data);
50
+ expect(getWorkflowIdResult).toHaveBeenCalledWith('wf-1');
51
+ expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Error: Legacy failure'));
52
+ expect(result).toEqual(data);
53
+ expect(process.exitCode).toBe(1);
54
+ });
55
+ it('returns and formats a current result', async () => {
56
+ const data = {
57
+ v: '2',
58
+ workflowId: 'wf-1',
59
+ runId: 'run-1',
60
+ status: 'failed',
61
+ input: {},
62
+ output: null,
63
+ trace: null,
64
+ error: { name: 'ValidationError', message: 'Invalid input' }
65
+ };
66
+ const { result } = await runCommand(data);
67
+ expect(result).toEqual(data);
68
+ expect(process.exitCode).toBe(1);
69
+ });
70
+ it('sets a failure exit code for the previous canceled spelling', async () => {
71
+ const data = {
72
+ workflowId: 'wf-1',
73
+ runId: 'run-1',
74
+ status: 'canceled',
75
+ input: {},
76
+ output: null,
77
+ trace: null,
78
+ error: 'Workflow was canceled',
79
+ errorDetails: null
80
+ };
81
+ await runCommand(data);
82
+ expect(process.exitCode).toBe(1);
83
+ });
84
+ });
21
85
  });
@@ -1,6 +1,6 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import { postWorkflowRun } from '#api/generated/api.js';
3
- import { formatWorkflowResult, ERROR_STATUSES } from '#utils/format_workflow_result.js';
3
+ import { formatWorkflowResult, isErrorStatus } from '#utils/format_workflow_result.js';
4
4
  import { handleApiError } from '#utils/error_handler.js';
5
5
  import { resolveInput } from '#utils/resolve_input.js';
6
6
  import { getRetryDelayFromResponse } from '#utils/header_utils.js';
@@ -63,7 +63,14 @@ export default class WorkflowRun extends Command {
63
63
  };
64
64
  async run() {
65
65
  const { args, flags } = await this.parse(WorkflowRun);
66
- const input = await resolveInput(args.workflowName, args.scenario, flags.input, 'run', flags.catalog);
66
+ const input = await resolveInput({
67
+ workflowName: args.workflowName,
68
+ scenario: args.scenario,
69
+ inputFlag: flags.input,
70
+ commandName: 'run',
71
+ catalog: flags.catalog,
72
+ json: this.jsonEnabled()
73
+ });
67
74
  this.log(`Executing workflow: ${args.workflowName}...`);
68
75
  const response = await executeWorkflow({
69
76
  body: {
@@ -79,7 +86,7 @@ export default class WorkflowRun extends Command {
79
86
  }
80
87
  const data = response.data;
81
88
  this.log(`\n${formatWorkflowResult(data)}`);
82
- if (ERROR_STATUSES.has(data.status)) {
89
+ if (isErrorStatus(data.status)) {
83
90
  process.exitCode = 1;
84
91
  }
85
92
  return data;
@@ -59,12 +59,26 @@ describe('workflow run command', () => {
59
59
  const { cmd, postWorkflowRun, resolveInput } = await createCommand();
60
60
  resolveInput.mockResolvedValue({ key: 'value' });
61
61
  postWorkflowRun.mockResolvedValue({
62
- data: { status: 'completed', result: { output: 'ok' } },
62
+ data: {
63
+ v: '2',
64
+ workflowId: 'wf-1',
65
+ runId: 'run-1',
66
+ status: 'completed',
67
+ input: { key: 'value' },
68
+ output: 'ok',
69
+ trace: null,
70
+ error: null
71
+ },
63
72
  status: 200,
64
73
  headers: new Headers()
65
74
  });
66
75
  await cmd.run();
67
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', undefined, undefined, 'run', undefined);
76
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
77
+ workflowName: 'my_workflow',
78
+ commandName: 'run',
79
+ catalog: undefined,
80
+ json: false
81
+ }));
68
82
  expect(postWorkflowRun).toHaveBeenCalledTimes(1);
69
83
  expect(postWorkflowRun).toHaveBeenCalledWith({ workflowName: 'my_workflow', input: { key: 'value' }, catalog: undefined }, expect.objectContaining({ config: { timeout: 600000 } }));
70
84
  expect(cmd.log).toHaveBeenCalledWith('Executing workflow: my_workflow...');
@@ -78,12 +92,26 @@ describe('workflow run command', () => {
78
92
  });
79
93
  resolveInput.mockResolvedValue({ key: 'value' });
80
94
  postWorkflowRun.mockResolvedValue({
81
- data: { status: 'completed', result: {} },
95
+ data: {
96
+ v: '2',
97
+ workflowId: 'wf-1',
98
+ runId: 'run-1',
99
+ status: 'completed',
100
+ input: { key: 'value' },
101
+ output: {},
102
+ trace: null,
103
+ error: null
104
+ },
82
105
  status: 200,
83
106
  headers: new Headers()
84
107
  });
85
108
  await cmd.run();
86
- expect(resolveInput).toHaveBeenCalledWith('my_workflow', 'basic', undefined, 'run', 'my-catalog');
109
+ expect(resolveInput).toHaveBeenCalledWith(expect.objectContaining({
110
+ workflowName: 'my_workflow',
111
+ scenario: 'basic',
112
+ commandName: 'run',
113
+ catalog: 'my-catalog'
114
+ }));
87
115
  expect(postWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ catalog: 'my-catalog' }), expect.anything());
88
116
  });
89
117
  it('retries when response has Retry-After and succeeds on second attempt', async () => {
@@ -93,7 +121,16 @@ describe('workflow run command', () => {
93
121
  postWorkflowRun
94
122
  .mockRejectedValueOnce(new HttpError('Unavailable', { status: 503, headers }))
95
123
  .mockResolvedValueOnce({
96
- data: { status: 'completed', result: {} },
124
+ data: {
125
+ v: '2',
126
+ workflowId: 'wf-1',
127
+ runId: 'run-1',
128
+ status: 'completed',
129
+ input: {},
130
+ output: {},
131
+ trace: null,
132
+ error: null
133
+ },
97
134
  status: 200,
98
135
  headers: new Headers()
99
136
  });
@@ -1,15 +1,21 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { type PostWorkflowStart200 } from '#api/generated/api.js';
2
3
  export default class WorkflowStart extends Command {
3
4
  static description: string;
5
+ static enableJsonFlag: boolean;
4
6
  static examples: string[];
5
7
  static args: {
6
8
  workflowName: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
9
  scenario: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
8
10
  };
9
11
  static flags: {
12
+ 'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ interval: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
15
  input: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
16
  catalog: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ monitor: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
18
  };
13
- run(): Promise<void>;
19
+ run(): Promise<PostWorkflowStart200>;
14
20
  catch(error: Error): Promise<void>;
15
21
  }