@outputai/cli 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.2caa4a1.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.
Files changed (63) hide show
  1. package/dist/api/generated/api.d.ts +12 -0
  2. package/dist/api/http_client.js +2 -2
  3. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  4. package/dist/commands/dev/down.d.ts +10 -0
  5. package/dist/commands/dev/down.js +34 -0
  6. package/dist/commands/dev/down.spec.d.ts +1 -0
  7. package/dist/commands/dev/down.spec.js +71 -0
  8. package/dist/commands/dev/index.d.ts +4 -0
  9. package/dist/commands/dev/index.js +200 -53
  10. package/dist/commands/dev/index.spec.js +390 -42
  11. package/dist/commands/workflow/history.js +3 -3
  12. package/dist/commands/workflow/history.spec.js +31 -2
  13. package/dist/commands/workflow/monitor.d.ts +49 -0
  14. package/dist/commands/workflow/monitor.js +230 -0
  15. package/dist/commands/workflow/monitor.spec.d.ts +1 -0
  16. package/dist/commands/workflow/monitor.spec.js +243 -0
  17. package/dist/commands/workflow/run.js +8 -1
  18. package/dist/commands/workflow/run.spec.js +12 -2
  19. package/dist/commands/workflow/start.d.ts +3 -1
  20. package/dist/commands/workflow/start.js +12 -2
  21. package/dist/commands/workflow/start.spec.js +30 -5
  22. package/dist/generated/framework_version.json +1 -1
  23. package/dist/services/docker.d.ts +28 -1
  24. package/dist/services/docker.js +106 -12
  25. package/dist/services/docker.spec.js +144 -14
  26. package/dist/services/workflow_history/correlator.d.ts +2 -0
  27. package/dist/services/workflow_history/correlator.js +2 -2
  28. package/dist/services/workflow_history.d.ts +28 -0
  29. package/dist/services/workflow_history.js +95 -12
  30. package/dist/services/workflow_history.spec.js +183 -1
  31. package/dist/templates/agent_instructions/CLAUDE.md.template +1 -1
  32. package/dist/templates/project/src/clients/jina.ts.template +4 -4
  33. package/dist/utils/color.d.ts +7 -0
  34. package/dist/utils/color.js +12 -0
  35. package/dist/utils/color.spec.d.ts +1 -0
  36. package/dist/utils/color.spec.js +43 -0
  37. package/dist/utils/format_workflow_result.d.ts +1 -0
  38. package/dist/utils/format_workflow_result.js +4 -0
  39. package/dist/utils/monitor_log.d.ts +20 -0
  40. package/dist/utils/monitor_log.js +48 -0
  41. package/dist/utils/monitor_log.spec.d.ts +1 -0
  42. package/dist/utils/monitor_log.spec.js +71 -0
  43. package/dist/utils/port_collision.d.ts +22 -7
  44. package/dist/utils/port_collision.js +39 -14
  45. package/dist/utils/port_collision.spec.js +40 -1
  46. package/dist/utils/resolve_input.d.ts +9 -1
  47. package/dist/utils/resolve_input.js +8 -2
  48. package/dist/utils/resolve_input.spec.d.ts +1 -0
  49. package/dist/utils/resolve_input.spec.js +75 -0
  50. package/dist/utils/waterfall.d.ts +3 -1
  51. package/dist/utils/waterfall.js +8 -2
  52. package/dist/views/dev/chrome/footer.d.ts +2 -0
  53. package/dist/views/dev/chrome/footer.js +4 -4
  54. package/dist/views/dev/dev_app.d.ts +1 -0
  55. package/dist/views/dev/dev_app.js +13 -4
  56. package/dist/views/dev/hooks/use_run_detail.js +7 -8
  57. package/dist/views/dev/hooks/use_step_graph.js +3 -1
  58. package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
  59. package/dist/views/dev/utils/bounded_cache.js +42 -0
  60. package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
  61. package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
  62. package/oclif.manifest.json +122 -4
  63. package/package.json +7 -8
@@ -24,10 +24,32 @@ export declare class DockerComposeConfigNotFoundError extends Error {
24
24
  declare const isDockerInstalled: () => boolean;
25
25
  export declare function validateDockerEnvironment(): void;
26
26
  export declare function getDefaultDockerComposePath(): string;
27
+ export declare function resolveDockerComposePath(customPath?: string): Promise<string>;
27
28
  export declare function parseServiceStatus(jsonOutput: string): ServiceStatus[];
28
29
  export declare function getServiceStatus(dockerComposePath: string): Promise<ServiceStatus[]>;
29
30
  export declare function isServiceHealthy(service: ServiceStatus): boolean;
30
31
  export declare function isServiceFailed(service: ServiceStatus): boolean;
32
+ export declare const STACK_STATE: {
33
+ /** Nothing live for this project — a fresh start we own. */
34
+ readonly NONE: "none";
35
+ /** Every container found is running and healthy (or has no healthcheck). */
36
+ readonly RUNNING: "running";
37
+ /** Something is live but not everything is healthy — reconcile. */
38
+ readonly PARTIAL: "partial";
39
+ };
40
+ export type StackState = typeof STACK_STATE[keyof typeof STACK_STATE];
41
+ /**
42
+ * Classify the current state of a project's stack from `docker compose ps`.
43
+ *
44
+ * This is the detection signal `output dev` branches on: nothing live means a
45
+ * fresh start; an all-healthy result means we can attach and monitor without
46
+ * touching the stack; anything in between is reconciled with `up -d`.
47
+ *
48
+ * Scoped to the shared `output-sdk` compose project, which distinguishes our
49
+ * containers from unrelated processes — but not one Output checkout from
50
+ * another, since the project name defaults to a machine-global constant.
51
+ */
52
+ export declare function classifyStackState(services: ServiceStatus[]): StackState;
31
53
  export declare function waitForServicesHealthy(dockerComposePath: string, timeoutMs?: number, pollIntervalMs?: number): Promise<void>;
32
54
  export interface DockerComposeHandlers {
33
55
  onError?: (error: Error, output: string) => void;
@@ -39,6 +61,11 @@ export interface StartDockerComposeOptions extends DockerComposeHandlers {
39
61
  pullPolicy?: PullPolicy;
40
62
  }
41
63
  export declare function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }: StartDockerComposeOptions): Promise<ChildProcess>;
42
- export declare function startDockerComposeDetached(dockerComposePath: string, pullPolicy?: PullPolicy): void;
64
+ export interface DetachedUpResult {
65
+ code: number | null;
66
+ signal: NodeJS.Signals | null;
67
+ output: string;
68
+ }
69
+ export declare function runDockerComposeUpDetached(dockerComposePath: string, pullPolicy?: PullPolicy): Promise<DetachedUpResult>;
43
70
  export declare function stopDockerCompose(dockerComposePath: string): Promise<void>;
44
71
  export { isDockerInstalled, DockerValidationError };
@@ -1,9 +1,12 @@
1
1
  import { execFileSync, execSync, spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { ux } from '@oclif/core';
5
6
  import semver from 'semver';
6
7
  import { config } from '#config.js';
8
+ import { getErrorMessage } from '#utils/error_utils.js';
9
+ import { formatComposeFailure } from '#utils/port_collision.js';
7
10
  const DEFAULT_COMPOSE_PATH = '../assets/docker/docker-compose-dev.yml';
8
11
  export const SERVICE_HEALTH = {
9
12
  HEALTHY: 'healthy',
@@ -83,6 +86,22 @@ export function validateDockerEnvironment() {
83
86
  export function getDefaultDockerComposePath() {
84
87
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), DEFAULT_COMPOSE_PATH);
85
88
  }
89
+ // Resolve the compose file a `dev` command should act on — a caller-supplied
90
+ // path (relative to cwd) or the bundled default — and verify it exists. Shared
91
+ // by `dev` and `dev down` so the resolution rule and not-found error stay in
92
+ // one place.
93
+ export async function resolveDockerComposePath(customPath) {
94
+ const dockerComposePath = customPath ?
95
+ path.resolve(process.cwd(), customPath) :
96
+ getDefaultDockerComposePath();
97
+ try {
98
+ await fs.access(dockerComposePath);
99
+ }
100
+ catch {
101
+ throw new DockerComposeConfigNotFoundError(dockerComposePath);
102
+ }
103
+ return dockerComposePath;
104
+ }
86
105
  export function parseServiceStatus(jsonOutput) {
87
106
  if (!jsonOutput.trim()) {
88
107
  return [];
@@ -118,6 +137,37 @@ export function isServiceHealthy(service) {
118
137
  export function isServiceFailed(service) {
119
138
  return service.state === SERVICE_STATE.EXITED || service.health === SERVICE_HEALTH.UNHEALTHY;
120
139
  }
140
+ export const STACK_STATE = {
141
+ /** Nothing live for this project — a fresh start we own. */
142
+ NONE: 'none',
143
+ /** Every container found is running and healthy (or has no healthcheck). */
144
+ RUNNING: 'running',
145
+ /** Something is live but not everything is healthy — reconcile. */
146
+ PARTIAL: 'partial'
147
+ };
148
+ /**
149
+ * Classify the current state of a project's stack from `docker compose ps`.
150
+ *
151
+ * This is the detection signal `output dev` branches on: nothing live means a
152
+ * fresh start; an all-healthy result means we can attach and monitor without
153
+ * touching the stack; anything in between is reconciled with `up -d`.
154
+ *
155
+ * Scoped to the shared `output-sdk` compose project, which distinguishes our
156
+ * containers from unrelated processes — but not one Output checkout from
157
+ * another, since the project name defaults to a machine-global constant.
158
+ */
159
+ export function classifyStackState(services) {
160
+ // No container is live. `ps --all` also reports exited ones, so a stack left
161
+ // behind by a reboot, a `docker compose stop`, or a failed teardown lands
162
+ // here — that's an owned fresh start, not something to attach to.
163
+ if (!services.some(service => service.state === SERVICE_STATE.RUNNING)) {
164
+ return STACK_STATE.NONE;
165
+ }
166
+ if (services.every(isServiceHealthy)) {
167
+ return STACK_STATE.RUNNING;
168
+ }
169
+ return STACK_STATE.PARTIAL;
170
+ }
121
171
  export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 120000, pollIntervalMs = 2000) {
122
172
  const startTime = Date.now();
123
173
  while (Date.now() - startTime < timeoutMs) {
@@ -129,6 +179,18 @@ export async function waitForServicesHealthy(dockerComposePath, timeoutMs = 1200
129
179
  }
130
180
  throw new Error('Timeout waiting for services to become healthy');
131
181
  }
182
+ // A rolling buffer that retains the last ~20k chars of a spawned process's
183
+ // combined output, so a startup failure can surface recent Docker logs without
184
+ // holding the whole stream. Shared by the two compose spawn sites.
185
+ function createOutputBuffer() {
186
+ const buffer = { value: '' };
187
+ return {
188
+ append: (chunk) => {
189
+ buffer.value = `${buffer.value}${chunk.toString()}`.slice(-20000).trimStart();
190
+ },
191
+ read: () => buffer.value.trimEnd()
192
+ };
193
+ }
132
194
  export async function startDockerCompose({ dockerComposePath, pullPolicy, onError, onExit }) {
133
195
  const args = [
134
196
  'compose',
@@ -140,29 +202,34 @@ export async function startDockerCompose({ dockerComposePath, pullPolicy, onErro
140
202
  if (pullPolicy) {
141
203
  args.push('--pull', pullPolicy);
142
204
  }
143
- const output = {
144
- value: ''
145
- };
146
- const appendOutput = (chunk) => {
147
- output.value = `${output.value}${chunk.toString()}`.slice(-20000).trimStart();
148
- };
205
+ const output = createOutputBuffer();
149
206
  const dockerProcess = spawn('docker', args, {
150
207
  cwd: process.cwd(),
151
208
  // The Ink dev UI owns the terminal. Drain compose output so Docker cannot
152
209
  // block on a full pipe, while keeping recent output for startup failures.
153
210
  stdio: ['ignore', 'pipe', 'pipe']
154
211
  });
155
- dockerProcess.stdout?.on('data', appendOutput);
156
- dockerProcess.stderr?.on('data', appendOutput);
212
+ dockerProcess.stdout?.on('data', output.append);
213
+ dockerProcess.stderr?.on('data', output.append);
157
214
  if (onError) {
158
- dockerProcess.on('error', error => onError(error, output.value.trimEnd()));
215
+ dockerProcess.on('error', error => onError(error, output.read()));
159
216
  }
160
217
  if (onExit) {
161
- dockerProcess.on('exit', (code, signal) => onExit(code, signal, output.value.trimEnd()));
218
+ // `close` rather than `exit` so stdio has drained the buffered output is
219
+ // what formatComposeFailure greps for a bind failure.
220
+ dockerProcess.on('close', (code, signal) => onExit(code, signal, output.read()));
162
221
  }
163
222
  return dockerProcess;
164
223
  }
165
- export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
224
+ // Run `docker compose up -d` to completion, teeing Docker's progress to the
225
+ // user's terminal while retaining recent output. The predecessor used
226
+ // execFileSync with inherited stdio, which threw a raw compose error the caller
227
+ // couldn't inspect; returning the exit code and output lets it surface a
228
+ // port-collision hint instead. Async so image pulls don't block the event loop.
229
+ //
230
+ // Trade-off: piping means Docker sees a non-TTY and drops its redrawing
231
+ // progress bars for plain scrolling lines.
232
+ export function runDockerComposeUpDetached(dockerComposePath, pullPolicy) {
166
233
  const args = [
167
234
  'compose',
168
235
  '-f', dockerComposePath,
@@ -173,7 +240,34 @@ export function startDockerComposeDetached(dockerComposePath, pullPolicy) {
173
240
  if (pullPolicy) {
174
241
  args.push('--pull', pullPolicy);
175
242
  }
176
- execFileSync('docker', args, { stdio: 'inherit', cwd: process.cwd() });
243
+ const output = createOutputBuffer();
244
+ return new Promise((resolve, reject) => {
245
+ // Pipe rather than inherit so we can both echo Docker's progress and keep
246
+ // recent output for a startup-failure hint.
247
+ const child = spawn('docker', args, {
248
+ cwd: process.cwd(),
249
+ stdio: ['ignore', 'pipe', 'pipe']
250
+ });
251
+ child.stdout?.on('data', (chunk) => {
252
+ process.stdout.write(chunk);
253
+ output.append(chunk);
254
+ });
255
+ child.stderr?.on('data', (chunk) => {
256
+ process.stderr.write(chunk);
257
+ output.append(chunk);
258
+ });
259
+ // `error` fires when the spawn itself failed (docker missing, EACCES).
260
+ // Reject with the buffered output attached so the caller keeps the context
261
+ // rather than surfacing a bare `spawn docker ENOENT`.
262
+ child.on('error', error => {
263
+ reject(new Error(formatComposeFailure(getErrorMessage(error), output.read(), config.ports)));
264
+ });
265
+ // `close`, not `exit`: exit fires while stdio may still be draining, and on
266
+ // a fast-failing `up -d` — exactly the bind-collision case — the stderr
267
+ // chunk carrying the bind error can land after it. Resolving early makes
268
+ // the port-collision hint disappear intermittently.
269
+ child.on('close', (code, signal) => resolve({ code, signal, output: output.read() }));
270
+ });
177
271
  }
178
272
  export async function stopDockerCompose(dockerComposePath) {
179
273
  ux.stdout('⏹️ Stopping services...\n');
@@ -1,11 +1,16 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { execFileSync, spawn } from 'node:child_process';
3
- import { parseServiceStatus, getServiceStatus, startDockerCompose, startDockerComposeDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed } from './docker.js';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { parseServiceStatus, getServiceStatus, startDockerCompose, runDockerComposeUpDetached, stopDockerCompose, waitForServicesHealthy, isServiceHealthy, isServiceFailed, classifyStackState, STACK_STATE, resolveDockerComposePath, getDefaultDockerComposePath, DockerComposeConfigNotFoundError } from './docker.js';
4
6
  vi.mock('node:child_process', () => ({
5
7
  execSync: vi.fn(),
6
8
  execFileSync: vi.fn(),
7
9
  spawn: vi.fn()
8
10
  }));
11
+ vi.mock('node:fs/promises', () => ({
12
+ default: { access: vi.fn() }
13
+ }));
9
14
  const mockChildProcess = (process) => process;
10
15
  vi.mock('log-update', () => {
11
16
  const fn = vi.fn();
@@ -155,7 +160,7 @@ describe('docker service', () => {
155
160
  onExit
156
161
  });
157
162
  expect(dockerProcess.on).toHaveBeenCalledWith('error', expect.any(Function));
158
- expect(dockerProcess.on).toHaveBeenCalledWith('exit', expect.any(Function));
163
+ expect(dockerProcess.on).toHaveBeenCalledWith('close', expect.any(Function));
159
164
  streamHandlers.stdout?.(Buffer.from('starting services\n'));
160
165
  streamHandlers.stderr?.(Buffer.from('compose failed\n'));
161
166
  const error = new Error('Docker failed');
@@ -165,26 +170,99 @@ describe('docker service', () => {
165
170
  expect(onExit).toHaveBeenCalledWith(1, null, 'starting services\ncompose failed');
166
171
  });
167
172
  });
168
- describe('startDockerComposeDetached', () => {
169
- it('should pass --project-name and -d to docker compose up', () => {
170
- vi.mocked(execFileSync).mockReturnValue('');
171
- startDockerComposeDetached('/path/to/docker-compose.yml');
172
- expect(execFileSync).toHaveBeenCalledWith('docker', [
173
+ describe('runDockerComposeUpDetached', () => {
174
+ const makeProcess = () => {
175
+ const handlers = {};
176
+ const proc = {
177
+ on: vi.fn((event, handler) => {
178
+ if (event === 'error') {
179
+ handlers.error = handler;
180
+ }
181
+ else {
182
+ handlers.exit = handler;
183
+ }
184
+ return proc;
185
+ }),
186
+ stdout: {
187
+ on: vi.fn((event, handler) => {
188
+ handlers.stdout = handler;
189
+ return proc.stdout;
190
+ })
191
+ },
192
+ stderr: {
193
+ on: vi.fn((event, handler) => {
194
+ handlers.stderr = handler;
195
+ return proc.stderr;
196
+ })
197
+ }
198
+ };
199
+ return { proc, handlers };
200
+ };
201
+ it('passes --project-name and -d, tees output, and resolves with the exit code', async () => {
202
+ const { proc, handlers } = makeProcess();
203
+ vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
204
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
205
+ const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
206
+ expect(spawn).toHaveBeenCalledWith('docker', [
173
207
  'compose', '-f', '/path/to/docker-compose.yml',
174
208
  '--project-directory', process.cwd(),
175
209
  '--project-name', 'output-sdk',
176
210
  'up', '-d'
177
- ], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
178
- });
179
- it('should append --pull when pullPolicy is provided', () => {
180
- vi.mocked(execFileSync).mockReturnValue('');
181
- startDockerComposeDetached('/path/to/docker-compose.yml', 'missing');
182
- expect(execFileSync).toHaveBeenCalledWith('docker', [
211
+ ], expect.objectContaining({ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }));
212
+ handlers.stdout?.(Buffer.from('pulling images\n'));
213
+ handlers.exit?.(0);
214
+ expect(await promise).toEqual({ code: 0, output: 'pulling images' });
215
+ expect(stdoutSpy).toHaveBeenCalledWith(Buffer.from('pulling images\n'));
216
+ stdoutSpy.mockRestore();
217
+ });
218
+ it('appends --pull when pullPolicy is provided', async () => {
219
+ const { proc, handlers } = makeProcess();
220
+ vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
221
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true);
222
+ const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml', 'missing');
223
+ handlers.exit?.(0);
224
+ await promise;
225
+ expect(spawn).toHaveBeenCalledWith('docker', [
183
226
  'compose', '-f', '/path/to/docker-compose.yml',
184
227
  '--project-directory', process.cwd(),
185
228
  '--project-name', 'output-sdk',
186
229
  'up', '-d', '--pull', 'missing'
187
- ], expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }));
230
+ ], expect.objectContaining({ cwd: process.cwd() }));
231
+ });
232
+ it('resolves with a non-zero code and captured stderr so the caller can hint the collision', async () => {
233
+ const { proc, handlers } = makeProcess();
234
+ vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
235
+ vi.spyOn(process.stderr, 'write').mockReturnValue(true);
236
+ const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
237
+ handlers.stderr?.(Buffer.from('Error: address already in use\n'));
238
+ handlers.exit?.(1);
239
+ expect(await promise).toEqual({ code: 1, output: 'Error: address already in use' });
240
+ });
241
+ it('rejects when the docker process fails to spawn', async () => {
242
+ const { proc, handlers } = makeProcess();
243
+ vi.mocked(spawn).mockReturnValue(mockChildProcess(proc));
244
+ const promise = runDockerComposeUpDetached('/path/to/docker-compose.yml');
245
+ handlers.error?.(new Error('spawn ENOENT'));
246
+ await expect(promise).rejects.toThrow('spawn ENOENT');
247
+ });
248
+ });
249
+ describe('resolveDockerComposePath', () => {
250
+ it('resolves a custom path against cwd and returns it when it exists', async () => {
251
+ vi.mocked(fs.access).mockResolvedValue(undefined);
252
+ const result = await resolveDockerComposePath('custom/compose.yml');
253
+ const expected = path.resolve(process.cwd(), 'custom/compose.yml');
254
+ expect(result).toBe(expected);
255
+ expect(fs.access).toHaveBeenCalledWith(expected);
256
+ });
257
+ it('throws DockerComposeConfigNotFoundError when the path does not exist', async () => {
258
+ vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
259
+ await expect(resolveDockerComposePath('missing.yml'))
260
+ .rejects.toBeInstanceOf(DockerComposeConfigNotFoundError);
261
+ });
262
+ it('falls back to the bundled default when no custom path is given', async () => {
263
+ vi.mocked(fs.access).mockResolvedValue(undefined);
264
+ const result = await resolveDockerComposePath();
265
+ expect(result).toBe(getDefaultDockerComposePath());
188
266
  });
189
267
  });
190
268
  describe('DOCKER_SERVICE_NAME wiring', () => {
@@ -259,6 +337,58 @@ describe('docker service', () => {
259
337
  expect(isServiceFailed({ name: 'temporal', state: 'running', health: 'starting', ports: [] })).toBe(false);
260
338
  });
261
339
  });
340
+ describe('classifyStackState', () => {
341
+ const svc = (state, health) => ({ name: 's', state, health, ports: [] });
342
+ it('returns NONE for an empty stack (fresh start)', () => {
343
+ expect(classifyStackState([])).toBe(STACK_STATE.NONE);
344
+ });
345
+ it('returns RUNNING when every service is up and healthy', () => {
346
+ expect(classifyStackState([
347
+ svc('running', 'healthy'),
348
+ svc('running', 'none')
349
+ ])).toBe(STACK_STATE.RUNNING);
350
+ });
351
+ it('returns PARTIAL when any service has failed (orphaned stack)', () => {
352
+ expect(classifyStackState([
353
+ svc('running', 'healthy'),
354
+ svc('exited', 'none')
355
+ ])).toBe(STACK_STATE.PARTIAL);
356
+ });
357
+ it('returns PARTIAL when services exist but some are still coming up', () => {
358
+ expect(classifyStackState([
359
+ svc('running', 'healthy'),
360
+ svc('created', 'none')
361
+ ])).toBe(STACK_STATE.PARTIAL);
362
+ });
363
+ it('treats an unhealthy service as PARTIAL, not RUNNING', () => {
364
+ expect(classifyStackState([
365
+ svc('running', 'healthy'),
366
+ svc('running', 'unhealthy')
367
+ ])).toBe(STACK_STATE.PARTIAL);
368
+ });
369
+ // `ps --all` reports exited containers, so a stack stopped by a reboot, a
370
+ // `docker compose stop`, or a failed teardown still has rows. Nothing is
371
+ // live, so this invocation would be the one starting it — that makes it an
372
+ // owned fresh start, not an attach.
373
+ it('returns NONE when every service is exited — an owned fresh start, not an attach', () => {
374
+ expect(classifyStackState([
375
+ svc('exited', 'none'),
376
+ svc('exited', 'none')
377
+ ])).toBe(STACK_STATE.NONE);
378
+ });
379
+ it('returns NONE when containers are created but none have started', () => {
380
+ expect(classifyStackState([
381
+ svc('created', 'none'),
382
+ svc('created', 'none')
383
+ ])).toBe(STACK_STATE.NONE);
384
+ });
385
+ it('still returns PARTIAL when at least one service is live', () => {
386
+ expect(classifyStackState([
387
+ svc('running', 'healthy'),
388
+ svc('exited', 'none')
389
+ ])).toBe(STACK_STATE.PARTIAL);
390
+ });
391
+ });
262
392
  describe('waitForServicesHealthy', () => {
263
393
  it('should resolve when all services are healthy', async () => {
264
394
  const mockOutput = `{"Service":"redis","State":"running","Health":"healthy","Publishers":[]}
@@ -19,6 +19,8 @@ export interface Span {
19
19
  output?: unknown;
20
20
  }
21
21
  export type HistoryEvent = Record<string, unknown>;
22
+ export declare function eventTypeName(event: HistoryEvent): string;
23
+ export declare function eventAttributes(event?: HistoryEvent): Record<string, unknown> | undefined;
22
24
  /**
23
25
  * @param events - flat Temporal history events, in chronological order
24
26
  * @param workflowStartTimeMs - epoch ms used as the timeline origin (0 offset)
@@ -24,13 +24,13 @@ const CHILD_TERMINAL_TYPES = [
24
24
  'CHILD_WORKFLOW_EXECUTION_TIMED_OUT', 'CHILD_WORKFLOW_EXECUTION_CANCELED',
25
25
  'CHILD_WORKFLOW_EXECUTION_TERMINATED', 'START_CHILD_WORKFLOW_EXECUTION_FAILED'
26
26
  ];
27
- function eventTypeName(event) {
27
+ export function eventTypeName(event) {
28
28
  return event.eventTypeName ?? '';
29
29
  }
30
30
  function eventId(event) {
31
31
  return String(event.eventId);
32
32
  }
33
- function eventAttributes(event) {
33
+ export function eventAttributes(event) {
34
34
  const key = event && Object.keys(event).find(k => k.endsWith('EventAttributes'));
35
35
  return key ? event[key] : undefined;
36
36
  }
@@ -12,12 +12,40 @@ export interface FetchWorkflowHistoryOptions {
12
12
  workflowId: string;
13
13
  runId?: string;
14
14
  includePayloads?: boolean;
15
+ longPollTimeoutMs?: number;
15
16
  }
16
17
  export interface WorkflowHistoryResult {
17
18
  workflow: WorkflowMeta | null;
19
+ rawWorkflow: WorkflowMeta | null;
18
20
  runId: string | null;
19
21
  events: HistoryEvent[];
20
22
  spans: Span[];
21
23
  totalDurationMs: number;
24
+ continuedAsNewRunId: string | null;
25
+ cursor: WorkflowHistoryCursor;
26
+ }
27
+ /**
28
+ * Resume state for `fetchWorkflowHistoryUpdates`: the accumulated events (so spans/duration
29
+ * are always computed over the full history, not just the latest delta), `lastEventId` for
30
+ * de-duping a replayed page, and `pageToken` — the position that fetched the *current* end of
31
+ * history, which is itself always a valid resume point (see `fetchPages`) even though the
32
+ * server's own `nextPageToken` for that position is empty.
33
+ */
34
+ export interface WorkflowHistoryCursor {
35
+ pageToken: string | undefined;
36
+ lastEventId: number;
37
+ meta: WorkflowMeta | null;
38
+ runId: string | undefined;
39
+ events: HistoryEvent[];
22
40
  }
23
41
  export declare function fetchWorkflowHistory(options: FetchWorkflowHistoryOptions): Promise<WorkflowHistoryResult>;
42
+ /**
43
+ * Incremental counterpart to `fetchWorkflowHistory`, for a poller (`workflow monitor`) that
44
+ * calls repeatedly while a workflow is still running. Pass the previous call's `cursor`
45
+ * (from either function's result) to resume from where it left off instead of re-paging the
46
+ * whole history; omit it only to start a completely fresh walk.
47
+ */
48
+ export declare function fetchWorkflowHistoryUpdates(options: FetchWorkflowHistoryOptions, cursor?: WorkflowHistoryCursor): Promise<{
49
+ result: WorkflowHistoryResult;
50
+ cursor: WorkflowHistoryCursor;
51
+ }>;
@@ -8,8 +8,20 @@
8
8
  * the `nextPageToken` (the endpoint requires runId once a pageToken is used).
9
9
  */
10
10
  import { getWorkflowIdHistory } from '#api/generated/api.js';
11
- import { correlate } from '#services/workflow_history/correlator.js';
11
+ import { correlate, eventAttributes, eventTypeName } from '#services/workflow_history/correlator.js';
12
+ import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
13
+ import { TERMINAL_STATUSES } from '#utils/format_workflow_result.js';
12
14
  const PAGE_SIZE = 50;
15
+ // The status here comes from the request's own describe (fresh whenever `wait` is
16
+ // set), so it can report the run closed before the closing event has been paged in.
17
+ function isRunClosed(meta) {
18
+ const status = normalizeWorkflowStatus(meta?.status);
19
+ return status === 'continued_as_new' || (status !== undefined && TERMINAL_STATUSES.has(status));
20
+ }
21
+ function numericEventId(event) {
22
+ const id = Number(event.eventId);
23
+ return Number.isFinite(id) ? id : 0;
24
+ }
13
25
  function toMs(value) {
14
26
  if (!value) {
15
27
  return null;
@@ -37,6 +49,13 @@ function workflowStartMs(meta, events) {
37
49
  }
38
50
  return earliestEventMs(events);
39
51
  }
52
+ // The paginated history endpoint doesn't surface a resolved `newRunId` the way
53
+ // the SSE stream endpoint does (see `stream_history.js`'s `doneChunk`), so pull
54
+ // it directly off the WORKFLOW_EXECUTION_CONTINUED_AS_NEW event when present.
55
+ function continuedAsNewRunId(events) {
56
+ const terminal = events.find(e => eventTypeName(e) === 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW');
57
+ return eventAttributes(terminal)?.newExecutionRunId ?? null;
58
+ }
40
59
  function totalDuration(meta, spans, startMs) {
41
60
  const closeMs = toMs(meta?.closeTime ?? undefined);
42
61
  if (closeMs !== null && startMs !== null && (closeMs - startMs) > 0) {
@@ -45,35 +64,99 @@ function totalDuration(meta, spans, startMs) {
45
64
  const maxEnd = spans.reduce((max, span) => Math.max(max, span.endOffsetMs), 0);
46
65
  return Math.max(maxEnd, 1);
47
66
  }
48
- async function fetchAllPages(workflowId, includePayloads, runId, pageToken, acc) {
49
- const response = await getWorkflowIdHistory(workflowId, { runId, pageSize: PAGE_SIZE, pageToken, includePayloads });
67
+ /**
68
+ * Pages through history starting from `acc` (its `pageToken`/`lastEventId`/`events` carry
69
+ * the resume position — pass a zeroed cursor for a fresh walk). When `longPollTimeoutMs` is
70
+ * set, every request asks the server to long-poll (`waitNewEvent`) rather than return
71
+ * immediately, so the final hop blocks — up to that many milliseconds (clamped to the server's
72
+ * ceiling) — until either a new event exists or the deadline elapses. `lastEventId` de-dupes:
73
+ * resuming from a previously-seen page token replays that page's events, which are filtered out
74
+ * here rather than appended twice.
75
+ */
76
+ async function fetchPages(workflowId, includePayloads, acc, longPollTimeoutMs) {
77
+ const wait = longPollTimeoutMs !== undefined && longPollTimeoutMs > 0;
78
+ const { pageToken, runId } = acc;
79
+ const response = await getWorkflowIdHistory(workflowId, {
80
+ runId, pageSize: PAGE_SIZE, pageToken, includePayloads,
81
+ ...(wait ? { longPollTimeoutMs } : {})
82
+ });
50
83
  if (!response.data) {
51
84
  throw new Error('API returned invalid response (missing data)');
52
85
  }
53
86
  const data = response.data;
54
87
  // The generated `data.workflow` is an opaque `{ [key: string]: unknown }`, so
55
88
  // narrow it to WorkflowMeta via `unknown` (its real fields are validated by
56
- // the server, mirroring Atlas's metadata shape).
57
- const meta = acc.meta ?? data.workflow ?? null;
89
+ // the server, mirroring Atlas's metadata shape). Prefer the *fresh* value when the
90
+ // server sent one it re-describes on every `wait` call specifically so status
91
+ // updates (e.g. running -> completed) are seen; falling back to `acc.meta` only
92
+ // covers the pages within a walk where the server didn't re-describe.
93
+ const meta = data.workflow ?? acc.meta;
58
94
  const resolvedRunId = runId ?? data.runId ?? acc.runId;
59
- const events = [...acc.events, ...(data.events ?? [])];
95
+ const pageEvents = data.events ?? [];
96
+ const newEvents = pageEvents.filter(event => numericEventId(event) > acc.lastEventId);
97
+ const events = [...acc.events, ...newEvents];
98
+ // Events arrive in increasing eventId order, so the last new one is the max — no scan needed.
99
+ const lastEventId = newEvents.length > 0 ? numericEventId(newEvents[newEvents.length - 1]) : acc.lastEventId;
60
100
  const nextToken = data.nextPageToken ?? undefined;
61
- const nextAcc = { meta, runId: resolvedRunId, events };
101
+ const nextAcc = { meta, runId: resolvedRunId, events, lastEventId, pageToken: nextToken ?? pageToken };
102
+ // While long-polling an open run, stop and hand back the first batch of new events
103
+ // instead of draining further pages — a poller needs each transition rendered as it
104
+ // arrives, and the buffered remainder will surface on subsequent ticks. Once the run
105
+ // is closed, though, no further ticks are coming: the poller acts on the closed
106
+ // status immediately, so stopping early would strand the trailing pages — including
107
+ // the terminal or CONTINUED_AS_NEW event — unfetched. Drain to the tip instead.
108
+ if (wait && newEvents.length > 0 && !isRunClosed(meta)) {
109
+ return nextAcc;
110
+ }
111
+ // The server echoes `pageToken` back unchanged (see `get_history.js`) when a waitNewEvent
112
+ // call's deadline elapses with nothing new — that's the tip, stop for this tick.
113
+ const timedOut = wait && nextToken === pageToken;
114
+ if (timedOut) {
115
+ return nextAcc;
116
+ }
62
117
  if (nextToken) {
63
- return fetchAllPages(workflowId, includePayloads, resolvedRunId, nextToken, nextAcc);
118
+ return fetchPages(workflowId, includePayloads, nextAcc, longPollTimeoutMs);
64
119
  }
120
+ // Drained: the server has nothing more buffered (`nextToken` is empty), but unlike
121
+ // `nextToken`, `pageToken` — the position that fetched this now-empty page — is still a
122
+ // valid resume point: a future waitNewEvent call from here replays this page (de-duped by
123
+ // `lastEventId`) and then genuinely waits at the tip, instead of restarting from page 1.
65
124
  return nextAcc;
66
125
  }
67
- export async function fetchWorkflowHistory(options) {
68
- const { workflowId, runId, includePayloads = false } = options;
69
- const { meta, runId: resolvedRunId, events } = await fetchAllPages(workflowId, includePayloads, runId, undefined, { meta: null, runId, events: [] });
126
+ function buildResult(pages) {
127
+ const { meta: rawMeta, runId: resolvedRunId, events } = pages;
128
+ // Normalize once here so every consumer (monitor, history, etc.) sees the
129
+ // same status vocabulary, matching status.ts/workflow_runs.ts/etc.
130
+ const status = normalizeWorkflowStatus(rawMeta?.status);
131
+ const meta = rawMeta ? { ...rawMeta, status } : rawMeta;
70
132
  const startMs = workflowStartMs(meta, events);
71
133
  const spans = correlate(events, startMs);
72
134
  return {
73
135
  workflow: meta,
136
+ rawWorkflow: rawMeta,
74
137
  runId: resolvedRunId ?? meta?.runId ?? null,
75
138
  events,
76
139
  spans,
77
- totalDurationMs: totalDuration(meta, spans, startMs)
140
+ totalDurationMs: totalDuration(meta, spans, startMs),
141
+ continuedAsNewRunId: continuedAsNewRunId(events),
142
+ cursor: pages
78
143
  };
79
144
  }
145
+ export async function fetchWorkflowHistory(options) {
146
+ const { workflowId, runId, includePayloads = false } = options;
147
+ const pages = await fetchPages(workflowId, includePayloads, { meta: null, runId, events: [], lastEventId: 0, pageToken: undefined });
148
+ return buildResult(pages);
149
+ }
150
+ /**
151
+ * Incremental counterpart to `fetchWorkflowHistory`, for a poller (`workflow monitor`) that
152
+ * calls repeatedly while a workflow is still running. Pass the previous call's `cursor`
153
+ * (from either function's result) to resume from where it left off instead of re-paging the
154
+ * whole history; omit it only to start a completely fresh walk.
155
+ */
156
+ export async function fetchWorkflowHistoryUpdates(options, cursor) {
157
+ const { workflowId, includePayloads = false, longPollTimeoutMs } = options;
158
+ const seed = cursor ??
159
+ { meta: null, runId: options.runId, events: [], lastEventId: 0, pageToken: undefined };
160
+ const pages = await fetchPages(workflowId, includePayloads, seed, longPollTimeoutMs);
161
+ return { result: buildResult(pages), cursor: pages };
162
+ }