@outputai/cli 0.10.1-next.09ed166.0 → 0.10.1-next.1070949.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 +81 -26
- package/dist/api/generated/api.js +7 -4
- package/dist/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.js +71 -0
- package/dist/commands/dev/index.d.ts +4 -0
- package/dist/commands/dev/index.js +200 -53
- package/dist/commands/dev/index.spec.js +390 -42
- package/dist/commands/workflow/result.js +2 -2
- package/dist/commands/workflow/result.spec.js +65 -1
- package/dist/commands/workflow/run.js +10 -3
- package/dist/commands/workflow/run.spec.js +42 -5
- package/dist/commands/workflow/start.d.ts +3 -1
- package/dist/commands/workflow/start.js +12 -2
- package/dist/commands/workflow/start.spec.js +30 -5
- package/dist/commands/workflow/status.spec.js +1 -1
- package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
- package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
- package/dist/commands/workflow/test.spec.d.ts +1 -0
- package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.d.ts +28 -1
- package/dist/services/docker.js +106 -12
- package/dist/services/docker.spec.js +144 -14
- package/dist/templates/agent_instructions/CLAUDE.md.template +5 -3
- package/dist/templates/project/README.md.template +3 -1
- package/dist/templates/project/package.json.template +2 -2
- package/dist/templates/project/src/clients/jina.ts.template +4 -4
- package/dist/utils/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- package/dist/utils/format_workflow_result.d.ts +3 -2
- package/dist/utils/format_workflow_result.js +6 -2
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/normalize_workflow_status.d.ts +4 -3
- package/dist/utils/normalize_workflow_status.js +12 -3
- package/dist/utils/normalize_workflow_status.spec.js +3 -0
- package/dist/utils/port_collision.d.ts +22 -7
- package/dist/utils/port_collision.js +39 -14
- package/dist/utils/port_collision.spec.js +40 -1
- package/dist/utils/resolve_input.d.ts +9 -1
- package/dist/utils/resolve_input.js +8 -2
- package/dist/utils/resolve_input.spec.d.ts +1 -0
- package/dist/utils/resolve_input.spec.js +75 -0
- package/dist/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- package/dist/views/dev/components/workflow_status.js +1 -1
- package/dist/views/dev/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- package/dist/views/dev/hooks/use_run_detail.js +4 -2
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
- package/dist/views/dev/utils/bounded_cache.js +42 -0
- package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
- package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
- package/oclif.manifest.json +52 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
-
import
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { parseServiceStatus, getServiceStatus, startDockerCompose, runDockerComposeUpDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed, classifyStackState, STACK_STATE, resolveDockerComposePath, getDefaultDockerComposePath, DockerComposeConfigNotFoundError } from './docker.js';
|
|
4
6
|
vi.mock('node:child_process', () => ({
|
|
5
7
|
execSync: vi.fn(),
|
|
6
8
|
execFileSync: vi.fn(),
|
|
7
9
|
spawn: vi.fn()
|
|
8
10
|
}));
|
|
11
|
+
vi.mock('node:fs/promises', () => ({
|
|
12
|
+
default: { access: vi.fn() }
|
|
13
|
+
}));
|
|
9
14
|
const mockChildProcess = (process) => process;
|
|
10
15
|
vi.mock('log-update', () => {
|
|
11
16
|
const fn = vi.fn();
|
|
@@ -155,7 +160,7 @@ describe('docker service', () => {
|
|
|
155
160
|
onExit
|
|
156
161
|
});
|
|
157
162
|
expect(dockerProcess.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
158
|
-
expect(dockerProcess.on).toHaveBeenCalledWith('
|
|
163
|
+
expect(dockerProcess.on).toHaveBeenCalledWith('close', expect.any(Function));
|
|
159
164
|
streamHandlers.stdout?.(Buffer.from('starting services\n'));
|
|
160
165
|
streamHandlers.stderr?.(Buffer.from('compose failed\n'));
|
|
161
166
|
const error = new Error('Docker failed');
|
|
@@ -165,26 +170,99 @@ describe('docker service', () => {
|
|
|
165
170
|
expect(onExit).toHaveBeenCalledWith(1, null, 'starting services\ncompose failed');
|
|
166
171
|
});
|
|
167
172
|
});
|
|
168
|
-
describe('
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
describe('runDockerComposeUpDetached', () => {
|
|
174
|
+
const makeProcess = () => {
|
|
175
|
+
const handlers = {};
|
|
176
|
+
const proc = {
|
|
177
|
+
on: vi.fn((event, handler) => {
|
|
178
|
+
if (event === 'error') {
|
|
179
|
+
handlers.error = handler;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
handlers.exit = handler;
|
|
183
|
+
}
|
|
184
|
+
return proc;
|
|
185
|
+
}),
|
|
186
|
+
stdout: {
|
|
187
|
+
on: vi.fn((event, handler) => {
|
|
188
|
+
handlers.stdout = handler;
|
|
189
|
+
return proc.stdout;
|
|
190
|
+
})
|
|
191
|
+
},
|
|
192
|
+
stderr: {
|
|
193
|
+
on: vi.fn((event, handler) => {
|
|
194
|
+
handlers.stderr = handler;
|
|
195
|
+
return proc.stderr;
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return { proc, handlers };
|
|
200
|
+
};
|
|
201
|
+
it('passes --project-name and -d, tees output, and resolves with the exit code', async () => {
|
|
202
|
+
const { proc, handlers } = makeProcess();
|
|
203
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
204
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
205
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
206
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
173
207
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
174
208
|
'--project-directory', process.cwd(),
|
|
175
209
|
'--project-name', 'output-sdk',
|
|
176
210
|
'up', '-d'
|
|
177
|
-
], expect.objectContaining({
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
], expect.objectContaining({ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
212
|
+
handlers.stdout?.(Buffer.from('pulling images\n'));
|
|
213
|
+
handlers.exit?.(0);
|
|
214
|
+
expect(await promise).toEqual({ code: 0, output: 'pulling images' });
|
|
215
|
+
expect(stdoutSpy).toHaveBeenCalledWith(Buffer.from('pulling images\n'));
|
|
216
|
+
stdoutSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
it('appends --pull when pullPolicy is provided', async () => {
|
|
219
|
+
const { proc, handlers } = makeProcess();
|
|
220
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
221
|
+
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
222
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml', 'missing');
|
|
223
|
+
handlers.exit?.(0);
|
|
224
|
+
await promise;
|
|
225
|
+
expect(spawn).toHaveBeenCalledWith('docker', [
|
|
183
226
|
'compose', '-f', '/path/to/docker-compose.yml',
|
|
184
227
|
'--project-directory', process.cwd(),
|
|
185
228
|
'--project-name', 'output-sdk',
|
|
186
229
|
'up', '-d', '--pull', 'missing'
|
|
187
|
-
], expect.objectContaining({
|
|
230
|
+
], expect.objectContaining({ cwd: process.cwd() }));
|
|
231
|
+
});
|
|
232
|
+
it('resolves with a non-zero code and captured stderr so the caller can hint the collision', async () => {
|
|
233
|
+
const { proc, handlers } = makeProcess();
|
|
234
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
235
|
+
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
236
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
237
|
+
handlers.stderr?.(Buffer.from('Error: address already in use\n'));
|
|
238
|
+
handlers.exit?.(1);
|
|
239
|
+
expect(await promise).toEqual({ code: 1, output: 'Error: address already in use' });
|
|
240
|
+
});
|
|
241
|
+
it('rejects when the docker process fails to spawn', async () => {
|
|
242
|
+
const { proc, handlers } = makeProcess();
|
|
243
|
+
vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
|
|
244
|
+
const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
|
|
245
|
+
handlers.error?.(new Error('spawn ENOENT'));
|
|
246
|
+
await expect(promise).rejects.toThrow('spawn ENOENT');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe('resolveDockerComposePath', () => {
|
|
250
|
+
it('resolves a custom path against cwd and returns it when it exists', async () => {
|
|
251
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
252
|
+
const result = await resolveDockerComposePath('custom/compose.yml');
|
|
253
|
+
const expected = path.resolve(process.cwd(), 'custom/compose.yml');
|
|
254
|
+
expect(result).toBe(expected);
|
|
255
|
+
expect(fs.access).toHaveBeenCalledWith(expected);
|
|
256
|
+
});
|
|
257
|
+
it('throws DockerComposeConfigNotFoundError when the path does not exist', async () => {
|
|
258
|
+
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
|
259
|
+
await expect(resolveDockerComposePath('missing.yml'))
|
|
260
|
+
.rejects.toBeInstanceOf(DockerComposeConfigNotFoundError);
|
|
261
|
+
});
|
|
262
|
+
it('falls back to the bundled default when no custom path is given', async () => {
|
|
263
|
+
vi.mocked(fs.access).mockResolvedValue(undefined);
|
|
264
|
+
const result = await resolveDockerComposePath();
|
|
265
|
+
expect(result).toBe(getDefaultDockerComposePath());
|
|
188
266
|
});
|
|
189
267
|
});
|
|
190
268
|
describe('DOCKER_SERVICE_NAME wiring', () => {
|
|
@@ -259,6 +337,58 @@ describe('docker service', () => {
|
|
|
259
337
|
expect(isServiceFailed({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
|
|
260
338
|
});
|
|
261
339
|
});
|
|
340
|
+
describe('classifyStackState', () => {
|
|
341
|
+
const svc = (state, health) => ({ name: 's', state, health, ports: [] });
|
|
342
|
+
it('returns NONE for an empty stack (fresh start)', () => {
|
|
343
|
+
expect(classifyStackState([])).toBe(STACK_STATE.NONE);
|
|
344
|
+
});
|
|
345
|
+
it('returns RUNNING when every service is up and healthy', () => {
|
|
346
|
+
expect(classifyStackState([
|
|
347
|
+
svc('running', 'healthy'),
|
|
348
|
+
svc('running', 'none')
|
|
349
|
+
])).toBe(STACK_STATE.RUNNING);
|
|
350
|
+
});
|
|
351
|
+
it('returns PARTIAL when any service has failed (orphaned stack)', () => {
|
|
352
|
+
expect(classifyStackState([
|
|
353
|
+
svc('running', 'healthy'),
|
|
354
|
+
svc('exited', 'none')
|
|
355
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
356
|
+
});
|
|
357
|
+
it('returns PARTIAL when services exist but some are still coming up', () => {
|
|
358
|
+
expect(classifyStackState([
|
|
359
|
+
svc('running', 'healthy'),
|
|
360
|
+
svc('created', 'none')
|
|
361
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
362
|
+
});
|
|
363
|
+
it('treats an unhealthy service as PARTIAL, not RUNNING', () => {
|
|
364
|
+
expect(classifyStackState([
|
|
365
|
+
svc('running', 'healthy'),
|
|
366
|
+
svc('running', 'unhealthy')
|
|
367
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
368
|
+
});
|
|
369
|
+
// `ps --all` reports exited containers, so a stack stopped by a reboot, a
|
|
370
|
+
// `docker compose stop`, or a failed teardown still has rows. Nothing is
|
|
371
|
+
// live, so this invocation would be the one starting it — that makes it an
|
|
372
|
+
// owned fresh start, not an attach.
|
|
373
|
+
it('returns NONE when every service is exited — an owned fresh start, not an attach', () => {
|
|
374
|
+
expect(classifyStackState([
|
|
375
|
+
svc('exited', 'none'),
|
|
376
|
+
svc('exited', 'none')
|
|
377
|
+
])).toBe(STACK_STATE.NONE);
|
|
378
|
+
});
|
|
379
|
+
it('returns NONE when containers are created but none have started', () => {
|
|
380
|
+
expect(classifyStackState([
|
|
381
|
+
svc('created', 'none'),
|
|
382
|
+
svc('created', 'none')
|
|
383
|
+
])).toBe(STACK_STATE.NONE);
|
|
384
|
+
});
|
|
385
|
+
it('still returns PARTIAL when at least one service is live', () => {
|
|
386
|
+
expect(classifyStackState([
|
|
387
|
+
svc('running', 'healthy'),
|
|
388
|
+
svc('exited', 'none')
|
|
389
|
+
])).toBe(STACK_STATE.PARTIAL);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
262
392
|
describe('waitForServicesHealthy', () => {
|
|
263
393
|
it('should resolve when all services are healthy', async () => {
|
|
264
394
|
const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
|
|
@@ -17,10 +17,12 @@ claude plugin install outputai@outputai --scope project
|
|
|
17
17
|
npm run output:dev # Start dev environment (worker + Temporal)
|
|
18
18
|
npm run output:worker:build # Build TypeScript to dist/
|
|
19
19
|
npm run output:worker:check # Optional: bundle-check workflows for bad imports (node: built-ins)
|
|
20
|
-
npm run output:worker:watch # Build + restart on file changes
|
|
21
|
-
npm run output:worker #
|
|
20
|
+
npm run output:worker:watch # Build + restart on src/ file changes
|
|
21
|
+
npm run output:worker # Build and start worker
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Hot-reload watches `src/` only. After changing dependencies (`package.json` / lockfile), run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
25
|
+
|
|
24
26
|
## Project Structure
|
|
25
27
|
|
|
26
28
|
```
|
|
@@ -44,7 +46,7 @@ config/
|
|
|
44
46
|
## Key Conventions
|
|
45
47
|
|
|
46
48
|
- **Workflows are deterministic**: No I/O, no `Date.now()`, no `Math.random()` in `workflow.ts`. All side effects go in steps or evaluators.
|
|
47
|
-
- **HTTP clients**:
|
|
49
|
+
- **HTTP clients**: Use `outputFetch` or `createKyClient` from `@outputai/http` instead of raw `fetch` or `axios`. Requests are automatically traced; use `addRequestCost` when cost tracking is needed.
|
|
48
50
|
- **LLM calls**: Use `generateText` from `@outputai/llm` with `.prompt` files. Never call LLM APIs directly.
|
|
49
51
|
|
|
50
52
|
---
|
|
@@ -79,7 +79,9 @@ This starts:
|
|
|
79
79
|
- Temporal server and UI (http://localhost:8080)
|
|
80
80
|
- PostgreSQL and Redis databases
|
|
81
81
|
- Output.ai API server (http://localhost:3001)
|
|
82
|
-
- Worker process for executing workflows
|
|
82
|
+
- Worker process for executing workflows (auto-reloads on `src/` changes)
|
|
83
|
+
|
|
84
|
+
Dependency changes (`package.json` / lockfile) are not picked up by hot-reload. Run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
83
85
|
|
|
84
86
|
### 4. Run a workflow
|
|
85
87
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"output:worker:build": "rm -rf dist/* && tsc -p ./ && output-copy-assets",
|
|
10
10
|
"output:worker:start": "output-worker",
|
|
11
11
|
"output:worker:check": "output-worker --check",
|
|
12
|
-
"output:worker": "npm run output:worker:
|
|
13
|
-
"output:worker:watch": "npx nodemon --watch src --
|
|
12
|
+
"output:worker": "npm run output:worker:build && npm run output:worker:start",
|
|
13
|
+
"output:worker:watch": "npx nodemon --watch src --ext ts,js,json,prompt,md --ignore 'dist/**' --ignore '**/*.spec.*' --ignore '**/*.test.*' --exec 'npm run output:worker'",
|
|
14
14
|
"output:dev": "output dev"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createKyClient } from '@outputai/http';
|
|
2
2
|
|
|
3
3
|
export interface JinaReaderResponse {
|
|
4
4
|
code: number;
|
|
@@ -12,13 +12,13 @@ export interface JinaReaderResponse {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const client = createKyClient( {
|
|
16
|
+
prefix: 'https://r.jina.ai',
|
|
17
17
|
timeout: 30000
|
|
18
18
|
} );
|
|
19
19
|
|
|
20
20
|
export async function fetchBlogContent( url: string ): Promise<JinaReaderResponse> {
|
|
21
|
-
const response = await
|
|
21
|
+
const response = await client.post( '', {
|
|
22
22
|
json: { url },
|
|
23
23
|
headers: {
|
|
24
24
|
'Accept': 'application/json',
|
package/dist/utils/env_loader.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import * as dotenv from 'dotenv';
|
|
9
8
|
import debugFactory from 'debug';
|
|
10
9
|
const debug = debugFactory('output-cli:env-loader');
|
|
11
10
|
export function loadEnvironment() {
|
|
@@ -17,5 +16,10 @@ export function loadEnvironment() {
|
|
|
17
16
|
return;
|
|
18
17
|
}
|
|
19
18
|
debug(`Loading env from: ${envPath}`);
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
process.loadEnvFile(envPath);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
debug(`Warning: Failed to load env file ${envPath}: ${err}`);
|
|
24
|
+
}
|
|
21
25
|
}
|
|
@@ -1,43 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
|
-
import * as dotenv from 'dotenv';
|
|
8
|
-
vi.mock('node:fs');
|
|
9
|
-
vi.mock('dotenv');
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { loadEnvironment } from './env_loader.js';
|
|
10
6
|
describe('loadEnvironment', () => {
|
|
11
|
-
const
|
|
12
|
-
const mockCwd = '/mock/project';
|
|
7
|
+
const mockCwd = mkdtempSync(join(tmpdir(), 'output-env-loader-'));
|
|
13
8
|
beforeEach(() => {
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
for (const name of readdirSync(mockCwd)) {
|
|
10
|
+
rmSync(join(mockCwd, name), { recursive: true, force: true });
|
|
11
|
+
}
|
|
16
12
|
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
|
17
|
-
vi.
|
|
18
|
-
vi.
|
|
13
|
+
vi.stubEnv('OUTPUT_CLI_ENV', undefined);
|
|
14
|
+
vi.stubEnv('OUTPUT_API_URL', undefined);
|
|
15
|
+
vi.stubEnv('OUTPUT_API_TOKEN', undefined);
|
|
19
16
|
});
|
|
20
17
|
afterEach(() => {
|
|
21
|
-
process.env = { ...originalEnv };
|
|
22
18
|
vi.restoreAllMocks();
|
|
19
|
+
vi.unstubAllEnvs();
|
|
23
20
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(mockCwd, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
it('loads variables from OUTPUT_CLI_ENV', () => {
|
|
25
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
26
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
27
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
28
|
+
].join('\n'));
|
|
29
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
30
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
31
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
32
|
+
].join('\n'));
|
|
33
|
+
process.env.OUTPUT_CLI_ENV = '.env.mock';
|
|
34
|
+
loadEnvironment();
|
|
35
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://mock.api.com');
|
|
36
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('mock-token');
|
|
37
|
+
});
|
|
38
|
+
it('loads variables from .env by default', () => {
|
|
39
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
40
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
41
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
42
|
+
].join('\n'));
|
|
43
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
44
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
45
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
46
|
+
].join('\n'));
|
|
30
47
|
loadEnvironment();
|
|
31
|
-
expect(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://default.api.com');
|
|
49
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('default-token');
|
|
50
|
+
});
|
|
51
|
+
it('does nothing when the env file is missing', () => {
|
|
52
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
53
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
54
|
+
expect(process.env.OUTPUT_API_TOKEN).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
it('does not throw when the env path is not a readable file', () => {
|
|
57
|
+
mkdirSync(join(mockCwd, 'not-a-file.env'));
|
|
58
|
+
process.env.OUTPUT_CLI_ENV = 'not-a-file.env';
|
|
59
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
60
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
it('does not overwrite already-set process.env values', () => {
|
|
63
|
+
vi.stubEnv('OUTPUT_API_URL', 'https://ambient.api.com');
|
|
64
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
65
|
+
'OUTPUT_API_URL=https://file.api.com',
|
|
66
|
+
'OUTPUT_API_TOKEN=file-token'
|
|
67
|
+
].join('\n'));
|
|
39
68
|
loadEnvironment();
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
69
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://ambient.api.com');
|
|
70
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('file-token');
|
|
42
71
|
});
|
|
43
72
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { WorkflowResultResponse,
|
|
1
|
+
import type { WorkflowResultResponse, WorkflowResultStatus } from '../api/generated/api.js';
|
|
2
2
|
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
|
-
export declare const ERROR_STATUSES: ReadonlySet<
|
|
3
|
+
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultStatus>;
|
|
4
|
+
export declare const isErrorWorkflowStatus: (status: string | null | undefined) => boolean;
|
|
4
5
|
export declare const TERMINAL_STATUSES: ReadonlySet<string>;
|
|
5
6
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
6
7
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
|
|
2
|
-
export const ERROR_STATUSES = new Set(['failed', '
|
|
2
|
+
export const ERROR_STATUSES = new Set(['failed', 'cancelled', 'terminated', 'timed_out']);
|
|
3
|
+
export const isErrorWorkflowStatus = (status) => ERROR_STATUSES.has(normalizeWorkflowStatus(status));
|
|
3
4
|
// Every error status plus the one success status — derived so the two sets can't
|
|
4
5
|
// silently drift apart as error statuses evolve. Shared by `workflow monitor` and
|
|
5
6
|
// the dev TUI's `useRunDetail`/`useStepGraph` so both agree on what "done" means.
|
|
@@ -17,7 +18,10 @@ export function formatWorkflowResult(result) {
|
|
|
17
18
|
else {
|
|
18
19
|
lines.push(`Status: ${status || 'unknown'}`);
|
|
19
20
|
if (result.error) {
|
|
20
|
-
|
|
21
|
+
const error = typeof result.error === 'string' ?
|
|
22
|
+
result.error :
|
|
23
|
+
result.error.message ?? JSON.stringify(result.error, null, 2);
|
|
24
|
+
lines.push(`Error: ${error}`);
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
return lines.join('\n');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatWorkflowResult } from './format_workflow_result.js';
|
|
2
|
+
import { formatWorkflowResult, isErrorWorkflowStatus } from './format_workflow_result.js';
|
|
3
3
|
describe('formatWorkflowResult', () => {
|
|
4
4
|
it('should display output for completed workflows', () => {
|
|
5
5
|
const result = formatWorkflowResult({
|
|
@@ -13,7 +13,7 @@ describe('formatWorkflowResult', () => {
|
|
|
13
13
|
expect(result).toContain('"values"');
|
|
14
14
|
expect(result).not.toContain('Status:');
|
|
15
15
|
});
|
|
16
|
-
it('should display
|
|
16
|
+
it('should display legacy string errors for failed workflows', () => {
|
|
17
17
|
const result = formatWorkflowResult({
|
|
18
18
|
workflowId: 'wf-456',
|
|
19
19
|
status: 'failed',
|
|
@@ -25,6 +25,29 @@ describe('formatWorkflowResult', () => {
|
|
|
25
25
|
expect(result).toContain('Error: Activity task failed');
|
|
26
26
|
expect(result).not.toContain('Output:');
|
|
27
27
|
});
|
|
28
|
+
it('should display the message from structured errors', () => {
|
|
29
|
+
const result = formatWorkflowResult({
|
|
30
|
+
workflowId: 'wf-v2',
|
|
31
|
+
status: 'failed',
|
|
32
|
+
output: null,
|
|
33
|
+
error: {
|
|
34
|
+
name: 'ValidationError',
|
|
35
|
+
message: 'Input is invalid',
|
|
36
|
+
code: 'INVALID_INPUT'
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
expect(result).toContain('Error: Input is invalid');
|
|
40
|
+
expect(result).not.toContain('[object Object]');
|
|
41
|
+
});
|
|
42
|
+
it('should serialize structured errors without a message', () => {
|
|
43
|
+
const result = formatWorkflowResult({
|
|
44
|
+
workflowId: 'wf-v2',
|
|
45
|
+
status: 'failed',
|
|
46
|
+
output: null,
|
|
47
|
+
error: { code: 'UNKNOWN' }
|
|
48
|
+
});
|
|
49
|
+
expect(result).toContain('"code": "UNKNOWN"');
|
|
50
|
+
});
|
|
28
51
|
it('should display status for terminated workflows', () => {
|
|
29
52
|
const result = formatWorkflowResult({
|
|
30
53
|
workflowId: 'wf-term',
|
|
@@ -35,15 +58,25 @@ describe('formatWorkflowResult', () => {
|
|
|
35
58
|
expect(result).toContain('Status: terminated');
|
|
36
59
|
expect(result).toContain('Error: Workflow terminated by user');
|
|
37
60
|
});
|
|
38
|
-
it('should display status for
|
|
61
|
+
it('should display status for cancelled workflows', () => {
|
|
39
62
|
const result = formatWorkflowResult({
|
|
63
|
+
workflowId: 'wf-cancel',
|
|
64
|
+
status: 'cancelled',
|
|
65
|
+
output: null,
|
|
66
|
+
error: 'Workflow was cancelled'
|
|
67
|
+
});
|
|
68
|
+
expect(result).toContain('Status: cancelled');
|
|
69
|
+
expect(result).toContain('Error: Workflow was cancelled');
|
|
70
|
+
});
|
|
71
|
+
it('normalizes canceled responses without changing the current status type', () => {
|
|
72
|
+
const legacyResult = {
|
|
40
73
|
workflowId: 'wf-cancel',
|
|
41
74
|
status: 'canceled',
|
|
42
75
|
output: null,
|
|
43
76
|
error: 'Workflow was canceled'
|
|
44
|
-
}
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
77
|
+
};
|
|
78
|
+
expect(formatWorkflowResult(legacyResult)).toContain('Status: cancelled');
|
|
79
|
+
expect(isErrorWorkflowStatus('canceled')).toBe(true);
|
|
47
80
|
});
|
|
48
81
|
it('should display status without error line for continued_as_new workflows', () => {
|
|
49
82
|
const result = formatWorkflowResult({
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Normalizes statuses from earlier API contracts.
|
|
3
|
+
*
|
|
4
|
+
* This can be removed after Aug, 2026
|
|
4
5
|
*
|
|
5
6
|
* @param status - Workflow status from the API
|
|
6
7
|
* @returns Normalized workflow status
|
|
7
8
|
*/
|
|
8
|
-
export declare const normalizeWorkflowStatus: <T extends string | null | undefined>(status: T) => T | "continued_as_new";
|
|
9
|
+
export declare const normalizeWorkflowStatus: <T extends string | null | undefined>(status: T) => T | "continued_as_new" | "cancelled";
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Normalizes statuses from earlier API contracts.
|
|
3
|
+
*
|
|
4
|
+
* This can be removed after Aug, 2026
|
|
4
5
|
*
|
|
5
6
|
* @param status - Workflow status from the API
|
|
6
7
|
* @returns Normalized workflow status
|
|
7
8
|
*/
|
|
8
|
-
export const normalizeWorkflowStatus = (status) =>
|
|
9
|
+
export const normalizeWorkflowStatus = (status) => {
|
|
10
|
+
if (status === 'continued') {
|
|
11
|
+
return 'continued_as_new';
|
|
12
|
+
}
|
|
13
|
+
if (status === 'canceled') {
|
|
14
|
+
return 'cancelled';
|
|
15
|
+
}
|
|
16
|
+
return status;
|
|
17
|
+
};
|
|
@@ -4,6 +4,9 @@ describe('normalizeWorkflowStatus', () => {
|
|
|
4
4
|
it('temporarily maps continued to continued_as_new', () => {
|
|
5
5
|
expect(normalizeWorkflowStatus('continued')).toBe('continued_as_new');
|
|
6
6
|
});
|
|
7
|
+
it('maps the previous canceled spelling to cancelled', () => {
|
|
8
|
+
expect(normalizeWorkflowStatus('canceled')).toBe('cancelled');
|
|
9
|
+
});
|
|
7
10
|
it('leaves other statuses and nullish values unchanged', () => {
|
|
8
11
|
expect(normalizeWorkflowStatus('completed')).toBe('completed');
|
|
9
12
|
expect(normalizeWorkflowStatus('continued_as_new')).toBe('continued_as_new');
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* an actionable hint that names the conflicting port and the env var to
|
|
4
4
|
* override.
|
|
5
5
|
*
|
|
6
|
-
* Docker
|
|
7
|
-
*
|
|
8
|
-
* - "failed to bind host port for 0.0.0.0:7233:.../tcp: address already in use"
|
|
6
|
+
* Docker wraps the same failure differently across versions and platforms —
|
|
7
|
+
* Docker 29 on macOS nests it three deep:
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Error response from daemon: failed to set up container networking: driver
|
|
10
|
+
* failed programming external connectivity on endpoint out-api-1 (a1b2…):
|
|
11
|
+
* Bind for 0.0.0.0:3001 failed: port is already allocated
|
|
12
|
+
*
|
|
13
|
+
* Matching whole message shapes means a new wrapper silently drops the hint, so
|
|
14
|
+
* we anchor on the terminal phrase instead and take the host port nearest to it.
|
|
15
|
+
* That survives wrappers we haven't seen.
|
|
16
|
+
*
|
|
17
|
+
* The port is then mapped back to the env var that sets it. The map prefers a
|
|
18
|
+
* runtime lookup of resolved ports (so a user who already set
|
|
19
|
+
* OUTPUT_API_HOST_PORT=3050 sees that var named when 3050 collides) and falls
|
|
20
|
+
* back to a default-port table for the unresolved case.
|
|
14
21
|
*/
|
|
15
22
|
/**
|
|
16
23
|
* Find the first host port mentioned in a docker compose bind failure.
|
|
@@ -24,6 +31,14 @@ export declare function extractCollidedPort(stderr: string): number | null;
|
|
|
24
31
|
* that overrides it; otherwise it suggests freeing the port.
|
|
25
32
|
*/
|
|
26
33
|
export declare function formatPortCollisionHint(stderr: string, resolvedPorts: Record<string, number>): string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Compose a docker-failure message from a caller-supplied core sentence and the
|
|
36
|
+
* process's recent output: an actionable port-collision hint (when one is
|
|
37
|
+
* detected) is prepended, and the raw recent output is appended. Shared by the
|
|
38
|
+
* foreground exit handler and the detached/reconcile path so both surface the
|
|
39
|
+
* same failure shape.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatComposeFailure(reason: string, output: string, resolvedPorts: Record<string, number>): string;
|
|
27
42
|
/**
|
|
28
43
|
* Build a hint from a known list of colliding ports. For a single collision
|
|
29
44
|
* the output matches `formatPortCollisionHint` exactly so callers stay
|