@outputai/cli 0.1.13-next.91c5d78.0 → 0.1.13-next.95fd251.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 +321 -83
- package/dist/api/generated/api.js +66 -8
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/list.d.ts +1 -0
- package/dist/commands/workflow/list.js +12 -6
- package/dist/commands/workflow/list.spec.js +21 -0
- package/dist/commands/workflow/test_eval.js +2 -2
- package/dist/components/command_footer.d.ts +8 -0
- package/dist/components/command_footer.js +4 -0
- package/dist/components/status_icon.d.ts +11 -0
- package/dist/components/status_icon.js +25 -0
- package/dist/components/workflow_summary.d.ts +10 -0
- package/dist/components/workflow_summary.js +4 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/claude_client.js +4 -1
- package/dist/services/datasets.d.ts +1 -1
- package/dist/services/datasets.js +41 -37
- package/dist/services/datasets.test.d.ts +1 -0
- package/dist/services/datasets.test.js +202 -0
- package/dist/utils/date_formatter.d.ts +11 -1
- package/dist/utils/date_formatter.js +26 -1
- package/dist/utils/format_workflow_result.d.ts +3 -3
- package/dist/utils/open_url.d.ts +1 -0
- package/dist/utils/open_url.js +12 -0
- package/dist/views/dev.js +62 -26
- package/dist/views/workflow/list.d.ts +6 -0
- package/dist/views/workflow/list.js +127 -0
- package/package.json +4 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { readDatasetFile, readAllDatasets, writeDataset, listDatasets } from './datasets.js';
|
|
7
|
+
const ctx = { tmpDir: '' };
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
ctx.tmpDir = await mkdtemp(join(tmpdir(), 'output-datasets-test-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await rm(ctx.tmpDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
function writeYaml(filePath, obj) {
|
|
15
|
+
return writeFile(filePath, yaml.dump(obj, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// readDatasetFile
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
describe('readDatasetFile', () => {
|
|
21
|
+
it('parses a multi-case file and returns all cases', async () => {
|
|
22
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
23
|
+
await writeYaml(filePath, {
|
|
24
|
+
case_a: { input: { query: 'foo' }, ground_truth: { expected: 1 } },
|
|
25
|
+
case_b: { input: { query: 'bar' } },
|
|
26
|
+
case_c: { input: { query: 'baz' }, ground_truth: { expected: 3 } }
|
|
27
|
+
});
|
|
28
|
+
const datasets = await readDatasetFile(filePath);
|
|
29
|
+
expect(datasets).toHaveLength(3);
|
|
30
|
+
expect(datasets.map(d => d.name)).toEqual(['case_a', 'case_b', 'case_c']);
|
|
31
|
+
expect(datasets[0].input).toEqual({ query: 'foo' });
|
|
32
|
+
expect(datasets[0].ground_truth).toEqual({ expected: 1 });
|
|
33
|
+
});
|
|
34
|
+
it('attaches _source with the absolute file path to each dataset', async () => {
|
|
35
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
36
|
+
await writeYaml(filePath, {
|
|
37
|
+
my_case: { input: { x: 1 } }
|
|
38
|
+
});
|
|
39
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
40
|
+
expect(dataset._source).toBe(filePath);
|
|
41
|
+
});
|
|
42
|
+
it('throws when a case is missing input', async () => {
|
|
43
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
44
|
+
await writeYaml(filePath, {
|
|
45
|
+
good_case: { input: { x: 1 } },
|
|
46
|
+
bad_case: { ground_truth: { expected: 42 } }
|
|
47
|
+
});
|
|
48
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Dataset case "bad_case" in');
|
|
49
|
+
});
|
|
50
|
+
it('throws when file content is not an object', async () => {
|
|
51
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
52
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
53
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
54
|
+
});
|
|
55
|
+
it('throws with clear message when file content is a YAML array', async () => {
|
|
56
|
+
const filePath = join(ctx.tmpDir, 'bad.yml');
|
|
57
|
+
await writeFile(filePath, '- foo\n- bar\n', 'utf-8');
|
|
58
|
+
await expect(readDatasetFile(filePath)).rejects.toThrow('Invalid dataset file');
|
|
59
|
+
});
|
|
60
|
+
it('preserves last_output and last_eval fields', async () => {
|
|
61
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
62
|
+
await writeYaml(filePath, {
|
|
63
|
+
cached_case: {
|
|
64
|
+
input: { q: 'hello' },
|
|
65
|
+
last_output: { output: { result: 42 }, executionTimeMs: 100, date: '2026-01-01T00:00:00.000Z' }
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
const [dataset] = await readDatasetFile(filePath);
|
|
69
|
+
expect(dataset.last_output?.output).toEqual({ result: 42 });
|
|
70
|
+
expect(dataset.last_output?.executionTimeMs).toBe(100);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// readAllDatasets
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
describe('readAllDatasets', () => {
|
|
77
|
+
it('flattens cases from multiple files', async () => {
|
|
78
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
79
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
80
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
81
|
+
case_1: { input: { x: 1 } },
|
|
82
|
+
case_2: { input: { x: 2 } }
|
|
83
|
+
});
|
|
84
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
85
|
+
case_3: { input: { x: 3 } }
|
|
86
|
+
});
|
|
87
|
+
const { datasets } = await readAllDatasets('my_workflow', undefined, ctx.tmpDir);
|
|
88
|
+
expect(datasets).toHaveLength(3);
|
|
89
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_1', 'case_2', 'case_3']);
|
|
90
|
+
});
|
|
91
|
+
it('filters by case name across files', async () => {
|
|
92
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
93
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
94
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), {
|
|
95
|
+
case_1: { input: { x: 1 } },
|
|
96
|
+
case_2: { input: { x: 2 } }
|
|
97
|
+
});
|
|
98
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), {
|
|
99
|
+
case_3: { input: { x: 3 } }
|
|
100
|
+
});
|
|
101
|
+
const { datasets } = await readAllDatasets('my_workflow', ['case_2', 'case_3'], ctx.tmpDir);
|
|
102
|
+
expect(datasets).toHaveLength(2);
|
|
103
|
+
expect(datasets.map(d => d.name).sort()).toEqual(['case_2', 'case_3']);
|
|
104
|
+
});
|
|
105
|
+
it('returns empty datasets and a default dir when workflow has no datasets dir', async () => {
|
|
106
|
+
const { datasets, dir } = await readAllDatasets('nonexistent_workflow', undefined, ctx.tmpDir);
|
|
107
|
+
expect(datasets).toHaveLength(0);
|
|
108
|
+
expect(dir).toContain('nonexistent_workflow');
|
|
109
|
+
});
|
|
110
|
+
it('throws when the same case name appears in two different files', async () => {
|
|
111
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
112
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
113
|
+
await writeYaml(join(datasetsDir, 'group_a.yml'), { case_1: { input: { x: 1 } } });
|
|
114
|
+
await writeYaml(join(datasetsDir, 'group_b.yml'), { case_1: { input: { x: 2 } } });
|
|
115
|
+
await expect(readAllDatasets('my_workflow', undefined, ctx.tmpDir)).rejects.toThrow('Duplicate dataset case name "case_1"');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// writeDataset
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
describe('writeDataset', () => {
|
|
122
|
+
it('creates a new file with one case keyed by name', async () => {
|
|
123
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
124
|
+
const dataset = { name: 'new_case', input: { q: 'hello' } };
|
|
125
|
+
await writeDataset(dataset, filePath);
|
|
126
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
127
|
+
expect(raw).toHaveProperty('new_case');
|
|
128
|
+
expect(raw.new_case.input).toEqual({ q: 'hello' });
|
|
129
|
+
expect(raw.new_case).not.toHaveProperty('name');
|
|
130
|
+
});
|
|
131
|
+
it('does not write _source into the file', async () => {
|
|
132
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
133
|
+
const dataset = { name: 'my_case', input: { q: 'x' }, _source: '/some/path.yml' };
|
|
134
|
+
await writeDataset(dataset, filePath);
|
|
135
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
136
|
+
expect(raw.my_case).not.toHaveProperty('_source');
|
|
137
|
+
});
|
|
138
|
+
it('updates only the target case, leaving other cases untouched', async () => {
|
|
139
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
140
|
+
await writeYaml(filePath, {
|
|
141
|
+
case_a: { input: { x: 1 }, ground_truth: { expected: 1 } },
|
|
142
|
+
case_b: { input: { x: 2 }, ground_truth: { expected: 2 } }
|
|
143
|
+
});
|
|
144
|
+
await writeDataset({ name: 'case_a', input: { x: 1 }, last_output: { output: { result: 1 }, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
145
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
146
|
+
expect(raw).toHaveProperty('case_b');
|
|
147
|
+
expect(raw.case_b.ground_truth).toEqual({ expected: 2 });
|
|
148
|
+
});
|
|
149
|
+
it('preserves existing fields when writing last_output then last_eval', async () => {
|
|
150
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
151
|
+
await writeYaml(filePath, {
|
|
152
|
+
my_case: { input: { q: 'hello' }, ground_truth: { expected: 42 } }
|
|
153
|
+
});
|
|
154
|
+
await writeDataset({ name: 'my_case', input: { q: 'hello' }, last_output: { output: { result: 42 }, executionTimeMs: 50, date: '2026-01-01T00:00:00.000Z' } }, filePath);
|
|
155
|
+
await writeDataset({
|
|
156
|
+
name: 'my_case', input: { q: 'hello' },
|
|
157
|
+
last_eval: { output: { datasetName: 'my_case', verdict: 'pass', evaluators: [] }, date: '2026-01-01T00:01:00.000Z' }
|
|
158
|
+
}, filePath);
|
|
159
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
160
|
+
const caseObj = raw.my_case;
|
|
161
|
+
expect(caseObj).toHaveProperty('last_output');
|
|
162
|
+
expect(caseObj).toHaveProperty('last_eval');
|
|
163
|
+
expect(caseObj).toHaveProperty('ground_truth');
|
|
164
|
+
});
|
|
165
|
+
it('creates parent directories if they do not exist', async () => {
|
|
166
|
+
const filePath = join(ctx.tmpDir, 'deep', 'nested', 'cases.yml');
|
|
167
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
168
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
169
|
+
expect(raw).toHaveProperty('my_case');
|
|
170
|
+
});
|
|
171
|
+
it('recovers gracefully when existing file contains non-object YAML', async () => {
|
|
172
|
+
const filePath = join(ctx.tmpDir, 'cases.yml');
|
|
173
|
+
await writeFile(filePath, 'just a string', 'utf-8');
|
|
174
|
+
await writeDataset({ name: 'my_case', input: { q: 'x' } }, filePath);
|
|
175
|
+
const raw = yaml.load(await readFile(filePath, 'utf-8'));
|
|
176
|
+
expect(raw).toHaveProperty('my_case');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// listDatasets
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
describe('listDatasets', () => {
|
|
183
|
+
it('returns one DatasetInfo per case across all files', async () => {
|
|
184
|
+
const datasetsDir = join(ctx.tmpDir, 'src', 'workflows', 'my_workflow', 'tests', 'datasets');
|
|
185
|
+
await mkdir(datasetsDir, { recursive: true });
|
|
186
|
+
await writeYaml(join(datasetsDir, 'core.yml'), {
|
|
187
|
+
case_1: { input: { x: 1 }, last_output: { output: { r: 1 }, date: '2026-01-01T00:00:00.000Z' } },
|
|
188
|
+
case_2: { input: { x: 2 } }
|
|
189
|
+
});
|
|
190
|
+
const infos = await listDatasets('my_workflow', ctx.tmpDir);
|
|
191
|
+
expect(infos).toHaveLength(2);
|
|
192
|
+
const case1 = infos.find(i => i.name === 'case_1');
|
|
193
|
+
expect(case1.hasLastOutput).toBe(true);
|
|
194
|
+
expect(case1.path).toContain('core.yml');
|
|
195
|
+
const case2 = infos.find(i => i.name === 'case_2');
|
|
196
|
+
expect(case2.hasLastOutput).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
it('returns empty array when no datasets directory exists', async () => {
|
|
199
|
+
const infos = await listDatasets('nonexistent_workflow', ctx.tmpDir);
|
|
200
|
+
expect(infos).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -14,7 +14,17 @@ export declare function formatDuration(ms: number): string;
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function formatDate(isoString: string | null | undefined): string;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
17
|
+
* Calculate elapsed milliseconds between two ISO timestamps.
|
|
18
|
+
* If completedAt is null/undefined, uses current time (for in-progress durations).
|
|
19
|
+
*/
|
|
20
|
+
export declare function elapsedMs(startedAt: string, completedAt?: string | null): number;
|
|
21
|
+
/**
|
|
22
|
+
* Format a duration in milliseconds to a compact string that fits in narrow columns.
|
|
23
|
+
* Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatDurationCompact(ms: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* Format a duration between two ISO timestamps.
|
|
18
28
|
*
|
|
19
29
|
* @param startedAt - ISO 8601 start timestamp
|
|
20
30
|
* @param completedAt - ISO 8601 end timestamp (or null if still running)
|
|
@@ -33,7 +33,32 @@ export function formatDate(isoString) {
|
|
|
33
33
|
return format(parseISO(isoString), 'MMM d, yyyy h:mm a');
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Calculate elapsed milliseconds between two ISO timestamps.
|
|
37
|
+
* If completedAt is null/undefined, uses current time (for in-progress durations).
|
|
38
|
+
*/
|
|
39
|
+
export function elapsedMs(startedAt, completedAt) {
|
|
40
|
+
const start = parseISO(startedAt).getTime();
|
|
41
|
+
const end = completedAt ? parseISO(completedAt).getTime() : Date.now();
|
|
42
|
+
return end - start;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format a duration in milliseconds to a compact string that fits in narrow columns.
|
|
46
|
+
* Always returns a short single-token string (e.g., "150ms", "7.56s", "24.2m", "1.3h").
|
|
47
|
+
*/
|
|
48
|
+
export function formatDurationCompact(ms) {
|
|
49
|
+
if (ms < 1000) {
|
|
50
|
+
return `${ms}ms`;
|
|
51
|
+
}
|
|
52
|
+
if (ms < 60_000) {
|
|
53
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
54
|
+
}
|
|
55
|
+
if (ms < 3_600_000) {
|
|
56
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
57
|
+
}
|
|
58
|
+
return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Format a duration between two ISO timestamps.
|
|
37
62
|
*
|
|
38
63
|
* @param startedAt - ISO 8601 start timestamp
|
|
39
64
|
* @param completedAt - ISO 8601 end timestamp (or null if still running)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
type WorkflowResult = Pick<
|
|
3
|
-
export declare const ERROR_STATUSES: ReadonlySet<
|
|
1
|
+
import type { WorkflowResultResponse, WorkflowResultResponseStatus } from '../api/generated/api.js';
|
|
2
|
+
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
+
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultResponseStatus | undefined>;
|
|
4
4
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
5
5
|
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const openUrl: (url: string) => void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export const openUrl = (url) => {
|
|
3
|
+
if (process.platform === 'darwin') {
|
|
4
|
+
execFile('open', [url]);
|
|
5
|
+
}
|
|
6
|
+
else if (process.platform === 'win32') {
|
|
7
|
+
execFile('cmd', ['/c', 'start', url]);
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
execFile('xdg-open', [url]);
|
|
11
|
+
}
|
|
12
|
+
};
|
package/dist/views/dev.js
CHANGED
|
@@ -1,31 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useRef } from 'react';
|
|
3
3
|
import { Box, Text, Static, useApp, useInput } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
|
-
import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH
|
|
5
|
+
import { getServiceStatus, isServiceHealthy, isServiceFailed, SERVICE_HEALTH } from '#services/docker.js';
|
|
6
6
|
import { config } from '#config.js';
|
|
7
|
+
import { fetchWorkflowRuns } from '#services/workflow_runs.js';
|
|
8
|
+
import { openUrl } from '#utils/open_url.js';
|
|
9
|
+
import { StatusIcon } from '#components/status_icon.js';
|
|
10
|
+
import { WorkflowSummarySection } from '#components/workflow_summary.js';
|
|
11
|
+
import { CommandFooter } from '#components/command_footer.js';
|
|
12
|
+
import { WorkflowListView } from '#views/workflow/list.js';
|
|
7
13
|
const POLL_INTERVAL_MS = 2000;
|
|
8
14
|
const HEALTH_TIMEOUT_MS = 120_000;
|
|
9
|
-
const
|
|
10
|
-
[SERVICE_HEALTH.HEALTHY]: '●',
|
|
11
|
-
[SERVICE_HEALTH.UNHEALTHY]: '○',
|
|
12
|
-
[SERVICE_HEALTH.STARTING]: '◐',
|
|
13
|
-
[SERVICE_HEALTH.NONE]: '●',
|
|
14
|
-
[SERVICE_STATE.RUNNING]: '●',
|
|
15
|
-
[SERVICE_STATE.EXITED]: '✗'
|
|
16
|
-
};
|
|
17
|
-
const STATUS_COLORS = {
|
|
18
|
-
[SERVICE_HEALTH.HEALTHY]: 'green',
|
|
19
|
-
[SERVICE_HEALTH.UNHEALTHY]: 'red',
|
|
20
|
-
[SERVICE_HEALTH.STARTING]: 'yellow',
|
|
21
|
-
[SERVICE_HEALTH.NONE]: 'blue',
|
|
22
|
-
[SERVICE_STATE.RUNNING]: 'blue',
|
|
23
|
-
[SERVICE_STATE.EXITED]: 'red'
|
|
24
|
-
};
|
|
25
|
-
const resolveServiceDisplay = (service) => {
|
|
26
|
-
const key = service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
27
|
-
return { icon: STATUS_ICONS[key] ?? '?', color: STATUS_COLORS[key] ?? 'white', status: key };
|
|
28
|
-
};
|
|
15
|
+
const resolveServiceStatus = (service) => service.health === SERVICE_HEALTH.NONE ? service.state : service.health;
|
|
29
16
|
const fetchServices = async (dockerComposePath) => {
|
|
30
17
|
try {
|
|
31
18
|
return await getServiceStatus(dockerComposePath);
|
|
@@ -97,6 +84,32 @@ const useStatusRefresh = (dockerComposePath, enabled, onServices) => {
|
|
|
97
84
|
return 'continue';
|
|
98
85
|
});
|
|
99
86
|
};
|
|
87
|
+
const useWorkflowPolling = (enabled, onRuns) => {
|
|
88
|
+
const onRunsRef = useRef(onRuns);
|
|
89
|
+
onRunsRef.current = onRuns;
|
|
90
|
+
usePoll(enabled, async () => {
|
|
91
|
+
try {
|
|
92
|
+
const { runs } = await fetchWorkflowRuns({ limit: 100 });
|
|
93
|
+
onRunsRef.current(runs);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// API may not be ready yet
|
|
97
|
+
}
|
|
98
|
+
return 'continue';
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const useMainViewInput = (isActive, callbacks) => {
|
|
102
|
+
const callbacksRef = useRef(callbacks);
|
|
103
|
+
callbacksRef.current = callbacks;
|
|
104
|
+
useInput(input => {
|
|
105
|
+
if (input === 'o') {
|
|
106
|
+
callbacksRef.current.onOpenTemporal();
|
|
107
|
+
}
|
|
108
|
+
if (input === 'w') {
|
|
109
|
+
callbacksRef.current.onOpenWorkflows();
|
|
110
|
+
}
|
|
111
|
+
}, { isActive });
|
|
112
|
+
};
|
|
100
113
|
const useCtrlC = (onCleanup) => {
|
|
101
114
|
const { exit } = useApp();
|
|
102
115
|
const isExitingRef = useRef(false);
|
|
@@ -108,9 +121,9 @@ const useCtrlC = (onCleanup) => {
|
|
|
108
121
|
});
|
|
109
122
|
};
|
|
110
123
|
const ServiceRow = ({ service }) => {
|
|
111
|
-
const
|
|
124
|
+
const status = resolveServiceStatus(service);
|
|
112
125
|
const ports = service.ports.length ? service.ports.join(', ') : '-';
|
|
113
|
-
return (_jsxs(Box, { children: [
|
|
126
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(StatusIcon, { status: status }) }), _jsx(Box, { width: 16, children: _jsx(Text, { children: service.name }) }), _jsx(Text, { dimColor: true, children: status.padEnd(10) }), _jsx(Text, { dimColor: true, children: ports })] }));
|
|
114
127
|
};
|
|
115
128
|
const FailureWarning = ({ services }) => {
|
|
116
129
|
const failed = services.filter(isServiceFailed);
|
|
@@ -128,12 +141,24 @@ const DevSuccessMessage = ({ services }) => {
|
|
|
128
141
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: '✅ SUCCESS! ' }), _jsx(Text, { bold: true, children: "Development services are running" })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDC33 SERVICES" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal: ' }), _jsx(Text, { color: "yellow", children: "localhost:7233" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'API Server: ' }), _jsx(Text, { color: "yellow", children: "localhost:3001" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Redis: ' }), _jsx(Text, { color: "yellow", children: "localhost:6379" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\uD83D\uDE80 RUN A WORKFLOW" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsx(Text, { color: "white", children: "In a new terminal, execute:" }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "cyan", children: "npx output workflow run blog_evaluator paulgraham_hwh" }) })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "\u26A1 USEFUL COMMANDS" }) }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Open Temporal UI: ' }), _jsx(Text, { color: "cyan", children: "open http://localhost:8080" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'View logs: ' }), _jsx(Text, { color: "cyan", children: logsCommand })] }), _jsxs(Box, { children: [_jsx(Text, { color: "white", children: 'Stop services: ' }), _jsx(Text, { color: "cyan", children: "Press Ctrl+C" })] })] }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: divider }) }), _jsx(Text, { dimColor: true, children: "\uD83D\uDCA1 Tip: The Temporal UI lets you monitor workflow executions in real-time" })] }));
|
|
129
142
|
};
|
|
130
143
|
const WaitingView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Waiting for services to become healthy..." })] }), services.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }))] }));
|
|
131
|
-
const RunningView = ({ services }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(
|
|
144
|
+
const RunningView = ({ services, workflowSummary }) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "\uD83D\uDCCA Service Status" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: services.map(s => _jsx(ServiceRow, { service: s }, s.name)) }), _jsx(FailureWarning, { services: services }), workflowSummary && _jsx(WorkflowSummarySection, { summary: workflowSummary }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: '🌐 Temporal UI: ' }), _jsx(Text, { bold: true, children: "http://localhost:8080" })] }), _jsx(CommandFooter, { hints: [
|
|
145
|
+
{ key: 'o', label: 'open ui' },
|
|
146
|
+
{ key: 'w', label: 'view workflow runs' },
|
|
147
|
+
{ key: 'ctrl+c', label: 'stop' }
|
|
148
|
+
] })] }));
|
|
149
|
+
const MainDevView = ({ phase, services, workflowSummary }) => {
|
|
150
|
+
if (phase === 'waiting') {
|
|
151
|
+
return _jsx(WaitingView, { services: services });
|
|
152
|
+
}
|
|
153
|
+
return _jsx(RunningView, { services: services, workflowSummary: workflowSummary });
|
|
154
|
+
};
|
|
132
155
|
export const DevApp = ({ dockerComposePath, onCleanup }) => {
|
|
133
156
|
const { exit } = useApp();
|
|
134
157
|
const [phase, setPhase] = useState('waiting');
|
|
135
158
|
const [services, setServices] = useState([]);
|
|
136
159
|
const [successItems, setSuccessItems] = useState([]);
|
|
160
|
+
const [activeView, setActiveView] = useState('main');
|
|
161
|
+
const [workflowRuns, setWorkflowRuns] = useState([]);
|
|
137
162
|
useHealthPolling(dockerComposePath, phase === 'waiting', {
|
|
138
163
|
onServices: setServices,
|
|
139
164
|
onAllHealthy: svcs => {
|
|
@@ -146,6 +171,17 @@ export const DevApp = ({ dockerComposePath, onCleanup }) => {
|
|
|
146
171
|
onTimeout: () => exit(new Error('Timeout waiting for services to become healthy'))
|
|
147
172
|
});
|
|
148
173
|
useStatusRefresh(dockerComposePath, phase === 'running', setServices);
|
|
174
|
+
useWorkflowPolling(phase === 'running' || phase === 'failed', setWorkflowRuns);
|
|
175
|
+
useMainViewInput(activeView === 'main' && phase !== 'waiting', {
|
|
176
|
+
onOpenTemporal: () => openUrl('http://localhost:8080'),
|
|
177
|
+
onOpenWorkflows: () => setActiveView('workflows')
|
|
178
|
+
});
|
|
149
179
|
useCtrlC(onCleanup);
|
|
150
|
-
|
|
180
|
+
const workflowSummary = workflowRuns.length > 0 ? {
|
|
181
|
+
running: workflowRuns.filter(r => r.status === 'running').length,
|
|
182
|
+
completed: workflowRuns.filter(r => r.status === 'completed').length,
|
|
183
|
+
failed: workflowRuns.filter(r => r.status === 'failed').length,
|
|
184
|
+
total: workflowRuns.length
|
|
185
|
+
} : null;
|
|
186
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: successItems, children: item => _jsx(DevSuccessMessage, { services: item.services }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [activeView === 'main' && (_jsx(MainDevView, { phase: phase, services: services, workflowSummary: workflowSummary })), activeView === 'workflows' && (_jsx(WorkflowListView, { runs: workflowRuns, onBack: () => setActiveView('main') }))] })] }));
|
|
151
187
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { getWorkflowIdResult } from '#api/generated/api.js';
|
|
6
|
+
import { StatusIcon, statusColor } from '#components/status_icon.js';
|
|
7
|
+
import { elapsedMs, formatDurationCompact } from '#utils/date_formatter.js';
|
|
8
|
+
import { CommandFooter } from '#components/command_footer.js';
|
|
9
|
+
import { openUrl } from '#utils/open_url.js';
|
|
10
|
+
const TEMPORAL_UI_BASE = 'http://localhost:8080';
|
|
11
|
+
const VISIBLE_ROWS = 15;
|
|
12
|
+
const STATUS_ORDER = {
|
|
13
|
+
running: 0,
|
|
14
|
+
failed: 1,
|
|
15
|
+
timed_out: 2,
|
|
16
|
+
terminated: 3,
|
|
17
|
+
canceled: 4,
|
|
18
|
+
continued: 5,
|
|
19
|
+
completed: 6
|
|
20
|
+
};
|
|
21
|
+
const sortRuns = (runs) => [...runs].sort((a, b) => {
|
|
22
|
+
const statusDiff = (STATUS_ORDER[a.status ?? ''] ?? Infinity) - (STATUS_ORDER[b.status ?? ''] ?? Infinity);
|
|
23
|
+
if (statusDiff !== 0) {
|
|
24
|
+
return statusDiff;
|
|
25
|
+
}
|
|
26
|
+
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
27
|
+
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
28
|
+
return bTime - aTime;
|
|
29
|
+
});
|
|
30
|
+
const truncate = (str, max) => str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
31
|
+
const COL = {
|
|
32
|
+
indicator: 2,
|
|
33
|
+
icon: 3,
|
|
34
|
+
status: 12,
|
|
35
|
+
type: 20,
|
|
36
|
+
id: 32,
|
|
37
|
+
duration: 10
|
|
38
|
+
};
|
|
39
|
+
const WorkflowRow = ({ run, selected }) => {
|
|
40
|
+
const status = run.status ?? 'running';
|
|
41
|
+
const color = statusColor(status);
|
|
42
|
+
const duration = run.startedAt ? formatDurationCompact(elapsedMs(run.startedAt, run.completedAt)) : '-';
|
|
43
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { color: selected ? 'cyan' : undefined, bold: selected, children: selected ? '▸' : ' ' }) }), _jsx(Box, { width: COL.icon, children: _jsx(StatusIcon, { 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 - 2) }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: !selected, children: truncate(run.workflowId ?? '-', COL.id - 2) }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, children: duration }) })] }));
|
|
44
|
+
};
|
|
45
|
+
const HeaderRow = () => (_jsxs(Box, { children: [_jsx(Box, { width: COL.indicator, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.icon, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, bold: true, children: "STATUS" }) }), _jsx(Box, { width: COL.type, children: _jsx(Text, { dimColor: true, bold: true, children: "TYPE" }) }), _jsx(Box, { width: COL.id, children: _jsx(Text, { dimColor: true, bold: true, children: "WORKFLOW ID" }) }), _jsx(Box, { width: COL.duration, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, bold: true, children: "DURATION" }) })] }));
|
|
46
|
+
const WorkflowDetailPane = ({ detail, loading }) => {
|
|
47
|
+
if (loading) {
|
|
48
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] }));
|
|
49
|
+
}
|
|
50
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Status: " }), _jsx(Text, { color: statusColor(detail.status ?? ''), children: detail.status })] }), detail.error && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "red", children: "Error:" }), _jsx(Text, { color: "red", children: truncate(detail.error, 300) })] })), detail.output !== undefined && detail.output !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Output:" }), _jsx(Text, { children: truncate(JSON.stringify(detail.output, null, 2), 400) })] }))] }));
|
|
51
|
+
};
|
|
52
|
+
export const WorkflowListView = ({ runs, onBack }) => {
|
|
53
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
54
|
+
const [detail, setDetail] = useState(null);
|
|
55
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
56
|
+
const cacheRef = useRef(new Map());
|
|
57
|
+
const fetchIdRef = useRef(0);
|
|
58
|
+
const sortedRuns = useMemo(() => sortRuns(runs), [runs]);
|
|
59
|
+
const clampedIndex = Math.min(selectedIndex, Math.max(0, sortedRuns.length - 1));
|
|
60
|
+
const selectedRun = sortedRuns[clampedIndex];
|
|
61
|
+
const selectedWorkflowId = selectedRun?.workflowId;
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (clampedIndex !== selectedIndex) {
|
|
64
|
+
setSelectedIndex(clampedIndex);
|
|
65
|
+
}
|
|
66
|
+
}, [clampedIndex, selectedIndex]);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!selectedWorkflowId) {
|
|
69
|
+
setDetail(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const cached = cacheRef.current.get(selectedWorkflowId);
|
|
73
|
+
if (cached) {
|
|
74
|
+
setDetail(cached);
|
|
75
|
+
setDetailLoading(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const currentFetchId = ++fetchIdRef.current;
|
|
79
|
+
setDetailLoading(true);
|
|
80
|
+
getWorkflowIdResult(selectedWorkflowId)
|
|
81
|
+
.then(response => {
|
|
82
|
+
if (fetchIdRef.current !== currentFetchId) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const data = response.data;
|
|
86
|
+
cacheRef.current.set(selectedWorkflowId, data);
|
|
87
|
+
setDetail(data);
|
|
88
|
+
setDetailLoading(false);
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
if (fetchIdRef.current !== currentFetchId) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
setDetail(null);
|
|
95
|
+
setDetailLoading(false);
|
|
96
|
+
});
|
|
97
|
+
}, [selectedWorkflowId]);
|
|
98
|
+
useInput((input, key) => {
|
|
99
|
+
if (key.upArrow) {
|
|
100
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
101
|
+
}
|
|
102
|
+
else if (key.downArrow) {
|
|
103
|
+
setSelectedIndex(i => Math.min(sortedRuns.length - 1, i + 1));
|
|
104
|
+
}
|
|
105
|
+
else if (key.escape || input === 'q') {
|
|
106
|
+
onBack();
|
|
107
|
+
}
|
|
108
|
+
else if (input === 'o' && selectedWorkflowId) {
|
|
109
|
+
openUrl(`${TEMPORAL_UI_BASE}/namespaces/default/workflows/${selectedWorkflowId}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const windowStart = useMemo(() => {
|
|
113
|
+
const half = Math.floor(VISIBLE_ROWS / 2);
|
|
114
|
+
const start = Math.max(0, clampedIndex - half);
|
|
115
|
+
const maxStart = Math.max(0, sortedRuns.length - VISIBLE_ROWS);
|
|
116
|
+
return Math.min(start, maxStart);
|
|
117
|
+
}, [clampedIndex, sortedRuns.length]);
|
|
118
|
+
const visibleRuns = sortedRuns.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
119
|
+
if (sortedRuns.length === 0) {
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Workflow Runs" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No workflow runs found." }) }), _jsx(CommandFooter, { hints: [{ key: 'q', label: 'back' }] })] }));
|
|
121
|
+
}
|
|
122
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Workflow Runs (", sortedRuns.length, ")"] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(HeaderRow, {}), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more above"] }), visibleRuns.map((run, i) => (_jsx(WorkflowRow, { run: run, selected: windowStart + i === clampedIndex }, `${run.workflowId}-${run.startedAt}-${windowStart + i}`))), windowStart + VISIBLE_ROWS < sortedRuns.length && (_jsxs(Text, { dimColor: true, children: [" \u2193 ", sortedRuns.length - windowStart - VISIBLE_ROWS, " more below"] }))] }), detail && _jsx(WorkflowDetailPane, { detail: detail, loading: detailLoading }), detailLoading && !detail && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading details..." })] })), _jsx(CommandFooter, { hints: [
|
|
123
|
+
{ key: '↑/↓', label: 'navigate' },
|
|
124
|
+
{ key: 'o', label: 'open in temporal' },
|
|
125
|
+
{ key: 'q', label: 'back' }
|
|
126
|
+
] })] }));
|
|
127
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.1.13-next.
|
|
3
|
+
"version": "0.1.13-next.95fd251.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"react": "19.2.4",
|
|
36
36
|
"semver": "7.7.4",
|
|
37
37
|
"yaml": "^2.8.3",
|
|
38
|
-
"@outputai/credentials": "0.1.13-next.
|
|
39
|
-
"@outputai/evals": "0.1.13-next.
|
|
40
|
-
"@outputai/llm": "0.1.13-next.
|
|
38
|
+
"@outputai/credentials": "0.1.13-next.95fd251.0",
|
|
39
|
+
"@outputai/evals": "0.1.13-next.95fd251.0",
|
|
40
|
+
"@outputai/llm": "0.1.13-next.95fd251.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/cli-progress": "3.11.6",
|