@outputai/cli 0.9.3-next.14a0cfc.0 → 0.9.3-next.1ad3dbc.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.
@@ -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.3-next.14a0cfc.0}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.9.3-next.1ad3dbc.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
  });
@@ -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.3-next.14a0cfc.0"
2
+ "framework": "0.9.3-next.1ad3dbc.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
+ });
@@ -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,
@@ -1366,81 +1366,6 @@
1366
1366
  "test_eval.js"
1367
1367
  ]
1368
1368
  },
1369
- "workflow:runs:list": {
1370
- "aliases": [],
1371
- "args": {
1372
- "workflowName": {
1373
- "description": "Filter by workflow type/name",
1374
- "name": "workflowName",
1375
- "required": false
1376
- }
1377
- },
1378
- "description": "List workflow runs with optional filtering by workflow type",
1379
- "examples": [
1380
- "<%= config.bin %> <%= command.id %>",
1381
- "<%= config.bin %> <%= command.id %> simple",
1382
- "<%= config.bin %> <%= command.id %> simple --limit 10",
1383
- "<%= config.bin %> <%= command.id %> --catalog my-catalog",
1384
- "<%= config.bin %> <%= command.id %> --json",
1385
- "<%= config.bin %> <%= command.id %> --format table"
1386
- ],
1387
- "flags": {
1388
- "json": {
1389
- "description": "Format output as json.",
1390
- "helpGroup": "GLOBAL",
1391
- "name": "json",
1392
- "allowNo": false,
1393
- "type": "boolean"
1394
- },
1395
- "catalog": {
1396
- "char": "c",
1397
- "description": "Filter runs by catalog (defaults to OUTPUT_CATALOG_ID)",
1398
- "env": "OUTPUT_CATALOG_ID",
1399
- "name": "catalog",
1400
- "hasDynamicHelp": false,
1401
- "multiple": false,
1402
- "type": "option"
1403
- },
1404
- "limit": {
1405
- "char": "l",
1406
- "description": "Maximum number of runs to return",
1407
- "name": "limit",
1408
- "default": 100,
1409
- "hasDynamicHelp": false,
1410
- "multiple": false,
1411
- "type": "option"
1412
- },
1413
- "format": {
1414
- "char": "f",
1415
- "description": "Output format (use --json for JSON output)",
1416
- "name": "format",
1417
- "default": "table",
1418
- "hasDynamicHelp": false,
1419
- "multiple": false,
1420
- "options": [
1421
- "table",
1422
- "text"
1423
- ],
1424
- "type": "option"
1425
- }
1426
- },
1427
- "hasDynamicHelp": false,
1428
- "hiddenAliases": [],
1429
- "id": "workflow:runs:list",
1430
- "pluginAlias": "@outputai/cli",
1431
- "pluginName": "@outputai/cli",
1432
- "pluginType": "core",
1433
- "strict": true,
1434
- "enableJsonFlag": true,
1435
- "isESM": true,
1436
- "relativePath": [
1437
- "dist",
1438
- "commands",
1439
- "workflow",
1440
- "runs",
1441
- "list.js"
1442
- ]
1443
- },
1444
1369
  "workflow:dataset:generate": {
1445
1370
  "aliases": [],
1446
1371
  "args": {
@@ -1455,7 +1380,7 @@
1455
1380
  "required": false
1456
1381
  }
1457
1382
  },
1458
- "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",
1459
1384
  "examples": [
1460
1385
  "<%= config.bin %> <%= command.id %> simple basic_input",
1461
1386
  "<%= config.bin %> <%= command.id %> simple --trace logs/runs/simple/trace.json --name edge_case",
@@ -1499,7 +1424,7 @@
1499
1424
  },
1500
1425
  "download": {
1501
1426
  "char": "d",
1502
- "description": "Download traces from S3 and create datasets",
1427
+ "description": "Generate datasets from recent workflow runs fetched via the Output API",
1503
1428
  "exclusive": [
1504
1429
  "trace"
1505
1430
  ],
@@ -1509,7 +1434,7 @@
1509
1434
  },
1510
1435
  "limit": {
1511
1436
  "char": "l",
1512
- "description": "Maximum number of traces to download from S3",
1437
+ "description": "Maximum number of recent runs to fetch",
1513
1438
  "name": "limit",
1514
1439
  "default": 5,
1515
1440
  "hasDynamicHelp": false,
@@ -1595,7 +1520,82 @@
1595
1520
  "dataset",
1596
1521
  "list.js"
1597
1522
  ]
1523
+ },
1524
+ "workflow:runs:list": {
1525
+ "aliases": [],
1526
+ "args": {
1527
+ "workflowName": {
1528
+ "description": "Filter by workflow type/name",
1529
+ "name": "workflowName",
1530
+ "required": false
1531
+ }
1532
+ },
1533
+ "description": "List workflow runs with optional filtering by workflow type",
1534
+ "examples": [
1535
+ "<%= config.bin %> <%= command.id %>",
1536
+ "<%= config.bin %> <%= command.id %> simple",
1537
+ "<%= config.bin %> <%= command.id %> simple --limit 10",
1538
+ "<%= config.bin %> <%= command.id %> --catalog my-catalog",
1539
+ "<%= config.bin %> <%= command.id %> --json",
1540
+ "<%= config.bin %> <%= command.id %> --format table"
1541
+ ],
1542
+ "flags": {
1543
+ "json": {
1544
+ "description": "Format output as json.",
1545
+ "helpGroup": "GLOBAL",
1546
+ "name": "json",
1547
+ "allowNo": false,
1548
+ "type": "boolean"
1549
+ },
1550
+ "catalog": {
1551
+ "char": "c",
1552
+ "description": "Filter runs by catalog (defaults to OUTPUT_CATALOG_ID)",
1553
+ "env": "OUTPUT_CATALOG_ID",
1554
+ "name": "catalog",
1555
+ "hasDynamicHelp": false,
1556
+ "multiple": false,
1557
+ "type": "option"
1558
+ },
1559
+ "limit": {
1560
+ "char": "l",
1561
+ "description": "Maximum number of runs to return",
1562
+ "name": "limit",
1563
+ "default": 100,
1564
+ "hasDynamicHelp": false,
1565
+ "multiple": false,
1566
+ "type": "option"
1567
+ },
1568
+ "format": {
1569
+ "char": "f",
1570
+ "description": "Output format (use --json for JSON output)",
1571
+ "name": "format",
1572
+ "default": "table",
1573
+ "hasDynamicHelp": false,
1574
+ "multiple": false,
1575
+ "options": [
1576
+ "table",
1577
+ "text"
1578
+ ],
1579
+ "type": "option"
1580
+ }
1581
+ },
1582
+ "hasDynamicHelp": false,
1583
+ "hiddenAliases": [],
1584
+ "id": "workflow:runs:list",
1585
+ "pluginAlias": "@outputai/cli",
1586
+ "pluginName": "@outputai/cli",
1587
+ "pluginType": "core",
1588
+ "strict": true,
1589
+ "enableJsonFlag": true,
1590
+ "isESM": true,
1591
+ "relativePath": [
1592
+ "dist",
1593
+ "commands",
1594
+ "workflow",
1595
+ "runs",
1596
+ "list.js"
1597
+ ]
1598
1598
  }
1599
1599
  },
1600
- "version": "0.9.3-next.14a0cfc.0"
1600
+ "version": "0.9.3-next.1ad3dbc.0"
1601
1601
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.9.3-next.14a0cfc.0",
3
+ "version": "0.9.3-next.1ad3dbc.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.3-next.14a0cfc.0",
43
- "@outputai/evals": "0.9.3-next.14a0cfc.0",
44
- "@outputai/llm": "0.9.3-next.14a0cfc.0"
41
+ "@outputai/credentials": "0.9.3-next.1ad3dbc.0",
42
+ "@outputai/evals": "0.9.3-next.1ad3dbc.0",
43
+ "@outputai/llm": "0.9.3-next.1ad3dbc.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
- }