@outputai/cli 0.10.1-next.f6a7c1a.0 → 0.11.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 +81 -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/monitor.d.ts +5 -20
- package/dist/commands/workflow/monitor.js +20 -182
- package/dist/commands/workflow/monitor.spec.js +82 -3
- 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 +7 -1
- package/dist/commands/workflow/start.js +107 -12
- package/dist/commands/workflow/start.spec.js +282 -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/monitor_stream.d.ts +62 -0
- package/dist/services/monitor_stream.js +285 -0
- package/dist/services/monitor_stream.spec.d.ts +1 -0
- package/dist/services/monitor_stream.spec.js +285 -0
- package/dist/services/workflow_history.js +2 -2
- 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/env_loader.js +6 -2
- package/dist/utils/env_loader.spec.js +61 -32
- package/dist/utils/error_handler.d.ts +10 -0
- package/dist/utils/error_handler.js +14 -0
- package/dist/utils/error_handler.spec.d.ts +1 -0
- package/dist/utils/error_handler.spec.js +62 -0
- package/dist/utils/format_workflow_result.d.ts +15 -3
- package/dist/utils/format_workflow_result.js +39 -6
- package/dist/utils/format_workflow_result.spec.js +39 -6
- package/dist/utils/monitor_flags.d.ts +35 -0
- package/dist/utils/monitor_flags.js +76 -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/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 +4 -4
- package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +2 -2
- package/oclif.manifest.json +91 -10
- package/package.json +7 -9
- /package/dist/commands/{workflow/test_eval.spec.d.ts → dev/down.spec.d.ts} +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } from '#services/workflow_history.js';
|
|
2
|
+
import buildSpanLabels from '#utils/span_labels.js';
|
|
3
|
+
import { formatDurationLabel } from '#utils/waterfall.js';
|
|
4
|
+
import { diffSpanUpdates, formatContinuedAsNew, formatSpanUpdate } from '#utils/monitor_log.js';
|
|
5
|
+
import { isErrorStatus, isTerminalStatus } from '#utils/format_workflow_result.js';
|
|
6
|
+
import { getErrorMessage } from '#utils/error_utils.js';
|
|
7
|
+
import { sleep } from '#utils/sleep.js';
|
|
8
|
+
import { shouldColorize } from '#utils/color.js';
|
|
9
|
+
import { HttpError } from '#api/http_client.js';
|
|
10
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
11
|
+
/**
|
|
12
|
+
* Retry sleep ceiling while no poll has succeeded yet. The retry budget covers
|
|
13
|
+
* the first poll for `start --monitor`'s sake (see `poll`), but `workflow
|
|
14
|
+
* monitor wf-x` against a server that was never reachable pays for that too —
|
|
15
|
+
* and with a large `--interval` it would sit through the whole budget before
|
|
16
|
+
* reporting a connection it could never make. Capping only the pre-first-success
|
|
17
|
+
* sleep keeps the retry useful without making "the API is down" take minutes to
|
|
18
|
+
* surface; once a poll has succeeded, the user's `--interval` is honored.
|
|
19
|
+
*/
|
|
20
|
+
const UNESTABLISHED_RETRY_MS = 1000;
|
|
21
|
+
const SIGINT_EXIT_CODE = 130;
|
|
22
|
+
const FLUSH_TIMEOUT_MS = 2000;
|
|
23
|
+
const TRANSIENT_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND']);
|
|
24
|
+
/**
|
|
25
|
+
* Adapts an oclif command to the above. Late-bound arrows rather than
|
|
26
|
+
* `command.log.bind( command )`: oclif (and the unit tests) replace these as own
|
|
27
|
+
* properties on the instance, so they must resolve at call time. Structurally
|
|
28
|
+
* typed so a test double satisfies it without a real oclif `Config`.
|
|
29
|
+
*/
|
|
30
|
+
export function commandStreamIo(command) {
|
|
31
|
+
return {
|
|
32
|
+
log: message => command.log(message),
|
|
33
|
+
warn: message => {
|
|
34
|
+
command.warn(message);
|
|
35
|
+
},
|
|
36
|
+
error: message => command.error(message, { exit: 1 })
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Distinguishes blips worth retrying from errors that will fail identically on
|
|
41
|
+
* every attempt: network hiccups, a client-side request timeout, and 5xx/408/429
|
|
42
|
+
* responses are transient. Everything else — a 4xx like a stale/invalid resume
|
|
43
|
+
* cursor (`InvalidPageTokenError`, surfaced as 400), or a bug in correlate()/
|
|
44
|
+
* buildResult() re-throwing the same exception — can't self-resolve by waiting,
|
|
45
|
+
* so it should surface immediately instead of burning the retry budget.
|
|
46
|
+
*/
|
|
47
|
+
function isTransientPollError(error) {
|
|
48
|
+
if (error instanceof HttpError) {
|
|
49
|
+
const status = error.response.status;
|
|
50
|
+
return status >= 500 || status === 408 || status === 429;
|
|
51
|
+
}
|
|
52
|
+
const err = error;
|
|
53
|
+
if (err.name === 'TimeoutError') {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return Boolean((err.code && TRANSIENT_ERROR_CODES.has(err.code)) ||
|
|
57
|
+
(err.cause?.code && TRANSIENT_ERROR_CODES.has(err.cause.code)));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wraps a single poll: a transient blip (see `isTransientPollError`) returns
|
|
61
|
+
* `null` so the loop can retry — matching the dev TUI's `useStepGraph` behavior
|
|
62
|
+
* of keeping the last good state on a poll hiccup. A non-transient error (e.g. a
|
|
63
|
+
* 404 for a mistyped workflow id, a stale resume cursor, or a bug in the parsing
|
|
64
|
+
* pipeline) rethrows immediately since retrying it cannot succeed.
|
|
65
|
+
* `MAX_CONSECUTIVE_ERRORS` bounds how long we'll retry transient failures before
|
|
66
|
+
* giving up.
|
|
67
|
+
*
|
|
68
|
+
* The retry budget deliberately covers the *first* poll too. `workflow start
|
|
69
|
+
* --monitor` issues it milliseconds after the API accepted the start request, so
|
|
70
|
+
* the first poll is the one most likely to catch a rolling restart or a single
|
|
71
|
+
* 503 — and aborting there abandons a workflow that is already running. The
|
|
72
|
+
* sleep between those pre-first-success retries is capped so `workflow monitor`
|
|
73
|
+
* against an unreachable server doesn't pay the full budget at `--interval`
|
|
74
|
+
* (see `UNESTABLISHED_RETRY_MS`).
|
|
75
|
+
*
|
|
76
|
+
* Fetch strategy is driven by `state.cursor`, not tick count: no cursor yet
|
|
77
|
+
* (the very first poll, or the first poll of a run chained via continue-as-new)
|
|
78
|
+
* uses `fetchWorkflowHistory` (fast, no long-poll) so that render isn't delayed;
|
|
79
|
+
* once a cursor exists, every poll resumes via `fetchWorkflowHistoryUpdates`
|
|
80
|
+
* instead of re-paging the whole history — see `plan_workflow_monitor_history.md`
|
|
81
|
+
* for why a full re-fetch every tick is expensive for long-running workflows.
|
|
82
|
+
*/
|
|
83
|
+
async function poll(options, state, io) {
|
|
84
|
+
try {
|
|
85
|
+
// Keeps the resumed long-poll's server-side block roughly aligned with `--interval`
|
|
86
|
+
// instead of always blocking for the server's full configured deadline regardless of
|
|
87
|
+
// it (see `plan_workflow_monitor_history.md`'s "Known tradeoff" note). Only takes
|
|
88
|
+
// effect once resuming (i.e. from the second tick onward); `fetchWorkflowHistory`
|
|
89
|
+
// ignores it on the initial full walk.
|
|
90
|
+
const fetchOptions = {
|
|
91
|
+
workflowId: options.workflowId,
|
|
92
|
+
runId: state.runId,
|
|
93
|
+
includePayloads: options.includePayloads,
|
|
94
|
+
longPollTimeoutMs: options.interval
|
|
95
|
+
};
|
|
96
|
+
if (!state.cursor) {
|
|
97
|
+
const result = await fetchWorkflowHistory(fetchOptions);
|
|
98
|
+
return { result, cursor: result.cursor };
|
|
99
|
+
}
|
|
100
|
+
return await fetchWorkflowHistoryUpdates(fetchOptions, state.cursor);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
// Whatever this poll was doing stopped mattering the moment the user
|
|
104
|
+
// detached: reporting a retry would contradict the "Detached" line already
|
|
105
|
+
// printed, and rethrowing would unwind past the loop's own detach guards
|
|
106
|
+
// into the caller's "monitoring stopped" handling, racing exit 3 against the
|
|
107
|
+
// 130 the detach already recorded. The loop breaks on `detached` right after
|
|
108
|
+
// this returns, so `null` here is not a retry.
|
|
109
|
+
if (state.detached) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (!isTransientPollError(error) || state.consecutiveErrors + 1 >= MAX_CONSECUTIVE_ERRORS) {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
io.warn(`Poll failed (${state.consecutiveErrors + 1}/${MAX_CONSECUTIVE_ERRORS}), retrying: ${getErrorMessage(error)}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* `process.exit` discards whatever is still queued on an asynchronous stdout —
|
|
121
|
+
* a pipe on macOS, where writes are buffered rather than synchronous (TTY and
|
|
122
|
+
* file writes are synchronous on POSIX and are not truncated). Detaching from
|
|
123
|
+
* `workflow start --monitor` that way can drop the `Workflow ID:` line the
|
|
124
|
+
* command printed moments earlier, leaving the user with no way to reattach to a
|
|
125
|
+
* workflow that is still running. So queue an empty write behind the pending
|
|
126
|
+
* output and exit once it drains, with a ceiling in case the reader has stalled.
|
|
127
|
+
*/
|
|
128
|
+
function exitAfterFlush(code) {
|
|
129
|
+
const state = { exited: false };
|
|
130
|
+
const exit = () => {
|
|
131
|
+
if (state.exited) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
state.exited = true;
|
|
135
|
+
process.exit(code);
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(exit, FLUSH_TIMEOUT_MS);
|
|
138
|
+
process.stdout.write('', () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
exit();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Polls a workflow run and emits status updates until it reaches a terminal
|
|
145
|
+
* state, following continue-as-new chains. Shared by `workflow monitor` and
|
|
146
|
+
* `workflow start --monitor` so both behave identically; see `MonitorStreamIo`
|
|
147
|
+
* for why output is injected rather than taken from a `Command`.
|
|
148
|
+
*
|
|
149
|
+
* Sets `process.exitCode = 1` on a terminal error status rather than throwing,
|
|
150
|
+
* so the caller's own output (e.g. `start`'s "Workflow started successfully")
|
|
151
|
+
* is still the command's primary result. Returns the terminal status it stopped
|
|
152
|
+
* on — `undefined` if it stopped because the user detached — so a caller can
|
|
153
|
+
* tailor its own follow-up (`workflow result` vs `workflow debug`).
|
|
154
|
+
*/
|
|
155
|
+
export async function streamWorkflowUpdates(options, io) {
|
|
156
|
+
const color = shouldColorize(options.color);
|
|
157
|
+
// Threaded via mutable properties (not `let` reassignment) so state
|
|
158
|
+
// persists across polls without local variable reassignment.
|
|
159
|
+
const state = {
|
|
160
|
+
runId: options.runId,
|
|
161
|
+
consecutiveErrors: 0,
|
|
162
|
+
// Set by the SIGINT handler so an in-flight poll can't print another update
|
|
163
|
+
// on top of "Detached" while stdout drains (see `exitAfterFlush`).
|
|
164
|
+
detached: false,
|
|
165
|
+
terminalStatus: undefined,
|
|
166
|
+
// Undefined until a resumable cursor is established (see `poll` and
|
|
167
|
+
// `fetchWorkflowHistoryUpdates`); reset on continue-as-new since a new run's
|
|
168
|
+
// cursor position is meaningless carried over from the old one.
|
|
169
|
+
cursor: undefined
|
|
170
|
+
};
|
|
171
|
+
const seen = new Map();
|
|
172
|
+
// Assigned once per span id and never overwritten: `buildSpanLabels` numbers
|
|
173
|
+
// same-named spans by how many are in the array *at call time*, so recomputing
|
|
174
|
+
// it fresh every poll could retroactively change a label already printed to
|
|
175
|
+
// the user (e.g. an unnumbered "Scrape Page" becoming "Scrape Page #1" once a
|
|
176
|
+
// second instance appears). Freezing on first sight keeps printed labels stable.
|
|
177
|
+
const labels = new Map();
|
|
178
|
+
// One emit point for both output formats: json mode wraps `fields` (plus
|
|
179
|
+
// the ambient workflow/run id) as a line of NDJSON, text mode prints `text`.
|
|
180
|
+
const emit = (fields, text) => {
|
|
181
|
+
io.log(options.json ?
|
|
182
|
+
JSON.stringify({ workflowId: options.workflowId, runId: state.runId, ...fields }) :
|
|
183
|
+
text);
|
|
184
|
+
};
|
|
185
|
+
emit({ monitoring: true }, `Monitoring ${options.workflowId}${state.runId ? ` (run ${state.runId})` : ''}... (Ctrl+C to detach)`);
|
|
186
|
+
const sigintHandler = () => {
|
|
187
|
+
// The listener stays registered until the loop unwinds through `finally`, and
|
|
188
|
+
// the exit is deferred behind a stdout flush — so an impatient second Ctrl+C
|
|
189
|
+
// lands here again and would print a second "Detached" line and schedule a
|
|
190
|
+
// second exit.
|
|
191
|
+
if (state.detached) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
state.detached = true;
|
|
195
|
+
// Recorded as well as exited with: deferring the exit for a stdout flush
|
|
196
|
+
// lets the loop unwind and the command return normally in the meantime, and
|
|
197
|
+
// whichever of the two finishes first has to land on 130.
|
|
198
|
+
process.exitCode = SIGINT_EXIT_CODE;
|
|
199
|
+
emit({ detached: true }, `\nDetached (the workflow keeps running). Use "workflow status ${options.workflowId}" to check on it, ` +
|
|
200
|
+
`or "workflow result ${options.workflowId}" once it finishes.`);
|
|
201
|
+
exitAfterFlush(SIGINT_EXIT_CODE);
|
|
202
|
+
};
|
|
203
|
+
process.on('SIGINT', sigintHandler);
|
|
204
|
+
try {
|
|
205
|
+
while (true) {
|
|
206
|
+
if (state.detached) {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
const outcome = await poll(options, state, io);
|
|
210
|
+
if (state.detached) {
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (outcome === null) {
|
|
214
|
+
state.consecutiveErrors += 1;
|
|
215
|
+
await sleep(state.cursor ? options.interval : Math.min(options.interval, UNESTABLISHED_RETRY_MS));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
state.consecutiveErrors = 0;
|
|
219
|
+
state.cursor = outcome.cursor;
|
|
220
|
+
const result = outcome.result;
|
|
221
|
+
state.runId = result.runId ?? state.runId;
|
|
222
|
+
for (const [id, label] of buildSpanLabels(result.spans)) {
|
|
223
|
+
if (!labels.has(id)) {
|
|
224
|
+
labels.set(id, label);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const update of diffSpanUpdates(result.spans, labels, seen)) {
|
|
228
|
+
emit({ span: update.span }, formatSpanUpdate(update, color));
|
|
229
|
+
}
|
|
230
|
+
const status = result.workflow?.status;
|
|
231
|
+
if (status === 'continued_as_new') {
|
|
232
|
+
if (!result.continuedAsNewRunId) {
|
|
233
|
+
io.error('Workflow continued as a new run, but the new run ID could not be determined.');
|
|
234
|
+
// `io.error` is typed `never`, but nothing enforces that at runtime.
|
|
235
|
+
// Without this, an `io` whose `error` returns would fall through to
|
|
236
|
+
// re-poll the latest run with the cursor cleared — replaying the whole
|
|
237
|
+
// span history every interval, forever, with a zero exit code.
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
emit({ continuedAsNewRunId: result.continuedAsNewRunId }, formatContinuedAsNew(result.continuedAsNewRunId));
|
|
241
|
+
state.runId = result.continuedAsNewRunId;
|
|
242
|
+
state.cursor = undefined;
|
|
243
|
+
seen.clear();
|
|
244
|
+
labels.clear();
|
|
245
|
+
await sleep(options.interval);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const terminalStatus = isTerminalStatus(status);
|
|
249
|
+
if (terminalStatus) {
|
|
250
|
+
const failed = isErrorStatus(terminalStatus);
|
|
251
|
+
emit({ status: terminalStatus }, `${failed ? '✗' : '✓'} workflow ${terminalStatus} · ${formatDurationLabel(result.totalDurationMs)}`);
|
|
252
|
+
if (failed) {
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
state.terminalStatus = terminalStatus;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
await sleep(options.interval);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
263
|
+
}
|
|
264
|
+
return state.terminalStatus;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Shared `catch` handling for both entry points. A 400 is the generic status for
|
|
268
|
+
* several distinct causes (invalid pageToken, a missing runId, an out-of-range
|
|
269
|
+
* longPollTimeoutMs) — only override it with the stale-cursor message when the
|
|
270
|
+
* server actually identifies that specific cause; otherwise let the real
|
|
271
|
+
* validation error surface instead of misdiagnosing an unrelated 400.
|
|
272
|
+
*
|
|
273
|
+
* Deliberately no 404 here: "check the workflow ID" only reads correctly where
|
|
274
|
+
* the user typed the id, so `workflow monitor` adds it and `start --monitor`
|
|
275
|
+
* doesn't — there the id came back from `postWorkflowStart`, and the server's own
|
|
276
|
+
* message is left to surface inside the "started, but monitoring stopped" wrapper
|
|
277
|
+
* instead of advising a fix that isn't the user's to make.
|
|
278
|
+
*/
|
|
279
|
+
export function monitorErrorOverrides(error) {
|
|
280
|
+
const response = error.response;
|
|
281
|
+
const isStaleCursor = response?.status === 400 && response.data?.error === 'InvalidPageTokenError';
|
|
282
|
+
return isStaleCursor ?
|
|
283
|
+
{ 400: 'Resume cursor is no longer valid for this workflow; restart the monitor.' } :
|
|
284
|
+
{};
|
|
285
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { HttpError } from '#api/http_client.js';
|
|
4
|
+
import { commandStreamIo, monitorErrorOverrides, streamWorkflowUpdates } from './monitor_stream.js';
|
|
5
|
+
vi.mock('#services/workflow_history.js', () => ({ fetchWorkflowHistory: vi.fn(), fetchWorkflowHistoryUpdates: vi.fn() }));
|
|
6
|
+
vi.mock('#utils/sleep.js', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }));
|
|
7
|
+
/**
|
|
8
|
+
* Direct coverage of the loop now that two commands share it. `monitor.spec.ts`
|
|
9
|
+
* exercises it end-to-end through `workflow monitor`, and `start.spec.ts` stubs
|
|
10
|
+
* it out entirely — so what's here is the behavior neither reaches: the return
|
|
11
|
+
* value both callers branch on, the pre-cursor retry cap, the detach guards, and
|
|
12
|
+
* the error classification at its edges.
|
|
13
|
+
*/
|
|
14
|
+
const cursor = { pageToken: 'token', lastEventId: 1, meta: null, runId: 'run-1', events: [] };
|
|
15
|
+
const history = (status, overrides = {}) => ({
|
|
16
|
+
workflow: { status },
|
|
17
|
+
runId: 'run-1',
|
|
18
|
+
events: [],
|
|
19
|
+
spans: [],
|
|
20
|
+
totalDurationMs: 0,
|
|
21
|
+
continuedAsNewRunId: null,
|
|
22
|
+
cursor,
|
|
23
|
+
...overrides
|
|
24
|
+
});
|
|
25
|
+
const update = (status, overrides = {}) => ({ result: history(status, overrides), cursor });
|
|
26
|
+
const createIo = (errorReturns = false) => ({
|
|
27
|
+
log: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
// Typed `never` in the interface but nothing enforces that at runtime, which is
|
|
30
|
+
// exactly what the continue-as-new guard defends against — so both shapes are
|
|
31
|
+
// constructible here.
|
|
32
|
+
error: (errorReturns ?
|
|
33
|
+
vi.fn() :
|
|
34
|
+
vi.fn((message) => {
|
|
35
|
+
throw new Error(message);
|
|
36
|
+
}))
|
|
37
|
+
});
|
|
38
|
+
const options = (overrides = {}) => ({
|
|
39
|
+
workflowId: 'wf-1',
|
|
40
|
+
runId: undefined,
|
|
41
|
+
includePayloads: false,
|
|
42
|
+
interval: 5000,
|
|
43
|
+
json: false,
|
|
44
|
+
color: false,
|
|
45
|
+
...overrides
|
|
46
|
+
});
|
|
47
|
+
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
48
|
+
describe('monitor_stream service', () => {
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
process.exitCode = undefined;
|
|
52
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
53
|
+
vi.mocked(sleep).mockResolvedValue(undefined);
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
process.exitCode = undefined;
|
|
57
|
+
});
|
|
58
|
+
const histories = async () => {
|
|
59
|
+
const { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await import('#services/workflow_history.js');
|
|
60
|
+
return {
|
|
61
|
+
fetchWorkflowHistory: vi.mocked(fetchWorkflowHistory),
|
|
62
|
+
fetchWorkflowHistoryUpdates: vi.mocked(fetchWorkflowHistoryUpdates)
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
describe('streamWorkflowUpdates() return value', () => {
|
|
66
|
+
it('returns the terminal status it stopped on so the caller can name a follow-up', async () => {
|
|
67
|
+
const { fetchWorkflowHistory } = await histories();
|
|
68
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
69
|
+
// `start --monitor` branches on this to print "workflow result" vs
|
|
70
|
+
// "workflow debug"; a boolean or a throw wouldn't carry enough.
|
|
71
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBe('completed');
|
|
72
|
+
expect(process.exitCode).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it('returns a failed status and records exit 1 without throwing', async () => {
|
|
75
|
+
const { fetchWorkflowHistory } = await histories();
|
|
76
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('failed'));
|
|
77
|
+
// Not thrown: the caller's own output stays the command's primary result.
|
|
78
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBe('failed');
|
|
79
|
+
expect(process.exitCode).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
it('returns undefined when the user detached, so no follow-up is printed', async () => {
|
|
82
|
+
const { fetchWorkflowHistory } = await histories();
|
|
83
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
84
|
+
fetchWorkflowHistory.mockResolvedValue(history('running'));
|
|
85
|
+
const onSpy = vi.spyOn(process, 'on');
|
|
86
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
87
|
+
vi.mocked(sleep).mockImplementation(async () => {
|
|
88
|
+
onSpy.mock.calls.find(([event]) => event === 'SIGINT')[1]();
|
|
89
|
+
});
|
|
90
|
+
expect(await streamWorkflowUpdates(options(), createIo())).toBeUndefined();
|
|
91
|
+
await flush();
|
|
92
|
+
onSpy.mockRestore();
|
|
93
|
+
exitSpy.mockRestore();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('retry pacing', () => {
|
|
97
|
+
it('caps the retry sleep while no cursor is established, ignoring a long --interval', async () => {
|
|
98
|
+
const { fetchWorkflowHistory } = await histories();
|
|
99
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
100
|
+
fetchWorkflowHistory
|
|
101
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
102
|
+
.mockResolvedValueOnce(history('completed'));
|
|
103
|
+
await streamWorkflowUpdates(options({ interval: 5000 }), createIo());
|
|
104
|
+
// Otherwise "the API is down" takes interval x budget to surface — minutes,
|
|
105
|
+
// for a `workflow monitor` against a server that was never reachable.
|
|
106
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(1000);
|
|
107
|
+
});
|
|
108
|
+
it('honors an --interval shorter than the cap rather than slowing the retry down', async () => {
|
|
109
|
+
const { fetchWorkflowHistory } = await histories();
|
|
110
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
111
|
+
fetchWorkflowHistory
|
|
112
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
113
|
+
.mockResolvedValueOnce(history('completed'));
|
|
114
|
+
await streamWorkflowUpdates(options({ interval: 250 }), createIo());
|
|
115
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(250);
|
|
116
|
+
});
|
|
117
|
+
it('sleeps the full --interval on a retry once a cursor exists', async () => {
|
|
118
|
+
const { fetchWorkflowHistory, fetchWorkflowHistoryUpdates } = await histories();
|
|
119
|
+
const { sleep } = await import('#utils/sleep.js');
|
|
120
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('running'));
|
|
121
|
+
fetchWorkflowHistoryUpdates
|
|
122
|
+
.mockRejectedValueOnce(new HttpError('Service unavailable', { status: 503 }))
|
|
123
|
+
.mockResolvedValueOnce(update('completed'));
|
|
124
|
+
await streamWorkflowUpdates(options({ interval: 5000 }), createIo());
|
|
125
|
+
expect(vi.mocked(sleep)).not.toHaveBeenCalledWith(1000);
|
|
126
|
+
expect(vi.mocked(sleep)).toHaveBeenCalledWith(5000);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('transient error classification', () => {
|
|
130
|
+
const retried = async (error) => {
|
|
131
|
+
const { fetchWorkflowHistory } = await histories();
|
|
132
|
+
fetchWorkflowHistory.mockRejectedValueOnce(error).mockResolvedValueOnce(history('completed'));
|
|
133
|
+
const io = createIo();
|
|
134
|
+
await streamWorkflowUpdates(options(), io);
|
|
135
|
+
return { calls: fetchWorkflowHistory.mock.calls.length, io };
|
|
136
|
+
};
|
|
137
|
+
it.each([
|
|
138
|
+
['a 503', new HttpError('Service unavailable', { status: 503 })],
|
|
139
|
+
['a 429 rate limit', new HttpError('Too many requests', { status: 429 })],
|
|
140
|
+
['a 408 request timeout', new HttpError('Request timeout', { status: 408 })],
|
|
141
|
+
['a client-side TimeoutError', Object.assign(new Error('timed out'), { name: 'TimeoutError' })],
|
|
142
|
+
['a nested ECONNREFUSED cause', Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })],
|
|
143
|
+
['a top-level EAI_AGAIN', Object.assign(new Error('dns'), { code: 'EAI_AGAIN' })]
|
|
144
|
+
])('retries %s', async (_label, error) => {
|
|
145
|
+
const { calls, io } = await retried(error);
|
|
146
|
+
expect(calls).toBe(2);
|
|
147
|
+
expect(io.warn).toHaveBeenCalledWith(expect.stringContaining('(1/5)'));
|
|
148
|
+
});
|
|
149
|
+
it.each([
|
|
150
|
+
['a 404 for a mistyped workflow id', new HttpError('Not found', { status: 404 })],
|
|
151
|
+
['a 400 stale resume cursor', new HttpError('Invalid page token', { status: 400 })],
|
|
152
|
+
['a bug in the parsing pipeline', new TypeError('spans is not iterable')]
|
|
153
|
+
])('surfaces %s immediately instead of burning the retry budget', async (_label, error) => {
|
|
154
|
+
const { fetchWorkflowHistory } = await histories();
|
|
155
|
+
fetchWorkflowHistory.mockRejectedValue(error);
|
|
156
|
+
const io = createIo();
|
|
157
|
+
await expect(streamWorkflowUpdates(options(), io)).rejects.toThrow();
|
|
158
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(io.warn).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('detaching', () => {
|
|
163
|
+
const detachDuring = async () => {
|
|
164
|
+
const onSpy = vi.spyOn(process, 'on');
|
|
165
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined));
|
|
166
|
+
const sigint = () => onSpy.mock.calls.find(([event]) => event === 'SIGINT')[1]();
|
|
167
|
+
return { onSpy, exitSpy, sigint };
|
|
168
|
+
};
|
|
169
|
+
it('swallows a poll failure raised after the user detached instead of racing the exit code', async () => {
|
|
170
|
+
const { fetchWorkflowHistory } = await histories();
|
|
171
|
+
const { onSpy, exitSpy, sigint } = await detachDuring();
|
|
172
|
+
// The in-flight poll dies because the process is on its way out. Rethrowing
|
|
173
|
+
// would unwind into `start`'s "monitoring stopped" handler and race exit 3
|
|
174
|
+
// against the 130 the detach already recorded.
|
|
175
|
+
fetchWorkflowHistory.mockImplementation(async () => {
|
|
176
|
+
sigint();
|
|
177
|
+
throw new Error('socket hang up');
|
|
178
|
+
});
|
|
179
|
+
const io = createIo();
|
|
180
|
+
expect(await streamWorkflowUpdates(options(), io)).toBeUndefined();
|
|
181
|
+
expect(process.exitCode).toBe(130);
|
|
182
|
+
expect(io.warn).not.toHaveBeenCalled();
|
|
183
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
184
|
+
await flush();
|
|
185
|
+
onSpy.mockRestore();
|
|
186
|
+
exitSpy.mockRestore();
|
|
187
|
+
});
|
|
188
|
+
it('ignores a second Ctrl+C while the first is still draining stdout', async () => {
|
|
189
|
+
const { fetchWorkflowHistory } = await histories();
|
|
190
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
191
|
+
const { onSpy, exitSpy, sigint } = await detachDuring();
|
|
192
|
+
const io = createIo();
|
|
193
|
+
await streamWorkflowUpdates(options(), io);
|
|
194
|
+
sigint();
|
|
195
|
+
// The exit is deferred behind a flush and the listener is still registered,
|
|
196
|
+
// so an impatient user lands here again before the process is gone.
|
|
197
|
+
sigint();
|
|
198
|
+
await flush();
|
|
199
|
+
const detached = io.log.mock.calls.filter((call) => String(call[0]).includes('Detached'));
|
|
200
|
+
expect(detached).toHaveLength(1);
|
|
201
|
+
expect(exitSpy).toHaveBeenCalledTimes(1);
|
|
202
|
+
onSpy.mockRestore();
|
|
203
|
+
exitSpy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
it('removes its SIGINT listener once the loop ends', async () => {
|
|
206
|
+
const { fetchWorkflowHistory } = await histories();
|
|
207
|
+
fetchWorkflowHistory.mockResolvedValueOnce(history('completed'));
|
|
208
|
+
const before = process.listenerCount('SIGINT');
|
|
209
|
+
await streamWorkflowUpdates(options(), createIo());
|
|
210
|
+
expect(process.listenerCount('SIGINT')).toBe(before);
|
|
211
|
+
});
|
|
212
|
+
it('removes its SIGINT listener even when the loop unwinds on an error', async () => {
|
|
213
|
+
const { fetchWorkflowHistory } = await histories();
|
|
214
|
+
fetchWorkflowHistory.mockRejectedValue(new HttpError('Not found', { status: 404 }));
|
|
215
|
+
const before = process.listenerCount('SIGINT');
|
|
216
|
+
await expect(streamWorkflowUpdates(options(), createIo())).rejects.toThrow();
|
|
217
|
+
expect(process.listenerCount('SIGINT')).toBe(before);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('continue-as-new', () => {
|
|
221
|
+
it('breaks instead of re-polling forever when the new run id is missing and io.error returns', async () => {
|
|
222
|
+
const { fetchWorkflowHistory } = await histories();
|
|
223
|
+
fetchWorkflowHistory.mockResolvedValue(history('continued_as_new'));
|
|
224
|
+
// `io.error` is typed `never`, but an io whose error returns would otherwise
|
|
225
|
+
// fall through and replay the whole history every interval, forever, with a
|
|
226
|
+
// zero exit code.
|
|
227
|
+
const io = createIo(true);
|
|
228
|
+
expect(await streamWorkflowUpdates(options(), io)).toBeUndefined();
|
|
229
|
+
expect(io.error).toHaveBeenCalledWith(expect.stringContaining('new run ID could not be determined'));
|
|
230
|
+
expect(fetchWorkflowHistory).toHaveBeenCalledTimes(1);
|
|
231
|
+
});
|
|
232
|
+
it('reports the chained run under the new run id in json mode', async () => {
|
|
233
|
+
const { fetchWorkflowHistory } = await histories();
|
|
234
|
+
fetchWorkflowHistory
|
|
235
|
+
.mockResolvedValueOnce(history('continued_as_new', { continuedAsNewRunId: 'run-2' }))
|
|
236
|
+
.mockResolvedValueOnce(history('completed', { runId: 'run-2' }));
|
|
237
|
+
const io = createIo();
|
|
238
|
+
await streamWorkflowUpdates(options({ json: true }), io);
|
|
239
|
+
const lines = io.log.mock.calls.map((call) => JSON.parse(call[0]));
|
|
240
|
+
expect(lines.some((line) => line.continuedAsNewRunId === 'run-2')).toBe(true);
|
|
241
|
+
// Every subsequent line carries the run actually being polled, not the one
|
|
242
|
+
// the stream attached to.
|
|
243
|
+
expect(lines.at(-1)).toMatchObject({ workflowId: 'wf-1', runId: 'run-2', status: 'completed' });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('commandStreamIo()', () => {
|
|
247
|
+
it('resolves log/warn at call time so a replaced command method still receives output', async () => {
|
|
248
|
+
const command = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
249
|
+
const io = commandStreamIo(command);
|
|
250
|
+
// oclif (and the command specs) swap these in as own properties after the
|
|
251
|
+
// adapter exists, which a `.bind()` at construction time would miss.
|
|
252
|
+
const replacement = vi.fn();
|
|
253
|
+
command.log = replacement;
|
|
254
|
+
io.log('hello');
|
|
255
|
+
expect(replacement).toHaveBeenCalledWith('hello');
|
|
256
|
+
});
|
|
257
|
+
it('raises command errors with an explicit exit 1', () => {
|
|
258
|
+
const command = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
259
|
+
commandStreamIo(command).error('boom');
|
|
260
|
+
expect(command.error).toHaveBeenCalledWith('boom', { exit: 1 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe('monitorErrorOverrides()', () => {
|
|
264
|
+
const withResponse = (status, data) => Object.assign(new Error('failed'), { response: { status, data } });
|
|
265
|
+
it('overrides a 400 the server identifies as a stale resume cursor', () => {
|
|
266
|
+
expect(monitorErrorOverrides(withResponse(400, { error: 'InvalidPageTokenError' }))).toEqual({
|
|
267
|
+
400: expect.stringContaining('Resume cursor is no longer valid')
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
it('leaves an unrelated 400 alone rather than misdiagnosing it as a stale cursor', () => {
|
|
271
|
+
// 400 also covers a missing runId and an out-of-range longPollTimeoutMs, whose
|
|
272
|
+
// real validation messages are more useful than a cursor guess.
|
|
273
|
+
expect(monitorErrorOverrides(withResponse(400, { error: 'ValidationError' }))).toEqual({});
|
|
274
|
+
expect(monitorErrorOverrides(withResponse(400))).toEqual({});
|
|
275
|
+
});
|
|
276
|
+
it('does not override a 404, which only reads correctly where the user typed the id', () => {
|
|
277
|
+
// `workflow monitor` adds its own; under `start --monitor` the id came back
|
|
278
|
+
// from the API, so "check the workflow ID" would misdirect the user.
|
|
279
|
+
expect(monitorErrorOverrides(withResponse(404))).toEqual({});
|
|
280
|
+
});
|
|
281
|
+
it('tolerates an error with no response at all', () => {
|
|
282
|
+
expect(monitorErrorOverrides(new Error('fetch failed'))).toEqual({});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
import { getWorkflowIdHistory } from '#api/generated/api.js';
|
|
11
11
|
import { correlate, eventAttributes, eventTypeName } from '#services/workflow_history/correlator.js';
|
|
12
12
|
import { normalizeWorkflowStatus } from '#utils/normalize_workflow_status.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isTerminalStatus } from '#utils/format_workflow_result.js';
|
|
14
14
|
const PAGE_SIZE = 50;
|
|
15
15
|
// The status here comes from the request's own describe (fresh whenever `wait` is
|
|
16
16
|
// set), so it can report the run closed before the closing event has been paged in.
|
|
17
17
|
function isRunClosed(meta) {
|
|
18
18
|
const status = normalizeWorkflowStatus(meta?.status);
|
|
19
|
-
return status === 'continued_as_new' || (status !== undefined
|
|
19
|
+
return status === 'continued_as_new' || isTerminalStatus(status) !== undefined;
|
|
20
20
|
}
|
|
21
21
|
function numericEventId(event) {
|
|
22
22
|
const id = Number(event.eventId);
|
|
@@ -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',
|