@bli-cockpit/cli 0.2.37 → 0.2.38
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/commands/jarvis.js +122 -65
- package/dist/commands/local-args.js +5 -0
- package/dist/commands/local-help.js +5 -3
- package/dist/commands/public-root.js +1 -1
- package/dist/tower-client.js +150 -0
- package/dist/tower-stream.js +252 -0
- package/package.json +2 -2
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,13 +5,21 @@
|
|
|
5
5
|
* existing paired device session, and every turn is executed by the dashboard
|
|
6
6
|
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
|
-
import {
|
|
10
|
+
import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
|
|
11
|
+
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
11
12
|
const TRACE_DIM = "\x1b[2m";
|
|
12
13
|
const TRACE_RESET = "\x1b[0m";
|
|
14
|
+
/**
|
|
15
|
+
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
16
|
+
* waits slightly longer so the server's own named failure wins the race
|
|
17
|
+
* whenever it manages to send one; past that, the terminal names the timeout
|
|
18
|
+
* itself rather than sitting there.
|
|
19
|
+
*/
|
|
20
|
+
const TURN_DEADLINE_MS = 125_000;
|
|
13
21
|
export async function runJarvis(command, io) {
|
|
14
|
-
const session = await loadPairedSession(command.homeDir);
|
|
22
|
+
const session = await loadPairedSession("jarvis", command.homeDir);
|
|
15
23
|
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
16
24
|
const oneShotPrompt = await resolveOneShotPrompt(command, io);
|
|
17
25
|
if (oneShotPrompt !== null) {
|
|
@@ -32,19 +40,6 @@ export async function runJarvis(command, io) {
|
|
|
32
40
|
return exitCode;
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
|
-
async function loadPairedSession(homeDir) {
|
|
36
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
37
|
-
try {
|
|
38
|
-
const session = await readLocalCollectorSessionFile(paths);
|
|
39
|
-
if (session.session_state !== "valid") {
|
|
40
|
-
throw new Error(`session_${session.session_state}`);
|
|
41
|
-
}
|
|
42
|
-
return session;
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
throw new Error(`JARVIS needs a valid paired Tower session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
43
|
async function resolveOneShotPrompt(command, io) {
|
|
49
44
|
if (command.prompt)
|
|
50
45
|
return validatePrompt(command.prompt);
|
|
@@ -68,65 +63,100 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
68
63
|
}
|
|
69
64
|
attachment = read;
|
|
70
65
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
66
|
+
// BLI-3457: streaming is the default. A dashboard that has not shipped the
|
|
67
|
+
// NDJSON half yet answers `application/json`, which the reader takes as a
|
|
68
|
+
// single final event and names `stream_not_available` — so the terminal
|
|
69
|
+
// works against both server versions with no flag.
|
|
70
|
+
const wantsStream = context.command.stream !== false;
|
|
71
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
72
|
+
const requested = await towerRequest({
|
|
73
|
+
dashboardUrl: context.dashboardUrl,
|
|
74
|
+
path: "/api/jarvis/cli",
|
|
75
|
+
deviceToken: context.deviceToken,
|
|
76
|
+
fetch: io.fetch,
|
|
77
|
+
label: "jarvis",
|
|
78
|
+
timeoutMs: TURN_DEADLINE_MS,
|
|
79
|
+
headers: wantsStream ? { accept: "application/x-ndjson" } : {},
|
|
80
|
+
body: attachment
|
|
81
|
+
? buildAttachmentForm(context.command, prompt, attachment)
|
|
82
|
+
: {
|
|
83
|
+
question: prompt,
|
|
84
|
+
thread: context.command.thread,
|
|
85
|
+
subject: context.command.subject,
|
|
86
|
+
model: context.command.model,
|
|
87
|
+
},
|
|
88
|
+
log,
|
|
89
|
+
});
|
|
90
|
+
if (!requested.ok) {
|
|
91
|
+
writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
|
|
92
|
+
return 1;
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// Live trace lines go to a person as they land, never to a `--json`
|
|
95
|
+
// consumer: that contract is exactly one object on stdout, so the events are
|
|
96
|
+
// buffered and folded into the final payload instead.
|
|
97
|
+
let liveTraceLines = 0;
|
|
98
|
+
const turn = await readTowerTurn(requested.response, {
|
|
99
|
+
startedAt,
|
|
100
|
+
log,
|
|
101
|
+
onActivity: (event) => {
|
|
102
|
+
if (context.command.json)
|
|
103
|
+
return;
|
|
104
|
+
if (writeActivityLine(io, event))
|
|
105
|
+
liveTraceLines += 1;
|
|
106
|
+
},
|
|
107
|
+
onNote: (reason, detail) => {
|
|
108
|
+
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
109
|
+
reason,
|
|
110
|
+
...(detail && reason !== "ndjson_line_unparseable" ? { detail } : {}),
|
|
111
|
+
})}`);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (!turn.ok) {
|
|
115
|
+
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
95
116
|
return 1;
|
|
96
117
|
}
|
|
97
|
-
const body =
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
const body = turn.final;
|
|
119
|
+
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
120
|
+
if (httpStatus >= 400 || !body.ok || !body.reply) {
|
|
121
|
+
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
100
122
|
writeFailure(context.command, io, "turn_failed", reason);
|
|
101
123
|
return 1;
|
|
102
124
|
}
|
|
125
|
+
// A streaming server may leave the settled trace out of the final event
|
|
126
|
+
// because it already sent every step live; the activity we collected is that
|
|
127
|
+
// same trace, so `--json` still gets one.
|
|
128
|
+
const trace = body.trace ?? activityToTrace(turn.activity);
|
|
103
129
|
if (context.command.json) {
|
|
104
130
|
writeLine(io.stdout, JSON.stringify({
|
|
105
131
|
ok: true,
|
|
106
132
|
reply: body.reply,
|
|
107
133
|
thread: body.thread ?? context.command.thread,
|
|
108
134
|
model: body.model ?? null,
|
|
109
|
-
trace
|
|
135
|
+
trace,
|
|
110
136
|
subject: body.subject ?? null,
|
|
111
137
|
}));
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
114
140
|
const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
|
|
115
141
|
writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
|
|
116
|
-
|
|
142
|
+
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
143
|
+
if (liveTraceLines === 0)
|
|
144
|
+
writeTraceBlock(io, trace);
|
|
117
145
|
writeModelReceipt(io, body.model);
|
|
118
146
|
}
|
|
119
147
|
writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
|
|
120
148
|
prompt_length: prompt.length,
|
|
121
149
|
reply_length: body.reply.length,
|
|
122
|
-
trace_steps:
|
|
123
|
-
trace_failed:
|
|
150
|
+
trace_steps: trace?.length ?? 0,
|
|
151
|
+
trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
|
|
124
152
|
model_requested: context.command.model ?? null,
|
|
125
153
|
elapsed_ms: Date.now() - startedAt,
|
|
126
154
|
thread: context.command.thread === "main" ? "default" : "named",
|
|
127
155
|
subject: context.command.subject ? "selected" : "caller",
|
|
128
156
|
image_attached: attachment !== null,
|
|
129
157
|
image_byte_size: attachment?.bytes.byteLength ?? null,
|
|
158
|
+
streamed: turn.streamed,
|
|
159
|
+
live_trace_lines: liveTraceLines,
|
|
130
160
|
})}`);
|
|
131
161
|
return 0;
|
|
132
162
|
}
|
|
@@ -166,11 +196,53 @@ function writeAttachmentRefusal(command, io, refusal, filePath) {
|
|
|
166
196
|
function writeTraceBlock(io, trace) {
|
|
167
197
|
if (!trace || trace.length === 0)
|
|
168
198
|
return;
|
|
169
|
-
for (const step of trace)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
199
|
+
for (const step of trace)
|
|
200
|
+
writeTraceLine(io, step);
|
|
201
|
+
}
|
|
202
|
+
/** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
|
|
203
|
+
function writeTraceLine(io, step) {
|
|
204
|
+
const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
|
|
205
|
+
const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
|
|
206
|
+
writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Draws one streamed tool call, and says whether it drew anything.
|
|
210
|
+
*
|
|
211
|
+
* Only settled steps print: a `running` event is the same tool arriving a
|
|
212
|
+
* second time, and printing both would double every line in a terminal that
|
|
213
|
+
* cannot rewrite the one above it.
|
|
214
|
+
*/
|
|
215
|
+
function writeActivityLine(io, event) {
|
|
216
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
217
|
+
return false;
|
|
218
|
+
const step = activityToStep(event);
|
|
219
|
+
if (!step)
|
|
220
|
+
return false;
|
|
221
|
+
writeTraceLine(io, step);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
/** The settled steps of a streamed turn, in the shape `--json` already promises. */
|
|
225
|
+
function activityToTrace(activity) {
|
|
226
|
+
const steps = [];
|
|
227
|
+
for (const event of activity) {
|
|
228
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
229
|
+
continue;
|
|
230
|
+
const step = activityToStep(event);
|
|
231
|
+
if (step)
|
|
232
|
+
steps.push(step);
|
|
173
233
|
}
|
|
234
|
+
return steps;
|
|
235
|
+
}
|
|
236
|
+
function activityToStep(event) {
|
|
237
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
238
|
+
return null;
|
|
239
|
+
return {
|
|
240
|
+
tool: event.tool ?? "unknown_tool",
|
|
241
|
+
label: event.label ?? event.tool ?? "A tool ran",
|
|
242
|
+
status: event.status,
|
|
243
|
+
...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
|
|
244
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
245
|
+
};
|
|
174
246
|
}
|
|
175
247
|
/**
|
|
176
248
|
* One line, only when the answer did not come from what was requested — a
|
|
@@ -187,21 +259,6 @@ function writeModelReceipt(io, model) {
|
|
|
187
259
|
return;
|
|
188
260
|
writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered (${requested ?? "the requested model"} unavailable)`);
|
|
189
261
|
}
|
|
190
|
-
async function readReply(response) {
|
|
191
|
-
const text = await response.text();
|
|
192
|
-
if (!text)
|
|
193
|
-
return { ok: false, error: "empty_response" };
|
|
194
|
-
try {
|
|
195
|
-
const value = JSON.parse(text);
|
|
196
|
-
if (!value || typeof value !== "object") {
|
|
197
|
-
return { ok: false, error: "response_body_not_object" };
|
|
198
|
-
}
|
|
199
|
-
return value;
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
return { ok: false, error: "response_body_not_json" };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
262
|
function writeFailure(command, io, reason, detail) {
|
|
206
263
|
if (command.json) {
|
|
207
264
|
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
@@ -620,6 +620,7 @@ function parseJarvisArgs(args) {
|
|
|
620
620
|
"--model",
|
|
621
621
|
"--image",
|
|
622
622
|
"--file",
|
|
623
|
+
"--no-stream",
|
|
623
624
|
"--json",
|
|
624
625
|
],
|
|
625
626
|
valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--image", "--file"],
|
|
@@ -651,6 +652,10 @@ function parseJarvisArgs(args) {
|
|
|
651
652
|
// to the inference server's own allowlist and relays its refusal.
|
|
652
653
|
model: optionalNonEmpty(values.flags.get("--model")),
|
|
653
654
|
imagePath: image ?? file,
|
|
655
|
+
// BLI-3457: streaming is on unless a caller opts out. A dashboard that
|
|
656
|
+
// does not stream yet still answers plain JSON, so this flag is for
|
|
657
|
+
// callers that want the single-body shape on purpose, not a compat knob.
|
|
658
|
+
stream: !values.booleans.has("--no-stream"),
|
|
654
659
|
json: values.booleans.has("--json"),
|
|
655
660
|
};
|
|
656
661
|
}
|
|
@@ -46,7 +46,7 @@ export function localCommandHelp(command) {
|
|
|
46
46
|
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
47
47
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
48
48
|
" cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
49
|
-
" cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
|
|
49
|
+
" cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
|
|
50
50
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
51
51
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
52
52
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -190,7 +190,7 @@ function localSubcommandHelp(command) {
|
|
|
190
190
|
[
|
|
191
191
|
"jarvis",
|
|
192
192
|
[
|
|
193
|
-
"Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--dashboard-url <url>] [--json]",
|
|
193
|
+
"Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
|
|
194
194
|
"",
|
|
195
195
|
"Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
|
|
196
196
|
"--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
|
|
@@ -199,7 +199,9 @@ function localSubcommandHelp(command) {
|
|
|
199
199
|
"Run with no question for an interactive terminal conversation.",
|
|
200
200
|
"Agents can pass --prompt, positional text, or pipe one question on stdin.",
|
|
201
201
|
"Each answer prints a per-tool trace line and, only on a fallback or model mismatch, one model receipt line.",
|
|
202
|
-
"
|
|
202
|
+
"Trace lines arrive live while JARVIS works; pass --no-stream to wait for the whole answer in one piece instead.",
|
|
203
|
+
"--json writes one machine-readable response (including trace and model) to stdout; operational metadata stays on stderr. A --json run never streams fragments, whichever mode it is in.",
|
|
204
|
+
"A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
|
|
203
205
|
"The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
|
|
204
206
|
"Run `cockpit login` first if this machine is not paired.",
|
|
205
207
|
],
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.38");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one shared "call the dashboard as me" client (BLI-3457).
|
|
3
|
+
*
|
|
4
|
+
* Every command that speaks to Tower on the paired person's behalf — `jarvis`
|
|
5
|
+
* today, the rest of the CLI-parity surface next — needs the same three
|
|
6
|
+
* things: the paired device session, a request that carries its device token,
|
|
7
|
+
* and a failure that names itself instead of throwing a transport stack trace
|
|
8
|
+
* at a person. Before this module each command grew its own copy; the copies
|
|
9
|
+
* drifted (one normalised the dashboard URL, one did not) and only one of them
|
|
10
|
+
* ever named a reason.
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Nothing here throws for a transport error. `towerRequest` returns
|
|
14
|
+
* `{ ok: false, reason }` for every network-shaped failure, and the reason
|
|
15
|
+
* label is the thing that travels to the operator.
|
|
16
|
+
* - `loadPairedSession` still throws, because "this machine is not paired" is
|
|
17
|
+
* not a transport failure — it is a precondition the caller cannot proceed
|
|
18
|
+
* past, and the command's top level turns it into one plain sentence.
|
|
19
|
+
* - The device token is attached to the request and never returned, logged, or
|
|
20
|
+
* put in a reason. Log lines here carry metadata only.
|
|
21
|
+
*
|
|
22
|
+
* The HTTP vocabulary (`normalizeDashboardUrl`, `readResponseJson`,
|
|
23
|
+
* `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
|
|
24
|
+
* the upload path and the conversation path answer to the same rules.
|
|
25
|
+
*/
|
|
26
|
+
import { errorMessage } from "./commands/cli-io.js";
|
|
27
|
+
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "./local-state.js";
|
|
28
|
+
import { normalizeDashboardUrl, readResponseJson, responseErrorMessage, } from "./upload-http.js";
|
|
29
|
+
const defaultLog = (line) => {
|
|
30
|
+
console.error(line);
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Loads the paired device session, or explains in one sentence why it cannot.
|
|
34
|
+
*
|
|
35
|
+
* `commandName` names the caller so the sentence reads as the command a person
|
|
36
|
+
* actually typed ("`cockpit jarvis` needs a valid paired Tower session"),
|
|
37
|
+
* rather than the JARVIS-specific wording this used to be hard-coded to.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadPairedSession(commandName, homeDir) {
|
|
40
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
41
|
+
try {
|
|
42
|
+
const session = await readLocalCollectorSessionFile(paths);
|
|
43
|
+
if (session.session_state !== "valid") {
|
|
44
|
+
throw new Error(`session_${session.session_state}`);
|
|
45
|
+
}
|
|
46
|
+
return session;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`\`cockpit ${commandName}\` needs a valid paired Tower session. ` +
|
|
50
|
+
`Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Makes one authenticated request to the dashboard. Never throws.
|
|
55
|
+
*
|
|
56
|
+
* A 4xx/5xx still comes back as `{ ok: true, response }` — the status is the
|
|
57
|
+
* caller's to interpret, because several routes put a readable refusal in a
|
|
58
|
+
* non-2xx body. Only a request that produced no response at all is a failure
|
|
59
|
+
* here, and it always carries a reason label.
|
|
60
|
+
*/
|
|
61
|
+
export async function towerRequest(options) {
|
|
62
|
+
const log = options.log ?? defaultLog;
|
|
63
|
+
const startedAt = Date.now();
|
|
64
|
+
const url = `${normalizeDashboardUrl(options.dashboardUrl)}${options.path}`;
|
|
65
|
+
const isForm = typeof FormData !== "undefined" && options.body instanceof FormData;
|
|
66
|
+
const headers = {
|
|
67
|
+
...(options.headers ?? {}),
|
|
68
|
+
authorization: `Bearer ${options.deviceToken}`,
|
|
69
|
+
};
|
|
70
|
+
// fetch derives the multipart boundary from a FormData body; declaring the
|
|
71
|
+
// content-type by hand would strip it and the server would parse nothing.
|
|
72
|
+
if (options.body !== undefined && !isForm)
|
|
73
|
+
headers["content-type"] = "application/json";
|
|
74
|
+
const init = {
|
|
75
|
+
method: options.method ?? "POST",
|
|
76
|
+
headers,
|
|
77
|
+
...(options.body === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { body: (isForm ? options.body : JSON.stringify(options.body)) }),
|
|
80
|
+
...(timeoutSignal(options.timeoutMs) ?? {}),
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const response = await options.fetch(url, init);
|
|
84
|
+
log(`[tower client] responded ${JSON.stringify({
|
|
85
|
+
request: options.label,
|
|
86
|
+
http_status: response.status,
|
|
87
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
88
|
+
streamed: (response.headers.get("content-type") ?? "").includes("application/x-ndjson"),
|
|
89
|
+
elapsed_ms: Date.now() - startedAt,
|
|
90
|
+
})}`);
|
|
91
|
+
return { ok: true, response };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const reason = isAbortError(error)
|
|
95
|
+
? "turn_timed_out"
|
|
96
|
+
: "gateway_unreachable";
|
|
97
|
+
log(`[tower client] request failed ${JSON.stringify({
|
|
98
|
+
request: options.label,
|
|
99
|
+
reason,
|
|
100
|
+
elapsed_ms: Date.now() - startedAt,
|
|
101
|
+
})}`);
|
|
102
|
+
return { ok: false, reason, detail: towerFailureDetail(reason, errorMessage(error)) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `towerRequest` plus reading the body as JSON and turning a non-2xx into a
|
|
107
|
+
* named failure carrying the server's own words when it supplied any.
|
|
108
|
+
*/
|
|
109
|
+
export async function towerJsonRequest(options) {
|
|
110
|
+
const result = await towerRequest(options);
|
|
111
|
+
if (!result.ok)
|
|
112
|
+
return result;
|
|
113
|
+
const body = await readResponseJson(result.response);
|
|
114
|
+
if (!result.response.ok) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: "http_error",
|
|
118
|
+
httpStatus: result.response.status,
|
|
119
|
+
detail: responseErrorMessage(body, `http_${result.response.status}`),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, httpStatus: result.response.status, body };
|
|
123
|
+
}
|
|
124
|
+
/** One plain sentence per reason, so no caller has to invent its own wording. */
|
|
125
|
+
export function towerFailureDetail(reason, detail) {
|
|
126
|
+
switch (reason) {
|
|
127
|
+
case "gateway_unreachable":
|
|
128
|
+
return `Tower could not be reached (${detail}).`;
|
|
129
|
+
case "turn_timed_out":
|
|
130
|
+
return "Tower did not finish in time, so the answer is incomplete. Ask again.";
|
|
131
|
+
case "http_error":
|
|
132
|
+
return detail;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function isAbortError(error) {
|
|
136
|
+
if (!error || typeof error !== "object")
|
|
137
|
+
return false;
|
|
138
|
+
const name = error.name;
|
|
139
|
+
return name === "AbortError" || name === "TimeoutError";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A deadline when the runtime has one. `AbortSignal.timeout` exists on Node 18+
|
|
143
|
+
* on both supported host families; the guard is here so a stripped runtime
|
|
144
|
+
* loses the ceiling rather than the whole request.
|
|
145
|
+
*/
|
|
146
|
+
function timeoutSignal(timeoutMs) {
|
|
147
|
+
if (!timeoutMs || typeof AbortSignal?.timeout !== "function")
|
|
148
|
+
return null;
|
|
149
|
+
return { signal: AbortSignal.timeout(timeoutMs) };
|
|
150
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a Tower turn as it happens (BLI-3457).
|
|
3
|
+
*
|
|
4
|
+
* The dashboard's conversation routes speak one JSON object per line
|
|
5
|
+
* (`application/x-ndjson`): `activity` events as tools start and settle, then
|
|
6
|
+
* exactly one `final` event carrying what the plain JSON response used to be.
|
|
7
|
+
* That is the same wire format the browser already reads
|
|
8
|
+
* (`apps/dashboard/src/lib/webchat/tool-trace.ts`); this is the terminal's
|
|
9
|
+
* reader for it.
|
|
10
|
+
*
|
|
11
|
+
* Three things it refuses to do:
|
|
12
|
+
* - Throw. A stream that dies mid-turn comes back as a named reason, never an
|
|
13
|
+
* exception in the middle of a person's answer.
|
|
14
|
+
* - Present a truncated turn as a complete one. No `final` event means the
|
|
15
|
+
* turn failed, and it says which way it failed
|
|
16
|
+
* (`turn_timed_out` when the dashboard's own 120s ceiling is the likely
|
|
17
|
+
* cause, `stream_ended_without_final` otherwise).
|
|
18
|
+
* - Require the stream. A dashboard that has not shipped streaming yet answers
|
|
19
|
+
* `application/json`; that single object is read as the final event and the
|
|
20
|
+
* fallback is named `stream_not_available` rather than passed off as a
|
|
21
|
+
* stream that happened to be quiet.
|
|
22
|
+
*
|
|
23
|
+
* A malformed line is skipped, named `ndjson_line_unparseable`, and the lines
|
|
24
|
+
* after it still count — one bad byte cannot swallow an answer.
|
|
25
|
+
*/
|
|
26
|
+
import { errorMessage } from "./commands/cli-io.js";
|
|
27
|
+
import { isAbortError } from "./tower-client.js";
|
|
28
|
+
/**
|
|
29
|
+
* The dashboard caps a turn at 120s (`maxDuration = 120`). A stream that stops
|
|
30
|
+
* without a final event anywhere near that mark was almost certainly cut off by
|
|
31
|
+
* the platform, and saying `turn_timed_out` is the honest label; earlier than
|
|
32
|
+
* that, the stream broke for some other reason and gets its own.
|
|
33
|
+
*/
|
|
34
|
+
const CEILING_HINT_MS = 110_000;
|
|
35
|
+
/**
|
|
36
|
+
* Yields every event on the response, then stops at the first `final`.
|
|
37
|
+
*
|
|
38
|
+
* Handles both wire shapes: real NDJSON, and a plain `application/json` body
|
|
39
|
+
* from a dashboard that does not stream yet (yielded as the final event, after
|
|
40
|
+
* a `stream_not_available` note).
|
|
41
|
+
*/
|
|
42
|
+
export async function* readTowerStream(response) {
|
|
43
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
44
|
+
if (!contentType.includes("application/x-ndjson")) {
|
|
45
|
+
yield* readPlainJsonBody(response);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let buffer = "";
|
|
49
|
+
try {
|
|
50
|
+
for await (const chunk of iterateBody(response)) {
|
|
51
|
+
buffer += chunk;
|
|
52
|
+
const lines = buffer.split("\n");
|
|
53
|
+
buffer = lines.pop() ?? "";
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const item = parseLine(line);
|
|
56
|
+
if (!item)
|
|
57
|
+
continue;
|
|
58
|
+
yield item;
|
|
59
|
+
if (item.kind === "event" && item.event.type === "final")
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
yield {
|
|
66
|
+
kind: "note",
|
|
67
|
+
reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
|
|
68
|
+
detail: errorMessage(error),
|
|
69
|
+
};
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Whatever was still in flight when the body ended.
|
|
73
|
+
const tail = parseLine(buffer);
|
|
74
|
+
if (tail)
|
|
75
|
+
yield tail;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The whole turn in one call: renders activity as it arrives, buffers the
|
|
79
|
+
* final, and names any way the turn failed to produce one.
|
|
80
|
+
*
|
|
81
|
+
* `onActivity` is where a caller draws its live trace. `onNote` sees every
|
|
82
|
+
* named oddity (a skipped line, a non-streaming dashboard) so nothing is
|
|
83
|
+
* silently absorbed — the caller decides whether that is a log line or a
|
|
84
|
+
* sentence.
|
|
85
|
+
*/
|
|
86
|
+
export async function readTowerTurn(response, options = {}) {
|
|
87
|
+
const startedAt = options.startedAt ?? Date.now();
|
|
88
|
+
const activity = [];
|
|
89
|
+
const notes = [];
|
|
90
|
+
let streamed = true;
|
|
91
|
+
let final = null;
|
|
92
|
+
for await (const item of readTowerStream(response)) {
|
|
93
|
+
if (item.kind === "note") {
|
|
94
|
+
if (item.reason === "stream_not_available")
|
|
95
|
+
streamed = false;
|
|
96
|
+
notes.push({ reason: item.reason, detail: item.detail });
|
|
97
|
+
options.onNote?.(item.reason, item.detail);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (item.event.type === "final") {
|
|
101
|
+
final = item.event;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
activity.push(item.event);
|
|
105
|
+
options.onActivity?.(item.event);
|
|
106
|
+
}
|
|
107
|
+
const elapsedMs = Date.now() - startedAt;
|
|
108
|
+
if (!final) {
|
|
109
|
+
// The last named note is the truest reason we have; absent one, a stream
|
|
110
|
+
// that simply stopped near the dashboard's ceiling is a timeout.
|
|
111
|
+
const terminal = notes.at(-1);
|
|
112
|
+
const reason = terminal && terminal.reason !== "stream_not_available"
|
|
113
|
+
? terminal.reason
|
|
114
|
+
: elapsedMs >= CEILING_HINT_MS
|
|
115
|
+
? "turn_timed_out"
|
|
116
|
+
: "stream_ended_without_final";
|
|
117
|
+
options.log?.(`[tower stream] turn incomplete ${JSON.stringify({
|
|
118
|
+
reason,
|
|
119
|
+
activity_events: activity.length,
|
|
120
|
+
notes: notes.length,
|
|
121
|
+
elapsed_ms: elapsedMs,
|
|
122
|
+
})}`);
|
|
123
|
+
return { ok: false, reason, detail: streamFailureDetail(reason, terminal?.detail) };
|
|
124
|
+
}
|
|
125
|
+
options.log?.(`[tower stream] turn complete ${JSON.stringify({
|
|
126
|
+
streamed,
|
|
127
|
+
activity_events: activity.length,
|
|
128
|
+
notes: notes.length,
|
|
129
|
+
elapsed_ms: elapsedMs,
|
|
130
|
+
})}`);
|
|
131
|
+
return { ok: true, final, activity, streamed };
|
|
132
|
+
}
|
|
133
|
+
/** One plain sentence per way a turn can end without an answer. */
|
|
134
|
+
export function streamFailureDetail(reason, detail) {
|
|
135
|
+
switch (reason) {
|
|
136
|
+
case "turn_timed_out":
|
|
137
|
+
return "Tower ran out of time before finishing that turn, so the answer is incomplete. Ask again.";
|
|
138
|
+
case "stream_ended_without_final":
|
|
139
|
+
return "Tower stopped sending mid-answer, so the answer is incomplete. Ask again.";
|
|
140
|
+
case "stream_read_failed":
|
|
141
|
+
return `The connection to Tower broke mid-answer${detail ? ` (${detail})` : ""}. Ask again.`;
|
|
142
|
+
case "response_body_not_json":
|
|
143
|
+
return "Tower answered with something that was not JSON.";
|
|
144
|
+
case "response_body_empty":
|
|
145
|
+
return "Tower answered with an empty body.";
|
|
146
|
+
default:
|
|
147
|
+
return detail ?? reason;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A dashboard that does not stream yet. The whole body is one JSON object; it
|
|
152
|
+
* becomes the final event so every caller has exactly one code path.
|
|
153
|
+
*/
|
|
154
|
+
async function* readPlainJsonBody(response) {
|
|
155
|
+
yield {
|
|
156
|
+
kind: "note",
|
|
157
|
+
reason: "stream_not_available",
|
|
158
|
+
detail: response.headers.get("content-type") ?? "none",
|
|
159
|
+
};
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = await response.text();
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
yield {
|
|
166
|
+
kind: "note",
|
|
167
|
+
reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
|
|
168
|
+
detail: errorMessage(error),
|
|
169
|
+
};
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!text.trim()) {
|
|
173
|
+
yield { kind: "note", reason: "response_body_empty" };
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
yield { kind: "note", reason: "response_body_not_json" };
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
185
|
+
yield { kind: "note", reason: "response_body_not_json" };
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
yield {
|
|
189
|
+
kind: "event",
|
|
190
|
+
event: { ...parsed, type: "final" },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** One NDJSON line: an event, a named skip, or nothing at all for blank lines. */
|
|
194
|
+
function parseLine(line) {
|
|
195
|
+
const trimmed = line.trim();
|
|
196
|
+
if (!trimmed)
|
|
197
|
+
return null;
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(trimmed);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// The line text is never logged: it is conversation content.
|
|
204
|
+
return { kind: "note", reason: "ndjson_line_unparseable" };
|
|
205
|
+
}
|
|
206
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
207
|
+
return { kind: "note", reason: "unexpected_event_type" };
|
|
208
|
+
}
|
|
209
|
+
const type = parsed.type;
|
|
210
|
+
if (type !== "activity" && type !== "final") {
|
|
211
|
+
return { kind: "note", reason: "unexpected_event_type" };
|
|
212
|
+
}
|
|
213
|
+
return { kind: "event", event: parsed };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Decoded text chunks from a response body.
|
|
217
|
+
*
|
|
218
|
+
* Node's web `ReadableStream` is async-iterable on both supported host
|
|
219
|
+
* families (macOS and Windows, Node 20+), and that is the fast path. The
|
|
220
|
+
* `getReader()` fallback is there because a test double or a polyfilled fetch
|
|
221
|
+
* may hand back a stream without the iterator, and losing the answer over an
|
|
222
|
+
* iteration protocol would be absurd.
|
|
223
|
+
*/
|
|
224
|
+
async function* iterateBody(response) {
|
|
225
|
+
const body = response.body;
|
|
226
|
+
if (!body) {
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
if (text)
|
|
229
|
+
yield text;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const decoder = new TextDecoder();
|
|
233
|
+
const asyncIterable = body;
|
|
234
|
+
if (typeof asyncIterable[Symbol.asyncIterator] === "function") {
|
|
235
|
+
for await (const chunk of asyncIterable) {
|
|
236
|
+
yield decoder.decode(chunk, { stream: true });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const reader = body.getReader();
|
|
241
|
+
while (true) {
|
|
242
|
+
const { done, value } = await reader.read();
|
|
243
|
+
if (done)
|
|
244
|
+
break;
|
|
245
|
+
if (value)
|
|
246
|
+
yield decoder.decode(value, { stream: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const rest = decoder.decode();
|
|
250
|
+
if (rest)
|
|
251
|
+
yield rest;
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.38",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"pretypecheck": "npm run build",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "npm run build",
|
|
27
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/telemetry-core": "0.1.25"
|