@outputai/cli 0.9.3-next.01f20d3.0 → 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.
- package/dist/api/generated/api.d.ts +61 -0
- package/dist/api/generated/api.js +18 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/dataset/generate.d.ts +2 -1
- package/dist/commands/workflow/dataset/generate.js +42 -21
- package/dist/commands/workflow/dataset/generate.spec.js +116 -11
- package/dist/commands/workflow/input.d.ts +16 -0
- package/dist/commands/workflow/input.js +74 -0
- package/dist/commands/workflow/input.spec.d.ts +1 -0
- package/dist/commands/workflow/input.spec.js +119 -0
- package/dist/commands/workflow/runs/list.js +6 -3
- package/dist/commands/workflow/runs/list.spec.d.ts +1 -0
- package/dist/commands/workflow/runs/list.spec.js +45 -0
- package/dist/config.d.ts +0 -6
- package/dist/config.js +1 -9
- package/dist/config.spec.js +1 -17
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/datasets.d.ts +1 -0
- package/dist/services/datasets.js +7 -0
- package/dist/services/datasets.test.js +10 -1
- package/dist/services/workflow_runs.d.ts +4 -1
- package/dist/services/workflow_runs.js +3 -1
- package/dist/services/workflow_runs.spec.d.ts +1 -0
- package/dist/services/workflow_runs.spec.js +39 -0
- package/dist/utils/error_handler.js +8 -0
- package/dist/views/dev/components/run_info_sidebar.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/oclif.manifest.json +67 -4
- package/package.json +4 -5
- package/dist/services/s3_trace_downloader.d.ts +0 -12
- 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
|
};
|
|
@@ -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
|
|
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 {
|
|
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
|
|
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: '
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
104
|
+
const filePath = datasetFilePath(dir, datasetName);
|
|
105
105
|
await writeDataset(dataset, filePath);
|
|
106
106
|
this.log(`Dataset saved: ${filePath}`);
|
|
107
107
|
}
|
|
108
|
-
async
|
|
109
|
-
this.log(`
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
121
|
-
const
|
|
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 =
|
|
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,
|
|
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 ${
|
|
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
|
};
|
package/dist/config.spec.js
CHANGED
|
@@ -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');
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
package/oclif.manifest.json
CHANGED
|
@@ -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
|
|
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": "
|
|
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
|
|
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.3-next.
|
|
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.3-next.
|
|
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/
|
|
43
|
-
"@outputai/
|
|
44
|
-
"@outputai/llm": "0.9.3-next.
|
|
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
|
-
}
|