@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.
- 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/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- 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 +6 -8
- /package/dist/commands/workflow/{test_eval.spec.d.ts → test.spec.d.ts} +0 -0
|
@@ -66,22 +66,29 @@ export interface Workflow {
|
|
|
66
66
|
/** Alternative names that resolve to this workflow */
|
|
67
67
|
aliases?: string[];
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Legacy trace information containing nested destinations
|
|
71
|
+
* @nullable
|
|
72
|
+
*/
|
|
73
|
+
export type TraceInfoV1 = {
|
|
74
|
+
/** Available destinations for trace data */
|
|
75
|
+
destinations?: {
|
|
76
|
+
/** Absolute path to local trace file, omitted if not saved locally */
|
|
77
|
+
local?: string;
|
|
78
|
+
/** Remote trace location (e.g., S3 URI), omitted if not saved remotely */
|
|
79
|
+
remote?: string;
|
|
80
|
+
};
|
|
81
|
+
} | null;
|
|
69
82
|
/**
|
|
70
83
|
* Available destinations for trace data
|
|
84
|
+
* @nullable
|
|
71
85
|
*/
|
|
72
|
-
export type
|
|
86
|
+
export type TraceInfoV2 = {
|
|
73
87
|
/** Absolute path to local trace file, omitted if not saved locally */
|
|
74
88
|
local?: string;
|
|
75
89
|
/** Remote trace location (e.g., S3 URI), omitted if not saved remotely */
|
|
76
90
|
remote?: string;
|
|
77
|
-
};
|
|
78
|
-
/**
|
|
79
|
-
* An object with information about the trace generated by the execution
|
|
80
|
-
*/
|
|
81
|
-
export interface TraceInfo {
|
|
82
|
-
/** Available destinations for trace data */
|
|
83
|
-
destinations?: TraceInfoDestinations;
|
|
84
|
-
}
|
|
91
|
+
} | null;
|
|
85
92
|
/**
|
|
86
93
|
* The workflow input
|
|
87
94
|
*/
|
|
@@ -148,7 +155,7 @@ export declare const WorkflowRunInfoStatus: {
|
|
|
148
155
|
readonly running: "running";
|
|
149
156
|
readonly completed: "completed";
|
|
150
157
|
readonly failed: "failed";
|
|
151
|
-
readonly
|
|
158
|
+
readonly cancelled: "cancelled";
|
|
152
159
|
readonly terminated: "terminated";
|
|
153
160
|
readonly timed_out: "timed_out";
|
|
154
161
|
readonly continued_as_new: "continued_as_new";
|
|
@@ -180,7 +187,7 @@ export interface WorkflowRunsResponse {
|
|
|
180
187
|
*/
|
|
181
188
|
export type WorkflowStatusResponseStatus = typeof WorkflowStatusResponseStatus[keyof typeof WorkflowStatusResponseStatus];
|
|
182
189
|
export declare const WorkflowStatusResponseStatus: {
|
|
183
|
-
readonly
|
|
190
|
+
readonly cancelled: "cancelled";
|
|
184
191
|
readonly completed: "completed";
|
|
185
192
|
readonly continued_as_new: "continued_as_new";
|
|
186
193
|
readonly failed: "failed";
|
|
@@ -204,20 +211,33 @@ export interface WorkflowStatusResponse {
|
|
|
204
211
|
/**
|
|
205
212
|
* The workflow execution status
|
|
206
213
|
*/
|
|
207
|
-
export type
|
|
208
|
-
export declare const
|
|
214
|
+
export type WorkflowResultStatus = typeof WorkflowResultStatus[keyof typeof WorkflowResultStatus];
|
|
215
|
+
export declare const WorkflowResultStatus: {
|
|
209
216
|
readonly completed: "completed";
|
|
210
217
|
readonly failed: "failed";
|
|
211
|
-
readonly
|
|
218
|
+
readonly cancelled: "cancelled";
|
|
212
219
|
readonly terminated: "terminated";
|
|
213
220
|
readonly timed_out: "timed_out";
|
|
214
221
|
readonly continued_as_new: "continued_as_new";
|
|
215
222
|
};
|
|
223
|
+
/**
|
|
224
|
+
* Structured error details captured from the workflow or activity failure
|
|
225
|
+
* @nullable
|
|
226
|
+
*/
|
|
227
|
+
export type SerializedWorkflowError = {
|
|
228
|
+
/** Failing activity type, omitted when the failure did not originate in an activity */
|
|
229
|
+
activityType?: string;
|
|
230
|
+
/** Original error class name */
|
|
231
|
+
name?: string;
|
|
232
|
+
/** Original error message */
|
|
233
|
+
message?: string;
|
|
234
|
+
[key: string]: unknown;
|
|
235
|
+
} | null;
|
|
216
236
|
/**
|
|
217
237
|
* Structured failure details if the workflow failed, null otherwise
|
|
218
238
|
* @nullable
|
|
219
239
|
*/
|
|
220
|
-
export type
|
|
240
|
+
export type WorkflowResultV1ResponseErrorDetails = {
|
|
221
241
|
/**
|
|
222
242
|
* Friendly failure message (from the underlying application error)
|
|
223
243
|
* @nullable
|
|
@@ -246,29 +266,64 @@ export type WorkflowResultResponseErrorDetails = {
|
|
|
246
266
|
[key: string]: unknown;
|
|
247
267
|
} | null;
|
|
248
268
|
} | null | null;
|
|
249
|
-
|
|
269
|
+
/**
|
|
270
|
+
* Legacy wrapped workflow result
|
|
271
|
+
* @deprecated
|
|
272
|
+
*/
|
|
273
|
+
export interface WorkflowResultV1Response {
|
|
250
274
|
/** The workflow execution id */
|
|
251
|
-
workflowId
|
|
252
|
-
/**
|
|
253
|
-
|
|
275
|
+
workflowId: string;
|
|
276
|
+
/**
|
|
277
|
+
* The specific run id for this execution
|
|
278
|
+
* @nullable
|
|
279
|
+
*/
|
|
280
|
+
runId: string | null;
|
|
281
|
+
status: WorkflowResultStatus;
|
|
254
282
|
/** The original input passed to the workflow, null if unavailable */
|
|
255
|
-
input
|
|
283
|
+
input: unknown | null;
|
|
256
284
|
/** The result of workflow, null if workflow failed */
|
|
257
|
-
output
|
|
258
|
-
trace
|
|
259
|
-
/** The workflow execution status */
|
|
260
|
-
status?: WorkflowResultResponseStatus;
|
|
285
|
+
output: unknown | null;
|
|
286
|
+
trace: TraceInfoV1 | null;
|
|
261
287
|
/**
|
|
262
288
|
* Error message if workflow failed, null otherwise
|
|
263
289
|
* @nullable
|
|
264
290
|
*/
|
|
265
|
-
error
|
|
291
|
+
error: string | null;
|
|
266
292
|
/**
|
|
267
293
|
* Structured failure details if the workflow failed, null otherwise
|
|
268
294
|
* @nullable
|
|
269
295
|
*/
|
|
270
|
-
errorDetails
|
|
296
|
+
errorDetails: WorkflowResultV1ResponseErrorDetails;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Workflow result response version
|
|
300
|
+
*/
|
|
301
|
+
export type WorkflowResultV2ResponseV = typeof WorkflowResultV2ResponseV[keyof typeof WorkflowResultV2ResponseV];
|
|
302
|
+
export declare const WorkflowResultV2ResponseV: {
|
|
303
|
+
readonly NUMBER_2: "2";
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Current workflow result with direct output and memo-based trace information
|
|
307
|
+
*/
|
|
308
|
+
export interface WorkflowResultV2Response {
|
|
309
|
+
/** Workflow result response version */
|
|
310
|
+
v: WorkflowResultV2ResponseV;
|
|
311
|
+
/** The workflow execution id */
|
|
312
|
+
workflowId: string;
|
|
313
|
+
/**
|
|
314
|
+
* The specific run id for this execution
|
|
315
|
+
* @nullable
|
|
316
|
+
*/
|
|
317
|
+
runId: string | null;
|
|
318
|
+
status: WorkflowResultStatus;
|
|
319
|
+
/** The original input passed to the workflow, null if unavailable */
|
|
320
|
+
input: unknown | null;
|
|
321
|
+
/** Direct workflow output, null if no output is available */
|
|
322
|
+
output: unknown | null;
|
|
323
|
+
trace: TraceInfoV2 | null;
|
|
324
|
+
error: (SerializedWorkflowError | null) | null;
|
|
271
325
|
}
|
|
326
|
+
export type WorkflowResultResponse = WorkflowResultV2Response | WorkflowResultV1Response;
|
|
272
327
|
export interface WorkflowInputResponse {
|
|
273
328
|
/** The workflow execution id */
|
|
274
329
|
workflowId: string;
|
|
@@ -19,13 +19,13 @@ export const WorkflowRunInfoStatus = {
|
|
|
19
19
|
running: 'running',
|
|
20
20
|
completed: 'completed',
|
|
21
21
|
failed: 'failed',
|
|
22
|
-
|
|
22
|
+
cancelled: 'cancelled',
|
|
23
23
|
terminated: 'terminated',
|
|
24
24
|
timed_out: 'timed_out',
|
|
25
25
|
continued_as_new: 'continued_as_new',
|
|
26
26
|
};
|
|
27
27
|
export const WorkflowStatusResponseStatus = {
|
|
28
|
-
|
|
28
|
+
cancelled: 'cancelled',
|
|
29
29
|
completed: 'completed',
|
|
30
30
|
continued_as_new: 'continued_as_new',
|
|
31
31
|
failed: 'failed',
|
|
@@ -34,14 +34,17 @@ export const WorkflowStatusResponseStatus = {
|
|
|
34
34
|
timed_out: 'timed_out',
|
|
35
35
|
unspecified: 'unspecified',
|
|
36
36
|
};
|
|
37
|
-
export const
|
|
37
|
+
export const WorkflowResultStatus = {
|
|
38
38
|
completed: 'completed',
|
|
39
39
|
failed: 'failed',
|
|
40
|
-
|
|
40
|
+
cancelled: 'cancelled',
|
|
41
41
|
terminated: 'terminated',
|
|
42
42
|
timed_out: 'timed_out',
|
|
43
43
|
continued_as_new: 'continued_as_new',
|
|
44
44
|
};
|
|
45
|
+
export const WorkflowResultV2ResponseV = {
|
|
46
|
+
NUMBER_2: '2',
|
|
47
|
+
};
|
|
45
48
|
;
|
|
46
49
|
export const getGetHealthUrl = () => {
|
|
47
50
|
return `/health`;
|
|
@@ -80,7 +80,7 @@ services:
|
|
|
80
80
|
condition: service_healthy
|
|
81
81
|
worker:
|
|
82
82
|
condition: service_healthy
|
|
83
|
-
image: outputai/api:${OUTPUT_API_VERSION:-0.
|
|
83
|
+
image: outputai/api:${OUTPUT_API_VERSION:-0.11.0}
|
|
84
84
|
init: true
|
|
85
85
|
networks:
|
|
86
86
|
- main
|
|
@@ -118,7 +118,7 @@ services:
|
|
|
118
118
|
- OUTPUT_CATALOG_ID=${OUTPUT_CATALOG_ID:-main}
|
|
119
119
|
- OUTPUT_REDIS_URL=redis://redis:6379
|
|
120
120
|
- OUTPUT_TRACE_LOCAL_ON=${OUTPUT_TRACE_LOCAL_ON:-true}
|
|
121
|
-
- OUTPUT_TRACE_HOST_PATH=${PWD}/logs
|
|
121
|
+
- OUTPUT_TRACE_HOST_PATH=${OUTPUT_TRACE_HOST_PATH:-${PWD}/logs}
|
|
122
122
|
- OUTPUT_TRACE_HTTP_VERBOSE=${OUTPUT_TRACE_HTTP_VERBOSE:-true}
|
|
123
123
|
- OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION=${OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION:-false}
|
|
124
124
|
- TEMPORAL_ADDRESS=temporal:7233
|
|
@@ -12,6 +12,9 @@ import { Command } from '@oclif/core';
|
|
|
12
12
|
* can tail and parse the stream incrementally, which native `--json`'s
|
|
13
13
|
* "one object at the end" model can't do. See docs/guides/packages/cli.mdx
|
|
14
14
|
* ("output workflow monitor") for the same rationale written up for users.
|
|
15
|
+
*
|
|
16
|
+
* The polling loop itself lives in `#services/monitor_stream.js` so
|
|
17
|
+
* `workflow start --monitor` (OUT-537) streams through the same code path.
|
|
15
18
|
*/
|
|
16
19
|
export default class WorkflowMonitor extends Command {
|
|
17
20
|
static description: string;
|
|
@@ -20,30 +23,12 @@ export default class WorkflowMonitor extends Command {
|
|
|
20
23
|
workflowId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
21
24
|
};
|
|
22
25
|
static flags: {
|
|
23
|
-
'run-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
|
-
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
25
26
|
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
26
27
|
interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
27
28
|
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
29
|
+
'run-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
30
|
+
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
28
31
|
};
|
|
29
32
|
run(): Promise<void>;
|
|
30
|
-
/**
|
|
31
|
-
* Wraps a single poll: a failure on the very first tick propagates (there's
|
|
32
|
-
* nothing to fall back on), but a transient blip (see `isTransientPollError`)
|
|
33
|
-
* after we've already been monitoring successfully just returns `null` so the
|
|
34
|
-
* loop can retry — matching the dev TUI's `useStepGraph` behavior of keeping
|
|
35
|
-
* the last good state on a poll hiccup. A non-transient error (e.g. a stale
|
|
36
|
-
* resume cursor, or a bug in the parsing pipeline) rethrows immediately since
|
|
37
|
-
* retrying it cannot succeed. `MAX_CONSECUTIVE_ERRORS` bounds how long we'll
|
|
38
|
-
* retry transient failures before giving up.
|
|
39
|
-
*
|
|
40
|
-
* Fetch strategy is driven by `state.cursor`, not tick count: no cursor yet
|
|
41
|
-
* (the very first poll, or the first poll of a run chained via continue-as-new)
|
|
42
|
-
* uses `fetchWorkflowHistory` (fast, no long-poll) so that render isn't delayed;
|
|
43
|
-
* once a cursor exists, every poll resumes via `fetchWorkflowHistoryUpdates`
|
|
44
|
-
* instead of re-paging the whole history — see `plan_workflow_monitor_history.md`
|
|
45
|
-
* for why a full re-fetch every tick is expensive for long-running workflows.
|
|
46
|
-
*/
|
|
47
|
-
private poll;
|
|
48
33
|
catch(error: Error): Promise<void>;
|
|
49
34
|
}
|
|
@@ -1,39 +1,8 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
27
|
+
if (isErrorStatus(data.status)) {
|
|
28
28
|
process.exitCode = 1;
|
|
29
29
|
}
|
|
30
30
|
return data;
|