@outputai/cli 0.10.1-next.37650c5.0 → 0.10.1-next.3c76007.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 +12 -0
- package/dist/api/http_client.js +2 -2
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/dev/down.d.ts +10 -0
- package/dist/commands/dev/down.js +34 -0
- package/dist/commands/dev/down.spec.d.ts +1 -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/run.js +8 -1
- package/dist/commands/workflow/run.spec.js +12 -2
- 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/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 +1 -0
- package/dist/utils/format_workflow_result.js +4 -0
- 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/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/dev_app.d.ts +1 -0
- package/dist/views/dev/dev_app.js +13 -4
- package/dist/views/dev/hooks/use_run_detail.js +7 -8
- 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 +122 -4
- package/package.json +7 -9
|
@@ -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
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"output:worker:build": "rm -rf dist/* && tsc -p ./ && output-copy-assets",
|
|
10
10
|
"output:worker:start": "output-worker",
|
|
11
11
|
"output:worker:check": "output-worker --check",
|
|
12
|
-
"output:worker": "npm run output:worker:
|
|
13
|
-
"output:worker:watch": "npx nodemon --watch src --
|
|
12
|
+
"output:worker": "npm run output:worker:build && npm run output:worker:start",
|
|
13
|
+
"output:worker:watch": "npx nodemon --watch src --ext ts,js,json,prompt,md --ignore 'dist/**' --ignore '**/*.spec.*' --ignore '**/*.test.*' --exec 'npm run output:worker'",
|
|
14
14
|
"output:dev": "output dev"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createKyClient } from '@outputai/http';
|
|
2
2
|
|
|
3
3
|
export interface JinaReaderResponse {
|
|
4
4
|
code: number;
|
|
@@ -12,13 +12,13 @@ export interface JinaReaderResponse {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const client = createKyClient( {
|
|
16
|
+
prefix: 'https://r.jina.ai',
|
|
17
17
|
timeout: 30000
|
|
18
18
|
} );
|
|
19
19
|
|
|
20
20
|
export async function fetchBlogContent( url: string ): Promise<JinaReaderResponse> {
|
|
21
|
-
const response = await
|
|
21
|
+
const response = await client.post( '', {
|
|
22
22
|
json: { url },
|
|
23
23
|
headers: {
|
|
24
24
|
'Accept': 'application/json',
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard TTY/env precedence for whether a command should colorize its
|
|
3
|
+
* output: the command's own --color/--no-color flag wins first, then
|
|
4
|
+
* NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
|
|
5
|
+
* fall back to whether stdout is an interactive terminal.
|
|
6
|
+
*/
|
|
7
|
+
export declare function shouldColorize(flag: boolean): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard TTY/env precedence for whether a command should colorize its
|
|
3
|
+
* output: the command's own --color/--no-color flag wins first, then
|
|
4
|
+
* NO_COLOR opts out, then FORCE_COLOR opts in even off a TTY, then finally
|
|
5
|
+
* fall back to whether stdout is an interactive terminal.
|
|
6
|
+
*/
|
|
7
|
+
export function shouldColorize(flag) {
|
|
8
|
+
// Per the NO_COLOR convention (https://no-color.org/), presence disables color
|
|
9
|
+
// regardless of value — `NO_COLOR=` must opt out just like `NO_COLOR=1`.
|
|
10
|
+
return flag && process.env.NO_COLOR === undefined &&
|
|
11
|
+
(!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { shouldColorize } from '#utils/color.js';
|
|
3
|
+
describe('shouldColorize', () => {
|
|
4
|
+
const originalEnv = { ...process.env };
|
|
5
|
+
const originalTTY = process.stdout.isTTY;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
delete process.env.NO_COLOR;
|
|
8
|
+
delete process.env.FORCE_COLOR;
|
|
9
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
process.env = { ...originalEnv };
|
|
13
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalTTY, configurable: true });
|
|
14
|
+
});
|
|
15
|
+
it('returns false when the flag itself is false', () => {
|
|
16
|
+
expect(shouldColorize(false)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it('returns true on a TTY with no overrides', () => {
|
|
19
|
+
expect(shouldColorize(true)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('disables color when NO_COLOR is set to a non-empty value', () => {
|
|
22
|
+
process.env.NO_COLOR = '1';
|
|
23
|
+
expect(shouldColorize(true)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('disables color when NO_COLOR is present but empty, per the NO_COLOR convention', () => {
|
|
26
|
+
process.env.NO_COLOR = '';
|
|
27
|
+
expect(shouldColorize(true)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('enables color off a TTY when FORCE_COLOR is set', () => {
|
|
30
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
31
|
+
process.env.FORCE_COLOR = '1';
|
|
32
|
+
expect(shouldColorize(true)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('disables color off a TTY with no FORCE_COLOR', () => {
|
|
35
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
36
|
+
expect(shouldColorize(true)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('NO_COLOR wins even when FORCE_COLOR is also set', () => {
|
|
39
|
+
process.env.FORCE_COLOR = '1';
|
|
40
|
+
process.env.NO_COLOR = '1';
|
|
41
|
+
expect(shouldColorize(true)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/utils/env_loader.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
-
import * as dotenv from 'dotenv';
|
|
9
8
|
import debugFactory from 'debug';
|
|
10
9
|
const debug = debugFactory('output-cli:env-loader');
|
|
11
10
|
export function loadEnvironment() {
|
|
@@ -17,5 +16,10 @@ export function loadEnvironment() {
|
|
|
17
16
|
return;
|
|
18
17
|
}
|
|
19
18
|
debug(`Loading env from: ${envPath}`);
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
process.loadEnvFile(envPath);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
debug(`Warning: Failed to load env file ${envPath}: ${err}`);
|
|
24
|
+
}
|
|
21
25
|
}
|
|
@@ -1,43 +1,72 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
|
-
import * as dotenv from 'dotenv';
|
|
8
|
-
vi.mock('node:fs');
|
|
9
|
-
vi.mock('dotenv');
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { loadEnvironment } from './env_loader.js';
|
|
10
6
|
describe('loadEnvironment', () => {
|
|
11
|
-
const
|
|
12
|
-
const mockCwd = '/mock/project';
|
|
7
|
+
const mockCwd = mkdtempSync(join(tmpdir(), 'output-env-loader-'));
|
|
13
8
|
beforeEach(() => {
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
for (const name of readdirSync(mockCwd)) {
|
|
10
|
+
rmSync(join(mockCwd, name), { recursive: true, force: true });
|
|
11
|
+
}
|
|
16
12
|
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
|
17
|
-
vi.
|
|
18
|
-
vi.
|
|
13
|
+
vi.stubEnv('OUTPUT_CLI_ENV', undefined);
|
|
14
|
+
vi.stubEnv('OUTPUT_API_URL', undefined);
|
|
15
|
+
vi.stubEnv('OUTPUT_API_TOKEN', undefined);
|
|
19
16
|
});
|
|
20
17
|
afterEach(() => {
|
|
21
|
-
process.env = { ...originalEnv };
|
|
22
18
|
vi.restoreAllMocks();
|
|
19
|
+
vi.unstubAllEnvs();
|
|
23
20
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(mockCwd, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
it('loads variables from OUTPUT_CLI_ENV', () => {
|
|
25
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
26
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
27
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
28
|
+
].join('\n'));
|
|
29
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
30
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
31
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
32
|
+
].join('\n'));
|
|
33
|
+
process.env.OUTPUT_CLI_ENV = '.env.mock';
|
|
34
|
+
loadEnvironment();
|
|
35
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://mock.api.com');
|
|
36
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('mock-token');
|
|
37
|
+
});
|
|
38
|
+
it('loads variables from .env by default', () => {
|
|
39
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
40
|
+
'OUTPUT_API_URL=https://default.api.com',
|
|
41
|
+
'OUTPUT_API_TOKEN=default-token'
|
|
42
|
+
].join('\n'));
|
|
43
|
+
writeFileSync(join(mockCwd, '.env.mock'), [
|
|
44
|
+
'OUTPUT_API_URL=https://mock.api.com',
|
|
45
|
+
'OUTPUT_API_TOKEN=mock-token'
|
|
46
|
+
].join('\n'));
|
|
30
47
|
loadEnvironment();
|
|
31
|
-
expect(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://default.api.com');
|
|
49
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('default-token');
|
|
50
|
+
});
|
|
51
|
+
it('does nothing when the env file is missing', () => {
|
|
52
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
53
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
54
|
+
expect(process.env.OUTPUT_API_TOKEN).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
it('does not throw when the env path is not a readable file', () => {
|
|
57
|
+
mkdirSync(join(mockCwd, 'not-a-file.env'));
|
|
58
|
+
process.env.OUTPUT_CLI_ENV = 'not-a-file.env';
|
|
59
|
+
expect(() => loadEnvironment()).not.toThrow();
|
|
60
|
+
expect(process.env.OUTPUT_API_URL).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
it('does not overwrite already-set process.env values', () => {
|
|
63
|
+
vi.stubEnv('OUTPUT_API_URL', 'https://ambient.api.com');
|
|
64
|
+
writeFileSync(join(mockCwd, '.env'), [
|
|
65
|
+
'OUTPUT_API_URL=https://file.api.com',
|
|
66
|
+
'OUTPUT_API_TOKEN=file-token'
|
|
67
|
+
].join('\n'));
|
|
39
68
|
loadEnvironment();
|
|
40
|
-
expect(
|
|
41
|
-
expect(
|
|
69
|
+
expect(process.env.OUTPUT_API_URL).toBe('https://ambient.api.com');
|
|
70
|
+
expect(process.env.OUTPUT_API_TOKEN).toBe('file-token');
|
|
42
71
|
});
|
|
43
72
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { WorkflowResultResponse, WorkflowResultResponseStatus } from '../api/generated/api.js';
|
|
2
2
|
type WorkflowResult = Pick<WorkflowResultResponse, 'workflowId' | 'output' | 'status' | 'error'>;
|
|
3
3
|
export declare const ERROR_STATUSES: ReadonlySet<WorkflowResultResponseStatus | undefined>;
|
|
4
|
+
export declare const TERMINAL_STATUSES: ReadonlySet<string>;
|
|
4
5
|
export declare function formatWorkflowResult(result: WorkflowResult): string;
|
|
5
6
|
export {};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { normalizeWorkflowStatus } from './normalize_workflow_status.js';
|
|
2
2
|
export const ERROR_STATUSES = new Set(['failed', 'canceled', 'terminated', 'timed_out']);
|
|
3
|
+
// Every error status plus the one success status — derived so the two sets can't
|
|
4
|
+
// silently drift apart as error statuses evolve. Shared by `workflow monitor` and
|
|
5
|
+
// the dev TUI's `useRunDetail`/`useStepGraph` so both agree on what "done" means.
|
|
6
|
+
export const TERMINAL_STATUSES = new Set(['completed', ...ERROR_STATUSES]);
|
|
3
7
|
export function formatWorkflowResult(result) {
|
|
4
8
|
const status = normalizeWorkflowStatus(result.status);
|
|
5
9
|
const lines = [
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns newly-correlated spans into append-only status lines for `workflow
|
|
3
|
+
* monitor`. Unlike the waterfall (which needs the full span set up front to
|
|
4
|
+
* lay out a time axis), a live monitor just reports each span's status
|
|
5
|
+
* transitions as they're observed on each poll.
|
|
6
|
+
*/
|
|
7
|
+
import type { Span, SpanStatus } from '#services/workflow_history/correlator.js';
|
|
8
|
+
export interface SpanUpdate {
|
|
9
|
+
span: Span;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
14
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
15
|
+
* nothing worth reporting until a step starts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function diffSpanUpdates(spans: Span[], labels: Map<string, string>, seen: Map<string, SpanStatus>): SpanUpdate[];
|
|
18
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
19
|
+
export declare function formatContinuedAsNew(runId: string): string;
|
|
20
|
+
export declare function formatSpanUpdate(update: SpanUpdate, color: boolean): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ANSI, formatDurationLabel, makeTint } from '#utils/waterfall.js';
|
|
2
|
+
const GLYPH = {
|
|
3
|
+
pending: '·',
|
|
4
|
+
running: '●',
|
|
5
|
+
completed: '✓',
|
|
6
|
+
failed: '✗'
|
|
7
|
+
};
|
|
8
|
+
// Continue-as-new is a workflow-level transition, not a span status, so it gets its own glyph
|
|
9
|
+
// here in the formatting layer rather than being concatenated into the message at the call site.
|
|
10
|
+
const CONTINUED_AS_NEW_GLYPH = '↻';
|
|
11
|
+
/**
|
|
12
|
+
* Returns spans whose status changed since the last call. `seen` is mutated
|
|
13
|
+
* in place so callers can carry it across polls. Pending spans are skipped —
|
|
14
|
+
* nothing worth reporting until a step starts.
|
|
15
|
+
*/
|
|
16
|
+
export function diffSpanUpdates(spans, labels, seen) {
|
|
17
|
+
const updates = [];
|
|
18
|
+
for (const span of spans) {
|
|
19
|
+
if (span.status === 'pending' || seen.get(span.id) === span.status) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen.set(span.id, span.status);
|
|
23
|
+
updates.push({ span, label: labels.get(span.id) ?? span.name });
|
|
24
|
+
}
|
|
25
|
+
return updates;
|
|
26
|
+
}
|
|
27
|
+
/** Formats the continue-as-new transition line, keeping its glyph in the formatting layer. */
|
|
28
|
+
export function formatContinuedAsNew(runId) {
|
|
29
|
+
return `${CONTINUED_AS_NEW_GLYPH} continued as new run ${runId}`;
|
|
30
|
+
}
|
|
31
|
+
export function formatSpanUpdate(update, color) {
|
|
32
|
+
const { span, label } = update;
|
|
33
|
+
const glyph = GLYPH[span.status];
|
|
34
|
+
const tint = makeTint(color);
|
|
35
|
+
const tintStatus = (text) => tint(text, ANSI[span.status]);
|
|
36
|
+
switch (span.status) {
|
|
37
|
+
case 'running':
|
|
38
|
+
return `${tintStatus(glyph)} ${label} running…`;
|
|
39
|
+
case 'completed':
|
|
40
|
+
return `${tintStatus(glyph)} ${label} ${formatDurationLabel(Math.max(0, span.durationMs))}`;
|
|
41
|
+
case 'failed': {
|
|
42
|
+
const reason = span.failureMessage ? `: ${span.failureMessage}` : '';
|
|
43
|
+
return `${tintStatus(glyph)} ${label} failed${reason}`;
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return `${glyph} ${label} ${span.status}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffSpanUpdates, formatSpanUpdate } from '#utils/monitor_log.js';
|
|
3
|
+
const span = (overrides) => ({
|
|
4
|
+
name: 'Step',
|
|
5
|
+
technicalName: 'wf#step',
|
|
6
|
+
description: null,
|
|
7
|
+
kind: 'activity',
|
|
8
|
+
attempt: 1,
|
|
9
|
+
startedAt: null,
|
|
10
|
+
scheduledAt: null,
|
|
11
|
+
completedAt: null,
|
|
12
|
+
startOffsetMs: 0,
|
|
13
|
+
endOffsetMs: 0,
|
|
14
|
+
durationMs: 0,
|
|
15
|
+
failureMessage: null,
|
|
16
|
+
...overrides
|
|
17
|
+
});
|
|
18
|
+
describe('diffSpanUpdates', () => {
|
|
19
|
+
it('skips pending spans', () => {
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'pending' })], new Map(), seen);
|
|
22
|
+
expect(updates).toHaveLength(0);
|
|
23
|
+
expect(seen.size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
it('reports a span the first time it is seen in a non-pending status', () => {
|
|
26
|
+
const seen = new Map();
|
|
27
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map([['1', 'Fetch page']]), seen);
|
|
28
|
+
expect(updates).toHaveLength(1);
|
|
29
|
+
expect(updates[0].label).toBe('Fetch page');
|
|
30
|
+
expect(seen.get('1')).toBe('running');
|
|
31
|
+
});
|
|
32
|
+
it('does not re-report a span whose status is unchanged since the last call', () => {
|
|
33
|
+
const seen = new Map([['1', 'running']]);
|
|
34
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running' })], new Map(), seen);
|
|
35
|
+
expect(updates).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
it('reports a span again once its status transitions (running -> completed)', () => {
|
|
38
|
+
const seen = new Map([['1', 'running']]);
|
|
39
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'completed' })], new Map(), seen);
|
|
40
|
+
expect(updates).toHaveLength(1);
|
|
41
|
+
expect(seen.get('1')).toBe('completed');
|
|
42
|
+
});
|
|
43
|
+
it('falls back to the span name when no label is provided', () => {
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
const updates = diffSpanUpdates([span({ id: '1', status: 'running', name: 'Unlabeled' })], new Map(), seen);
|
|
46
|
+
expect(updates[0].label).toBe('Unlabeled');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('formatSpanUpdate', () => {
|
|
50
|
+
it('formats a running span', () => {
|
|
51
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, false);
|
|
52
|
+
expect(line).toBe('● Fetch page running…');
|
|
53
|
+
});
|
|
54
|
+
it('formats a completed span with its duration', () => {
|
|
55
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'completed', durationMs: 1234 }), label: 'Fetch page' }, false);
|
|
56
|
+
expect(line).toBe('✓ Fetch page 1s');
|
|
57
|
+
});
|
|
58
|
+
it('formats a failed span with its failure message', () => {
|
|
59
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed', failureMessage: 'boom' }), label: 'Fetch page' }, false);
|
|
60
|
+
expect(line).toBe('✗ Fetch page failed: boom');
|
|
61
|
+
});
|
|
62
|
+
it('formats a failed span without a failure message', () => {
|
|
63
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'failed' }), label: 'Fetch page' }, false);
|
|
64
|
+
expect(line).toBe('✗ Fetch page failed');
|
|
65
|
+
});
|
|
66
|
+
it('wraps the glyph in ANSI codes when color is enabled', () => {
|
|
67
|
+
const line = formatSpanUpdate({ span: span({ id: '1', status: 'running' }), label: 'Fetch page' }, true);
|
|
68
|
+
expect(line).toContain('●');
|
|
69
|
+
expect(line).not.toBe('● Fetch page running…'); // color codes present
|
|
70
|
+
});
|
|
71
|
+
});
|