@outputai/cli 0.10.1-dev.b7b2fbe.0 → 0.10.1-next.1070949.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 +93 -26
- package/dist/api/generated/api.js +7 -4
- package/dist/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +2 -2
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.js +71 -0
- package/dist/commands/dev/index.d.ts +4 -0
- package/dist/commands/dev/index.js +200 -53
- package/dist/commands/dev/index.spec.js +390 -42
- package/dist/commands/workflow/history.js +3 -3
- package/dist/commands/workflow/history.spec.js +31 -2
- package/dist/commands/workflow/monitor.d.ts +49 -0
- package/dist/commands/workflow/monitor.js +230 -0
- package/dist/commands/workflow/monitor.spec.d.ts +1 -0
- package/dist/commands/workflow/monitor.spec.js +243 -0
- package/dist/commands/workflow/result.js +2 -2
- package/dist/commands/workflow/result.spec.js +65 -1
- package/dist/commands/workflow/run.js +10 -3
- package/dist/commands/workflow/run.spec.js +42 -5
- package/dist/commands/workflow/start.d.ts +3 -1
- package/dist/commands/workflow/start.js +12 -2
- package/dist/commands/workflow/start.spec.js +30 -5
- package/dist/commands/workflow/status.spec.js +1 -1
- package/dist/commands/workflow/{test_eval.d.ts → test.d.ts} +0 -1
- package/dist/commands/workflow/{test_eval.js → test.js} +0 -1
- package/dist/commands/workflow/test.spec.d.ts +1 -0
- package/dist/commands/workflow/{test_eval.spec.js → test.spec.js} +4 -4
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/docker.d.ts +28 -1
- package/dist/services/docker.js +106 -12
- package/dist/services/docker.spec.js +144 -14
- package/dist/services/workflow_history/correlator.d.ts +2 -0
- package/dist/services/workflow_history/correlator.js +2 -2
- package/dist/services/workflow_history.d.ts +28 -0
- package/dist/services/workflow_history.js +95 -12
- package/dist/services/workflow_history.spec.js +183 -1
- package/dist/templates/agent_instructions/CLAUDE.md.template +5 -3
- package/dist/templates/project/README.md.template +3 -1
- package/dist/templates/project/package.json.template +2 -2
- package/dist/templates/project/src/clients/jina.ts.template +4 -4
- package/dist/utils/color.d.ts +7 -0
- package/dist/utils/color.js +12 -0
- package/dist/utils/color.spec.d.ts +1 -0
- package/dist/utils/color.spec.js +43 -0
- package/dist/utils/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- package/dist/utils/format_workflow_result.d.ts +4 -2
- package/dist/utils/format_workflow_result.js +10 -2
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/monitor_log.d.ts +20 -0
- package/dist/utils/monitor_log.js +48 -0
- package/dist/utils/monitor_log.spec.d.ts +1 -0
- package/dist/utils/monitor_log.spec.js +71 -0
- package/dist/utils/normalize_workflow_status.d.ts +4 -3
- package/dist/utils/normalize_workflow_status.js +12 -3
- package/dist/utils/normalize_workflow_status.spec.js +3 -0
- package/dist/utils/port_collision.d.ts +22 -7
- package/dist/utils/port_collision.js +39 -14
- package/dist/utils/port_collision.spec.js +40 -1
- package/dist/utils/resolve_input.d.ts +9 -1
- package/dist/utils/resolve_input.js +8 -2
- package/dist/utils/resolve_input.spec.d.ts +1 -0
- package/dist/utils/resolve_input.spec.js +75 -0
- package/dist/utils/waterfall.d.ts +3 -1
- package/dist/utils/waterfall.js +8 -2
- package/dist/views/dev/chrome/footer.d.ts +2 -0
- package/dist/views/dev/chrome/footer.js +4 -4
- package/dist/views/dev/components/workflow_status.js +1 -1
- package/dist/views/dev/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- package/dist/views/dev/hooks/use_run_detail.js +8 -9
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/hooks/use_step_graph.js +3 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/dist/views/dev/utils/bounded_cache.d.ts +14 -0
- package/dist/views/dev/utils/bounded_cache.js +42 -0
- package/dist/views/dev/utils/bounded_cache.spec.d.ts +1 -0
- package/dist/views/dev/utils/bounded_cache.spec.js +53 -0
- package/oclif.manifest.json +126 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
|
@@ -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
|
|
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('
|
|
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('
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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({
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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({
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
68
|
-
const {
|
|
69
|
-
|
|
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { getWorkflowIdHistory } from '#api/generated/api.js';
|
|
3
|
-
import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
3
|
+
import { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } from '#services/workflow_history.js';
|
|
4
4
|
vi.mock('#api/generated/api.js', () => ({ getWorkflowIdHistory: vi.fn() }));
|
|
5
5
|
// The real correlator runs (only the API client is mocked), so spans are derived
|
|
6
6
|
// from the events below — letting us assert duration/offset behaviour end to end.
|
|
@@ -48,6 +48,39 @@ describe('fetchWorkflowHistory', () => {
|
|
|
48
48
|
expect(result.totalDurationMs).toBe(300_000); // closeTime - startTime
|
|
49
49
|
expect(result.spans).toHaveLength(1);
|
|
50
50
|
expect(result.spans[0].durationMs).toBe(27_000);
|
|
51
|
+
// Even though the server's own nextPageToken for the final page is empty (nothing more
|
|
52
|
+
// buffered), the cursor keeps the *request* token that reached it — a genuinely resumable
|
|
53
|
+
// position for a follow-up fetchWorkflowHistoryUpdates call. See fetchPages.
|
|
54
|
+
expect(result.cursor.pageToken).toBe('token-2');
|
|
55
|
+
});
|
|
56
|
+
it('a follow-up fetchWorkflowHistoryUpdates call resumes from fetchWorkflowHistory\'s cursor instead of re-paging from page 1', async () => {
|
|
57
|
+
mockGet
|
|
58
|
+
.mockResolvedValueOnce(page({
|
|
59
|
+
workflow: { workflowId: 'wf-123', runId: 'run-456', status: 'running', startTime: at(0) },
|
|
60
|
+
runId: 'run-456',
|
|
61
|
+
events: [workflowStarted(0), scheduled('2', 'wf#step', 0)],
|
|
62
|
+
nextPageToken: 'token-2'
|
|
63
|
+
}))
|
|
64
|
+
.mockResolvedValueOnce(page({
|
|
65
|
+
workflow: null,
|
|
66
|
+
runId: 'run-456',
|
|
67
|
+
events: [started('3', '2', 0)],
|
|
68
|
+
nextPageToken: null
|
|
69
|
+
}));
|
|
70
|
+
const first = await fetchWorkflowHistory({ workflowId: 'wf-123' });
|
|
71
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
72
|
+
// The next tick replays the last page (deduped) then finds nothing new and times out —
|
|
73
|
+
// one call, not a full re-walk from page 1 through both prior pages again.
|
|
74
|
+
mockGet.mockResolvedValueOnce(page({
|
|
75
|
+
workflow: null, runId: 'run-456', events: [], nextPageToken: 'token-2'
|
|
76
|
+
}));
|
|
77
|
+
const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-123', runId: 'run-456', longPollTimeoutMs: 2500 }, first.cursor);
|
|
78
|
+
expect(mockGet).toHaveBeenCalledTimes(3);
|
|
79
|
+
expect(mockGet).toHaveBeenNthCalledWith(3, 'wf-123', {
|
|
80
|
+
runId: 'run-456', pageSize: 50, pageToken: 'token-2', includePayloads: false, longPollTimeoutMs: 2500
|
|
81
|
+
});
|
|
82
|
+
expect(result.events).toHaveLength(3);
|
|
83
|
+
expect(cursor.pageToken).toBe('token-2');
|
|
51
84
|
});
|
|
52
85
|
it('forwards an explicit runId and includePayloads, stopping after a single page', async () => {
|
|
53
86
|
mockGet.mockResolvedValueOnce(page({
|
|
@@ -84,4 +117,153 @@ describe('fetchWorkflowHistory', () => {
|
|
|
84
117
|
mockGet.mockResolvedValueOnce({ status: 200 });
|
|
85
118
|
await expect(fetchWorkflowHistory({ workflowId: 'wf-x' })).rejects.toThrow(/invalid response/);
|
|
86
119
|
});
|
|
120
|
+
it('extracts the chained run id from a WORKFLOW_EXECUTION_CONTINUED_AS_NEW event', async () => {
|
|
121
|
+
mockGet.mockResolvedValueOnce(page({
|
|
122
|
+
workflow: { workflowId: 'wf-4', runId: 'run-4', status: 'continued_as_new', startTime: at(0) },
|
|
123
|
+
runId: 'run-4',
|
|
124
|
+
events: [
|
|
125
|
+
workflowStarted(0),
|
|
126
|
+
{
|
|
127
|
+
eventId: '9', eventTypeName: 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW', eventTime: at(60),
|
|
128
|
+
workflowExecutionContinuedAsNewEventAttributes: { newExecutionRunId: 'run-5' }
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
nextPageToken: null
|
|
132
|
+
}));
|
|
133
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-4' });
|
|
134
|
+
expect(result.continuedAsNewRunId).toBe('run-5');
|
|
135
|
+
});
|
|
136
|
+
it('returns null continuedAsNewRunId when there is no continue-as-new event', async () => {
|
|
137
|
+
mockGet.mockResolvedValueOnce(page({
|
|
138
|
+
workflow: { workflowId: 'wf-5', runId: 'run-5', status: 'completed', startTime: at(0), closeTime: at(1) },
|
|
139
|
+
runId: 'run-5', events: [workflowStarted(0)], nextPageToken: null
|
|
140
|
+
}));
|
|
141
|
+
const result = await fetchWorkflowHistory({ workflowId: 'wf-5' });
|
|
142
|
+
expect(result.continuedAsNewRunId).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('fetchWorkflowHistoryUpdates', () => {
|
|
146
|
+
it('stops as soon as the first hop finds new events, without draining further buffered pages', async () => {
|
|
147
|
+
// Two pages already exist beyond the resume point; the old behavior kept paging until a
|
|
148
|
+
// timeout, which could silently merge several transitions (even a terminal one) into a
|
|
149
|
+
// single delayed render. A poller needs each batch back as soon as it has something new.
|
|
150
|
+
mockGet.mockResolvedValueOnce(page({
|
|
151
|
+
workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
|
|
152
|
+
runId: 'run-7',
|
|
153
|
+
events: [workflowStarted(0)],
|
|
154
|
+
nextPageToken: 'page-2'
|
|
155
|
+
}));
|
|
156
|
+
const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7', longPollTimeoutMs: 2500 });
|
|
157
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
158
|
+
expect(mockGet).toHaveBeenCalledWith('wf-7', {
|
|
159
|
+
runId: 'run-7', pageSize: 50, pageToken: undefined, includePayloads: false, longPollTimeoutMs: 2500
|
|
160
|
+
});
|
|
161
|
+
expect(result.events).toHaveLength(1);
|
|
162
|
+
expect(result.workflow?.status).toBe('running');
|
|
163
|
+
// Resumes from the still-unfetched page 2 next time, not from the start.
|
|
164
|
+
expect(cursor.pageToken).toBe('page-2');
|
|
165
|
+
expect(cursor.lastEventId).toBe(1);
|
|
166
|
+
});
|
|
167
|
+
it('drains buffered pages when the describe already reports the run closed, so the terminal events are not stranded', async () => {
|
|
168
|
+
// >1 page of events accumulated between polls, then the run completed: the fresh
|
|
169
|
+
// describe says 'completed' before the closing events have been paged in. Stopping
|
|
170
|
+
// after the first batch would make the poller act on the terminal status with the
|
|
171
|
+
// final steps' events unfetched.
|
|
172
|
+
mockGet
|
|
173
|
+
.mockResolvedValueOnce(page({
|
|
174
|
+
workflow: { workflowId: 'wf-8', runId: 'run-8', status: 'completed', startTime: at(0), closeTime: at(30) },
|
|
175
|
+
runId: 'run-8',
|
|
176
|
+
events: [workflowStarted(0), scheduled('2', 'wf#step', 0)],
|
|
177
|
+
nextPageToken: 'page-2'
|
|
178
|
+
}))
|
|
179
|
+
.mockResolvedValueOnce(page({
|
|
180
|
+
workflow: null,
|
|
181
|
+
runId: 'run-8',
|
|
182
|
+
events: [started('3', '2', 1), completed('4', '2', 30)],
|
|
183
|
+
nextPageToken: null
|
|
184
|
+
}));
|
|
185
|
+
const { result } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-8', runId: 'run-8' });
|
|
186
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
187
|
+
expect(result.events).toHaveLength(4);
|
|
188
|
+
expect(result.spans).toHaveLength(1);
|
|
189
|
+
expect(result.spans[0].status).toBe('completed');
|
|
190
|
+
});
|
|
191
|
+
it('drains to the CONTINUED_AS_NEW event on a later page when the describe already reports continued_as_new', async () => {
|
|
192
|
+
mockGet
|
|
193
|
+
.mockResolvedValueOnce(page({
|
|
194
|
+
workflow: { workflowId: 'wf-9', runId: 'run-9', status: 'continued_as_new', startTime: at(0) },
|
|
195
|
+
runId: 'run-9',
|
|
196
|
+
events: [workflowStarted(0)],
|
|
197
|
+
nextPageToken: 'page-2'
|
|
198
|
+
}))
|
|
199
|
+
.mockResolvedValueOnce(page({
|
|
200
|
+
workflow: null,
|
|
201
|
+
runId: 'run-9',
|
|
202
|
+
events: [{
|
|
203
|
+
eventId: '2', eventTypeName: 'WORKFLOW_EXECUTION_CONTINUED_AS_NEW', eventTime: at(60),
|
|
204
|
+
workflowExecutionContinuedAsNewEventAttributes: { newExecutionRunId: 'run-10' }
|
|
205
|
+
}],
|
|
206
|
+
nextPageToken: null
|
|
207
|
+
}));
|
|
208
|
+
const { result } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-9', runId: 'run-9' });
|
|
209
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
210
|
+
expect(result.continuedAsNewRunId).toBe('run-10');
|
|
211
|
+
});
|
|
212
|
+
it('forwards longPollTimeoutMs to the API on a resumed poll', async () => {
|
|
213
|
+
mockGet.mockResolvedValueOnce(page({
|
|
214
|
+
workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
|
|
215
|
+
runId: 'run-7', events: [], nextPageToken: null
|
|
216
|
+
}));
|
|
217
|
+
await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7', longPollTimeoutMs: 2500 });
|
|
218
|
+
expect(mockGet).toHaveBeenCalledWith('wf-7', {
|
|
219
|
+
runId: 'run-7', pageSize: 50, pageToken: undefined, includePayloads: false, longPollTimeoutMs: 2500
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
it('sends no long-poll param when longPollTimeoutMs is not provided', async () => {
|
|
223
|
+
mockGet.mockResolvedValueOnce(page({
|
|
224
|
+
workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
|
|
225
|
+
runId: 'run-7', events: [], nextPageToken: null
|
|
226
|
+
}));
|
|
227
|
+
await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7' });
|
|
228
|
+
expect(mockGet).toHaveBeenCalledWith('wf-7', expect.not.objectContaining({ longPollTimeoutMs: expect.anything() }));
|
|
229
|
+
});
|
|
230
|
+
it('times out with the sent token echoed back when there is genuinely nothing new', async () => {
|
|
231
|
+
mockGet.mockResolvedValueOnce(page({
|
|
232
|
+
workflow: { workflowId: 'wf-7', runId: 'run-7', status: 'running', startTime: at(0) },
|
|
233
|
+
runId: 'run-7',
|
|
234
|
+
events: [],
|
|
235
|
+
nextPageToken: null
|
|
236
|
+
}));
|
|
237
|
+
const { result, cursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-7', runId: 'run-7' });
|
|
238
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(result.events).toHaveLength(0);
|
|
240
|
+
expect(cursor.pageToken).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
it('resumes from a prior cursor and picks up the workflow status finishing, not the status frozen on the cursor', async () => {
|
|
243
|
+
const cursor = {
|
|
244
|
+
pageToken: 'token-2',
|
|
245
|
+
lastEventId: 2,
|
|
246
|
+
meta: { workflowId: 'wf-6', runId: 'run-6', status: 'running', startTime: at(0) },
|
|
247
|
+
runId: 'run-6',
|
|
248
|
+
events: [workflowStarted(0), scheduled('2', 'wf#step', 0)]
|
|
249
|
+
};
|
|
250
|
+
mockGet.mockResolvedValueOnce(page({
|
|
251
|
+
// The server re-describes on every `wait` call specifically so a resumed poll can see
|
|
252
|
+
// status changes — this must win over the (now-stale) status carried on the cursor.
|
|
253
|
+
workflow: { workflowId: 'wf-6', runId: 'run-6', status: 'completed', startTime: at(0), closeTime: at(5) },
|
|
254
|
+
runId: 'run-6',
|
|
255
|
+
events: [started('3', '2', 1), completed('4', '2', 5)],
|
|
256
|
+
nextPageToken: null
|
|
257
|
+
}));
|
|
258
|
+
const { result, cursor: nextCursor } = await fetchWorkflowHistoryUpdates({ workflowId: 'wf-6', runId: 'run-6', longPollTimeoutMs: 2500 }, cursor);
|
|
259
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(mockGet).toHaveBeenCalledWith('wf-6', {
|
|
261
|
+
runId: 'run-6', pageSize: 50, pageToken: 'token-2', includePayloads: false, longPollTimeoutMs: 2500
|
|
262
|
+
});
|
|
263
|
+
expect(result.workflow?.status).toBe('completed');
|
|
264
|
+
// The events already carried on the cursor (2) plus the genuinely new ones (2).
|
|
265
|
+
expect(result.events).toHaveLength(4);
|
|
266
|
+
expect(nextCursor.pageToken).toBe('token-2');
|
|
267
|
+
expect(nextCursor.lastEventId).toBe(4);
|
|
268
|
+
});
|
|
87
269
|
});
|
|
@@ -17,10 +17,12 @@ claude plugin install outputai@outputai --scope project
|
|
|
17
17
|
npm run output:dev # Start dev environment (worker + Temporal)
|
|
18
18
|
npm run output:worker:build # Build TypeScript to dist/
|
|
19
19
|
npm run output:worker:check # Optional: bundle-check workflows for bad imports (node: built-ins)
|
|
20
|
-
npm run output:worker:watch # Build + restart on file changes
|
|
21
|
-
npm run output:worker #
|
|
20
|
+
npm run output:worker:watch # Build + restart on src/ file changes
|
|
21
|
+
npm run output:worker # Build and start worker
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Hot-reload watches `src/` only. After changing dependencies (`package.json` / lockfile), run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
25
|
+
|
|
24
26
|
## Project Structure
|
|
25
27
|
|
|
26
28
|
```
|
|
@@ -44,7 +46,7 @@ config/
|
|
|
44
46
|
## Key Conventions
|
|
45
47
|
|
|
46
48
|
- **Workflows are deterministic**: No I/O, no `Date.now()`, no `Math.random()` in `workflow.ts`. All side effects go in steps or evaluators.
|
|
47
|
-
- **HTTP clients**:
|
|
49
|
+
- **HTTP clients**: Use `outputFetch` or `createKyClient` from `@outputai/http` instead of raw `fetch` or `axios`. Requests are automatically traced; use `addRequestCost` when cost tracking is needed.
|
|
48
50
|
- **LLM calls**: Use `generateText` from `@outputai/llm` with `.prompt` files. Never call LLM APIs directly.
|
|
49
51
|
|
|
50
52
|
---
|
|
@@ -79,7 +79,9 @@ This starts:
|
|
|
79
79
|
- Temporal server and UI (http://localhost:8080)
|
|
80
80
|
- PostgreSQL and Redis databases
|
|
81
81
|
- Output.ai API server (http://localhost:3001)
|
|
82
|
-
- Worker process for executing workflows
|
|
82
|
+
- Worker process for executing workflows (auto-reloads on `src/` changes)
|
|
83
|
+
|
|
84
|
+
Dependency changes (`package.json` / lockfile) are not picked up by hot-reload. Run `npm install`, then `npx output dev down` and `npm run output:dev` again so the worker container reinstalls. A hot-reload alone is not enough; if the stack is still running, `npx output dev down` is required.
|
|
83
85
|
|
|
84
86
|
### 4. Run a workflow
|
|
85
87
|
|