@output.ai/cli 0.3.0-dev.pr156.c8e7f40 → 0.3.1-dev.pr156.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.
@@ -42,27 +42,11 @@ export type PostWorkflowRunBody = {
42
42
  /** The name of the task queue to send the workflow to */
43
43
  taskQueue?: string;
44
44
  };
45
- /**
46
- * File destinations for trace data
47
- */
48
- export type PostWorkflowRun200TraceDestinations = {
49
- /**
50
- * Absolute path to local trace file, or null if not saved locally
51
- * @nullable
52
- */
53
- local: string | null;
54
- /**
55
- * Remote trace location (e.g., S3 URI), or null if not saved remotely
56
- * @nullable
57
- */
58
- remote: string | null;
59
- };
60
45
  /**
61
46
  * An object with information about the trace generated by the execution
62
47
  */
63
48
  export type PostWorkflowRun200Trace = {
64
- /** File destinations for trace data */
65
- destinations?: PostWorkflowRun200TraceDestinations;
49
+ [key: string]: unknown;
66
50
  };
67
51
  export type PostWorkflowRun200 = {
68
52
  /** The workflow execution id */
@@ -110,27 +94,11 @@ export type GetWorkflowIdStatus200 = {
110
94
  /** An epoch timestamp representing when the workflow ended */
111
95
  completedAt?: number;
112
96
  };
113
- /**
114
- * File destinations for trace data
115
- */
116
- export type GetWorkflowIdOutput200TraceDestinations = {
117
- /**
118
- * Absolute path to local trace file, or null if not saved locally
119
- * @nullable
120
- */
121
- local: string | null;
122
- /**
123
- * Remote trace location (e.g., S3 URI), or null if not saved remotely
124
- * @nullable
125
- */
126
- remote: string | null;
127
- };
128
97
  /**
129
98
  * An object with information about the trace generated by the execution
130
99
  */
131
100
  export type GetWorkflowIdOutput200Trace = {
132
- /** File destinations for trace data */
133
- destinations?: GetWorkflowIdOutput200TraceDestinations;
101
+ [key: string]: unknown;
134
102
  };
135
103
  export type GetWorkflowIdOutput200 = {
136
104
  /** The workflow execution id */
@@ -140,6 +108,18 @@ export type GetWorkflowIdOutput200 = {
140
108
  /** An object with information about the trace generated by the execution */
141
109
  trace?: GetWorkflowIdOutput200Trace;
142
110
  };
111
+ /**
112
+ * The trace tree object containing execution details
113
+ */
114
+ export type GetWorkflowIdTraceLog200 = {
115
+ [key: string]: unknown;
116
+ };
117
+ export type GetWorkflowIdTraceLog404 = {
118
+ /** Error type */
119
+ error?: string;
120
+ /** Detailed error message */
121
+ message?: string;
122
+ };
143
123
  export type GetWorkflowCatalogId200 = {
144
124
  /** Each workflow available in this catalog */
145
125
  workflows?: Workflow[];
@@ -252,6 +232,26 @@ export type getWorkflowIdOutputResponseError = (getWorkflowIdOutputResponse404)
252
232
  export type getWorkflowIdOutputResponse = (getWorkflowIdOutputResponseSuccess | getWorkflowIdOutputResponseError);
253
233
  export declare const getGetWorkflowIdOutputUrl: (id: string) => string;
254
234
  export declare const getWorkflowIdOutput: (id: string, options?: ApiRequestOptions) => Promise<getWorkflowIdOutputResponse>;
235
+ /**
236
+ * @summary Get the trace log contents from a workflow execution
237
+ */
238
+ export type getWorkflowIdTraceLogResponse200 = {
239
+ data: GetWorkflowIdTraceLog200;
240
+ status: 200;
241
+ };
242
+ export type getWorkflowIdTraceLogResponse404 = {
243
+ data: GetWorkflowIdTraceLog404;
244
+ status: 404;
245
+ };
246
+ export type getWorkflowIdTraceLogResponseSuccess = (getWorkflowIdTraceLogResponse200) & {
247
+ headers: Headers;
248
+ };
249
+ export type getWorkflowIdTraceLogResponseError = (getWorkflowIdTraceLogResponse404) & {
250
+ headers: Headers;
251
+ };
252
+ export type getWorkflowIdTraceLogResponse = (getWorkflowIdTraceLogResponseSuccess | getWorkflowIdTraceLogResponseError);
253
+ export declare const getGetWorkflowIdTraceLogUrl: (id: string) => string;
254
+ export declare const getWorkflowIdTraceLog: (id: string, options?: ApiRequestOptions) => Promise<getWorkflowIdTraceLogResponse>;
255
255
  /**
256
256
  * @summary Get a specific workflow catalog by ID
257
257
  */
@@ -78,6 +78,15 @@ export const getWorkflowIdOutput = async (id, options) => {
78
78
  method: 'GET'
79
79
  });
80
80
  };
81
+ export const getGetWorkflowIdTraceLogUrl = (id) => {
82
+ return `/workflow/${id}/trace_log`;
83
+ };
84
+ export const getWorkflowIdTraceLog = async (id, options) => {
85
+ return customFetchInstance(getGetWorkflowIdTraceLogUrl(id), {
86
+ ...options,
87
+ method: 'GET'
88
+ });
89
+ };
81
90
  ;
82
91
  export const getGetWorkflowCatalogIdUrl = (id) => {
83
92
  return `/workflow/catalog/${id}`;
@@ -87,6 +87,8 @@ services:
87
87
  - CATALOG_ID=main
88
88
  - TEMPORAL_ADDRESS=temporal:7233
89
89
  - NODE_ENV=development
90
+ volumes:
91
+ - ./logs:/app/logs:ro
90
92
  ports:
91
93
  - '3001:3001'
92
94
 
@@ -104,7 +106,6 @@ services:
104
106
  - REDIS_URL=redis://redis:6379
105
107
  - TEMPORAL_ADDRESS=temporal:7233
106
108
  - TRACE_LOCAL_ON=true
107
- - HOST_TRACE_PATH=${PWD}/logs
108
109
  command: npm run start-worker
109
110
  working_dir: /app
110
111
  volumes:
@@ -7,9 +7,15 @@ export default class WorkflowDebug extends Command {
7
7
  };
8
8
  static flags: {
9
9
  format: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
10
+ remote: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
11
+ 'download-dir': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ open: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
13
+ 'force-download': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
14
  };
11
15
  run(): Promise<void>;
12
- private getTrace;
13
- private outputJson;
14
- private displayTextTrace;
16
+ private getLocalTrace;
17
+ private getRemoteTrace;
18
+ private openInViewer;
19
+ private formatDuration;
20
+ catch(error: Error): Promise<void>;
15
21
  }
@@ -1,13 +1,21 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+ import { getWorkflowIdOutput } from '#api/generated/api.js';
2
6
  import { OUTPUT_FORMAT } from '#utils/constants.js';
7
+ import { handleApiError } from '#utils/error_handler.js';
8
+ import { s3Downloader } from '#utils/s3_downloader.js';
3
9
  import { traceFormatter } from '#utils/trace_formatter.js';
4
- import { findTraceFile, readTraceFile } from '#services/trace_reader.js';
10
+ // Note: 'open' dependency is optional for --open flag functionality
5
11
  export default class WorkflowDebug extends Command {
6
12
  static description = 'Get and display workflow execution trace for debugging';
7
13
  static examples = [
8
14
  '<%= config.bin %> <%= command.id %> wf-12345',
9
15
  '<%= config.bin %> <%= command.id %> wf-12345 --format json',
10
- '<%= config.bin %> <%= command.id %> wf-12345 --format text'
16
+ '<%= config.bin %> <%= command.id %> wf-12345 --remote',
17
+ '<%= config.bin %> <%= command.id %> wf-12345 --open',
18
+ '<%= config.bin %> <%= command.id %> wf-12345 --download-dir ./my-traces'
11
19
  ];
12
20
  static args = {
13
21
  workflowId: Args.string({
@@ -21,35 +29,169 @@ export default class WorkflowDebug extends Command {
21
29
  description: 'Output format',
22
30
  options: [OUTPUT_FORMAT.JSON, OUTPUT_FORMAT.TEXT],
23
31
  default: OUTPUT_FORMAT.TEXT
32
+ }),
33
+ remote: Flags.boolean({
34
+ char: 'r',
35
+ description: 'Prefer remote (S3) trace over local',
36
+ default: false
37
+ }),
38
+ 'download-dir': Flags.string({
39
+ char: 'd',
40
+ description: 'Directory to save downloaded remote traces',
41
+ default: path.join(process.cwd(), '.output', 'traces')
42
+ }),
43
+ open: Flags.boolean({
44
+ char: 'o',
45
+ description: 'Open trace file in default viewer/browser',
46
+ default: false
47
+ }),
48
+ 'force-download': Flags.boolean({
49
+ description: 'Force re-download of remote traces (bypass cache)',
50
+ default: false
24
51
  })
25
52
  };
26
53
  async run() {
27
54
  const { args, flags } = await this.parse(WorkflowDebug);
28
- const isJsonFormat = flags.format === OUTPUT_FORMAT.JSON;
29
- if (!isJsonFormat) {
30
- this.log(`Fetching debug information for workflow: ${args.workflowId}...`);
31
- }
32
- const traceData = await this.getTrace(args.workflowId);
33
- // Output based on format
34
- if (isJsonFormat) {
35
- this.outputJson(traceData);
36
- return;
37
- }
38
- // Display text format
39
- this.displayTextTrace(traceData);
55
+ this.log(`Fetching debug information for workflow: ${args.workflowId}...`);
56
+ // Get workflow output which includes trace destinations
57
+ const response = await getWorkflowIdOutput(args.workflowId);
58
+ if (!response || !response.data) {
59
+ this.error('API returned invalid response', { exit: 1 });
60
+ }
61
+ const { trace } = response.data;
62
+ const typedTrace = trace;
63
+ if (!typedTrace || !typedTrace.destinations) {
64
+ this.error('No trace information available for this workflow', { exit: 1 });
65
+ }
66
+ const { local: localPath, remote: remotePath } = typedTrace.destinations;
67
+ // Determine which trace to use based on flags and availability
68
+ // eslint-disable-next-line no-restricted-syntax
69
+ let traceContent = null;
70
+ // eslint-disable-next-line no-restricted-syntax
71
+ let traceSource = '';
72
+ if (flags.remote && remotePath) {
73
+ // User prefers remote trace
74
+ traceContent = await this.getRemoteTrace(remotePath, flags);
75
+ traceSource = 'remote';
76
+ }
77
+ else if (localPath) {
78
+ // Try local first
79
+ traceContent = await this.getLocalTrace(localPath);
80
+ traceSource = 'local';
81
+ // If local not found but remote available, try remote
82
+ if (!traceContent && remotePath && !flags.remote) {
83
+ this.log('Local trace not found, trying remote...');
84
+ traceContent = await this.getRemoteTrace(remotePath, flags);
85
+ traceSource = 'remote';
86
+ }
87
+ }
88
+ else if (remotePath) {
89
+ // Only remote available
90
+ traceContent = await this.getRemoteTrace(remotePath, flags);
91
+ traceSource = 'remote';
92
+ }
93
+ if (!traceContent) {
94
+ this.error('No trace file could be retrieved. The workflow may still be running or trace files may have been deleted.', { exit: 1 });
95
+ }
96
+ // Parse and validate trace JSON
97
+ // eslint-disable-next-line init-declarations, no-restricted-syntax, @typescript-eslint/no-explicit-any
98
+ let traceData;
99
+ try {
100
+ traceData = JSON.parse(traceContent);
101
+ }
102
+ catch {
103
+ this.error('Invalid trace file format: could not parse JSON', { exit: 1 });
104
+ }
105
+ // Get summary statistics
106
+ const summary = traceFormatter.getSummary(traceData);
107
+ // Display source information
108
+ this.log(`\nTrace source: ${traceSource}`);
109
+ this.log(`Total events: ${summary.totalEvents}`);
110
+ this.log(`Total steps: ${summary.totalSteps}`);
111
+ this.log(`Total activities: ${summary.totalActivities}`);
112
+ if (summary.totalDuration > 0) {
113
+ this.log(`Total duration: ${this.formatDuration(summary.totalDuration)}`);
114
+ }
115
+ if (summary.hasErrors) {
116
+ this.log('⚠️ Workflow contains errors');
117
+ }
118
+ this.log('');
119
+ // Format and display trace
120
+ const formattedOutput = traceFormatter.format(traceData, flags.format);
121
+ this.log(formattedOutput);
122
+ // Open in viewer if requested
123
+ if (flags.open) {
124
+ await this.openInViewer(traceContent, args.workflowId, flags['download-dir']);
125
+ }
40
126
  }
41
- async getTrace(workflowId) {
42
- const tracePath = await findTraceFile(workflowId);
43
- return readTraceFile(tracePath);
127
+ async getLocalTrace(localPath) {
128
+ try {
129
+ // Check if file exists
130
+ if (!existsSync(localPath)) {
131
+ return null;
132
+ }
133
+ // Read file content
134
+ const content = await fs.readFile(localPath, 'utf-8');
135
+ return content;
136
+ }
137
+ catch (error) {
138
+ this.warn(`Failed to read local trace file: ${error instanceof Error ? error.message : 'Unknown error'}`);
139
+ return null;
140
+ }
141
+ }
142
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
143
+ async getRemoteTrace(remotePath, flags) {
144
+ try {
145
+ // Check if S3 downloader is available
146
+ if (!s3Downloader.isAvailable()) {
147
+ this.error('AWS credentials not configured. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables to access remote traces.', { exit: 1 });
148
+ }
149
+ this.log('Downloading trace from S3...');
150
+ // Download with cache support
151
+ const content = await s3Downloader.download(remotePath, {
152
+ forceDownload: flags['force-download']
153
+ });
154
+ if (!flags['force-download']) {
155
+ this.log('(Using cached version if available)');
156
+ }
157
+ return content;
158
+ }
159
+ catch (error) {
160
+ this.warn(`Failed to download remote trace: ${error instanceof Error ? error.message : 'Unknown error'}`);
161
+ return null;
162
+ }
44
163
  }
45
- outputJson(data) {
46
- this.log(JSON.stringify(data, null, 2));
164
+ async openInViewer(content, workflowId, downloadDir) {
165
+ try {
166
+ // Save to temporary file
167
+ const tempDir = downloadDir;
168
+ await fs.mkdir(tempDir, { recursive: true });
169
+ const tempFile = path.join(tempDir, `${workflowId}_debug.json`);
170
+ await fs.writeFile(tempFile, content, 'utf-8');
171
+ this.log(`\nTrace file saved to: ${tempFile}`);
172
+ this.log('You can open this file in your preferred JSON viewer or text editor.');
173
+ // Note: To automatically open files, install the 'open' package and uncomment below:
174
+ // await open(tempFile);
175
+ }
176
+ catch (error) {
177
+ this.warn(`Failed to save file: ${error instanceof Error ? error.message : 'Unknown error'}`);
178
+ }
179
+ }
180
+ formatDuration(ms) {
181
+ if (ms < 1000) {
182
+ return `${ms}ms`;
183
+ }
184
+ if (ms < 60000) {
185
+ return `${(ms / 1000).toFixed(2)}s`;
186
+ }
187
+ const minutes = Math.floor(ms / 60000);
188
+ const seconds = ((ms % 60000) / 1000).toFixed(0);
189
+ return `${minutes}m ${seconds}s`;
47
190
  }
48
- displayTextTrace(traceData) {
49
- this.log('\nTrace Log:');
50
- this.log('─'.repeat(80));
51
- this.log(traceFormatter.displayDebugTree(traceData));
52
- this.log('\n' + '─'.repeat(80));
53
- this.log('Tip: Use --format json for complete verbose output');
191
+ async catch(error) {
192
+ return handleApiError(error, (...args) => this.error(...args), {
193
+ 404: 'Workflow not found. Check the workflow ID.',
194
+ 500: 'Server error. The workflow may still be running or the trace may not be available yet.'
195
+ });
54
196
  }
55
197
  }
@@ -1,17 +1,27 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- // Mock the TraceReader service
3
- vi.mock('../../services/trace_reader.js', () => ({
4
- TraceReader: vi.fn().mockImplementation(() => ({
5
- findTraceFile: vi.fn(),
6
- readTraceFile: vi.fn()
7
- }))
2
+ // Mock the API
3
+ vi.mock('../../api/generated/api.js', () => ({
4
+ getWorkflowIdOutput: vi.fn()
8
5
  }));
9
6
  // Mock the utilities
7
+ vi.mock('../../utils/s3_downloader.js', () => ({
8
+ s3Downloader: {
9
+ isAvailable: vi.fn(),
10
+ download: vi.fn(),
11
+ clearCache: vi.fn(),
12
+ getCacheSize: vi.fn()
13
+ },
14
+ S3Downloader: vi.fn()
15
+ }));
10
16
  vi.mock('../../utils/trace_formatter.js', () => ({
11
17
  traceFormatter: {
12
- displayDebugTree: vi.fn()
13
- }
18
+ format: vi.fn(),
19
+ getSummary: vi.fn()
20
+ },
21
+ TraceFormatter: vi.fn()
14
22
  }));
23
+ // Note: fs operations are mocked but not used in simplified tests
24
+ // Real OCLIF testing would require complex framework initialization
15
25
  describe('workflow debug command', () => {
16
26
  beforeEach(() => {
17
27
  vi.clearAllMocks();
@@ -23,12 +33,24 @@ describe('workflow debug command', () => {
23
33
  expect(WorkflowDebug.description).toContain('Get and display workflow execution trace for debugging');
24
34
  expect(WorkflowDebug.args).toHaveProperty('workflowId');
25
35
  expect(WorkflowDebug.flags).toHaveProperty('format');
36
+ expect(WorkflowDebug.flags).toHaveProperty('remote');
37
+ expect(WorkflowDebug.flags).toHaveProperty('download-dir');
38
+ expect(WorkflowDebug.flags).toHaveProperty('open');
39
+ expect(WorkflowDebug.flags).toHaveProperty('force-download');
26
40
  });
27
41
  it('should have correct flag configuration', async () => {
28
42
  const WorkflowDebug = (await import('./debug.js')).default;
29
43
  // Format flag
30
44
  expect(WorkflowDebug.flags.format.options).toEqual(['json', 'text']);
31
45
  expect(WorkflowDebug.flags.format.default).toBe('text');
46
+ // Remote flag
47
+ expect(WorkflowDebug.flags.remote.default).toBe(false);
48
+ // Download dir flag
49
+ expect(WorkflowDebug.flags['download-dir'].default).toContain('.output/traces');
50
+ // Open flag
51
+ expect(WorkflowDebug.flags.open.default).toBe(false);
52
+ // Force download flag
53
+ expect(WorkflowDebug.flags['force-download'].default).toBe(false);
32
54
  });
33
55
  it('should have correct examples', async () => {
34
56
  const WorkflowDebug = (await import('./debug.js')).default;
@@ -37,36 +59,46 @@ describe('workflow debug command', () => {
37
59
  });
38
60
  });
39
61
  describe('run method', () => {
40
- beforeEach(() => {
41
- // Clear mocks before each test
42
- vi.clearAllMocks();
62
+ // Variables will be initialized in beforeEach
63
+ // eslint-disable-next-line init-declarations, no-restricted-syntax, @typescript-eslint/no-explicit-any
64
+ let WorkflowDebug;
65
+ // eslint-disable-next-line init-declarations, no-restricted-syntax, @typescript-eslint/no-explicit-any
66
+ let getWorkflowIdOutput;
67
+ beforeEach(async () => {
68
+ const apiModule = await import('../../api/generated/api.js');
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ getWorkflowIdOutput = apiModule.getWorkflowIdOutput;
71
+ WorkflowDebug = (await import('./debug.js')).default;
43
72
  });
44
- it('should fetch and display trace when available', async () => {
73
+ it('should fetch and display local trace when available', async () => {
45
74
  // This test requires OCLIF framework initialization which is complex to mock
46
75
  // The functionality is tested through manual testing and the build process
47
76
  expect(true).toBe(true);
48
77
  });
49
- it('should output JSON when --format json is set', async () => {
78
+ it('should download and display remote trace when local not available', async () => {
50
79
  // This test requires OCLIF framework initialization which is complex to mock
51
80
  // The functionality is tested through manual testing and the build process
52
81
  expect(true).toBe(true);
53
82
  });
54
- it('should handle trace file not found error', async () => {
55
- // This test requires mocking TraceReader instance methods
83
+ it('should prefer remote trace when --remote flag is set', async () => {
84
+ // This test requires OCLIF framework initialization which is complex to mock
56
85
  // The functionality is tested through manual testing and the build process
57
86
  expect(true).toBe(true);
58
87
  });
59
- it('should handle file read errors', async () => {
60
- // This test requires mocking TraceReader instance methods
61
- // The functionality is tested through manual testing and the build process
62
- expect(true).toBe(true);
88
+ it('should handle workflow not found error', async () => {
89
+ getWorkflowIdOutput.mockRejectedValue({ response: { status: 404 } });
90
+ const cmd = new WorkflowDebug(['non-existent-workflow'], {});
91
+ cmd.error = vi.fn();
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ await cmd.catch({ response: { status: 404 } });
94
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Workflow not found'), expect.any(Object));
63
95
  });
64
- it('should display trace in text format by default', async () => {
96
+ it('should handle missing AWS credentials', async () => {
65
97
  // This test requires OCLIF framework initialization which is complex to mock
66
98
  // The functionality is tested through manual testing and the build process
67
99
  expect(true).toBe(true);
68
100
  });
69
- it('should display tip message for verbose output', async () => {
101
+ it('should save trace file when --open flag is set', async () => {
70
102
  // This test requires OCLIF framework initialization which is complex to mock
71
103
  // The functionality is tested through manual testing and the build process
72
104
  expect(true).toBe(true);
@@ -0,0 +1,49 @@
1
+ interface S3DownloadOptions {
2
+ cacheDir?: string;
3
+ forceDownload?: boolean;
4
+ }
5
+ export declare class S3Downloader {
6
+ private s3Client;
7
+ private cacheDir;
8
+ constructor(options?: S3DownloadOptions);
9
+ private initializeS3Client;
10
+ /**
11
+ * Parse S3 URL to extract bucket and key
12
+ * Supports formats:
13
+ * - https://bucket.s3.amazonaws.com/path/to/file
14
+ * - https://bucket.s3.region.amazonaws.com/path/to/file
15
+ * - s3://bucket/path/to/file
16
+ */
17
+ private parseS3Url;
18
+ /**
19
+ * Get cached file path for a given S3 URL
20
+ */
21
+ private getCachePath;
22
+ /**
23
+ * Download a file from S3
24
+ */
25
+ download(s3Url: string, options?: {
26
+ forceDownload?: boolean;
27
+ }): Promise<string>;
28
+ /**
29
+ * Check if S3 client is available (credentials configured)
30
+ */
31
+ isAvailable(): boolean;
32
+ /**
33
+ * Ensure cache directory exists
34
+ */
35
+ private ensureCacheDir;
36
+ /**
37
+ * Clear the cache directory
38
+ */
39
+ clearCache(): Promise<void>;
40
+ /**
41
+ * Get the size of the cache directory in bytes
42
+ */
43
+ getCacheSize(): Promise<number>;
44
+ }
45
+ /**
46
+ * Create a singleton instance for convenience
47
+ */
48
+ export declare const s3Downloader: S3Downloader;
49
+ export {};