@outputai/cli 0.9.2 → 0.9.3-next.105840b.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 (31) hide show
  1. package/dist/api/generated/api.d.ts +61 -0
  2. package/dist/api/generated/api.js +18 -0
  3. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  4. package/dist/commands/workflow/dataset/generate.d.ts +2 -1
  5. package/dist/commands/workflow/dataset/generate.js +42 -21
  6. package/dist/commands/workflow/dataset/generate.spec.js +116 -11
  7. package/dist/commands/workflow/input.d.ts +16 -0
  8. package/dist/commands/workflow/input.js +74 -0
  9. package/dist/commands/workflow/input.spec.d.ts +1 -0
  10. package/dist/commands/workflow/input.spec.js +119 -0
  11. package/dist/commands/workflow/runs/list.js +6 -3
  12. package/dist/commands/workflow/runs/list.spec.d.ts +1 -0
  13. package/dist/commands/workflow/runs/list.spec.js +45 -0
  14. package/dist/config.d.ts +0 -6
  15. package/dist/config.js +1 -9
  16. package/dist/config.spec.js +1 -17
  17. package/dist/generated/framework_version.json +1 -1
  18. package/dist/services/datasets.d.ts +1 -0
  19. package/dist/services/datasets.js +7 -0
  20. package/dist/services/datasets.test.js +10 -1
  21. package/dist/services/workflow_runs.d.ts +4 -1
  22. package/dist/services/workflow_runs.js +3 -1
  23. package/dist/services/workflow_runs.spec.d.ts +1 -0
  24. package/dist/services/workflow_runs.spec.js +39 -0
  25. package/dist/utils/error_handler.js +8 -0
  26. package/dist/views/dev/components/run_info_sidebar.js +1 -1
  27. package/dist/views/dev/panels/runs_panel.js +2 -2
  28. package/oclif.manifest.json +67 -4
  29. package/package.json +4 -5
  30. package/dist/services/s3_trace_downloader.d.ts +0 -12
  31. package/dist/services/s3_trace_downloader.js +0 -57
@@ -275,6 +275,14 @@ export interface WorkflowResultResponse {
275
275
  */
276
276
  errorDetails?: WorkflowResultResponseErrorDetails;
277
277
  }
278
+ export interface WorkflowInputResponse {
279
+ /** The workflow execution id */
280
+ workflowId: string;
281
+ /** The specific run id the input was read from */
282
+ runId: string;
283
+ /** The first input argument the workflow was started with, null if unavailable */
284
+ input: unknown;
285
+ }
278
286
  export interface StopWorkflowResponse {
279
287
  workflowId?: string;
280
288
  runId?: string;
@@ -956,6 +964,59 @@ export type getWorkflowIdRunsRidResultResponseError = (getWorkflowIdRunsRidResul
956
964
  export type getWorkflowIdRunsRidResultResponse = (getWorkflowIdRunsRidResultResponseSuccess | getWorkflowIdRunsRidResultResponseError);
957
965
  export declare const getGetWorkflowIdRunsRidResultUrl: (id: string, rid: string) => string;
958
966
  export declare const getWorkflowIdRunsRidResult: (id: string, rid: string, options?: ApiRequestOptions) => Promise<getWorkflowIdRunsRidResultResponse>;
967
+ /**
968
+ * Returns the original input passed to the latest run of the given workflow. Works for workflows in any state, including running. To pin a specific run, use `/workflow/{id}/runs/{rid}/input`.
969
+ * @summary Return the original input of a workflow (latest run)
970
+ */
971
+ export type getWorkflowIdInputResponse200 = {
972
+ data: WorkflowInputResponse;
973
+ status: 200;
974
+ };
975
+ export type getWorkflowIdInputResponse404 = {
976
+ data: NotFoundResponse;
977
+ status: 404;
978
+ };
979
+ export type getWorkflowIdInputResponse500 = {
980
+ data: InternalServerErrorResponse;
981
+ status: 500;
982
+ };
983
+ export type getWorkflowIdInputResponseSuccess = (getWorkflowIdInputResponse200) & {
984
+ headers: Headers;
985
+ };
986
+ export type getWorkflowIdInputResponseError = (getWorkflowIdInputResponse404 | getWorkflowIdInputResponse500) & {
987
+ headers: Headers;
988
+ };
989
+ export type getWorkflowIdInputResponse = (getWorkflowIdInputResponseSuccess | getWorkflowIdInputResponseError);
990
+ export declare const getGetWorkflowIdInputUrl: (id: string) => string;
991
+ export declare const getWorkflowIdInput: (id: string, options?: ApiRequestOptions) => Promise<getWorkflowIdInputResponse>;
992
+ /**
993
+ * @summary Return the original input of a specific workflow run
994
+ */
995
+ export type getWorkflowIdRunsRidInputResponse200 = {
996
+ data: WorkflowInputResponse;
997
+ status: 200;
998
+ };
999
+ export type getWorkflowIdRunsRidInputResponse400 = {
1000
+ data: BadRequestResponse;
1001
+ status: 400;
1002
+ };
1003
+ export type getWorkflowIdRunsRidInputResponse404 = {
1004
+ data: NotFoundResponse;
1005
+ status: 404;
1006
+ };
1007
+ export type getWorkflowIdRunsRidInputResponse500 = {
1008
+ data: InternalServerErrorResponse;
1009
+ status: 500;
1010
+ };
1011
+ export type getWorkflowIdRunsRidInputResponseSuccess = (getWorkflowIdRunsRidInputResponse200) & {
1012
+ headers: Headers;
1013
+ };
1014
+ export type getWorkflowIdRunsRidInputResponseError = (getWorkflowIdRunsRidInputResponse400 | getWorkflowIdRunsRidInputResponse404 | getWorkflowIdRunsRidInputResponse500) & {
1015
+ headers: Headers;
1016
+ };
1017
+ export type getWorkflowIdRunsRidInputResponse = (getWorkflowIdRunsRidInputResponseSuccess | getWorkflowIdRunsRidInputResponseError);
1018
+ export declare const getGetWorkflowIdRunsRidInputUrl: (id: string, rid: string) => string;
1019
+ export declare const getWorkflowIdRunsRidInput: (id: string, rid: string, options?: ApiRequestOptions) => Promise<getWorkflowIdRunsRidInputResponse>;
959
1020
  /**
960
1021
  * Returns trace data for the latest run of the given workflow. If trace is stored remotely (S3), fetches and returns the data inline. If trace is local only, returns the local path. To pin a specific run, use `/workflow/{id}/runs/{rid}/trace-log`.
961
1022
  * @summary Get workflow trace log data (latest run)
@@ -181,6 +181,24 @@ export const getWorkflowIdRunsRidResult = async (id, rid, options) => {
181
181
  method: 'GET'
182
182
  });
183
183
  };
184
+ export const getGetWorkflowIdInputUrl = (id) => {
185
+ return `/workflow/${id}/input`;
186
+ };
187
+ export const getWorkflowIdInput = async (id, options) => {
188
+ return customFetchInstance(getGetWorkflowIdInputUrl(id), {
189
+ ...options,
190
+ method: 'GET'
191
+ });
192
+ };
193
+ export const getGetWorkflowIdRunsRidInputUrl = (id, rid) => {
194
+ return `/workflow/${id}/runs/${rid}/input`;
195
+ };
196
+ export const getWorkflowIdRunsRidInput = async (id, rid, options) => {
197
+ return customFetchInstance(getGetWorkflowIdRunsRidInputUrl(id, rid), {
198
+ ...options,
199
+ method: 'GET'
200
+ });
201
+ };
184
202
  export const getGetWorkflowIdTraceLogUrl = (id) => {
185
203
  return `/workflow/${id}/trace-log`;
186
204
  };
@@ -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.9.2}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.9.3-next.105840b.0}
84
84
  init: true
85
85
  networks:
86
86
  - main
@@ -17,7 +17,8 @@ export default class DatasetGenerate extends Command {
17
17
  run(): Promise<void>;
18
18
  private generateFromScenario;
19
19
  private generateFromTrace;
20
- private generateFromS3;
20
+ private generateFromRuns;
21
+ private generateFromRun;
21
22
  private resolveScenarioInput;
22
23
  catch(error: Error): Promise<void>;
23
24
  }
@@ -1,15 +1,15 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
2
  import { Args, Command, Flags, ux } from '@oclif/core';
4
3
  import { postWorkflowRun } from '#api/generated/api.js';
5
- import { writeDataset, resolveDefaultDatasetsDir, buildDataset, getExecutionTime, extractDatasetName } from '#services/datasets.js';
6
- import { listRemoteTraces, downloadRemoteTrace } from '#services/s3_trace_downloader.js';
4
+ import { writeDataset, resolveDefaultDatasetsDir, buildDataset, getExecutionTime, extractDatasetName, datasetFilePath } from '#services/datasets.js';
5
+ import { fetchWorkflowRuns } from '#services/workflow_runs.js';
6
+ import { getTrace } from '#services/trace_reader.js';
7
7
  import { extractDatasetFromTrace } from '#utils/trace_extractor.js';
8
8
  import { resolveScenarioPath, getScenarioNotFoundMessage } from '#utils/scenario_resolver.js';
9
9
  import { parseInputFlag } from '#utils/input_parser.js';
10
10
  import { handleApiError } from '#utils/error_handler.js';
11
11
  export default class DatasetGenerate extends Command {
12
- static description = 'Generate a dataset for a workflow from a scenario, trace file, or S3';
12
+ static description = 'Generate a dataset for a workflow from a scenario, trace file, or recent runs';
13
13
  static examples = [
14
14
  '<%= config.bin %> <%= command.id %> simple basic_input',
15
15
  '<%= config.bin %> <%= command.id %> simple --trace logs/runs/simple/trace.json --name edge_case',
@@ -45,13 +45,13 @@ export default class DatasetGenerate extends Command {
45
45
  }),
46
46
  download: Flags.boolean({
47
47
  char: 'd',
48
- description: 'Download traces from S3 and create datasets',
48
+ description: 'Generate datasets from recent workflow runs fetched via the Output API',
49
49
  default: false,
50
50
  exclusive: ['trace']
51
51
  }),
52
52
  limit: Flags.integer({
53
53
  char: 'l',
54
- description: 'Maximum number of traces to download from S3',
54
+ description: 'Maximum number of recent runs to fetch',
55
55
  default: 5
56
56
  }),
57
57
  input: Flags.string({
@@ -62,7 +62,7 @@ export default class DatasetGenerate extends Command {
62
62
  async run() {
63
63
  const { args, flags } = await this.parse(DatasetGenerate);
64
64
  if (flags.download) {
65
- await this.generateFromS3(args.workflowName, flags.limit);
65
+ await this.generateFromRuns(args.workflowName, flags.limit, flags.catalog);
66
66
  return;
67
67
  }
68
68
  if (flags.trace) {
@@ -89,7 +89,7 @@ export default class DatasetGenerate extends Command {
89
89
  const executionTimeMs = await getExecutionTime(workflowId);
90
90
  const dataset = buildDataset(datasetName, resolvedInput, output, executionTimeMs);
91
91
  const dir = await resolveDefaultDatasetsDir(workflowName);
92
- const filePath = join(dir, `${datasetName}.yml`);
92
+ const filePath = datasetFilePath(dir, datasetName);
93
93
  await writeDataset(dataset, filePath);
94
94
  this.log(`Dataset saved: ${filePath}`);
95
95
  }
@@ -101,29 +101,50 @@ export default class DatasetGenerate extends Command {
101
101
  const datasetName = nameOverride ?? extractDatasetName(tracePath);
102
102
  const dataset = buildDataset(datasetName, extracted.input, extracted.output, extracted.executionTimeMs);
103
103
  const dir = await resolveDefaultDatasetsDir(workflowName);
104
- const filePath = join(dir, `${datasetName}.yml`);
104
+ const filePath = datasetFilePath(dir, datasetName);
105
105
  await writeDataset(dataset, filePath);
106
106
  this.log(`Dataset saved: ${filePath}`);
107
107
  }
108
- async generateFromS3(workflowName, limit) {
109
- this.log(`Listing remote traces for "${workflowName}"...`);
110
- const traces = await listRemoteTraces(workflowName, { limit });
111
- if (traces.length === 0) {
112
- this.log('No remote traces found.');
108
+ async generateFromRuns(workflowName, limit, catalog) {
109
+ this.log(`Fetching recent runs for "${workflowName}"...`);
110
+ const { runs, skipped } = await fetchWorkflowRuns({ workflowType: workflowName, catalog, limit });
111
+ if (runs.length === 0) {
112
+ if (skipped > 0) {
113
+ this.error(`Found ${skipped} run(s) but none had a workflow ID.`, { exit: 1 });
114
+ }
115
+ this.log('No recent runs found.');
113
116
  return;
114
117
  }
115
- this.log(`Found ${traces.length} trace(s). Downloading...`);
118
+ if (skipped > 0) {
119
+ this.warn(`Skipping ${skipped} run(s) with no workflow ID.`);
120
+ }
121
+ // The trace-log endpoint always targets a workflow's latest run, so distinct
122
+ // runIds sharing one workflowId (continue-as-new, reset) collapse to one dataset.
123
+ const workflowIds = [...new Set(runs.map(run => run.workflowId))];
124
+ this.log(`Found ${workflowIds.length} run(s). Fetching traces...`);
116
125
  const dir = await resolveDefaultDatasetsDir(workflowName);
117
- for (const trace of traces) {
118
- const traceData = await downloadRemoteTrace(trace.key);
126
+ const generated = (await Promise.all(workflowIds.map(id => this.generateFromRun(id, dir))))
127
+ .filter(Boolean).length;
128
+ this.log(`\nGenerated ${generated} dataset(s)`);
129
+ if (generated === 0) {
130
+ this.error(`Failed to generate any datasets from ${workflowIds.length} run(s).`, { exit: 1 });
131
+ }
132
+ }
133
+ async generateFromRun(workflowId, dir) {
134
+ try {
135
+ const { data: traceData } = await getTrace(workflowId);
119
136
  const extracted = extractDatasetFromTrace(traceData);
120
- const datasetName = extractDatasetName(trace.key);
121
- const dataset = buildDataset(datasetName, extracted.input, extracted.output, extracted.executionTimeMs);
122
- const filePath = join(dir, `${datasetName}.yml`);
137
+ const dataset = buildDataset(workflowId, extracted.input, extracted.output, extracted.executionTimeMs);
138
+ const filePath = datasetFilePath(dir, workflowId);
123
139
  await writeDataset(dataset, filePath);
124
140
  this.log(` Saved: ${filePath}`);
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ this.warn(` Skipped ${workflowId}: ${message}`);
146
+ return false;
125
147
  }
126
- this.log(`\nGenerated ${traces.length} dataset(s)`);
127
148
  }
128
149
  async resolveScenarioInput(workflowName, scenario, inputFlag, catalog) {
129
150
  if (inputFlag && scenario) {
@@ -3,6 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
3
  vi.mock('#api/generated/api.js', () => ({
4
4
  postWorkflowRun: vi.fn()
5
5
  }));
6
+ vi.mock('#services/workflow_runs.js', () => ({
7
+ fetchWorkflowRuns: vi.fn()
8
+ }));
9
+ vi.mock('#services/trace_reader.js', () => ({
10
+ getTrace: vi.fn()
11
+ }));
12
+ vi.mock('#utils/trace_extractor.js', () => ({
13
+ extractDatasetFromTrace: vi.fn().mockReturnValue({ input: { a: 1 }, output: { ok: true }, executionTimeMs: 5 })
14
+ }));
6
15
  vi.mock('#utils/scenario_resolver.js', () => ({
7
16
  resolveScenarioPath: vi.fn(),
8
17
  getScenarioNotFoundMessage: vi.fn().mockReturnValue('not found')
@@ -15,13 +24,25 @@ vi.mock('#services/datasets.js', () => ({
15
24
  resolveDefaultDatasetsDir: vi.fn().mockResolvedValue('/datasets'),
16
25
  buildDataset: vi.fn().mockReturnValue({ name: 'basic' }),
17
26
  getExecutionTime: vi.fn().mockResolvedValue(100),
18
- extractDatasetName: vi.fn()
27
+ extractDatasetName: vi.fn(),
28
+ datasetFilePath: vi.fn((dir, name) => `${dir}/${name.replace(/[^a-zA-Z0-9._-]/g, '_')}.yml`)
19
29
  }));
20
30
  describe('workflow dataset generate command', () => {
21
31
  beforeEach(() => {
22
32
  vi.clearAllMocks();
23
33
  delete process.env.OUTPUT_CATALOG_ID;
24
34
  });
35
+ const makeCmd = async (args, flags) => {
36
+ const DatasetGenerate = (await import('./generate.js')).default;
37
+ const cmd = new DatasetGenerate(['my_workflow'], {});
38
+ cmd.log = vi.fn();
39
+ cmd.warn = vi.fn();
40
+ cmd.error = vi.fn(() => {
41
+ throw new Error('error called');
42
+ });
43
+ cmd.parse = vi.fn().mockResolvedValue({ args, flags });
44
+ return cmd;
45
+ };
25
46
  describe('command definition', () => {
26
47
  it('binds the catalog flag to OUTPUT_CATALOG_ID', async () => {
27
48
  const DatasetGenerate = (await import('./generate.js')).default;
@@ -32,19 +53,10 @@ describe('workflow dataset generate command', () => {
32
53
  });
33
54
  describe('run()', () => {
34
55
  const createCommand = async (flagOverrides = {}) => {
35
- const DatasetGenerate = (await import('./generate.js')).default;
36
56
  const { postWorkflowRun } = await import('#api/generated/api.js');
37
57
  const { resolveScenarioPath } = await import('#utils/scenario_resolver.js');
38
58
  const { parseInputFlag } = await import('#utils/input_parser.js');
39
- const cmd = new DatasetGenerate(['my_workflow'], {});
40
- cmd.log = vi.fn();
41
- cmd.error = vi.fn(() => {
42
- throw new Error('error called');
43
- });
44
- cmd.parse = vi.fn().mockResolvedValue({
45
- args: { workflowName: 'my_workflow', scenario: 'basic' },
46
- flags: { catalog: undefined, trace: undefined, name: undefined, download: false, limit: 5, input: undefined, ...flagOverrides }
47
- });
59
+ const cmd = await makeCmd({ workflowName: 'my_workflow', scenario: 'basic' }, { catalog: undefined, trace: undefined, name: undefined, download: false, limit: 5, input: undefined, ...flagOverrides });
48
60
  return {
49
61
  cmd,
50
62
  postWorkflowRun: vi.mocked(postWorkflowRun),
@@ -66,4 +78,97 @@ describe('workflow dataset generate command', () => {
66
78
  expect(postWorkflowRun).toHaveBeenCalledWith(expect.objectContaining({ workflowName: 'my_workflow', catalog: 'my-catalog' }), expect.anything());
67
79
  });
68
80
  });
81
+ describe('run() --download', () => {
82
+ const createDownloadCommand = async (flagOverrides = {}) => {
83
+ const { fetchWorkflowRuns } = await import('#services/workflow_runs.js');
84
+ const { getTrace } = await import('#services/trace_reader.js');
85
+ const { writeDataset } = await import('#services/datasets.js');
86
+ const cmd = await makeCmd({ workflowName: 'my_workflow', scenario: undefined }, { catalog: 'my-catalog', trace: undefined, name: undefined, download: true, limit: 5, input: undefined, ...flagOverrides });
87
+ return {
88
+ cmd,
89
+ fetchWorkflowRuns: vi.mocked(fetchWorkflowRuns),
90
+ getTrace: vi.mocked(getTrace),
91
+ writeDataset: vi.mocked(writeDataset)
92
+ };
93
+ };
94
+ it('fetches recent runs scoped to the catalog and writes a dataset per run', async () => {
95
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
96
+ fetchWorkflowRuns.mockResolvedValue({
97
+ runs: [{ workflowId: 'wf-1' }, { workflowId: 'wf-2' }],
98
+ skipped: 0,
99
+ count: 2
100
+ });
101
+ getTrace.mockResolvedValue({ data: {}, location: { path: 'remote', isRemote: true } });
102
+ await cmd.run();
103
+ expect(fetchWorkflowRuns).toHaveBeenCalledWith({ workflowType: 'my_workflow', catalog: 'my-catalog', limit: 5 });
104
+ expect(getTrace).toHaveBeenCalledTimes(2);
105
+ expect(getTrace).toHaveBeenCalledWith('wf-1');
106
+ expect(getTrace).toHaveBeenCalledWith('wf-2');
107
+ expect(writeDataset).toHaveBeenCalledTimes(2);
108
+ });
109
+ it('skips runs whose trace cannot be fetched and continues', async () => {
110
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
111
+ fetchWorkflowRuns.mockResolvedValue({
112
+ runs: [{ workflowId: 'wf-1' }, { workflowId: 'wf-2' }],
113
+ skipped: 0,
114
+ count: 2
115
+ });
116
+ getTrace
117
+ .mockRejectedValueOnce(new Error('no trace available'))
118
+ .mockResolvedValueOnce({ data: {}, location: { path: 'remote', isRemote: true } });
119
+ await cmd.run();
120
+ expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('wf-1'));
121
+ expect(writeDataset).toHaveBeenCalledTimes(1);
122
+ });
123
+ it('reports when no recent runs are found', async () => {
124
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
125
+ fetchWorkflowRuns.mockResolvedValue({ runs: [], skipped: 0, count: 0 });
126
+ await cmd.run();
127
+ expect(getTrace).not.toHaveBeenCalled();
128
+ expect(writeDataset).not.toHaveBeenCalled();
129
+ });
130
+ it('exits non-zero when every run fails to produce a dataset', async () => {
131
+ const { cmd, fetchWorkflowRuns, getTrace } = await createDownloadCommand();
132
+ fetchWorkflowRuns.mockResolvedValue({ runs: [{ workflowId: 'wf-1' }], skipped: 0, count: 1 });
133
+ getTrace.mockRejectedValue(new Error('no trace available'));
134
+ await expect(cmd.run()).rejects.toThrow('error called');
135
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Failed to generate'), { exit: 1 });
136
+ });
137
+ it('warns about runs the service skipped for missing a workflow ID', async () => {
138
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
139
+ fetchWorkflowRuns.mockResolvedValue({ runs: [{ workflowId: 'wf-1' }], skipped: 1, count: 2 });
140
+ getTrace.mockResolvedValue({ data: {}, location: { path: 'remote', isRemote: true } });
141
+ await cmd.run();
142
+ expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('no workflow ID'));
143
+ expect(getTrace).toHaveBeenCalledTimes(1);
144
+ expect(writeDataset).toHaveBeenCalledTimes(1);
145
+ });
146
+ it('exits non-zero when every run was skipped for missing a workflow ID', async () => {
147
+ const { cmd, fetchWorkflowRuns, getTrace } = await createDownloadCommand();
148
+ fetchWorkflowRuns.mockResolvedValue({ runs: [], skipped: 2, count: 2 });
149
+ await expect(cmd.run()).rejects.toThrow('error called');
150
+ expect(getTrace).not.toHaveBeenCalled();
151
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('none had a workflow ID'), { exit: 1 });
152
+ });
153
+ it('deduplicates runs that share a workflow ID', async () => {
154
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
155
+ fetchWorkflowRuns.mockResolvedValue({
156
+ runs: [{ workflowId: 'wf-1' }, { workflowId: 'wf-1' }],
157
+ skipped: 0,
158
+ count: 2
159
+ });
160
+ getTrace.mockResolvedValue({ data: {}, location: { path: 'remote', isRemote: true } });
161
+ await cmd.run();
162
+ expect(getTrace).toHaveBeenCalledTimes(1);
163
+ expect(writeDataset).toHaveBeenCalledTimes(1);
164
+ });
165
+ it('sanitizes path separators in the workflow ID used for the filename', async () => {
166
+ const { cmd, fetchWorkflowRuns, getTrace, writeDataset } = await createDownloadCommand();
167
+ fetchWorkflowRuns.mockResolvedValue({ runs: [{ workflowId: '../../escape' }], skipped: 0, count: 1 });
168
+ getTrace.mockResolvedValue({ data: {}, location: { path: 'remote', isRemote: true } });
169
+ await cmd.run();
170
+ const writtenPath = writeDataset.mock.calls[0][1];
171
+ expect(writtenPath).toBe('/datasets/.._.._escape.yml');
172
+ });
173
+ });
69
174
  });
@@ -0,0 +1,16 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class WorkflowInput extends Command {
3
+ static description: string;
4
+ static enableJsonFlag: boolean;
5
+ static examples: string[];
6
+ static args: {
7
+ workflowId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ 'run-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'output-file': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<unknown>;
15
+ catch(error: Error): Promise<void>;
16
+ }
@@ -0,0 +1,74 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getWorkflowIdInput, getWorkflowIdRunsRidInput } from '#api/generated/api.js';
5
+ import { handleApiError } from '#utils/error_handler.js';
6
+ export default class WorkflowInput extends Command {
7
+ static description = 'Get the original input a workflow run was started with';
8
+ static enableJsonFlag = true;
9
+ static examples = [
10
+ '<%= config.bin %> <%= command.id %> wf-12345',
11
+ '<%= config.bin %> <%= command.id %> wf-12345 --run-id 11111111-2222-4333-8444-555555555555',
12
+ '<%= config.bin %> <%= command.id %> wf-12345 -o w_input.json',
13
+ '<%= config.bin %> <%= command.id %> wf-12345 --output-file w_input.json --force'
14
+ ];
15
+ static args = {
16
+ workflowId: Args.string({
17
+ description: 'The workflow ID to get the input for',
18
+ required: true
19
+ })
20
+ };
21
+ static flags = {
22
+ 'run-id': Flags.string({
23
+ description: 'Specific run id to target (defaults to the latest run)'
24
+ }),
25
+ 'output-file': Flags.string({
26
+ char: 'o',
27
+ description: 'Write the input JSON to this file instead of stdout'
28
+ }),
29
+ force: Flags.boolean({
30
+ char: 'f',
31
+ default: false,
32
+ description: 'Overwrite the output file if it already exists'
33
+ })
34
+ };
35
+ async run() {
36
+ const { args, flags } = await this.parse(WorkflowInput);
37
+ const runId = flags['run-id'];
38
+ const outputFile = flags['output-file'];
39
+ const response = runId ?
40
+ await getWorkflowIdRunsRidInput(args.workflowId, runId) :
41
+ await getWorkflowIdInput(args.workflowId);
42
+ if (!response || !response.data) {
43
+ this.error('API returned invalid response', { exit: 1 });
44
+ }
45
+ const data = response.data;
46
+ const input = data.input;
47
+ const json = JSON.stringify(input, null, 2);
48
+ if (outputFile) {
49
+ const destPath = path.resolve(process.cwd(), outputFile);
50
+ const fileExists = await fs.access(destPath).then(() => true).catch(() => false);
51
+ if (fileExists && !flags.force) {
52
+ this.error(`File already exists at ${destPath}. Use --force to overwrite or choose a different --output-file.`, { exit: 1 });
53
+ }
54
+ await fs.writeFile(destPath, `${json}\n`, 'utf-8');
55
+ this.logToStderr(`Wrote workflow input to ${destPath}`);
56
+ // Don't return the bare input here: under --json oclif serializes run()'s return value to
57
+ // stdout, which would duplicate the input we just wrote to the file. Return a status object
58
+ // so --json emits a confirmation instead, and non-json mode keeps stdout empty.
59
+ return { outputFile: destPath };
60
+ }
61
+ // Emit only the bare input (never the response envelope) so every mode yields the same
62
+ // pipeable value (e.g. `output workflow input <id> | jq .`). Under --json oclif serializes
63
+ // run()'s return value, which is also the bare input, so skip the manual log here.
64
+ if (!this.jsonEnabled()) {
65
+ this.log(json);
66
+ }
67
+ return input;
68
+ }
69
+ async catch(error) {
70
+ return handleApiError(error, (...args) => this.error(...args), {
71
+ 404: 'Workflow not found. Check the workflow ID.'
72
+ });
73
+ }
74
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import fs from 'node:fs/promises';
4
+ import { getWorkflowIdInput, getWorkflowIdRunsRidInput } from '#api/generated/api.js';
5
+ import WorkflowInput from './input.js';
6
+ vi.mock('node:fs/promises');
7
+ vi.mock('#api/generated/api.js', () => ({
8
+ getWorkflowIdInput: vi.fn(),
9
+ getWorkflowIdRunsRidInput: vi.fn()
10
+ }));
11
+ const RID = '11111111-2222-4333-8444-555555555555';
12
+ const INPUT = { values: [1, 2, 3] };
13
+ const makeCmd = (argv) => {
14
+ const config = { runHook: vi.fn().mockResolvedValue({ failures: [], successes: [] }) };
15
+ const cmd = new WorkflowInput(argv, config);
16
+ cmd.log = vi.fn();
17
+ cmd.logToStderr = vi.fn();
18
+ cmd.error = vi.fn().mockImplementation((msg) => {
19
+ throw new Error(msg);
20
+ });
21
+ cmd.jsonEnabled = vi.fn().mockReturnValue(false);
22
+ return cmd;
23
+ };
24
+ describe('workflow input command', () => {
25
+ beforeEach(() => vi.clearAllMocks());
26
+ afterEach(() => vi.restoreAllMocks());
27
+ describe('command definition', () => {
28
+ it('exports a valid OCLIF command', () => {
29
+ expect(WorkflowInput.description).toContain('input');
30
+ expect(WorkflowInput.args).toHaveProperty('workflowId');
31
+ expect(WorkflowInput.enableJsonFlag).toBe(true);
32
+ });
33
+ });
34
+ describe('fetching input', () => {
35
+ it('prints the bare input JSON to stdout and returns it for the latest run', async () => {
36
+ vi.mocked(getWorkflowIdInput).mockResolvedValue({ data: { workflowId: 'wf-1', runId: RID, input: INPUT } });
37
+ const cmd = makeCmd(['wf-1']);
38
+ const result = await cmd.run();
39
+ expect(getWorkflowIdInput).toHaveBeenCalledWith('wf-1');
40
+ expect(getWorkflowIdRunsRidInput).not.toHaveBeenCalled();
41
+ expect(cmd.log).toHaveBeenCalledWith(JSON.stringify(INPUT, null, 2));
42
+ // run() returns the bare input (not the envelope), so --json emits the same shape.
43
+ expect(result).toEqual(INPUT);
44
+ });
45
+ it('returns the bare input and skips manual logging in --json mode', async () => {
46
+ vi.mocked(getWorkflowIdInput).mockResolvedValue({ data: { workflowId: 'wf-1', runId: RID, input: INPUT } });
47
+ const cmd = makeCmd(['wf-1', '--json']);
48
+ cmd.jsonEnabled.mockReturnValue(true);
49
+ const result = await cmd.run();
50
+ expect(cmd.log).not.toHaveBeenCalled();
51
+ expect(result).toEqual(INPUT);
52
+ });
53
+ it('uses the run-pinned endpoint when --run-id is given', async () => {
54
+ vi.mocked(getWorkflowIdRunsRidInput).mockResolvedValue({ data: { workflowId: 'wf-1', runId: RID, input: INPUT } });
55
+ const cmd = makeCmd(['wf-1', '--run-id', RID]);
56
+ await cmd.run();
57
+ expect(getWorkflowIdRunsRidInput).toHaveBeenCalledWith('wf-1', RID);
58
+ expect(getWorkflowIdInput).not.toHaveBeenCalled();
59
+ });
60
+ it('prints null when no input is available', async () => {
61
+ vi.mocked(getWorkflowIdInput).mockResolvedValue({ data: { workflowId: 'wf-1', runId: RID, input: null } });
62
+ const cmd = makeCmd(['wf-1']);
63
+ await cmd.run();
64
+ expect(cmd.log).toHaveBeenCalledWith('null');
65
+ });
66
+ });
67
+ describe('writing to a file', () => {
68
+ beforeEach(() => {
69
+ vi.mocked(getWorkflowIdInput).mockResolvedValue({ data: { workflowId: 'wf-1', runId: RID, input: INPUT } });
70
+ });
71
+ it('writes the input JSON to the output file', async () => {
72
+ vi.mocked(fs.access).mockRejectedValue(new Error('not found'));
73
+ vi.mocked(fs.writeFile).mockResolvedValue();
74
+ const cmd = makeCmd(['wf-1', '-o', 'out.json']);
75
+ await cmd.run();
76
+ expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('out.json'), `${JSON.stringify(INPUT, null, 2)}\n`, 'utf-8');
77
+ expect(cmd.log).not.toHaveBeenCalled();
78
+ expect(cmd.logToStderr).toHaveBeenCalledWith(expect.stringContaining('out.json'));
79
+ });
80
+ it('does not return the bare input in file mode, so --json never duplicates it to stdout', async () => {
81
+ vi.mocked(fs.access).mockRejectedValue(new Error('not found'));
82
+ vi.mocked(fs.writeFile).mockResolvedValue();
83
+ const cmd = makeCmd(['wf-1', '-o', 'out.json', '--json']);
84
+ cmd.jsonEnabled.mockReturnValue(true);
85
+ const result = await cmd.run();
86
+ // The input went to the file; --json must emit a confirmation, not the input again.
87
+ expect(result).not.toEqual(INPUT);
88
+ expect(result).toMatchObject({ outputFile: expect.stringContaining('out.json') });
89
+ });
90
+ it('refuses to overwrite an existing file without --force', async () => {
91
+ vi.mocked(fs.access).mockResolvedValue();
92
+ const cmd = makeCmd(['wf-1', '-o', 'out.json']);
93
+ await expect(cmd.run()).rejects.toThrow('File already exists');
94
+ expect(fs.writeFile).not.toHaveBeenCalled();
95
+ });
96
+ it('overwrites an existing file when --force is set', async () => {
97
+ vi.mocked(fs.access).mockResolvedValue();
98
+ vi.mocked(fs.writeFile).mockResolvedValue();
99
+ const cmd = makeCmd(['wf-1', '-o', 'out.json', '--force']);
100
+ await cmd.run();
101
+ expect(fs.writeFile).toHaveBeenCalled();
102
+ });
103
+ });
104
+ describe('error handling', () => {
105
+ it('maps a 404 to a friendly message', async () => {
106
+ const cmd = makeCmd(['wf-1']);
107
+ const apiError = Object.assign(new Error('Not Found'), { response: { status: 404 } });
108
+ await expect(cmd.catch(apiError)).rejects.toThrow('Workflow not found');
109
+ });
110
+ it('shows the friendly 404 even when the API body carries error/message', async () => {
111
+ const cmd = makeCmd(['wf-1']);
112
+ // Real API 404 shape: the override must win over this body, not be shadowed by it.
113
+ const apiError = Object.assign(new Error('Not Found'), {
114
+ response: { status: 404, data: { error: 'WorkflowNotFoundError', message: 'Workflow "wf-1" not found' } }
115
+ });
116
+ await expect(cmd.catch(apiError)).rejects.toThrow('Workflow not found. Check the workflow ID.');
117
+ });
118
+ });
119
+ });
@@ -18,7 +18,7 @@ function createRunsTable(runs) {
18
18
  });
19
19
  runs.forEach(run => {
20
20
  table.push([
21
- run.workflowId || '-',
21
+ run.workflowId,
22
22
  run.workflowType || '-',
23
23
  run.status || '-',
24
24
  formatDate(run.startedAt),
@@ -79,11 +79,14 @@ export default class WorkflowRunsList extends Command {
79
79
  };
80
80
  async run() {
81
81
  const { args, flags } = await this.parse(WorkflowRunsList);
82
- const { runs, count } = await fetchWorkflowRuns({
82
+ const { runs, skipped } = await fetchWorkflowRuns({
83
83
  workflowType: args.workflowName,
84
84
  catalog: flags.catalog,
85
85
  limit: flags.limit
86
86
  });
87
+ if (skipped > 0) {
88
+ this.warn(`Skipping ${skipped} run(s) with no workflow ID.`);
89
+ }
87
90
  if (this.jsonEnabled()) {
88
91
  return runs;
89
92
  }
@@ -95,7 +98,7 @@ export default class WorkflowRunsList extends Command {
95
98
  const output = formatRuns(runs, flags.format);
96
99
  this.log(output);
97
100
  const filterMsg = args.workflowName ? ` of type "${args.workflowName}"` : '';
98
- this.log(`\nFound ${count} run(s)${filterMsg}`);
101
+ this.log(`\nFound ${runs.length} run(s)${filterMsg}`);
99
102
  return runs;
100
103
  }
101
104
  async catch(error) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ vi.mock('#services/workflow_runs.js', () => ({
4
+ fetchWorkflowRuns: vi.fn()
5
+ }));
6
+ describe('workflow runs list command', () => {
7
+ beforeEach(() => {
8
+ vi.clearAllMocks();
9
+ });
10
+ const createCommand = async (flagOverrides = {}) => {
11
+ const WorkflowRunsList = (await import('./list.js')).default;
12
+ const { fetchWorkflowRuns } = await import('#services/workflow_runs.js');
13
+ const cmd = new WorkflowRunsList([], {});
14
+ cmd.log = vi.fn();
15
+ cmd.warn = vi.fn();
16
+ cmd.jsonEnabled = vi.fn().mockReturnValue(false);
17
+ cmd.parse = vi.fn().mockResolvedValue({
18
+ args: { workflowName: undefined },
19
+ flags: { catalog: undefined, limit: 100, format: 'table', ...flagOverrides }
20
+ });
21
+ return { cmd, fetchWorkflowRuns: vi.mocked(fetchWorkflowRuns) };
22
+ };
23
+ it('reports the actual number of returned runs, not the API\'s pre-filter count', async () => {
24
+ const { cmd, fetchWorkflowRuns } = await createCommand();
25
+ fetchWorkflowRuns.mockResolvedValue({
26
+ runs: [{ workflowId: 'wf-1', workflowType: 'demo', status: 'completed', startedAt: '2026-01-01T00:00:00Z', completedAt: null }],
27
+ skipped: 1,
28
+ count: 2
29
+ });
30
+ await cmd.run();
31
+ expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('Found 1 run(s)'));
32
+ });
33
+ it('warns when runs were skipped for missing a workflow ID', async () => {
34
+ const { cmd, fetchWorkflowRuns } = await createCommand();
35
+ fetchWorkflowRuns.mockResolvedValue({ runs: [], skipped: 2, count: 2 });
36
+ await cmd.run();
37
+ expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('no workflow ID'));
38
+ });
39
+ it('does not warn when no runs were skipped', async () => {
40
+ const { cmd, fetchWorkflowRuns } = await createCommand();
41
+ fetchWorkflowRuns.mockResolvedValue({ runs: [], skipped: 0, count: 0 });
42
+ await cmd.run();
43
+ expect(cmd.warn).not.toHaveBeenCalled();
44
+ });
45
+ });
package/dist/config.d.ts CHANGED
@@ -12,10 +12,4 @@ export declare const config: {
12
12
  readonly debugMode: boolean;
13
13
  readonly envFile: string;
14
14
  agentConfigDir: string;
15
- readonly s3: {
16
- bucket: string | undefined;
17
- region: string | undefined;
18
- accessKeyId: string | undefined;
19
- secretAccessKey: string | undefined;
20
- };
21
15
  };
package/dist/config.js CHANGED
@@ -29,13 +29,5 @@ export const config = {
29
29
  get envFile() {
30
30
  return process.env.OUTPUT_CLI_ENV || '.env';
31
31
  },
32
- agentConfigDir: '.outputai',
33
- get s3() {
34
- return {
35
- bucket: process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET,
36
- region: process.env.OUTPUT_AWS_REGION,
37
- accessKeyId: process.env.OUTPUT_AWS_ACCESS_KEY_ID,
38
- secretAccessKey: process.env.OUTPUT_AWS_SECRET_ACCESS_KEY
39
- };
40
- }
32
+ agentConfigDir: '.outputai'
41
33
  };
@@ -10,11 +10,7 @@ describe('config', () => {
10
10
  'OUTPUT_API_AUTH_TOKEN',
11
11
  'DOCKER_SERVICE_NAME',
12
12
  'OUTPUT_DEBUG',
13
- 'OUTPUT_CLI_ENV',
14
- 'OUTPUT_TRACE_REMOTE_S3_BUCKET',
15
- 'OUTPUT_AWS_REGION',
16
- 'OUTPUT_AWS_ACCESS_KEY_ID',
17
- 'OUTPUT_AWS_SECRET_ACCESS_KEY'
13
+ 'OUTPUT_CLI_ENV'
18
14
  ];
19
15
  const saved = {};
20
16
  beforeEach(() => {
@@ -118,18 +114,6 @@ describe('config', () => {
118
114
  process.env.OUTPUT_DEBUG = 'false';
119
115
  expect(config.debugMode).toBe(false);
120
116
  });
121
- it('reads s3 config lazily', () => {
122
- process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'my-bucket';
123
- process.env.OUTPUT_AWS_REGION = 'us-west-2';
124
- process.env.OUTPUT_AWS_ACCESS_KEY_ID = 'AKIA123';
125
- process.env.OUTPUT_AWS_SECRET_ACCESS_KEY = 'secret123';
126
- expect(config.s3).toEqual({
127
- bucket: 'my-bucket',
128
- region: 'us-west-2',
129
- accessKeyId: 'AKIA123',
130
- secretAccessKey: 'secret123'
131
- });
132
- });
133
117
  it('has static properties that are not env-derived', () => {
134
118
  expect(config.requestTimeout).toBe(30000);
135
119
  expect(config.agentConfigDir).toBe('.outputai');
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.9.2"
2
+ "framework": "0.9.3-next.105840b.0"
3
3
  }
@@ -17,4 +17,5 @@ export declare function writeDataset(dataset: Dataset, filePath: string): Promis
17
17
  export declare function listDatasets(workflowName: string, basePath?: string): Promise<DatasetInfo[]>;
18
18
  export declare function buildDataset(name: string, input: Record<string, unknown>, output: unknown, executionTimeMs?: number): Dataset;
19
19
  export declare function extractDatasetName(tracePathOrKey: string): string;
20
+ export declare function datasetFilePath(dir: string, name: string): string;
20
21
  export declare function getExecutionTime(workflowId: string | undefined): Promise<number | undefined>;
@@ -123,6 +123,13 @@ export function buildDataset(name, input, output, executionTimeMs) {
123
123
  export function extractDatasetName(tracePathOrKey) {
124
124
  return tracePathOrKey.replace(/^.*\//, '').replace(/\.json$/, '');
125
125
  }
126
+ // Resolve the .yml path for a dataset case, sanitizing the name so it can never
127
+ // escape the datasets directory regardless of where the name came from
128
+ // (scenario arg, --name flag, or a workflow ID).
129
+ export function datasetFilePath(dir, name) {
130
+ const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_');
131
+ return join(dir, `${safeName}.yml`);
132
+ }
126
133
  export function getExecutionTime(workflowId) {
127
134
  if (!workflowId) {
128
135
  return Promise.resolve(undefined);
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import yaml from 'js-yaml';
6
- import { readDatasetFile, readAllDatasets, writeDataset, listDatasets, resolveDefaultDatasetsDir } from './datasets.js';
6
+ import { readDatasetFile, readAllDatasets, writeDataset, listDatasets, resolveDefaultDatasetsDir, datasetFilePath } from './datasets.js';
7
7
  import * as catalog from '#api/workflow_catalog.js';
8
8
  vi.mock('#api/workflow_catalog.js', () => ({
9
9
  fetchWorkflowCatalog: vi.fn()
@@ -235,3 +235,12 @@ describe('resolveDefaultDatasetsDir', () => {
235
235
  expect(dir).toBe(join(ctx.tmpDir, 'src', 'workflows', 'unknown_flow', 'tests', 'datasets'));
236
236
  });
237
237
  });
238
+ describe('datasetFilePath', () => {
239
+ it('joins the directory with a .yml file named after the case', () => {
240
+ expect(datasetFilePath('/datasets', 'basic_input')).toBe(join('/datasets', 'basic_input.yml'));
241
+ });
242
+ it('replaces path separators and other unsafe characters so the name cannot escape the directory', () => {
243
+ expect(datasetFilePath('/datasets', '../../escape')).toBe(join('/datasets', '.._.._escape.yml'));
244
+ expect(datasetFilePath('/datasets', 'a/b c')).toBe(join('/datasets', 'a_b_c.yml'));
245
+ });
246
+ });
@@ -2,9 +2,12 @@
2
2
  * Workflow runs service for fetching workflow run data from the API
3
3
  */
4
4
  import { type WorkflowRunInfo } from '#api/generated/api.js';
5
- export type WorkflowRun = WorkflowRunInfo;
5
+ export type WorkflowRun = Omit<WorkflowRunInfo, 'workflowId'> & {
6
+ workflowId: string;
7
+ };
6
8
  export interface WorkflowRunsResult {
7
9
  runs: WorkflowRun[];
10
+ skipped: number;
8
11
  count: number;
9
12
  }
10
13
  export interface FetchWorkflowRunsOptions {
@@ -22,12 +22,14 @@ export async function fetchWorkflowRuns(options = {}) {
22
22
  throw new Error('API returned invalid response (missing data)');
23
23
  }
24
24
  const data = response.data;
25
- const runs = (data.runs || []).map(run => ({
25
+ const allRuns = (data.runs || []).map(run => ({
26
26
  ...run,
27
27
  status: normalizeWorkflowStatus(run.status)
28
28
  }));
29
+ const runs = allRuns.filter(run => Boolean(run.workflowId));
29
30
  return {
30
31
  runs,
32
+ skipped: allRuns.length - runs.length,
31
33
  count: data.count || 0
32
34
  };
33
35
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('#api/generated/api.js', () => ({
3
+ getWorkflowRuns: vi.fn()
4
+ }));
5
+ import { getWorkflowRuns } from '#api/generated/api.js';
6
+ import { fetchWorkflowRuns } from '#services/workflow_runs.js';
7
+ describe('fetchWorkflowRuns', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+ it('filters out runs with no workflow ID and reports how many were skipped', async () => {
12
+ vi.mocked(getWorkflowRuns).mockResolvedValue({
13
+ data: {
14
+ runs: [{ workflowId: 'wf-1' }, { workflowId: undefined }, { workflowId: 'wf-2' }],
15
+ count: 3
16
+ },
17
+ status: 200,
18
+ headers: new Headers()
19
+ });
20
+ const result = await fetchWorkflowRuns();
21
+ expect(result.runs.map(run => run.workflowId)).toEqual(['wf-1', 'wf-2']);
22
+ expect(result.skipped).toBe(1);
23
+ expect(result.count).toBe(3);
24
+ });
25
+ it('reports zero skipped when every run has a workflow ID', async () => {
26
+ vi.mocked(getWorkflowRuns).mockResolvedValue({
27
+ data: { runs: [{ workflowId: 'wf-1' }], count: 1 },
28
+ status: 200,
29
+ headers: new Headers()
30
+ });
31
+ const result = await fetchWorkflowRuns();
32
+ expect(result.skipped).toBe(0);
33
+ expect(result.runs).toHaveLength(1);
34
+ });
35
+ it('throws when the API server cannot be reached', async () => {
36
+ vi.mocked(getWorkflowRuns).mockResolvedValue(undefined);
37
+ await expect(fetchWorkflowRuns()).rejects.toThrow('Failed to connect to API server');
38
+ });
39
+ });
@@ -57,6 +57,14 @@ export function handleApiError(error, errorFn, overrides = {}) {
57
57
  }
58
58
  if (apiError.response?.status) {
59
59
  const status = apiError.response.status;
60
+ // A caller-supplied override for this exact status wins over the raw server body. The API's
61
+ // 404 body is a bare "WorkflowNotFoundError: ..." that the friendly override is meant to
62
+ // replace; without this, extractApiErrorDetails consumes the body first and every command's
63
+ // per-status override is dead code. Defaults are not consulted here, so commands that pass no
64
+ // override still get the rich server detail below.
65
+ if (status in overrides) {
66
+ errorFn(overrides[status], { exit: 1 });
67
+ }
60
68
  // Extract error details from response body
61
69
  const apiErrorDetails = extractApiErrorDetails(apiError.response.data);
62
70
  if (apiErrorDetails) {
@@ -9,7 +9,7 @@ export const RunInfoSidebar = ({ run, resultStatus, maxRows }) => {
9
9
  const rows = [
10
10
  _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "RUN STATUS\u00A0" }), _jsx(WorkflowStatusIcon, { status: status }), _jsx(Text, { children: "\u00A0" }), _jsx(Text, { bold: true, color: workflowStatusColor(status), children: status.toUpperCase() })] }, "status"),
11
11
  _jsx(SidebarKV, { label: "RUN ID", value: run.runId ?? '-' }, "run-id"),
12
- _jsx(SidebarKV, { label: "WORKFLOW ID", value: run.workflowId ?? '-' }, "workflow-id"),
12
+ _jsx(SidebarKV, { label: "WORKFLOW ID", value: run.workflowId }, "workflow-id"),
13
13
  _jsx(SidebarKV, { label: "TYPE", value: run.workflowType ?? '-' }, "type"),
14
14
  _jsx(SidebarKV, { label: "DURATION", value: duration }, "duration"),
15
15
  _jsx(SidebarKV, { label: "START", value: formatDate(run.startedAt) }, "start"),
@@ -40,7 +40,7 @@ const matchesFilter = (run, query) => {
40
40
  }
41
41
  const q = query.toLowerCase();
42
42
  return (run.workflowType ?? '').toLowerCase().includes(q) ||
43
- (run.workflowId ?? '').toLowerCase().includes(q) ||
43
+ run.workflowId.toLowerCase().includes(q) ||
44
44
  (run.status ?? '').toLowerCase().includes(q);
45
45
  };
46
46
  export const buildVisibleRuns = (runs, query) => {
@@ -68,7 +68,7 @@ const RunRow = ({ run, selected }) => {
68
68
  const status = run.status ?? 'running';
69
69
  const color = workflowStatusColor(status);
70
70
  const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
71
- return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(WorkflowStatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 1) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: duration }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: !selected, children: formatStartedShort(run.startedAt) }) })] }));
71
+ return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(SelectionIndicator, { selected: selected }) }), _jsx(Box, { width: COL.icon, children: _jsx(WorkflowStatusIcon, { status: status }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { color: color, children: status }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { bold: selected, children: truncate(run.workflowType ?? '-', COL.type - 1) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId, COL.id - 1) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: !selected, children: duration }) }), _jsx(Box, { width: COL.started, marginLeft: 2, children: _jsx(Text, { dimColor: !selected, children: formatStartedShort(run.startedAt) }) })] }));
72
72
  };
73
73
  const statusPaneValue = (run, pane) => ({
74
74
  status: pane.status,
@@ -762,6 +762,69 @@
762
762
  "history.js"
763
763
  ]
764
764
  },
765
+ "workflow:input": {
766
+ "aliases": [],
767
+ "args": {
768
+ "workflowId": {
769
+ "description": "The workflow ID to get the input for",
770
+ "name": "workflowId",
771
+ "required": true
772
+ }
773
+ },
774
+ "description": "Get the original input a workflow run was started with",
775
+ "examples": [
776
+ "<%= config.bin %> <%= command.id %> wf-12345",
777
+ "<%= config.bin %> <%= command.id %> wf-12345 --run-id 11111111-2222-4333-8444-555555555555",
778
+ "<%= config.bin %> <%= command.id %> wf-12345 -o w_input.json",
779
+ "<%= config.bin %> <%= command.id %> wf-12345 --output-file w_input.json --force"
780
+ ],
781
+ "flags": {
782
+ "json": {
783
+ "description": "Format output as json.",
784
+ "helpGroup": "GLOBAL",
785
+ "name": "json",
786
+ "allowNo": false,
787
+ "type": "boolean"
788
+ },
789
+ "run-id": {
790
+ "description": "Specific run id to target (defaults to the latest run)",
791
+ "name": "run-id",
792
+ "hasDynamicHelp": false,
793
+ "multiple": false,
794
+ "type": "option"
795
+ },
796
+ "output-file": {
797
+ "char": "o",
798
+ "description": "Write the input JSON to this file instead of stdout",
799
+ "name": "output-file",
800
+ "hasDynamicHelp": false,
801
+ "multiple": false,
802
+ "type": "option"
803
+ },
804
+ "force": {
805
+ "char": "f",
806
+ "description": "Overwrite the output file if it already exists",
807
+ "name": "force",
808
+ "allowNo": false,
809
+ "type": "boolean"
810
+ }
811
+ },
812
+ "hasDynamicHelp": false,
813
+ "hiddenAliases": [],
814
+ "id": "workflow:input",
815
+ "pluginAlias": "@outputai/cli",
816
+ "pluginName": "@outputai/cli",
817
+ "pluginType": "core",
818
+ "strict": true,
819
+ "enableJsonFlag": true,
820
+ "isESM": true,
821
+ "relativePath": [
822
+ "dist",
823
+ "commands",
824
+ "workflow",
825
+ "input.js"
826
+ ]
827
+ },
765
828
  "workflow:list": {
766
829
  "aliases": [],
767
830
  "args": {},
@@ -1317,7 +1380,7 @@
1317
1380
  "required": false
1318
1381
  }
1319
1382
  },
1320
- "description": "Generate a dataset for a workflow from a scenario, trace file, or S3",
1383
+ "description": "Generate a dataset for a workflow from a scenario, trace file, or recent runs",
1321
1384
  "examples": [
1322
1385
  "<%= config.bin %> <%= command.id %> simple basic_input",
1323
1386
  "<%= config.bin %> <%= command.id %> simple --trace logs/runs/simple/trace.json --name edge_case",
@@ -1361,7 +1424,7 @@
1361
1424
  },
1362
1425
  "download": {
1363
1426
  "char": "d",
1364
- "description": "Download traces from S3 and create datasets",
1427
+ "description": "Generate datasets from recent workflow runs fetched via the Output API",
1365
1428
  "exclusive": [
1366
1429
  "trace"
1367
1430
  ],
@@ -1371,7 +1434,7 @@
1371
1434
  },
1372
1435
  "limit": {
1373
1436
  "char": "l",
1374
- "description": "Maximum number of traces to download from S3",
1437
+ "description": "Maximum number of recent runs to fetch",
1375
1438
  "name": "limit",
1376
1439
  "default": 5,
1377
1440
  "hasDynamicHelp": false,
@@ -1534,5 +1597,5 @@
1534
1597
  ]
1535
1598
  }
1536
1599
  },
1537
- "version": "0.9.2"
1600
+ "version": "0.9.3-next.105840b.0"
1538
1601
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.3-next.105840b.0",
4
4
  "description": "CLI for Output.ai workflow generation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,7 +18,6 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@anthropic-ai/claude-agent-sdk": "0.2.92",
21
- "@aws-sdk/client-s3": "3.1038.0",
22
21
  "@hackylabs/deep-redact": "3.0.5",
23
22
  "@inquirer/prompts": "8.4.2",
24
23
  "@oclif/core": "4.10.6",
@@ -39,9 +38,9 @@
39
38
  "semver": "7.7.4",
40
39
  "undici": "8.5.0",
41
40
  "yaml": "^2.8.3",
42
- "@outputai/credentials": "0.9.2",
43
- "@outputai/llm": "0.9.2",
44
- "@outputai/evals": "0.9.2"
41
+ "@outputai/credentials": "0.9.3-next.105840b.0",
42
+ "@outputai/evals": "0.9.3-next.105840b.0",
43
+ "@outputai/llm": "0.9.3-next.105840b.0"
45
44
  },
46
45
  "devDependencies": {
47
46
  "@types/cli-progress": "3.11.6",
@@ -1,12 +0,0 @@
1
- import type { TraceData } from '#types/trace.js';
2
- interface RemoteTraceInfo {
3
- key: string;
4
- lastModified?: Date;
5
- size?: number;
6
- }
7
- export declare function listRemoteTraces(workflowName: string, options?: {
8
- limit?: number;
9
- since?: Date;
10
- }): Promise<RemoteTraceInfo[]>;
11
- export declare function downloadRemoteTrace(key: string): Promise<TraceData>;
12
- export {};
@@ -1,57 +0,0 @@
1
- import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
2
- import { config } from '#config.js';
3
- function getS3Config() {
4
- const { bucket, region, accessKeyId, secretAccessKey } = config.s3;
5
- if (!bucket || !region || !accessKeyId || !secretAccessKey) {
6
- throw new Error('Missing S3 configuration. Set OUTPUT_TRACE_REMOTE_S3_BUCKET, OUTPUT_AWS_REGION, ' +
7
- 'OUTPUT_AWS_ACCESS_KEY_ID, and OUTPUT_AWS_SECRET_ACCESS_KEY environment variables.');
8
- }
9
- return { bucket, region, accessKeyId, secretAccessKey };
10
- }
11
- function createS3Client(s3Config) {
12
- return new S3Client({
13
- region: s3Config.region,
14
- credentials: {
15
- accessKeyId: s3Config.accessKeyId,
16
- secretAccessKey: s3Config.secretAccessKey
17
- }
18
- });
19
- }
20
- export async function listRemoteTraces(workflowName, options = {}) {
21
- const s3Config = getS3Config();
22
- const client = createS3Client(s3Config);
23
- const limit = options.limit ?? 20;
24
- const command = new ListObjectsV2Command({
25
- Bucket: s3Config.bucket,
26
- Prefix: `${workflowName}/`,
27
- MaxKeys: limit
28
- });
29
- const response = await client.send(command);
30
- const contents = response.Contents ?? [];
31
- return contents
32
- .filter(obj => {
33
- if (!obj.Key) {
34
- return false;
35
- }
36
- if (options.since && obj.LastModified && obj.LastModified < options.since) {
37
- return false;
38
- }
39
- return true;
40
- })
41
- .map(obj => ({
42
- key: obj.Key,
43
- lastModified: obj.LastModified,
44
- size: obj.Size
45
- }));
46
- }
47
- export async function downloadRemoteTrace(key) {
48
- const s3Config = getS3Config();
49
- const client = createS3Client(s3Config);
50
- const command = new GetObjectCommand({ Bucket: s3Config.bucket, Key: key });
51
- const response = await client.send(command);
52
- const body = await response.Body?.transformToString();
53
- if (!body) {
54
- throw new Error(`Empty response for S3 object: ${key}`);
55
- }
56
- return JSON.parse(body);
57
- }