@bli-cockpit/cli 0.2.37 → 0.2.39

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.
@@ -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.37",
3
+ "version": "0.2.39",
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"