@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.
@@ -80,7 +80,7 @@ services:
80
80
  condition: service_healthy
81
81
  worker:
82
82
  condition: service_healthy
83
- image: outputai/api:${OUTPUT_API_VERSION:-0.10.1-next.09ed166.0}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.10.1-next.52bedcf.0}
84
84
  init: true
85
85
  networks:
86
86
  - main
@@ -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
- process.on('exit', exitAltScreenOnce);
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(() => instanceRef.current?.unmount());
148
+ .finally(restoreTerminal);
128
149
  };
129
- process.on('SIGINT', handleSignal);
130
- process.on('SIGTERM', handleSignal);
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
- instanceRef.current?.unmount();
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([], {});
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.10.1-next.09ed166.0"
2
+ "framework": "0.10.1-next.52bedcf.0"
3
3
  }
@@ -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 runDetailCache = new Map();
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 stepGraphCache = new Map();
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
+ });
@@ -1671,5 +1671,5 @@
1671
1671
  ]
1672
1672
  }
1673
1673
  },
1674
- "version": "0.10.1-next.09ed166.0"
1674
+ "version": "0.10.1-next.52bedcf.0"
1675
1675
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.10.1-next.09ed166.0",
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.09ed166.0",
42
- "@outputai/llm": "0.10.1-next.09ed166.0",
43
- "@outputai/evals": "0.10.1-next.09ed166.0"
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",