@outputai/cli 0.10.1-next.fc0a41f.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 (47) hide show
  1. package/dist/api/generated/api.d.ts +81 -26
  2. package/dist/api/generated/api.js +7 -4
  3. package/dist/assets/docker/docker-compose-dev.yml +2 -2
  4. package/dist/commands/workflow/monitor.d.ts +5 -20
  5. package/dist/commands/workflow/monitor.js +20 -182
  6. package/dist/commands/workflow/monitor.spec.js +82 -3
  7. package/dist/commands/workflow/result.js +2 -2
  8. package/dist/commands/workflow/result.spec.js +65 -1
  9. package/dist/commands/workflow/run.js +2 -2
  10. package/dist/commands/workflow/run.spec.js +30 -3
  11. package/dist/commands/workflow/start.d.ts +4 -0
  12. package/dist/commands/workflow/start.js +95 -10
  13. package/dist/commands/workflow/start.spec.js +252 -0
  14. package/dist/commands/workflow/status.spec.js +1 -1
  15. package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
  16. package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
  17. package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
  18. package/dist/generated/framework_version.json +1 -1
  19. package/dist/services/monitor_stream.d.ts +62 -0
  20. package/dist/services/monitor_stream.js +285 -0
  21. package/dist/services/monitor_stream.spec.d.ts +1 -0
  22. package/dist/services/monitor_stream.spec.js +285 -0
  23. package/dist/services/workflow_history.js +2 -2
  24. package/dist/templates/agent_instructions/CLAUDE.md.template +4 -2
  25. package/dist/templates/project/README.md.template +3 -1
  26. package/dist/templates/project/package.json.template +2 -2
  27. package/dist/utils/env_loader.js +6 -2
  28. package/dist/utils/env_loader.spec.js +61 -32
  29. package/dist/utils/error_handler.d.ts +10 -0
  30. package/dist/utils/error_handler.js +14 -0
  31. package/dist/utils/error_handler.spec.d.ts +1 -0
  32. package/dist/utils/error_handler.spec.js +62 -0
  33. package/dist/utils/format_workflow_result.d.ts +15 -3
  34. package/dist/utils/format_workflow_result.js +39 -6
  35. package/dist/utils/format_workflow_result.spec.js +39 -6
  36. package/dist/utils/monitor_flags.d.ts +35 -0
  37. package/dist/utils/monitor_flags.js +76 -0
  38. package/dist/utils/normalize_workflow_status.d.ts +4 -3
  39. package/dist/utils/normalize_workflow_status.js +12 -3
  40. package/dist/utils/normalize_workflow_status.spec.js +3 -0
  41. package/dist/views/dev/components/workflow_status.js +1 -1
  42. package/dist/views/dev/hooks/use_run_detail.js +4 -4
  43. package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
  44. package/dist/views/dev/panels/runs_panel.js +2 -2
  45. package/oclif.manifest.json +44 -7
  46. package/package.json +6 -8
  47. /package/dist/commands/workflow/{test_eval.spec.d.ts → test.spec.d.ts} +0 -0
@@ -45,11 +45,11 @@ describe('workflow test command', () => {
45
45
  });
46
46
  describe('command definition', () => {
47
47
  it('enables the built-in --json flag', async () => {
48
- const WorkflowTest = (await import('./test_eval.js')).default;
48
+ const WorkflowTest = (await import('./test.js')).default;
49
49
  expect(WorkflowTest.enableJsonFlag).toBe(true);
50
50
  });
51
51
  it('binds the catalog flag to OUTPUT_CATALOG_ID', async () => {
52
- const WorkflowTest = (await import('./test_eval.js')).default;
52
+ const WorkflowTest = (await import('./test.js')).default;
53
53
  expect(WorkflowTest.flags).toHaveProperty('catalog');
54
54
  expect(WorkflowTest.flags.catalog.env).toBe('OUTPUT_CATALOG_ID');
55
55
  expect(WorkflowTest.flags.catalog.char).toBe('c');
@@ -57,7 +57,7 @@ describe('workflow test command', () => {
57
57
  });
58
58
  describe('run()', () => {
59
59
  const createCommand = async (jsonEnabled) => {
60
- const WorkflowTest = (await import('./test_eval.js')).default;
60
+ const WorkflowTest = (await import('./test.js')).default;
61
61
  const { postWorkflowRun } = await import('#api/generated/api.js');
62
62
  const cmd = new WorkflowTest(['simple'], {});
63
63
  cmd.log = vi.fn();
@@ -99,7 +99,7 @@ describe('workflow test command', () => {
99
99
  expect(process.exitCode).toBe(1);
100
100
  });
101
101
  it('routes registration, dataset runs, and the eval run to the resolved catalog', async () => {
102
- const WorkflowTest = (await import('./test_eval.js')).default;
102
+ const WorkflowTest = (await import('./test.js')).default;
103
103
  const { postWorkflowRun } = await import('#api/generated/api.js');
104
104
  const { fetchWorkflowCatalog } = await import('#api/workflow_catalog.js');
105
105
  const cmd = new WorkflowTest(['my_workflow'], {});
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.10.1-next.fc0a41f.0"
2
+ "framework": "0.11.0"
3
3
  }
@@ -0,0 +1,62 @@
1
+ import { type TerminalStatus } from '#utils/format_workflow_result.js';
2
+ export type MonitorStreamOptions = {
3
+ workflowId: string;
4
+ runId?: string;
5
+ includePayloads: boolean;
6
+ interval: number;
7
+ json: boolean;
8
+ color: boolean;
9
+ };
10
+ /**
11
+ * The command surface the stream needs, kept as a parameter rather than a
12
+ * `Command` instance so `workflow start --monitor` can reuse the loop without
13
+ * constructing (or delegating to) a second oclif command — the repo has no
14
+ * `runCommand` precedent and `Command.run( argv, config )` would need a real
15
+ * oclif `Config` that unit tests don't have. `error` must be typed `never` so
16
+ * callers keep type narrowing after an error branch.
17
+ */
18
+ export type MonitorStreamIo = {
19
+ log: (message: string) => void;
20
+ warn: (message: string) => void;
21
+ error: (message: string) => never;
22
+ };
23
+ /**
24
+ * Adapts an oclif command to the above. Late-bound arrows rather than
25
+ * `command.log.bind( command )`: oclif (and the unit tests) replace these as own
26
+ * properties on the instance, so they must resolve at call time. Structurally
27
+ * typed so a test double satisfies it without a real oclif `Config`.
28
+ */
29
+ export declare function commandStreamIo(command: {
30
+ log: (message: string) => void;
31
+ warn: (message: string) => unknown;
32
+ error: (message: string, options: {
33
+ exit: number;
34
+ }) => never;
35
+ }): MonitorStreamIo;
36
+ /**
37
+ * Polls a workflow run and emits status updates until it reaches a terminal
38
+ * state, following continue-as-new chains. Shared by `workflow monitor` and
39
+ * `workflow start --monitor` so both behave identically; see `MonitorStreamIo`
40
+ * for why output is injected rather than taken from a `Command`.
41
+ *
42
+ * Sets `process.exitCode = 1` on a terminal error status rather than throwing,
43
+ * so the caller's own output (e.g. `start`'s "Workflow started successfully")
44
+ * is still the command's primary result. Returns the terminal status it stopped
45
+ * on — `undefined` if it stopped because the user detached — so a caller can
46
+ * tailor its own follow-up (`workflow result` vs `workflow debug`).
47
+ */
48
+ export declare function streamWorkflowUpdates(options: MonitorStreamOptions, io: MonitorStreamIo): Promise<TerminalStatus | undefined>;
49
+ /**
50
+ * Shared `catch` handling for both entry points. A 400 is the generic status for
51
+ * several distinct causes (invalid pageToken, a missing runId, an out-of-range
52
+ * longPollTimeoutMs) — only override it with the stale-cursor message when the
53
+ * server actually identifies that specific cause; otherwise let the real
54
+ * validation error surface instead of misdiagnosing an unrelated 400.
55
+ *
56
+ * Deliberately no 404 here: "check the workflow ID" only reads correctly where
57
+ * the user typed the id, so `workflow monitor` adds it and `start --monitor`
58
+ * doesn't — there the id came back from `postWorkflowStart`, and the server's own
59
+ * message is left to surface inside the "started, but monitoring stopped" wrapper
60
+ * instead of advising a fix that isn't the user's to make.
61
+ */
62
+ export declare function monitorErrorOverrides(error: Error): Record<number, string>;
@@ -0,0 +1,285 @@
1
+ import { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } from '#services/workflow_history.js';
2
+ import buildSpanLabels from '#utils/span_labels.js';
3
+ import { formatDurationLabel } from '#utils/waterfall.js';
4
+ import { diffSpanUpdates, formatContinuedAsNew, formatSpanUpdate } from '#utils/monitor_log.js';
5
+ import { isErrorStatus, isTerminalStatus } from '#utils/format_workflow_result.js';
6
+ import { getErrorMessage } from '#utils/error_utils.js';
7
+ import { sleep } from '#utils/sleep.js';
8
+ import { shouldColorize } from '#utils/color.js';
9
+ import { HttpError } from '#api/http_client.js';
10
+ const MAX_CONSECUTIVE_ERRORS = 5;
11
+ /**
12
+ * Retry sleep ceiling while no poll has succeeded yet. The retry budget covers
13
+ * the first poll for `start --monitor`'s sake (see `poll`), but `workflow
14
+ * monitor wf-x` against a server that was never reachable pays for that too —
15
+ * and with a large `--interval` it would sit through the whole budget before
16
+ * reporting a connection it could never make. Capping only the pre-first-success
17
+ * sleep keeps the retry useful without making "the API is down" take minutes to
18
+ * surface; once a poll has succeeded, the user's `--interval` is honored.
19
+ */
20
+ const UNESTABLISHED_RETRY_MS = 1000;
21
+ const SIGINT_EXIT_CODE = 130;
22
+ const FLUSH_TIMEOUT_MS = 2000;
23
+ const TRANSIENT_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
24
+ /**
25
+ * Adapts an oclif command to the above. Late-bound arrows rather than
26
+ * `command.log.bind( command )`: oclif (and the unit tests) replace these as own
27
+ * properties on the instance, so they must resolve at call time. Structurally
28
+ * typed so a test double satisfies it without a real oclif `Config`.
29
+ */
30
+ export function commandStreamIo(command) {
31
+ return {
32
+ log: message => command.log(message),
33
+ warn: message => {
34
+ command.warn(message);
35
+ },
36
+ error: message => command.error(message, { exit: 1 })
37
+ };
38
+ }
39
+ /**
40
+ * Distinguishes blips worth retrying from errors that will fail identically on
41
+ * every attempt: network hiccups, a client-side request timeout, and 5xx/408/429
42
+ * responses are transient. Everything else — a 4xx like a stale/invalid resume
43
+ * cursor (`InvalidPageTokenError`, surfaced as 400), or a bug in correlate()/
44
+ * buildResult() re-throwing the same exception — can't self-resolve by waiting,
45
+ * so it should surface immediately instead of burning the retry budget.
46
+ */
47
+ function isTransientPollError(error) {
48
+ if (error instanceof HttpError) {
49
+ const status = error.response.status;
50
+ return status >= 500 || status === 408 || status === 429;
51
+ }
52
+ const err = error;
53
+ if (err.name === 'TimeoutError') {
54
+ return true;
55
+ }
56
+ return Boolean((err.code && TRANSIENT_ERROR_CODES.has(err.code)) ||
57
+ (err.cause?.code && TRANSIENT_ERROR_CODES.has(err.cause.code)));
58
+ }
59
+ /**
60
+ * Wraps a single poll: a transient blip (see `isTransientPollError`) returns
61
+ * `null` so the loop can retry — matching the dev TUI's `useStepGraph` behavior
62
+ * of keeping the last good state on a poll hiccup. A non-transient error (e.g. a
63
+ * 404 for a mistyped workflow id, a stale resume cursor, or a bug in the parsing
64
+ * pipeline) rethrows immediately since retrying it cannot succeed.
65
+ * `MAX_CONSECUTIVE_ERRORS` bounds how long we'll retry transient failures before
66
+ * giving up.
67
+ *
68
+ * The retry budget deliberately covers the *first* poll too. `workflow start
69
+ * --monitor` issues it milliseconds after the API accepted the start request, so
70
+ * the first poll is the one most likely to catch a rolling restart or a single
71
+ * 503 — and aborting there abandons a workflow that is already running. The
72
+ * sleep between those pre-first-success retries is capped so `workflow monitor`
73
+ * against an unreachable server doesn't pay the full budget at `--interval`
74
+ * (see `UNESTABLISHED_RETRY_MS`).
75
+ *
76
+ * Fetch strategy is driven by `state.cursor`, not tick count: no cursor yet
77
+ * (the very first poll, or the first poll of a run chained via continue-as-new)
78
+ * uses `fetchWorkflowHistory` (fast, no long-poll) so that render isn't delayed;
79
+ * once a cursor exists, every poll resumes via `fetchWorkflowHistoryUpdates`
80
+ * instead of re-paging the whole history — see `plan_workflow_monitor_history.md`
81
+ * for why a full re-fetch every tick is expensive for long-running workflows.
82
+ */
83
+ async function poll(options, state, io) {
84
+ try {
85
+ // Keeps the resumed long-poll's server-side block roughly aligned with `--interval`
86
+ // instead of always blocking for the server's full configured deadline regardless of
87
+ // it (see `plan_workflow_monitor_history.md`'s "Known tradeoff" note). Only takes
88
+ // effect once resuming (i.e. from the second tick onward); `fetchWorkflowHistory`
89
+ // ignores it on the initial full walk.
90
+ const fetchOptions = {
91
+ workflowId: options.workflowId,
92
+ runId: state.runId,
93
+ includePayloads: options.includePayloads,
94
+ longPollTimeoutMs: options.interval
95
+ };
96
+ if (!state.cursor) {
97
+ const result = await fetchWorkflowHistory(fetchOptions);
98
+ return { result, cursor: result.cursor };
99
+ }
100
+ return await fetchWorkflowHistoryUpdates(fetchOptions, state.cursor);
101
+ }
102
+ catch (error) {
103
+ // Whatever this poll was doing stopped mattering the moment the user
104
+ // detached: reporting a retry would contradict the "Detached" line already
105
+ // printed, and rethrowing would unwind past the loop's own detach guards
106
+ // into the caller's "monitoring stopped" handling, racing exit 3 against the
107
+ // 130 the detach already recorded. The loop breaks on `detached` right after
108
+ // this returns, so `null` here is not a retry.
109
+ if (state.detached) {
110
+ return null;
111
+ }
112
+ if (!isTransientPollError(error) || state.consecutiveErrors + 1 >= MAX_CONSECUTIVE_ERRORS) {
113
+ throw error;
114
+ }
115
+ io.warn(`Poll failed (${state.consecutiveErrors + 1}/${MAX_CONSECUTIVE_ERRORS}), retrying: ${getErrorMessage(error)}`);
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * `process.exit` discards whatever is still queued on an asynchronous stdout —
121
+ * a pipe on macOS, where writes are buffered rather than synchronous (TTY and
122
+ * file writes are synchronous on POSIX and are not truncated). Detaching from
123
+ * `workflow start --monitor` that way can drop the `Workflow ID:` line the
124
+ * command printed moments earlier, leaving the user with no way to reattach to a
125
+ * workflow that is still running. So queue an empty write behind the pending
126
+ * output and exit once it drains, with a ceiling in case the reader has stalled.
127
+ */
128
+ function exitAfterFlush(code) {
129
+ const state = { exited: false };
130
+ const exit = () => {
131
+ if (state.exited) {
132
+ return;
133
+ }
134
+ state.exited = true;
135
+ process.exit(code);
136
+ };
137
+ const timer = setTimeout(exit, FLUSH_TIMEOUT_MS);
138
+ process.stdout.write('', () => {
139
+ clearTimeout(timer);
140
+ exit();
141
+ });
142
+ }
143
+ /**
144
+ * Polls a workflow run and emits status updates until it reaches a terminal
145
+ * state, following continue-as-new chains. Shared by `workflow monitor` and
146
+ * `workflow start --monitor` so both behave identically; see `MonitorStreamIo`
147
+ * for why output is injected rather than taken from a `Command`.
148
+ *
149
+ * Sets `process.exitCode = 1` on a terminal error status rather than throwing,
150
+ * so the caller's own output (e.g. `start`'s "Workflow started successfully")
151
+ * is still the command's primary result. Returns the terminal status it stopped
152
+ * on — `undefined` if it stopped because the user detached — so a caller can
153
+ * tailor its own follow-up (`workflow result` vs `workflow debug`).
154
+ */
155
+ export async function streamWorkflowUpdates(options, io) {
156
+ const color = shouldColorize(options.color);
157
+ // Threaded via mutable properties (not `let` reassignment) so state
158
+ // persists across polls without local variable reassignment.
159
+ const state = {
160
+ runId: options.runId,
161
+ consecutiveErrors: 0,
162
+ // Set by the SIGINT handler so an in-flight poll can't print another update
163
+ // on top of "Detached" while stdout drains (see `exitAfterFlush`).
164
+ detached: false,
165
+ terminalStatus: undefined,
166
+ // Undefined until a resumable cursor is established (see `poll` and
167
+ // `fetchWorkflowHistoryUpdates`); reset on continue-as-new since a new run's
168
+ // cursor position is meaningless carried over from the old one.
169
+ cursor: undefined
170
+ };
171
+ const seen = new Map();
172
+ // Assigned once per span id and never overwritten: `buildSpanLabels` numbers
173
+ // same-named spans by how many are in the array *at call time*, so recomputing
174
+ // it fresh every poll could retroactively change a label already printed to
175
+ // the user (e.g. an unnumbered "Scrape Page" becoming "Scrape Page #1" once a
176
+ // second instance appears). Freezing on first sight keeps printed labels stable.
177
+ const labels = new Map();
178
+ // One emit point for both output formats: json mode wraps `fields` (plus
179
+ // the ambient workflow/run id) as a line of NDJSON, text mode prints `text`.
180
+ const emit = (fields, text) => {
181
+ io.log(options.json ?
182
+ JSON.stringify({ workflowId: options.workflowId, runId: state.runId, ...fields }) :
183
+ text);
184
+ };
185
+ emit({ monitoring: true }, `Monitoring ${options.workflowId}${state.runId ? ` (run ${state.runId})` : ''}... (Ctrl+C to detach)`);
186
+ const sigintHandler = () => {
187
+ // The listener stays registered until the loop unwinds through `finally`, and
188
+ // the exit is deferred behind a stdout flush — so an impatient second Ctrl+C
189
+ // lands here again and would print a second "Detached" line and schedule a
190
+ // second exit.
191
+ if (state.detached) {
192
+ return;
193
+ }
194
+ state.detached = true;
195
+ // Recorded as well as exited with: deferring the exit for a stdout flush
196
+ // lets the loop unwind and the command return normally in the meantime, and
197
+ // whichever of the two finishes first has to land on 130.
198
+ process.exitCode = SIGINT_EXIT_CODE;
199
+ emit({ detached: true }, `\nDetached (the workflow keeps running). Use "workflow status ${options.workflowId}" to check on it, ` +
200
+ `or "workflow result ${options.workflowId}" once it finishes.`);
201
+ exitAfterFlush(SIGINT_EXIT_CODE);
202
+ };
203
+ process.on('SIGINT', sigintHandler);
204
+ try {
205
+ while (true) {
206
+ if (state.detached) {
207
+ break;
208
+ }
209
+ const outcome = await poll(options, state, io);
210
+ if (state.detached) {
211
+ break;
212
+ }
213
+ if (outcome === null) {
214
+ state.consecutiveErrors += 1;
215
+ await sleep(state.cursor ? options.interval : Math.min(options.interval, UNESTABLISHED_RETRY_MS));
216
+ continue;
217
+ }
218
+ state.consecutiveErrors = 0;
219
+ state.cursor = outcome.cursor;
220
+ const result = outcome.result;
221
+ state.runId = result.runId ?? state.runId;
222
+ for (const [id, label] of buildSpanLabels(result.spans)) {
223
+ if (!labels.has(id)) {
224
+ labels.set(id, label);
225
+ }
226
+ }
227
+ for (const update of diffSpanUpdates(result.spans, labels, seen)) {
228
+ emit({ span: update.span }, formatSpanUpdate(update, color));
229
+ }
230
+ const status = result.workflow?.status;
231
+ if (status === 'continued_as_new') {
232
+ if (!result.continuedAsNewRunId) {
233
+ io.error('Workflow continued as a new run, but the new run ID could not be determined.');
234
+ // `io.error` is typed `never`, but nothing enforces that at runtime.
235
+ // Without this, an `io` whose `error` returns would fall through to
236
+ // re-poll the latest run with the cursor cleared — replaying the whole
237
+ // span history every interval, forever, with a zero exit code.
238
+ break;
239
+ }
240
+ emit({ continuedAsNewRunId: result.continuedAsNewRunId }, formatContinuedAsNew(result.continuedAsNewRunId));
241
+ state.runId = result.continuedAsNewRunId;
242
+ state.cursor = undefined;
243
+ seen.clear();
244
+ labels.clear();
245
+ await sleep(options.interval);
246
+ continue;
247
+ }
248
+ const terminalStatus = isTerminalStatus(status);
249
+ if (terminalStatus) {
250
+ const failed = isErrorStatus(terminalStatus);
251
+ emit({ status: terminalStatus }, `${failed ? '✗' : '✓'} workflow ${terminalStatus} · ${formatDurationLabel(result.totalDurationMs)}`);
252
+ if (failed) {
253
+ process.exitCode = 1;
254
+ }
255
+ state.terminalStatus = terminalStatus;
256
+ break;
257
+ }
258
+ await sleep(options.interval);
259
+ }
260
+ }
261
+ finally {
262
+ process.removeListener('SIGINT', sigintHandler);
263
+ }
264
+ return state.terminalStatus;
265
+ }
266
+ /**
267
+ * Shared `catch` handling for both entry points. A 400 is the generic status for
268
+ * several distinct causes (invalid pageToken, a missing runId, an out-of-range
269
+ * longPollTimeoutMs) — only override it with the stale-cursor message when the
270
+ * server actually identifies that specific cause; otherwise let the real
271
+ * validation error surface instead of misdiagnosing an unrelated 400.
272
+ *
273
+ * Deliberately no 404 here: "check the workflow ID" only reads correctly where
274
+ * the user typed the id, so `workflow monitor` adds it and `start --monitor`
275
+ * doesn't — there the id came back from `postWorkflowStart`, and the server's own
276
+ * message is left to surface inside the "started, but monitoring stopped" wrapper
277
+ * instead of advising a fix that isn't the user's to make.
278
+ */
279
+ export function monitorErrorOverrides(error) {
280
+ const response = error.response;
281
+ const isStaleCursor = response?.status === 400 && response.data?.error === 'InvalidPageTokenError';
282
+ return isStaleCursor ?
283
+ { 400: 'Resume cursor is no longer valid for this workflow; restart the monitor.' } :
284
+ {};
285
+ }
@@ -0,0 +1 @@
1
+ export {};