@outputai/cli 0.10.1-next.09ed166.0 → 0.10.1-next.52bedcf.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/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/index.js +57 -6
- package/dist/commands/dev/index.spec.js +55 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/views/dev/hooks/use_run_detail.js +3 -1
- 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 +1 -1
- package/package.json +4 -4
|
@@ -110,7 +110,28 @@ export default class Dev extends Command {
|
|
|
110
110
|
// `instance` ref is filled in once `render()` returns; until then,
|
|
111
111
|
// signal handlers just stop docker and exit.
|
|
112
112
|
const instanceRef = { current: null };
|
|
113
|
-
|
|
113
|
+
// Single terminal-restore sequence shared by every exit path: stop Ink
|
|
114
|
+
// (frees raw mode) then leave the alt-screen. The `finally` guarantees
|
|
115
|
+
// the alt-screen is left even if `unmount()` throws — otherwise a crash
|
|
116
|
+
// could strand the user in the blank alt buffer. This is the one place
|
|
117
|
+
// the unmount → leave-alt-screen order lives, so the paths can't drift.
|
|
118
|
+
const restoreTerminal = () => {
|
|
119
|
+
try {
|
|
120
|
+
instanceRef.current?.unmount();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
exitAltScreenOnce();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
// Collect a disposer for every process listener so registration and
|
|
127
|
+
// teardown can't drift: a handler added through `on` is always removed by
|
|
128
|
+
// the `finally` below, with no separate removeListener list to keep in sync.
|
|
129
|
+
const disposers = [];
|
|
130
|
+
const on = (event, handler) => {
|
|
131
|
+
process.on(event, handler);
|
|
132
|
+
disposers.push(() => process.removeListener(event, handler));
|
|
133
|
+
};
|
|
134
|
+
on('exit', exitAltScreenOnce);
|
|
114
135
|
// `process.on` doesn't await the handler, so the cleanup promise would
|
|
115
136
|
// float and any rejection would surface as an unhandled rejection.
|
|
116
137
|
// Wrap the async work in a sync registration that explicitly logs
|
|
@@ -124,10 +145,35 @@ export default class Dev extends Command {
|
|
|
124
145
|
exitAltScreenOnce();
|
|
125
146
|
console.error('Cleanup failed:', getErrorMessage(err));
|
|
126
147
|
})
|
|
127
|
-
.finally(
|
|
148
|
+
.finally(restoreTerminal);
|
|
128
149
|
};
|
|
129
|
-
|
|
130
|
-
|
|
150
|
+
on('SIGINT', handleSignal);
|
|
151
|
+
on('SIGTERM', handleSignal);
|
|
152
|
+
// A fatal crash (uncaught exception / unhandled rejection) skips both the
|
|
153
|
+
// clean-exit path and the signal handlers. Tear docker down via cleanup()
|
|
154
|
+
// FIRST, then restore the terminal and print the crash: unmounting Ink
|
|
155
|
+
// resolves the awaited waitUntilExit(), which resumes run() and strips the
|
|
156
|
+
// signal listeners — so unmounting before cleanup would drop them
|
|
157
|
+
// mid-teardown and let a Ctrl+C orphan the stack. Print the raw error so
|
|
158
|
+
// Node's stack trace survives, then re-exit non-zero. Fire-once: a second
|
|
159
|
+
// catchable crash during teardown is a no-op. A hard V8 abort() (SIGABRT)
|
|
160
|
+
// is uncatchable and not covered here.
|
|
161
|
+
const fatalState = { handled: false };
|
|
162
|
+
const handleFatalError = (err) => {
|
|
163
|
+
if (fatalState.handled) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
fatalState.handled = true;
|
|
167
|
+
cleanup()
|
|
168
|
+
.catch(cleanupErr => console.error('Cleanup failed:', getErrorMessage(cleanupErr)))
|
|
169
|
+
.finally(() => {
|
|
170
|
+
restoreTerminal();
|
|
171
|
+
console.error(err);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
on('uncaughtException', handleFatalError);
|
|
176
|
+
on('unhandledRejection', handleFatalError);
|
|
131
177
|
try {
|
|
132
178
|
enterAltScreen();
|
|
133
179
|
const instance = render(React.createElement(DevApp, { dockerComposePath, onCleanup: cleanup }), { exitOnCtrlC: false });
|
|
@@ -158,9 +204,14 @@ export default class Dev extends Command {
|
|
|
158
204
|
exitAltScreenOnce();
|
|
159
205
|
}
|
|
160
206
|
catch (error) {
|
|
161
|
-
|
|
162
|
-
exitAltScreenOnce();
|
|
207
|
+
restoreTerminal();
|
|
163
208
|
this.error(getErrorMessage(error), { exit: 1 });
|
|
164
209
|
}
|
|
210
|
+
finally {
|
|
211
|
+
// Remove every process-global listener registered above; otherwise each
|
|
212
|
+
// run() (test invocations included) leaks a live handler that force-
|
|
213
|
+
// exits the process on the next stray signal or rejection.
|
|
214
|
+
disposers.forEach(dispose => dispose());
|
|
215
|
+
}
|
|
165
216
|
}
|
|
166
217
|
}
|
|
@@ -328,6 +328,61 @@ describe('dev command', () => {
|
|
|
328
328
|
expect(cmd.error).not.toHaveBeenCalled();
|
|
329
329
|
});
|
|
330
330
|
});
|
|
331
|
+
describe('fatal error handling', () => {
|
|
332
|
+
it('restores the terminal and exits non-zero on an uncaught exception', async () => {
|
|
333
|
+
const inkInstance = createControllableInkInstance();
|
|
334
|
+
vi.mocked(render).mockReturnValue(inkInstance);
|
|
335
|
+
// Capture the registered handlers instead of letting them attach to the
|
|
336
|
+
// real process, and neutralize process.exit so firing one doesn't kill
|
|
337
|
+
// the test runner.
|
|
338
|
+
const handlers = {};
|
|
339
|
+
const onSpy = vi.spyOn(process, 'on').mockImplementation(((event, handler) => {
|
|
340
|
+
handlers[event] = handler;
|
|
341
|
+
return process;
|
|
342
|
+
}));
|
|
343
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
344
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
345
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
346
|
+
const cmd = new Dev([], {});
|
|
347
|
+
cmd.log = vi.fn();
|
|
348
|
+
cmd.error = vi.fn();
|
|
349
|
+
Object.defineProperty(cmd, 'parse', {
|
|
350
|
+
value: vi.fn().mockResolvedValue({ flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
|
|
351
|
+
configurable: true
|
|
352
|
+
});
|
|
353
|
+
const runPromise = cmd.run();
|
|
354
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
355
|
+
expect(handlers.uncaughtException).toBeInstanceOf(Function);
|
|
356
|
+
expect(handlers.unhandledRejection).toBeInstanceOf(Function);
|
|
357
|
+
const crash = new Error('boom');
|
|
358
|
+
handlers.uncaughtException(crash);
|
|
359
|
+
// Terminal restore, the crash print, and exit all run after docker
|
|
360
|
+
// teardown settles, so they land a tick later.
|
|
361
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
362
|
+
// Docker is torn down before exit, so a crash doesn't orphan the
|
|
363
|
+
// compose stack.
|
|
364
|
+
expect(dockerService.stopDockerCompose).toHaveBeenCalled();
|
|
365
|
+
expect(inkInstance.unmount).toHaveBeenCalled();
|
|
366
|
+
expect(stdoutSpy).toHaveBeenCalledWith('\x1b[?1049l');
|
|
367
|
+
expect(errorSpy).toHaveBeenCalledWith(crash);
|
|
368
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
369
|
+
// Discriminating order: docker must be fully torn down BEFORE Ink
|
|
370
|
+
// unmounts. Unmounting resolves waitUntilExit() and resumes run(),
|
|
371
|
+
// which strips the signal listeners — so an unmount-first ordering
|
|
372
|
+
// would drop the SIGINT handler mid-teardown and risk orphaning the
|
|
373
|
+
// stack on a Ctrl+C.
|
|
374
|
+
expect(vi.mocked(dockerService.stopDockerCompose).mock.invocationCallOrder[0])
|
|
375
|
+
.toBeLessThan(inkInstance.unmount.mock.invocationCallOrder[0]);
|
|
376
|
+
// Within the restore, Ink unmounts before the alt-screen is left, and
|
|
377
|
+
// the crash prints only after — otherwise console.error paints into a
|
|
378
|
+
// buffer the user never sees.
|
|
379
|
+
const leaveAltScreenCall = stdoutSpy.mock.invocationCallOrder[stdoutSpy.mock.calls.findIndex(([seq]) => seq === '\x1b[?1049l')];
|
|
380
|
+
expect(inkInstance.unmount.mock.invocationCallOrder[0]).toBeLessThan(leaveAltScreenCall);
|
|
381
|
+
expect(leaveAltScreenCall).toBeLessThan(errorSpy.mock.invocationCallOrder[0]);
|
|
382
|
+
onSpy.mockRestore();
|
|
383
|
+
runPromise.catch(() => { });
|
|
384
|
+
});
|
|
385
|
+
});
|
|
331
386
|
describe('image pull policy', () => {
|
|
332
387
|
it('should pass pull policy to startDockerCompose', async () => {
|
|
333
388
|
const cmd = new Dev([], {});
|
|
@@ -3,13 +3,15 @@ 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
5
|
import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
|
|
6
|
+
import { createBoundedCache } from '#views/dev/utils/bounded_cache.js';
|
|
6
7
|
const EMPTY_DETAIL = {
|
|
7
8
|
result: null,
|
|
8
9
|
trace: null,
|
|
9
10
|
steps: [],
|
|
10
11
|
loading: false
|
|
11
12
|
};
|
|
12
|
-
const
|
|
13
|
+
const RUN_DETAIL_CACHE_MAX = 50;
|
|
14
|
+
const runDetailCache = createBoundedCache(RUN_DETAIL_CACHE_MAX);
|
|
13
15
|
const stepNameOf = (node) => {
|
|
14
16
|
if (node.name) {
|
|
15
17
|
return node.name;
|
|
@@ -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>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small LRU cache backed by an insertion-ordered `Map`, capped at `maxSize`
|
|
3
|
+
* entries. Reading a key refreshes its recency; once the cap is exceeded the
|
|
4
|
+
* least-recently-used entries are evicted. Drop-in compatible with the
|
|
5
|
+
* `Map.get` / `Map.set` calls it replaces.
|
|
6
|
+
*/
|
|
7
|
+
export const createBoundedCache = (maxSize) => {
|
|
8
|
+
if (maxSize < 1) {
|
|
9
|
+
throw new Error('createBoundedCache: maxSize must be >= 1');
|
|
10
|
+
}
|
|
11
|
+
const entries = new Map();
|
|
12
|
+
return {
|
|
13
|
+
get(key) {
|
|
14
|
+
const value = entries.get(key);
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
entries.delete(key);
|
|
19
|
+
entries.set(key, value);
|
|
20
|
+
return value;
|
|
21
|
+
},
|
|
22
|
+
set(key, value) {
|
|
23
|
+
entries.delete(key);
|
|
24
|
+
entries.set(key, value);
|
|
25
|
+
if (entries.size > maxSize) {
|
|
26
|
+
const oldest = entries.keys().next();
|
|
27
|
+
if (!oldest.done) {
|
|
28
|
+
entries.delete(oldest.value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
has(key) {
|
|
33
|
+
return entries.has(key);
|
|
34
|
+
},
|
|
35
|
+
clear() {
|
|
36
|
+
entries.clear();
|
|
37
|
+
},
|
|
38
|
+
size() {
|
|
39
|
+
return entries.size;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createBoundedCache } from './bounded_cache.js';
|
|
3
|
+
describe('createBoundedCache', () => {
|
|
4
|
+
it('round-trips set and get', () => {
|
|
5
|
+
const cache = createBoundedCache(3);
|
|
6
|
+
cache.set('a', 1);
|
|
7
|
+
expect(cache.get('a')).toBe(1);
|
|
8
|
+
expect(cache.get('missing')).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
it('evicts the oldest entry once maxSize is exceeded', () => {
|
|
11
|
+
const cache = createBoundedCache(2);
|
|
12
|
+
cache.set('a', 1);
|
|
13
|
+
cache.set('b', 2);
|
|
14
|
+
cache.set('c', 3);
|
|
15
|
+
expect(cache.has('a')).toBe(false);
|
|
16
|
+
expect(cache.get('b')).toBe(2);
|
|
17
|
+
expect(cache.get('c')).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
it('refreshes recency on get so the touched entry survives eviction', () => {
|
|
20
|
+
const cache = createBoundedCache(2);
|
|
21
|
+
cache.set('a', 1);
|
|
22
|
+
cache.set('b', 2);
|
|
23
|
+
// Touch 'a' so 'b' becomes the least-recently-used entry.
|
|
24
|
+
expect(cache.get('a')).toBe(1);
|
|
25
|
+
cache.set('c', 3);
|
|
26
|
+
expect(cache.has('a')).toBe(true);
|
|
27
|
+
expect(cache.has('b')).toBe(false);
|
|
28
|
+
expect(cache.has('c')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('updates an existing key in place without growing', () => {
|
|
31
|
+
const cache = createBoundedCache(2);
|
|
32
|
+
cache.set('a', 1);
|
|
33
|
+
cache.set('a', 2);
|
|
34
|
+
expect(cache.get('a')).toBe(2);
|
|
35
|
+
expect(cache.size()).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
it('supports has and clear', () => {
|
|
38
|
+
const cache = createBoundedCache(2);
|
|
39
|
+
cache.set('a', 1);
|
|
40
|
+
expect(cache.has('a')).toBe(true);
|
|
41
|
+
cache.clear();
|
|
42
|
+
expect(cache.has('a')).toBe(false);
|
|
43
|
+
expect(cache.size()).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
it('never exceeds maxSize', () => {
|
|
46
|
+
const cache = createBoundedCache(3);
|
|
47
|
+
Array.from({ length: 100 }, (_, i) => i).forEach(i => {
|
|
48
|
+
cache.set(`key-${i}`, i);
|
|
49
|
+
expect(cache.size()).toBeLessThanOrEqual(3);
|
|
50
|
+
});
|
|
51
|
+
expect(cache.size()).toBe(3);
|
|
52
|
+
});
|
|
53
|
+
});
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.10.1-next.
|
|
3
|
+
"version": "0.10.1-next.52bedcf.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"semver": "7.7.4",
|
|
39
39
|
"undici": "8.5.0",
|
|
40
40
|
"yaml": "^2.8.3",
|
|
41
|
-
"@outputai/credentials": "0.10.1-next.
|
|
42
|
-
"@outputai/llm": "0.10.1-next.
|
|
43
|
-
"@outputai/evals": "0.10.1-next.
|
|
41
|
+
"@outputai/credentials": "0.10.1-next.52bedcf.0",
|
|
42
|
+
"@outputai/llm": "0.10.1-next.52bedcf.0",
|
|
43
|
+
"@outputai/evals": "0.10.1-next.52bedcf.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/cli-progress": "3.11.6",
|