@outputai/cli 0.10.1-next.b7b2fbe.0 → 0.10.1-next.c717e35.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 +12 -0
- package/dist/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- 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/history.js +3 -3
- package/dist/commands/workflow/history.spec.js +31 -2
- package/dist/commands/workflow/monitor.d.ts +49 -0
- package/dist/commands/workflow/monitor.js +230 -0
- package/dist/commands/workflow/monitor.spec.d.ts +1 -0
- package/dist/commands/workflow/monitor.spec.js +243 -0
- package/dist/commands/workflow/run.js +8 -1
- package/dist/commands/workflow/run.spec.js +12 -2
- 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/{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/services/workflow_history/correlator.d.ts +2 -0
- package/dist/services/workflow_history/correlator.js +2 -2
- package/dist/services/workflow_history.d.ts +28 -0
- package/dist/services/workflow_history.js +95 -12
- package/dist/services/workflow_history.spec.js +183 -1
- 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/color.d.ts +7 -0
- package/dist/utils/color.js +12 -0
- package/dist/utils/color.spec.d.ts +1 -0
- package/dist/utils/color.spec.js +43 -0
- 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 +1 -0
- package/dist/utils/format_workflow_result.js +4 -0
- package/dist/utils/monitor_log.d.ts +20 -0
- package/dist/utils/monitor_log.js +48 -0
- package/dist/utils/monitor_log.spec.d.ts +1 -0
- package/dist/utils/monitor_log.spec.js +71 -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/utils/waterfall.d.ts +3 -1
- package/dist/utils/waterfall.js +8 -2
- package/dist/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- 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 +7 -8
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- 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 +126 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns newly-correlated spans into append-only status lines for `workflow
|
|
3
|
+
* monitor`. Unlike the waterfall (which needs the full span set up front to
|
|
4
|
+
* lay out a time axis), a live monitor just reports each span's status
|
|
5
|
+
* transitions as they're observed on each poll.
|
|
6
|
+
*/
|
|
7
|
+
import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
|
|
8
|
+
export interface SpanUpdate {
|
|
9
|
+
span: Span;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
14
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
15
|
+
* nothing worth reporting until a step starts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function diffSpanUpdates(spans: Span[], labels: Map<string, string>, seen: Map<string, SpanStatus>): SpanUpdate[];
|
|
18
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
19
|
+
export declare function formatContinuedAsNew(runId: string): string;
|
|
20
|
+
export declare function formatSpanUpdate(update: SpanUpdate, color: boolean): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ANSI, formatDurationLabel, makeTint } from '#utils/waterfall.js';
|
|
2
|
+
const GLYPH = {
|
|
3
|
+
pending: '·',
|
|
4
|
+
running: '●',
|
|
5
|
+
completed: '✓',
|
|
6
|
+
failed: '✗'
|
|
7
|
+
};
|
|
8
|
+
// Continue-as-new is a workflow-level transition, not a span status, so it gets its own glyph
|
|
9
|
+
// here in the formatting layer rather than being concatenated into the message at the call site.
|
|
10
|
+
const CONTINUED_AS_NEW_GLYPH = '↻';
|
|
11
|
+
/**
|
|
12
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
13
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
14
|
+
* nothing worth reporting until a step starts.
|
|
15
|
+
*/
|
|
16
|
+
export function diffSpanUpdates(spans, labels, seen) {
|
|
17
|
+
const updates = [];
|
|
18
|
+
for (const span of spans) {
|
|
19
|
+
if (span.status === 'pending' || seen.get(span.id) === span.status) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen.set(span.id, span.status);
|
|
23
|
+
updates.push({ span, label: labels.get(span.id) ?? span.name });
|
|
24
|
+
}
|
|
25
|
+
return updates;
|
|
26
|
+
}
|
|
27
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
28
|
+
export function formatContinuedAsNew(runId) {
|
|
29
|
+
return `${CONTINUED_AS_NEW_GLYPH} continued as new run ${runId}`;
|
|
30
|
+
}
|
|
31
|
+
export function formatSpanUpdate(update, color) {
|
|
32
|
+
const { span, label } = update;
|
|
33
|
+
const glyph = GLYPH[span.status];
|
|
34
|
+
const tint = makeTint(color);
|
|
35
|
+
const tintStatus = (text) => tint(text, ANSI[span.status]);
|
|
36
|
+
switch (span.status) {
|
|
37
|
+
case 'running':
|
|
38
|
+
return `${tintStatus(glyph)} ${label} running…`;
|
|
39
|
+
case 'completed':
|
|
40
|
+
return `${tintStatus(glyph)} ${label} ${formatDurationLabel(Math.max(0, span.durationMs))}`;
|
|
41
|
+
case 'failed': {
|
|
42
|
+
const reason = span.failureMessage ? `: ${span.failureMessage}` : '';
|
|
43
|
+
return `${tintStatus(glyph)} ${label} failed${reason}`;
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return `${glyph} ${label} ${span.status}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffSpanUpdates, formatSpanUpdate } from '#utils/monitor_log.js';
|
|
3
|
+
const span = (overrides) => ({
|
|
4
|
+
name: 'Step',
|
|
5
|
+
technicalName: 'wf#step',
|
|
6
|
+
description: null,
|
|
7
|
+
kind: 'activity',
|
|
8
|
+
attempt: 1,
|
|
9
|
+
startedAt: null,
|
|
10
|
+
scheduledAt: null,
|
|
11
|
+
completedAt: null,
|
|
12
|
+
startOffsetMs: 0,
|
|
13
|
+
endOffsetMs: 0,
|
|
14
|
+
durationMs: 0,
|
|
15
|
+
failureMessage: null,
|
|
16
|
+
...overrides
|
|
17
|
+
});
|
|
18
|
+
describe('diffSpanUpdates', () => {
|
|
19
|
+
it('skips pending spans', () => {
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'pending' })], new Map(), seen);
|
|
22
|
+
expect(updates).toHaveLength(0);
|
|
23
|
+
expect(seen.size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
it('reports a span the first time it is seen in a non-pending status', () => {
|
|
26
|
+
const seen = new Map();
|
|
27
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map([['1', 'Fetch page']]), seen);
|
|
28
|
+
expect(updates).toHaveLength(1);
|
|
29
|
+
expect(updates[0].label).toBe('Fetch page');
|
|
30
|
+
expect(seen.get('1')).toBe('running');
|
|
31
|
+
});
|
|
32
|
+
it('does not re-report a span whose status is unchanged since the last call', () => {
|
|
33
|
+
const seen = new Map([['1', 'running']]);
|
|
34
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map(), seen);
|
|
35
|
+
expect(updates).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
it('reports a span again once its status transitions (running -> completed)', () => {
|
|
38
|
+
const seen = new Map([['1', 'running']]);
|
|
39
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'completed' })], new Map(), seen);
|
|
40
|
+
expect(updates).toHaveLength(1);
|
|
41
|
+
expect(seen.get('1')).toBe('completed');
|
|
42
|
+
});
|
|
43
|
+
it('falls back to the span name when no label is provided', () => {
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running', name: 'Unlabeled' })], new Map(), seen);
|
|
46
|
+
expect(updates[0].label).toBe('Unlabeled');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('formatSpanUpdate', () => {
|
|
50
|
+
it('formats a running span', () => {
|
|
51
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, false);
|
|
52
|
+
expect(line).toBe('● Fetch page running…');
|
|
53
|
+
});
|
|
54
|
+
it('formats a completed span with its duration', () => {
|
|
55
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'completed', durationMs: 1234 }), label: 'Fetch page' }, false);
|
|
56
|
+
expect(line).toBe('✓ Fetch page 1s');
|
|
57
|
+
});
|
|
58
|
+
it('formats a failed span with its failure message', () => {
|
|
59
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed', failureMessage: 'boom' }), label: 'Fetch page' }, false);
|
|
60
|
+
expect(line).toBe('✗ Fetch page failed: boom');
|
|
61
|
+
});
|
|
62
|
+
it('formats a failed span without a failure message', () => {
|
|
63
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed' }), label: 'Fetch page' }, false);
|
|
64
|
+
expect(line).toBe('✗ Fetch page failed');
|
|
65
|
+
});
|
|
66
|
+
it('wraps the glyph in ANSI codes when color is enabled', () => {
|
|
67
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, true);
|
|
68
|
+
expect(line).toContain('●');
|
|
69
|
+
expect(line).not.toBe('● Fetch page running…'); // color codes present
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -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
|
|
@@ -3,20 +3,25 @@
|
|
|
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
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/listen tcp [^:\s]+:(\d+):\s*bind: address already in use/
|
|
19
|
-
];
|
|
22
|
+
const COLLISION_PHRASES = ['port is already allocated', 'address already in use'];
|
|
23
|
+
/** Trailing `:<port>` in a fragment — the host port a bind failure names. */
|
|
24
|
+
const TRAILING_PORT = /:(\d+)(?!.*:\d)/s;
|
|
20
25
|
const DEFAULT_PORT_TO_ENV_VAR = {
|
|
21
26
|
3001: 'OUTPUT_API_HOST_PORT',
|
|
22
27
|
8080: 'OUTPUT_TEMPORAL_UI_HOST_PORT',
|
|
@@ -35,8 +40,15 @@ export function extractCollidedPort(stderr) {
|
|
|
35
40
|
if (!stderr) {
|
|
36
41
|
return null;
|
|
37
42
|
}
|
|
38
|
-
for (const
|
|
39
|
-
const
|
|
43
|
+
for (const phrase of COLLISION_PHRASES) {
|
|
44
|
+
const phraseIndex = stderr.indexOf(phrase);
|
|
45
|
+
if (phraseIndex === -1) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// The host port is the last one named before the phrase — every shape puts
|
|
49
|
+
// it there ("Bind for 0.0.0.0:3001 failed: port is already allocated",
|
|
50
|
+
// "listen tcp 0.0.0.0:3001: bind: address already in use").
|
|
51
|
+
const match = stderr.slice(0, phraseIndex).match(TRAILING_PORT);
|
|
40
52
|
if (match) {
|
|
41
53
|
return parseInt(match[1], 10);
|
|
42
54
|
}
|
|
@@ -87,6 +99,19 @@ export function formatPortCollisionHint(stderr, resolvedPorts) {
|
|
|
87
99
|
}
|
|
88
100
|
return formatSingleCollision(port, resolvedPorts);
|
|
89
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Compose a docker-failure message from a caller-supplied core sentence and the
|
|
104
|
+
* process's recent output: an actionable port-collision hint (when one is
|
|
105
|
+
* detected) is prepended, and the raw recent output is appended. Shared by the
|
|
106
|
+
* foreground exit handler and the detached/reconcile path so both surface the
|
|
107
|
+
* same failure shape.
|
|
108
|
+
*/
|
|
109
|
+
export function formatComposeFailure(reason, output, resolvedPorts) {
|
|
110
|
+
const hint = formatPortCollisionHint(output, resolvedPorts);
|
|
111
|
+
const prefix = hint ? `${hint}\n\n` : '';
|
|
112
|
+
const detail = output ? `\n\nRecent Docker output:\n${output}` : '';
|
|
113
|
+
return `${prefix}${reason}${detail}`;
|
|
114
|
+
}
|
|
90
115
|
/**
|
|
91
116
|
* Build a hint from a known list of colliding ports. For a single collision
|
|
92
117
|
* the output matches `formatPortCollisionHint` exactly so callers stay
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { extractCollidedPort, formatPortCollisionHint, formatPortCollisionsHint } from './port_collision.js';
|
|
2
|
+
import { extractCollidedPort, formatPortCollisionHint, formatPortCollisionsHint, formatComposeFailure } from './port_collision.js';
|
|
3
3
|
const DEFAULT_PORTS = { api: 3001, temporalUi: 8080, temporal: 7233 };
|
|
4
4
|
describe('extractCollidedPort', () => {
|
|
5
5
|
it('matches the "Bind for ... port is already allocated" shape', () => {
|
|
@@ -22,6 +22,24 @@ describe('extractCollidedPort', () => {
|
|
|
22
22
|
it('returns null when no bind failure is present', () => {
|
|
23
23
|
expect(extractCollidedPort('some unrelated stderr line')).toBeNull();
|
|
24
24
|
});
|
|
25
|
+
// Captured verbatim from Docker 29.4.0 on macOS. The bind failure is nested
|
|
26
|
+
// three wrappers deep; matching whole message shapes missed it.
|
|
27
|
+
it('extracts the port from Docker 29\'s nested container-networking wrapper', () => {
|
|
28
|
+
const stderr = 'Error response from daemon: failed to set up container networking: ' +
|
|
29
|
+
'driver failed programming external connectivity on endpoint out-api-1 ' +
|
|
30
|
+
'(e72baf85643fb5dc19000acf62c1ad0d11bffc653cabe1fc8861387ec1ebd629): ' +
|
|
31
|
+
'Bind for 0.0.0.0:3001 failed: port is already allocated';
|
|
32
|
+
expect(extractCollidedPort(stderr)).toBe(3001);
|
|
33
|
+
});
|
|
34
|
+
it('extracts the port from the "ports are not available" wrapper', () => {
|
|
35
|
+
const stderr = 'Error: ports are not available: exposing port TCP 0.0.0.0:3001 -> 0.0.0.0:0: ' +
|
|
36
|
+
'listen tcp 0.0.0.0:3001: bind: address already in use';
|
|
37
|
+
expect(extractCollidedPort(stderr)).toBe(3001);
|
|
38
|
+
});
|
|
39
|
+
it('ignores an IP-like prefix and takes the port nearest the failure phrase', () => {
|
|
40
|
+
const stderr = 'container 172.17.0.2:5432 started\nBind for 0.0.0.0:8080 failed: port is already allocated';
|
|
41
|
+
expect(extractCollidedPort(stderr)).toBe(8080);
|
|
42
|
+
});
|
|
25
43
|
it('returns null for empty input', () => {
|
|
26
44
|
expect(extractCollidedPort('')).toBeNull();
|
|
27
45
|
});
|
|
@@ -80,3 +98,24 @@ describe('formatPortCollisionsHint', () => {
|
|
|
80
98
|
expect(hint).toContain('• Port 5432 — stop the process holding it');
|
|
81
99
|
});
|
|
82
100
|
});
|
|
101
|
+
describe('formatComposeFailure', () => {
|
|
102
|
+
const reason = 'Docker compose failed to start services (exit code 1).';
|
|
103
|
+
it('prepends the actionable hint when the output names a collision', () => {
|
|
104
|
+
const message = formatComposeFailure(reason, 'Bind for 0.0.0.0:3001 failed: port is already allocated', DEFAULT_PORTS);
|
|
105
|
+
expect(message.startsWith('Port 3001 is already in use.')).toBe(true);
|
|
106
|
+
expect(message).toContain('OUTPUT_API_HOST_PORT=<other port>');
|
|
107
|
+
expect(message).toContain(reason);
|
|
108
|
+
expect(message).toContain('Recent Docker output:');
|
|
109
|
+
});
|
|
110
|
+
it('omits the output section entirely when nothing was captured', () => {
|
|
111
|
+
const message = formatComposeFailure(reason, '', DEFAULT_PORTS);
|
|
112
|
+
expect(message).toBe(reason);
|
|
113
|
+
expect(message).not.toContain('Recent Docker output:');
|
|
114
|
+
});
|
|
115
|
+
it('returns reason plus raw output, with no hint, for an unrecognized failure', () => {
|
|
116
|
+
const message = formatComposeFailure(reason, 'no such image: outputai/api:dev', DEFAULT_PORTS);
|
|
117
|
+
expect(message.startsWith(reason)).toBe(true);
|
|
118
|
+
expect(message).toContain('Recent Docker output:\nno such image');
|
|
119
|
+
expect(message).not.toContain('is already in use');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type ResolveInputOptions = {
|
|
2
|
+
workflowName: string;
|
|
3
|
+
scenario?: string;
|
|
4
|
+
inputFlag?: string;
|
|
5
|
+
commandName: string;
|
|
6
|
+
catalog?: string;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function resolveInput(options: ResolveInputOptions): Promise<unknown>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ux } from '@oclif/core';
|
|
2
2
|
import { parseInputFlag } from '#utils/input_parser.js';
|
|
3
3
|
import { resolveScenarioPath, getScenarioNotFoundMessage } from '#utils/scenario_resolver.js';
|
|
4
|
-
export async function resolveInput(
|
|
4
|
+
export async function resolveInput(options) {
|
|
5
|
+
const { workflowName, scenario, inputFlag, commandName, catalog, json } = options;
|
|
5
6
|
if (inputFlag && scenario) {
|
|
6
7
|
return ux.error('Cannot use both scenario argument and --input flag. Choose one.', { exit: 1 });
|
|
7
8
|
}
|
|
@@ -13,7 +14,12 @@ export async function resolveInput(workflowName, scenario, inputFlag, commandNam
|
|
|
13
14
|
if (!resolution.found) {
|
|
14
15
|
return ux.error(getScenarioNotFoundMessage(workflowName, scenario, resolution.searchedPaths), { exit: 1 });
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
// Advisory notice goes to stderr so stdout stays clean for piping, and is
|
|
18
|
+
// skipped entirely under --json where even stderr is noise to a script
|
|
19
|
+
// consuming the structured output (same rule as the init hook's banner).
|
|
20
|
+
if (!json) {
|
|
21
|
+
ux.stderr(`Using scenario: ${resolution.path}`);
|
|
22
|
+
}
|
|
17
23
|
return parseInputFlag(resolution.path);
|
|
18
24
|
}
|
|
19
25
|
return ux.error('Input required. Provide either:\n' +
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
vi.mock('@oclif/core', () => ({
|
|
4
|
+
ux: {
|
|
5
|
+
stdout: vi.fn(),
|
|
6
|
+
stderr: vi.fn(),
|
|
7
|
+
error: vi.fn(() => {
|
|
8
|
+
throw new Error('ux.error called');
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('#utils/input_parser.js', () => ({
|
|
13
|
+
parseInputFlag: vi.fn()
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('#utils/scenario_resolver.js', () => ({
|
|
16
|
+
resolveScenarioPath: vi.fn(),
|
|
17
|
+
getScenarioNotFoundMessage: vi.fn()
|
|
18
|
+
}));
|
|
19
|
+
describe('resolveInput', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
it('emits the scenario notice on stderr so --json stdout stays clean', async () => {
|
|
24
|
+
const { ux } = await import('@oclif/core');
|
|
25
|
+
const { parseInputFlag } = await import('#utils/input_parser.js');
|
|
26
|
+
const { resolveScenarioPath } = await import('#utils/scenario_resolver.js');
|
|
27
|
+
const { resolveInput } = await import('./resolve_input.js');
|
|
28
|
+
vi.mocked(resolveScenarioPath).mockResolvedValue({
|
|
29
|
+
found: true,
|
|
30
|
+
path: '/scenarios/happy_path.json'
|
|
31
|
+
});
|
|
32
|
+
vi.mocked(parseInputFlag).mockReturnValue({ key: 'value' });
|
|
33
|
+
const result = await resolveInput({
|
|
34
|
+
workflowName: 'web_search',
|
|
35
|
+
scenario: 'happy_path',
|
|
36
|
+
commandName: 'start'
|
|
37
|
+
});
|
|
38
|
+
expect(result).toEqual({ key: 'value' });
|
|
39
|
+
expect(ux.stderr).toHaveBeenCalledWith('Using scenario: /scenarios/happy_path.json');
|
|
40
|
+
expect(ux.stdout).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
it('suppresses the scenario notice entirely under --json', async () => {
|
|
43
|
+
const { ux } = await import('@oclif/core');
|
|
44
|
+
const { parseInputFlag } = await import('#utils/input_parser.js');
|
|
45
|
+
const { resolveScenarioPath } = await import('#utils/scenario_resolver.js');
|
|
46
|
+
const { resolveInput } = await import('./resolve_input.js');
|
|
47
|
+
vi.mocked(resolveScenarioPath).mockResolvedValue({
|
|
48
|
+
found: true,
|
|
49
|
+
path: '/scenarios/happy_path.json'
|
|
50
|
+
});
|
|
51
|
+
vi.mocked(parseInputFlag).mockReturnValue({ key: 'value' });
|
|
52
|
+
const result = await resolveInput({
|
|
53
|
+
workflowName: 'web_search',
|
|
54
|
+
scenario: 'happy_path',
|
|
55
|
+
commandName: 'start',
|
|
56
|
+
json: true
|
|
57
|
+
});
|
|
58
|
+
expect(result).toEqual({ key: 'value' });
|
|
59
|
+
expect(ux.stderr).not.toHaveBeenCalled();
|
|
60
|
+
expect(ux.stdout).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
it('does not emit the scenario notice when input comes from --input', async () => {
|
|
63
|
+
const { ux } = await import('@oclif/core');
|
|
64
|
+
const { parseInputFlag } = await import('#utils/input_parser.js');
|
|
65
|
+
const { resolveInput } = await import('./resolve_input.js');
|
|
66
|
+
vi.mocked(parseInputFlag).mockReturnValue({ key: 'value' });
|
|
67
|
+
await resolveInput({
|
|
68
|
+
workflowName: 'web_search',
|
|
69
|
+
inputFlag: '{"key":"value"}',
|
|
70
|
+
commandName: 'start'
|
|
71
|
+
});
|
|
72
|
+
expect(ux.stderr).not.toHaveBeenCalled();
|
|
73
|
+
expect(ux.stdout).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Atlas's `StepGantt`; the bar geometry mirrors its leftPct/widthPct as integer
|
|
9
9
|
* terminal columns.
|
|
10
10
|
*/
|
|
11
|
-
import type { Span } from '#services/workflow_history/correlator.js';
|
|
11
|
+
import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
|
|
12
12
|
export interface WaterfallOptions {
|
|
13
13
|
width: number;
|
|
14
14
|
color: boolean;
|
|
@@ -22,6 +22,8 @@ export interface BarGeometry {
|
|
|
22
22
|
}
|
|
23
23
|
export declare const FULL_BLOCK = "\u2588";
|
|
24
24
|
export declare const THIN_BLOCK = "\u258F";
|
|
25
|
+
export declare const ANSI: Record<SpanStatus | 'dim' | 'reset', string>;
|
|
26
|
+
export declare function makeTint(color: boolean): (text: string, code: string) => string;
|
|
25
27
|
export declare function pickTickStep(totalMs: number): number;
|
|
26
28
|
export declare function buildTicks(totalMs: number): number[];
|
|
27
29
|
export declare function formatTickLabel(ms: number): string;
|
package/dist/utils/waterfall.js
CHANGED
|
@@ -12,7 +12,7 @@ const TICK_STEPS_MS = [
|
|
|
12
12
|
60 * 60_000, 2 * 60 * 60_000, 6 * 60 * 60_000, 12 * 60 * 60_000, 24 * 60 * 60_000
|
|
13
13
|
];
|
|
14
14
|
const TARGET_TICKS = 8;
|
|
15
|
-
const ANSI = {
|
|
15
|
+
export const ANSI = {
|
|
16
16
|
completed: '[92m',
|
|
17
17
|
running: '[33m',
|
|
18
18
|
failed: '[31m',
|
|
@@ -20,6 +20,12 @@ const ANSI = {
|
|
|
20
20
|
dim: '[2m',
|
|
21
21
|
reset: '[0m'
|
|
22
22
|
};
|
|
23
|
+
// Shared by renderWaterfall and monitor_log's live status lines so there's one
|
|
24
|
+
// place that knows how to wrap text in an ANSI code (and reset it) -- or not,
|
|
25
|
+
// when color is disabled.
|
|
26
|
+
export function makeTint(color) {
|
|
27
|
+
return (text, code) => (color ? `${code}${text}${ANSI.reset}` : text);
|
|
28
|
+
}
|
|
23
29
|
function clamp(value, lo, hi) {
|
|
24
30
|
return Math.min(Math.max(value, lo), hi);
|
|
25
31
|
}
|
|
@@ -162,7 +168,7 @@ export default function renderWaterfall(spans, totalDurationMs, options) {
|
|
|
162
168
|
if (spans.length === 0) {
|
|
163
169
|
return [header, 'No steps found for this run.'].filter(Boolean).join('\n\n');
|
|
164
170
|
}
|
|
165
|
-
const tint = (
|
|
171
|
+
const tint = makeTint(color);
|
|
166
172
|
const labelFor = (span) => labels?.get(span.id) ?? span.name;
|
|
167
173
|
const durationFor = (span) => formatDurationLabel(Math.max(0, span.durationMs));
|
|
168
174
|
const longestLabel = Math.max(...spans.map(s => labelFor(s).length));
|
|
@@ -5,16 +5,16 @@ import packageJson from '../../../../package.json' with { type: 'json' };
|
|
|
5
5
|
const GLOBAL_HINT_ROWS = 1;
|
|
6
6
|
const LOCAL_HINT_ROWS = 1;
|
|
7
7
|
export const getHeight = () => GLOBAL_HINT_ROWS + LOCAL_HINT_ROWS;
|
|
8
|
-
const
|
|
8
|
+
const globalHints = (attached) => [
|
|
9
9
|
{ key: 'tab', label: 'next tab' },
|
|
10
10
|
{ key: 'shift-tab', label: 'prev tab' },
|
|
11
11
|
{ key: '1-4', label: 'tabs' },
|
|
12
12
|
{ key: '/', label: 'search' },
|
|
13
13
|
{ key: '?', label: 'help' },
|
|
14
|
-
{ key: 'ctrl+c', label: 'quit' }
|
|
14
|
+
{ key: 'ctrl+c', label: attached ? 'detach (keeps running)' : 'quit' }
|
|
15
15
|
];
|
|
16
16
|
const VERSION = packageJson.version;
|
|
17
17
|
const HintRow = ({ hints }) => (_jsx(Box, { flexDirection: "row", children: hints.length === 0 ? (_jsx(Text, { children: " " })) : hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: ' ' }), _jsx(Text, { bold: true, children: hint.key }), _jsx(Text, { dimColor: true, children: ` ${hint.label}` })] }, hint.key))) }));
|
|
18
|
-
export const Footer = ({ hints = [], itemCount, itemLabel }) => {
|
|
19
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints:
|
|
18
|
+
export const Footer = ({ hints = [], itemCount, itemLabel, attached = false }) => {
|
|
19
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints: globalHints(attached) }), typeof itemCount === 'number' && itemLabel && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [itemCount, " ", itemLabel] }) }))] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(HintRow, { hints: hints }), _jsxs(Text, { color: "blackBright", children: ["v", VERSION] })] })] }));
|
|
20
20
|
};
|
|
@@ -150,7 +150,7 @@ const overlayFor = (opts) => {
|
|
|
150
150
|
}
|
|
151
151
|
return null;
|
|
152
152
|
};
|
|
153
|
-
const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
153
|
+
const Shell = ({ dockerComposePath, onCleanup, attached }) => {
|
|
154
154
|
const { exit } = useApp();
|
|
155
155
|
const ui = useUiState();
|
|
156
156
|
const [phase, setPhase] = useState('waiting');
|
|
@@ -160,7 +160,16 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
160
160
|
onServices: setServices,
|
|
161
161
|
onAllHealthy: () => setPhase('running'),
|
|
162
162
|
onFailure: () => setPhase('failed'),
|
|
163
|
-
|
|
163
|
+
// An attach/reconcile session monitors a stack it doesn't own, so a slow
|
|
164
|
+
// health check must not be fatal — drop into the dashboard and keep
|
|
165
|
+
// polling status. Only an owned fresh start treats the timeout as an error.
|
|
166
|
+
onTimeout: () => {
|
|
167
|
+
if (attached) {
|
|
168
|
+
setPhase('running');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
exit(new Error('Timeout waiting for services to become healthy'));
|
|
172
|
+
}
|
|
164
173
|
});
|
|
165
174
|
useStatusRefresh(dockerComposePath, phase !== 'waiting', setServices);
|
|
166
175
|
useWorkflowRunsPolling(phase !== 'waiting', setRuns);
|
|
@@ -231,6 +240,6 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
231
240
|
if (overlay) {
|
|
232
241
|
return (_jsx(Box, { flexDirection: "column", height: rows, paddingX: 1, children: overlay }));
|
|
233
242
|
}
|
|
234
|
-
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel })] }));
|
|
243
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, paddingX: 1, children: [_jsx(Header, { counters: counters }), _jsx(TabBar, { active: ui.tab, borderColor: RULE_PURPLE }), _jsx(SearchBar, { active: ui.search.open }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, height: contentRows, overflow: "hidden", children: [ui.tab === 'workflows' && _jsx(WorkflowsPanel, { workflows: workflows, runs: runs }), ui.tab === 'runs' && _jsx(RunsPanel, { runs: runs, height: contentRows }), ui.tab === 'services' && (_jsx(ServicesPanel, { height: contentRows, phase: phase, services: services, dockerComposePath: dockerComposePath })), ui.tab === 'help' && _jsx(HelpPanel, {})] }), _jsx(Toasts, {}), _jsx(Footer, { hints: footer.hints, itemCount: footer.itemCount, itemLabel: footer.itemLabel, attached: attached })] }));
|
|
235
244
|
};
|
|
236
|
-
export const DevApp = ({ dockerComposePath, onCleanup }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup }) }));
|
|
245
|
+
export const DevApp = ({ dockerComposePath, onCleanup, attached = false }) => (_jsx(UiStateProvider, { children: _jsx(Shell, { dockerComposePath: dockerComposePath, onCleanup: onCleanup, attached: attached }) }));
|
|
@@ -2,13 +2,16 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { getWorkflowIdResult, getWorkflowIdRunsRidResult, getWorkflowIdTraceLog, getWorkflowIdRunsRidTraceLog } from '#api/generated/api.js';
|
|
4
4
|
import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
|
|
5
|
+
import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
|
|
6
|
+
import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
|
|
5
7
|
const EMPTY_DETAIL = {
|
|
6
8
|
result: null,
|
|
7
9
|
trace: null,
|
|
8
10
|
steps: [],
|
|
9
11
|
loading: false
|
|
10
12
|
};
|
|
11
|
-
const
|
|
13
|
+
const RUN_DETAIL_CACHE_MAX = 50;
|
|
14
|
+
const runDetailCache = createBoundedCache(RUN_DETAIL_CACHE_MAX);
|
|
12
15
|
const stepNameOf = (node) => {
|
|
13
16
|
if (node.name) {
|
|
14
17
|
return node.name;
|
|
@@ -99,13 +102,9 @@ const fetchResult = async (workflowId, runId) => {
|
|
|
99
102
|
return null;
|
|
100
103
|
}
|
|
101
104
|
};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* running workflow would otherwise stick and stall the UI when the run
|
|
106
|
-
* eventually finishes.
|
|
107
|
-
*/
|
|
108
|
-
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled', 'terminated', 'timed_out']);
|
|
105
|
+
// The cache is intentionally only populated for terminal statuses — partial results
|
|
106
|
+
// from a still-running workflow would otherwise stick and stall the UI when the run
|
|
107
|
+
// eventually finishes. `TERMINAL_STATUSES` is shared with `workflow monitor`.
|
|
109
108
|
export const isTerminalRunStatus = (status) => Boolean(status && TERMINAL_STATUSES.has(status));
|
|
110
109
|
export const useRunDetail = (workflowId, runId, status) => {
|
|
111
110
|
const [detail, setDetail] = useState(EMPTY_DETAIL);
|
|
@@ -3,6 +3,7 @@ import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
|
3
3
|
import buildSpanLabels from '#utils/span_labels.js';
|
|
4
4
|
import { isTerminalRunStatus } from '#views/dev/hooks/use_run_detail.js';
|
|
5
5
|
import { usePoll, POLL_INTERVAL_MS } from '#views/dev/hooks/use_poll.js';
|
|
6
|
+
import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
|
|
6
7
|
const EMPTY_GRAPH = {
|
|
7
8
|
spans: [],
|
|
8
9
|
totalDurationMs: 0,
|
|
@@ -11,7 +12,8 @@ const EMPTY_GRAPH = {
|
|
|
11
12
|
loading: false,
|
|
12
13
|
error: null
|
|
13
14
|
};
|
|
14
|
-
const
|
|
15
|
+
const STEP_GRAPH_CACHE_MAX = 50;
|
|
16
|
+
const stepGraphCache = createBoundedCache(STEP_GRAPH_CACHE_MAX);
|
|
15
17
|
/**
|
|
16
18
|
* Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
|
|
17
19
|
* reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface BoundedCache<K, V extends {}> {
|
|
2
|
+
get(key: K): V | undefined;
|
|
3
|
+
set(key: K, value: V): void;
|
|
4
|
+
has(key: K): boolean;
|
|
5
|
+
clear(): void;
|
|
6
|
+
size(): number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A small LRU cache backed by an insertion-ordered `Map`, capped at `maxSize`
|
|
10
|
+
* entries. Reading a key refreshes its recency; once the cap is exceeded the
|
|
11
|
+
* least-recently-used entries are evicted. Drop-in compatible with the
|
|
12
|
+
* `Map.get` / `Map.set` calls it replaces.
|
|
13
|
+
*/
|
|
14
|
+
export declare const createBoundedCache: <K, V extends {}>(maxSize: number) => BoundedCache<K, V>;
|