@outputai/cli 0.8.2-next.edf06bb.0 → 0.9.1-dev.000c5f3.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 +79 -0
- package/dist/api/generated/api.js +32 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/history.d.ts +19 -0
- package/dist/commands/workflow/history.js +99 -0
- package/dist/commands/workflow/history.spec.d.ts +1 -0
- package/dist/commands/workflow/history.spec.js +26 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/workflow_history/correlator.d.ts +26 -0
- package/dist/services/workflow_history/correlator.js +261 -0
- package/dist/services/workflow_history/correlator.spec.d.ts +1 -0
- package/dist/services/workflow_history/correlator.spec.js +116 -0
- package/dist/services/workflow_history.d.ts +23 -0
- package/dist/services/workflow_history.js +79 -0
- package/dist/services/workflow_history.spec.d.ts +1 -0
- package/dist/services/workflow_history.spec.js +87 -0
- package/dist/utils/span_labels.d.ts +11 -0
- package/dist/utils/span_labels.js +22 -0
- package/dist/utils/span_labels.spec.d.ts +1 -0
- package/dist/utils/span_labels.spec.js +36 -0
- package/dist/utils/waterfall.d.ts +31 -0
- package/dist/utils/waterfall.js +192 -0
- package/dist/utils/waterfall.spec.d.ts +1 -0
- package/dist/utils/waterfall.spec.js +100 -0
- package/dist/views/dev/dev_app.js +17 -3
- package/dist/views/dev/hooks/use_step_graph.d.ts +22 -0
- package/dist/views/dev/hooks/use_step_graph.js +76 -0
- package/dist/views/dev/modals/run_modal.d.ts +9 -0
- package/dist/views/dev/modals/run_modal.js +56 -56
- package/dist/views/dev/modals/run_modal.spec.d.ts +1 -0
- package/dist/views/dev/modals/run_modal.spec.js +34 -0
- package/dist/views/dev/modals/step_graph_modal.d.ts +6 -0
- package/dist/views/dev/modals/step_graph_modal.js +148 -0
- package/dist/views/dev/modals/steps_modal.js +1 -1
- package/dist/views/dev/panels/help_panel.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +7 -2
- package/dist/views/dev/panels/workflows_panel.js +1 -1
- package/dist/views/dev/state/ui_state.d.ts +8 -0
- package/dist/views/dev/state/ui_state.js +5 -1
- package/dist/views/dev/utils/json_editor.d.ts +2 -0
- package/dist/views/dev/utils/json_editor.js +28 -15
- package/dist/views/dev/utils/json_editor.spec.js +16 -1
- package/oclif.manifest.json +81 -1
- package/package.json +4 -4
- /package/dist/views/dev/utils/{constants.d.ts → ui_constants.d.ts} +0 -0
- /package/dist/views/dev/utils/{constants.js → ui_constants.js} +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import renderWaterfall, { pickTickStep, formatTickLabel, formatDurationLabel, computeBar } from './waterfall.js';
|
|
3
|
+
const span = (id, name, status, startOffsetMs, durationMs) => ({
|
|
4
|
+
id,
|
|
5
|
+
name,
|
|
6
|
+
technicalName: name,
|
|
7
|
+
description: null,
|
|
8
|
+
status,
|
|
9
|
+
kind: 'activity',
|
|
10
|
+
attempt: 1,
|
|
11
|
+
startedAt: null,
|
|
12
|
+
scheduledAt: null,
|
|
13
|
+
completedAt: null,
|
|
14
|
+
startOffsetMs,
|
|
15
|
+
endOffsetMs: startOffsetMs + durationMs,
|
|
16
|
+
durationMs,
|
|
17
|
+
failureMessage: null
|
|
18
|
+
});
|
|
19
|
+
describe('pickTickStep', () => {
|
|
20
|
+
it('targets ~8 ticks by choosing the smallest sufficient step', () => {
|
|
21
|
+
expect(pickTickStep(0)).toBe(1_000);
|
|
22
|
+
expect(pickTickStep(8_000)).toBe(1_000);
|
|
23
|
+
expect(pickTickStep(80_000)).toBe(10_000);
|
|
24
|
+
expect(pickTickStep(160_000)).toBe(30_000);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('formatTickLabel', () => {
|
|
28
|
+
it('formats axis ticks like the UI', () => {
|
|
29
|
+
expect(formatTickLabel(0)).toBe('0');
|
|
30
|
+
expect(formatTickLabel(500)).toBe('500ms');
|
|
31
|
+
expect(formatTickLabel(27_000)).toBe('27s');
|
|
32
|
+
expect(formatTickLabel(60_000)).toBe('1m');
|
|
33
|
+
expect(formatTickLabel(72_000)).toBe('1m12s');
|
|
34
|
+
expect(formatTickLabel(3_600_000)).toBe('1h');
|
|
35
|
+
expect(formatTickLabel(3_660_000)).toBe('1h1m');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('formatDurationLabel', () => {
|
|
39
|
+
it('always shows a unit, including zero', () => {
|
|
40
|
+
expect(formatDurationLabel(0)).toBe('0ms');
|
|
41
|
+
expect(formatDurationLabel(999)).toBe('999ms');
|
|
42
|
+
expect(formatDurationLabel(1_000)).toBe('1s');
|
|
43
|
+
expect(formatDurationLabel(27_000)).toBe('27s');
|
|
44
|
+
});
|
|
45
|
+
it('carries a rounded-up remainder instead of rendering 60s/60m', () => {
|
|
46
|
+
expect(formatDurationLabel(59_500)).toBe('1m');
|
|
47
|
+
expect(formatDurationLabel(119_500)).toBe('2m');
|
|
48
|
+
expect(formatDurationLabel(3_599_500)).toBe('1h');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('computeBar', () => {
|
|
52
|
+
it('maps offsets to columns with a min width and lane clamp', () => {
|
|
53
|
+
expect(computeBar(0, 27_000, 100_000, 50)).toEqual({ startCol: 0, barLen: 14, instantaneous: false });
|
|
54
|
+
// Zero-duration step → a single thin marker
|
|
55
|
+
expect(computeBar(50_000, 50_000, 100_000, 50)).toEqual({ startCol: 25, barLen: 1, instantaneous: true });
|
|
56
|
+
// A short step at the very end is clamped so it stays inside the lane
|
|
57
|
+
expect(computeBar(99_000, 100_000, 100_000, 50)).toEqual({ startCol: 49, barLen: 1, instantaneous: false });
|
|
58
|
+
});
|
|
59
|
+
it('sizes the bar to span start→end without right-edge drift', () => {
|
|
60
|
+
// Full-span step fills the lane exactly.
|
|
61
|
+
expect(computeBar(0, 100_000, 100_000, 50)).toEqual({ startCol: 0, barLen: 50, instantaneous: false });
|
|
62
|
+
// End column is rounded from the true end offset (33), not start + a
|
|
63
|
+
// separately-rounded width (which used to overshoot to col 34).
|
|
64
|
+
expect(computeBar(33_000, 66_000, 100_000, 50)).toEqual({ startCol: 17, barLen: 16, instantaneous: false });
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('renderWaterfall', () => {
|
|
68
|
+
const spans = [
|
|
69
|
+
span('a', 'Compress Text', 'completed', 0, 27_000),
|
|
70
|
+
span('b', 'Generate Brief', 'pending', 40_000, 0)
|
|
71
|
+
];
|
|
72
|
+
it('renders labels, bars, durations and a header without color by default', () => {
|
|
73
|
+
const out = renderWaterfall(spans, 40_000, { width: 70, color: false, header: 'wf-1 · run abcd1234 · running · 40s' });
|
|
74
|
+
expect(out).toContain('Compress Text');
|
|
75
|
+
expect(out).toContain('Generate Brief');
|
|
76
|
+
expect(out).toContain('█');
|
|
77
|
+
expect(out).toContain('27s');
|
|
78
|
+
expect(out).toContain('wf-1 · run abcd1234');
|
|
79
|
+
expect(out).not.toContain('\x1b[');
|
|
80
|
+
});
|
|
81
|
+
it('emits ANSI color when enabled', () => {
|
|
82
|
+
const out = renderWaterfall(spans, 40_000, { width: 70, color: true });
|
|
83
|
+
expect(out).toContain('\x1b[');
|
|
84
|
+
expect(out).toContain('\x1b[92m'); // completed → green
|
|
85
|
+
});
|
|
86
|
+
it('shows a friendly message when there are no steps', () => {
|
|
87
|
+
const out = renderWaterfall([], 1, { width: 70, color: false, header: 'wf-1' });
|
|
88
|
+
expect(out).toContain('No steps found for this run.');
|
|
89
|
+
});
|
|
90
|
+
it('lists the reason under the chart for failed steps that carry a message', () => {
|
|
91
|
+
const failed = { ...span('c', 'Scrape Serp Page', 'failed', 10_000, 2_000), failureMessage: 'connection reset' };
|
|
92
|
+
const out = renderWaterfall([...spans, failed], 40_000, { width: 70, color: false });
|
|
93
|
+
expect(out).toContain('Failures');
|
|
94
|
+
expect(out).toContain('✗ Scrape Serp Page: connection reset');
|
|
95
|
+
});
|
|
96
|
+
it('omits the failures section when no failed step has a message', () => {
|
|
97
|
+
const out = renderWaterfall(spans, 40_000, { width: 70, color: false });
|
|
98
|
+
expect(out).not.toContain('Failures');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -18,7 +18,8 @@ import { HELP_HINTS, HELP_SECTION_COUNT, HelpPanel } from '#views/dev/panels/hel
|
|
|
18
18
|
import { RunModal } from '#views/dev/modals/run_modal.js';
|
|
19
19
|
import { ExpandedJsonModal } from '#views/dev/modals/expanded_json_modal.js';
|
|
20
20
|
import { StepsModal } from '#views/dev/modals/steps_modal.js';
|
|
21
|
-
import {
|
|
21
|
+
import { StepGraphModal } from '#views/dev/modals/step_graph_modal.js';
|
|
22
|
+
import { MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS } from '#views/dev/utils/ui_constants.js';
|
|
22
23
|
const TAB_NUMBER_KEYS = {
|
|
23
24
|
1: 'workflows',
|
|
24
25
|
2: 'runs',
|
|
@@ -65,7 +66,7 @@ const useGlobalInput = (opts) => {
|
|
|
65
66
|
.catch(err => exit(err instanceof Error ? err : new Error(String(err))));
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
68
|
-
if (ui.search.open || ui.runModal.open || ui.expandedJson.open || opts.runDetailOpen) {
|
|
69
|
+
if (ui.search.open || ui.runModal.open || ui.expandedJson.open || ui.stepGraph.open || opts.runDetailOpen) {
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
72
|
// Esc on a list view drops an active filter. Skip when we're on
|
|
@@ -133,9 +134,14 @@ const footerFor = (opts) => {
|
|
|
133
134
|
return { hints: HELP_HINTS, itemCount: HELP_SECTION_COUNT, itemLabel: 'sections' };
|
|
134
135
|
};
|
|
135
136
|
const overlayFor = (opts) => {
|
|
137
|
+
// expandedJson sits on top of everything — it's popped from another overlay
|
|
138
|
+
// (e.g. the step graph's `e`), so it must win when both are open.
|
|
136
139
|
if (opts.ui.expandedJson.open) {
|
|
137
140
|
return _jsx(ExpandedJsonModal, {});
|
|
138
141
|
}
|
|
142
|
+
if (opts.ui.stepGraph.open && opts.stepGraphRun) {
|
|
143
|
+
return _jsx(StepGraphModal, { run: opts.stepGraphRun, height: opts.rows });
|
|
144
|
+
}
|
|
139
145
|
if (opts.ui.runModal.open) {
|
|
140
146
|
return _jsx(RunModal, { workflowName: opts.ui.runModal.workflowName, workflowPath: opts.ui.runModal.workflowPath });
|
|
141
147
|
}
|
|
@@ -177,6 +183,14 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
177
183
|
runs.find(r => r.runId === ui.selection.runId && r.workflowId === ui.selection.workflowId) :
|
|
178
184
|
undefined;
|
|
179
185
|
const runDetailOpen = ui.runsView === 'detail' && detailRun !== undefined;
|
|
186
|
+
// Re-resolve the step-graph run from the polled list each render so its
|
|
187
|
+
// status/duration stay live; fall back to the snapshot captured at open.
|
|
188
|
+
const stepGraphRun = useMemo(() => {
|
|
189
|
+
const captured = ui.stepGraph.open ? ui.stepGraph.run : null;
|
|
190
|
+
return captured ?
|
|
191
|
+
(runs.find(r => r.runId === captured.runId && r.workflowId === captured.workflowId) ?? captured) :
|
|
192
|
+
undefined;
|
|
193
|
+
}, [ui.stepGraph.open, ui.stepGraph.run, runs]);
|
|
180
194
|
useGlobalInput({ onCleanup, runDetailOpen });
|
|
181
195
|
const failingServices = useMemo(() => services.filter(isServiceFailed).length, [services]);
|
|
182
196
|
const serviceBadge = useMemo(() => {
|
|
@@ -210,7 +224,7 @@ const Shell = ({ dockerComposePath, onCleanup }) => {
|
|
|
210
224
|
serviceCount: services.length,
|
|
211
225
|
phase
|
|
212
226
|
});
|
|
213
|
-
const overlay = overlayFor({ ui, detailRun, runDetailOpen, rows });
|
|
227
|
+
const overlay = overlayFor({ ui, detailRun, stepGraphRun, runDetailOpen, rows });
|
|
214
228
|
if (terminalTooSmall) {
|
|
215
229
|
return _jsx(TerminalTooSmall, { rows: rows, cols: cols });
|
|
216
230
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type WorkflowMeta } from '#services/workflow_history.js';
|
|
2
|
+
import type { Span } from '#services/workflow_history/correlator.js';
|
|
3
|
+
export interface StepGraph {
|
|
4
|
+
spans: Span[];
|
|
5
|
+
totalDurationMs: number;
|
|
6
|
+
workflow: WorkflowMeta | null;
|
|
7
|
+
labels: Map<string, string>;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
|
|
13
|
+
* reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
|
|
14
|
+
* command. Driven by `usePoll`: while the run is still advancing it re-pulls
|
|
15
|
+
* every tick so newly scheduled / finished steps appear, then stops and caches
|
|
16
|
+
* once terminal. The modal ticks the time axis between polls so the right edge
|
|
17
|
+
* tracks elapsed time.
|
|
18
|
+
*
|
|
19
|
+
* A hard fetch failure surfaces as `error` only when nothing has loaded yet — a
|
|
20
|
+
* transient poll blip keeps the last good chart on screen.
|
|
21
|
+
*/
|
|
22
|
+
export declare const useStepGraph: (workflowId: string | undefined, runId: string | undefined, status?: string) => StepGraph;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
3
|
+
import buildSpanLabels from '#utils/span_labels.js';
|
|
4
|
+
import { isTerminalRunStatus } from '#views/dev/hooks/use_run_detail.js';
|
|
5
|
+
import { usePoll, POLL_INTERVAL_MS } from '#views/dev/hooks/use_poll.js';
|
|
6
|
+
const EMPTY_GRAPH = {
|
|
7
|
+
spans: [],
|
|
8
|
+
totalDurationMs: 0,
|
|
9
|
+
workflow: null,
|
|
10
|
+
labels: new Map(),
|
|
11
|
+
loading: false,
|
|
12
|
+
error: null
|
|
13
|
+
};
|
|
14
|
+
const stepGraphCache = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* Fetches a run's correlated step spans for the dev TUI's waterfall overlay,
|
|
17
|
+
* reusing the same `fetchWorkflowHistory` path as the `workflow history` CLI
|
|
18
|
+
* command. Driven by `usePoll`: while the run is still advancing it re-pulls
|
|
19
|
+
* every tick so newly scheduled / finished steps appear, then stops and caches
|
|
20
|
+
* once terminal. The modal ticks the time axis between polls so the right edge
|
|
21
|
+
* tracks elapsed time.
|
|
22
|
+
*
|
|
23
|
+
* A hard fetch failure surfaces as `error` only when nothing has loaded yet — a
|
|
24
|
+
* transient poll blip keeps the last good chart on screen.
|
|
25
|
+
*/
|
|
26
|
+
export const useStepGraph = (workflowId, runId, status) => {
|
|
27
|
+
const [graph, setGraph] = useState(EMPTY_GRAPH);
|
|
28
|
+
const terminal = isTerminalRunStatus(status);
|
|
29
|
+
const requestKeyRef = useRef('');
|
|
30
|
+
usePoll(Boolean(workflowId), POLL_INTERVAL_MS, async () => {
|
|
31
|
+
if (!workflowId) {
|
|
32
|
+
return 'done';
|
|
33
|
+
}
|
|
34
|
+
const key = `${workflowId}:${runId ?? 'latest'}`;
|
|
35
|
+
requestKeyRef.current = key;
|
|
36
|
+
const cached = stepGraphCache.get(key);
|
|
37
|
+
if (cached) {
|
|
38
|
+
setGraph(cached);
|
|
39
|
+
return 'done';
|
|
40
|
+
}
|
|
41
|
+
setGraph(current => (current.spans.length === 0 ? { ...current, loading: true } : current));
|
|
42
|
+
try {
|
|
43
|
+
// Pull payloads so the detail pane can show each step's input/output and
|
|
44
|
+
// a failed step's reason.
|
|
45
|
+
const result = await fetchWorkflowHistory({ workflowId, runId, includePayloads: true });
|
|
46
|
+
if (requestKeyRef.current !== key) {
|
|
47
|
+
return 'done'; // a different run was selected mid-flight
|
|
48
|
+
}
|
|
49
|
+
const next = {
|
|
50
|
+
spans: result.spans,
|
|
51
|
+
totalDurationMs: result.totalDurationMs,
|
|
52
|
+
workflow: result.workflow,
|
|
53
|
+
labels: buildSpanLabels(result.spans),
|
|
54
|
+
loading: false,
|
|
55
|
+
error: null
|
|
56
|
+
};
|
|
57
|
+
// Cache only terminal runs — a running run's history is still growing.
|
|
58
|
+
if (terminal) {
|
|
59
|
+
stepGraphCache.set(key, next);
|
|
60
|
+
}
|
|
61
|
+
setGraph(next);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (requestKeyRef.current !== key) {
|
|
65
|
+
return 'done';
|
|
66
|
+
}
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
// Keep the last good chart if a poll blips; only surface a cold failure.
|
|
69
|
+
setGraph(current => (current.spans.length > 0 ?
|
|
70
|
+
{ ...current, loading: false } :
|
|
71
|
+
{ ...EMPTY_GRAPH, error: message }));
|
|
72
|
+
}
|
|
73
|
+
return terminal ? 'done' : 'continue';
|
|
74
|
+
});
|
|
75
|
+
return graph;
|
|
76
|
+
};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
type EntryKind = 'scenario' | 'custom';
|
|
3
|
+
interface Entry {
|
|
4
|
+
kind: EntryKind;
|
|
5
|
+
label: string;
|
|
6
|
+
scenarioName?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const buildEntries: (scenarios: string[]) => Entry[];
|
|
9
|
+
export declare const validateScenarioName: (raw: string, existing: string[]) => string | null;
|
|
2
10
|
export declare const RunModal: React.FC<{
|
|
3
11
|
workflowName: string;
|
|
4
12
|
workflowPath?: string;
|
|
5
13
|
}>;
|
|
14
|
+
export {};
|
|
@@ -9,26 +9,40 @@ import { startWorkflow } from '#views/dev/services/run_workflow.js';
|
|
|
9
9
|
import { readScenario, writeScenario } from '#views/dev/services/scenario_io.js';
|
|
10
10
|
import { JsonEditor } from '#views/dev/utils/json_editor.js';
|
|
11
11
|
import { ModalFrame } from '#views/dev/modals/modal_frame.js';
|
|
12
|
-
const CUSTOM_SEED = { '': '' };
|
|
13
12
|
const SCENARIO_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
14
|
-
|
|
13
|
+
// Seed for a brand-new input — an empty "": "" pair reads friendlier than a bare {}.
|
|
14
|
+
const CUSTOM_SEED = { '': '' };
|
|
15
|
+
export const buildEntries = (scenarios) => {
|
|
15
16
|
const list = scenarios.map(s => ({
|
|
16
17
|
kind: 'scenario',
|
|
17
18
|
label: s,
|
|
18
19
|
scenarioName: s
|
|
19
20
|
}));
|
|
20
|
-
list.push({ kind: 'custom', label: '[
|
|
21
|
+
list.push({ kind: 'custom', label: '[Enter input]' });
|
|
21
22
|
return list;
|
|
22
23
|
};
|
|
24
|
+
export const validateScenarioName = (raw, existing) => {
|
|
25
|
+
const name = raw.trim();
|
|
26
|
+
if (!name) {
|
|
27
|
+
return 'Scenario name cannot be empty.';
|
|
28
|
+
}
|
|
29
|
+
if (!SCENARIO_NAME_RE.test(name)) {
|
|
30
|
+
return 'Use letters, numbers, dashes, and underscores only.';
|
|
31
|
+
}
|
|
32
|
+
if (existing.includes(name)) {
|
|
33
|
+
return `A scenario named '${name}' already exists.`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
23
37
|
const SELECT_SHORTCUTS = [
|
|
24
38
|
['↑/↓', 'navigate'],
|
|
25
39
|
['enter', 'run'],
|
|
26
40
|
['d', 'duplicate'],
|
|
27
41
|
['esc', 'cancel']
|
|
28
42
|
];
|
|
29
|
-
const
|
|
30
|
-
['enter', '
|
|
31
|
-
['esc', 'back']
|
|
43
|
+
const SAVE_SHORTCUTS = [
|
|
44
|
+
['enter', 'save & run'],
|
|
45
|
+
['esc', 'back to editor']
|
|
32
46
|
];
|
|
33
47
|
const ERROR_SHORTCUTS = [
|
|
34
48
|
{ key: 'enter', label: 'return' },
|
|
@@ -41,9 +55,11 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
41
55
|
const entries = useMemo(() => buildEntries(scenarios), [scenarios]);
|
|
42
56
|
const [mode, setMode] = useState('select');
|
|
43
57
|
const [index, setIndex] = useState(0);
|
|
44
|
-
const [
|
|
45
|
-
const [editSeed, setEditSeed] = useState(CUSTOM_SEED);
|
|
58
|
+
const [editSeed, setEditSeed] = useState({});
|
|
46
59
|
const [editFrameTitle, setEditFrameTitle] = useState('');
|
|
60
|
+
const [defaultSaveName, setDefaultSaveName] = useState('');
|
|
61
|
+
const [editName, setEditName] = useState('');
|
|
62
|
+
const [pendingValue, setPendingValue] = useState(null);
|
|
47
63
|
const [nameError, setNameError] = useState(null);
|
|
48
64
|
const [errorMessage, setErrorMessage] = useState(null);
|
|
49
65
|
const closeWith = (message, tone = 'info') => {
|
|
@@ -76,63 +92,55 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
76
92
|
setMode('error');
|
|
77
93
|
}
|
|
78
94
|
};
|
|
95
|
+
// Custom + duplicate both open the editor first; saving a scenario is opt-in (ctrl+s).
|
|
96
|
+
const startCustom = () => {
|
|
97
|
+
setEditSeed(CUSTOM_SEED);
|
|
98
|
+
setDefaultSaveName('');
|
|
99
|
+
setEditFrameTitle('Enter input');
|
|
100
|
+
setMode('edit_content');
|
|
101
|
+
};
|
|
79
102
|
const startDuplicate = async (scenarioName) => {
|
|
80
103
|
try {
|
|
81
104
|
const sourceContent = await readScenario(workflowName, scenarioName, workflowPath);
|
|
82
|
-
setEditName(`${scenarioName}_copy`);
|
|
83
105
|
setEditSeed(sourceContent);
|
|
106
|
+
setDefaultSaveName(`${scenarioName}_copy`);
|
|
84
107
|
setEditFrameTitle(`Duplicate '${scenarioName}'`);
|
|
85
|
-
|
|
86
|
-
setMode('edit_name');
|
|
108
|
+
setMode('edit_content');
|
|
87
109
|
}
|
|
88
110
|
catch (err) {
|
|
89
111
|
setErrorMessage(err instanceof Error ? err.message : String(err));
|
|
90
112
|
setMode('error');
|
|
91
113
|
}
|
|
92
114
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
setEditFrameTitle('New scenario');
|
|
97
|
-
setNameError(null);
|
|
98
|
-
setMode('edit_name');
|
|
115
|
+
// ctrl+r in the editor: run the payload as-is, nothing written to disk.
|
|
116
|
+
const runEphemeral = (value) => {
|
|
117
|
+
void submit(value, defaultSaveName || 'input');
|
|
99
118
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
if (scenarios.includes(name)) {
|
|
109
|
-
return `A scenario named '${name}' already exists.`;
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
119
|
+
// ctrl+s in the editor: keep the payload and ask for a name before saving + running.
|
|
120
|
+
const beginSave = (value) => {
|
|
121
|
+
setPendingValue(value);
|
|
122
|
+
setEditSeed(value);
|
|
123
|
+
setEditName(defaultSaveName);
|
|
124
|
+
setNameError(null);
|
|
125
|
+
setMode('name_for_save');
|
|
112
126
|
};
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
setNameError(writeError);
|
|
118
|
-
setMode('edit_name');
|
|
127
|
+
const confirmSave = async () => {
|
|
128
|
+
const validationError = validateScenarioName(editName, scenarios);
|
|
129
|
+
if (validationError) {
|
|
130
|
+
setNameError(validationError);
|
|
119
131
|
return;
|
|
120
132
|
}
|
|
121
133
|
setMode('submitting');
|
|
122
134
|
try {
|
|
123
|
-
const writtenPath = await writeScenario(workflowName,
|
|
135
|
+
const writtenPath = await writeScenario(workflowName, editName.trim(), pendingValue, workflowPath);
|
|
124
136
|
ui.pushToast(`Saved scenario at ${writtenPath}`, 'info');
|
|
125
|
-
await submit(
|
|
137
|
+
await submit(pendingValue, editName.trim());
|
|
126
138
|
}
|
|
127
139
|
catch (err) {
|
|
128
140
|
setErrorMessage(err instanceof Error ? err.message : String(err));
|
|
129
141
|
setMode('error');
|
|
130
142
|
}
|
|
131
143
|
};
|
|
132
|
-
const handleEditorCancel = () => {
|
|
133
|
-
// Bring the user back to the name step so they can adjust it or bail.
|
|
134
|
-
setMode('edit_name');
|
|
135
|
-
};
|
|
136
144
|
useInput((input, key) => {
|
|
137
145
|
if (mode === 'edit_content' || mode === 'submitting') {
|
|
138
146
|
return;
|
|
@@ -168,19 +176,13 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
168
176
|
}
|
|
169
177
|
return;
|
|
170
178
|
}
|
|
171
|
-
if (mode === '
|
|
179
|
+
if (mode === 'name_for_save') {
|
|
172
180
|
if (key.escape) {
|
|
173
|
-
setMode('
|
|
181
|
+
setMode('edit_content');
|
|
174
182
|
return;
|
|
175
183
|
}
|
|
176
184
|
if (key.return) {
|
|
177
|
-
|
|
178
|
-
if (err) {
|
|
179
|
-
setNameError(err);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
setNameError(null);
|
|
183
|
-
setMode('edit_content');
|
|
185
|
+
void confirmSave();
|
|
184
186
|
return;
|
|
185
187
|
}
|
|
186
188
|
if (key.backspace || key.delete) {
|
|
@@ -206,12 +208,10 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
206
208
|
}
|
|
207
209
|
});
|
|
208
210
|
if (mode === 'edit_content') {
|
|
209
|
-
return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: `${
|
|
210
|
-
void handleEditorSubmit(value);
|
|
211
|
-
}, onCancel: handleEditorCancel }) }));
|
|
211
|
+
return (_jsx(ModalFrame, { title: editFrameTitle, children: _jsx(JsonEditor, { seed: editSeed, title: defaultSaveName ? `${defaultSaveName}.json` : 'input', isActive: true, onSubmit: runEphemeral, onSave: beginSave, onCancel: () => setMode('select') }) }));
|
|
212
212
|
}
|
|
213
|
-
if (mode === '
|
|
214
|
-
return (_jsxs(ModalFrame, { title:
|
|
213
|
+
if (mode === 'name_for_save') {
|
|
214
|
+
return (_jsxs(ModalFrame, { title: "Save & run", shortcuts: SAVE_SHORTCUTS, children: [_jsx(TextPrompt, { label: "Scenario name:", value: editName }), nameError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: nameError }) })) : null] }));
|
|
215
215
|
}
|
|
216
216
|
if (mode === 'submitting') {
|
|
217
217
|
return (_jsxs(ModalFrame, { title: `Run ${workflowName}`, children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: "\u00A0Starting workflow\u2026" })] }));
|
|
@@ -219,5 +219,5 @@ export const RunModal = ({ workflowName, workflowPath }) => {
|
|
|
219
219
|
if (mode === 'error') {
|
|
220
220
|
return (_jsx(ModalFrame, { title: `Run workflow "${workflowName}"`, shortcuts: ERROR_SHORTCUTS, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", errorMessage ?? 'Something went wrong.'] }) }));
|
|
221
221
|
}
|
|
222
|
-
return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No scenarios
|
|
222
|
+
return (_jsx(ModalFrame, { title: `Run ${workflowName}`, shortcuts: SELECT_SHORTCUTS, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { dimColor: true, children: scenarios.length === 0 ? 'No saved scenarios. Enter input to run:' : 'Select a scenario:' }), _jsx(Box, { flexDirection: "column", children: entries.map((entry, i) => (_jsxs(Box, { children: [_jsx(SelectionIndicator, { selected: i === index }), _jsxs(Text, { bold: i === index, children: ["\u00A0", entry.label] })] }, `${entry.kind}-${entry.scenarioName ?? i}`))) })] }) }));
|
|
223
223
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildEntries, validateScenarioName } from './run_modal.js';
|
|
3
|
+
describe('buildEntries', () => {
|
|
4
|
+
it('lists scenarios then the custom-JSON entry', () => {
|
|
5
|
+
const entries = buildEntries(['basic', 'edge']);
|
|
6
|
+
expect(entries.map(e => e.label)).toEqual(['basic', 'edge', '[Enter input]']);
|
|
7
|
+
expect(entries[0]).toMatchObject({ kind: 'scenario', scenarioName: 'basic' });
|
|
8
|
+
expect(entries.at(-1)).toMatchObject({ kind: 'custom' });
|
|
9
|
+
});
|
|
10
|
+
it('offers the custom entry even with no saved scenarios', () => {
|
|
11
|
+
const entries = buildEntries([]);
|
|
12
|
+
expect(entries).toHaveLength(1);
|
|
13
|
+
expect(entries[0].kind).toBe('custom');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('validateScenarioName', () => {
|
|
17
|
+
it('rejects empty or whitespace-only names', () => {
|
|
18
|
+
expect(validateScenarioName('', [])).toMatch(/cannot be empty/);
|
|
19
|
+
expect(validateScenarioName(' ', [])).toMatch(/cannot be empty/);
|
|
20
|
+
});
|
|
21
|
+
it('rejects names with unsupported characters', () => {
|
|
22
|
+
expect(validateScenarioName('has space', [])).toMatch(/letters, numbers/);
|
|
23
|
+
expect(validateScenarioName('bad/name', [])).toMatch(/letters, numbers/);
|
|
24
|
+
});
|
|
25
|
+
it('rejects names that already exist', () => {
|
|
26
|
+
expect(validateScenarioName('basic', ['basic'])).toMatch(/already exists/);
|
|
27
|
+
});
|
|
28
|
+
it('trims before checking for duplicates', () => {
|
|
29
|
+
expect(validateScenarioName(' basic ', ['basic'])).toMatch(/already exists/);
|
|
30
|
+
});
|
|
31
|
+
it('accepts a unique name with allowed characters', () => {
|
|
32
|
+
expect(validateScenarioName('edge_case-1', ['basic'])).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|