@bli-cockpit/cli 0.2.40 → 0.2.41

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.
@@ -16,7 +16,7 @@
16
16
  * as the page is; moving a card is super_admin whichever surface asks, and a
17
17
  * refusal never costs a person the board.
18
18
  */
19
- import { writeLine } from "./cli-io.js";
19
+ import { colorEnabled, writeLine } from "./cli-io.js";
20
20
  import { renderScoutBoard, resolveExperimentRef, refusalSentence, shortId, truncationLines, } from "./scout-render.js";
21
21
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
22
  /** The dashboard route caps nothing here, but a board read should never hang a shell. */
@@ -49,7 +49,7 @@ export async function runScout(command, io) {
49
49
  writeLine(io.stdout, JSON.stringify({ ok: true, audience: payload.audience ?? null, windowDays: payload.windowDays ?? board.windowDays ?? null, board, lines }));
50
50
  }
51
51
  else {
52
- for (const line of renderScoutBoard(payload))
52
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
53
53
  writeLine(io.stdout, line);
54
54
  }
55
55
  writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
@@ -78,7 +78,7 @@ export async function runScout(command, io) {
78
78
  }
79
79
  else {
80
80
  writeLine(io.stderr, sentence);
81
- for (const line of renderScoutBoard(payload))
81
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
82
82
  writeLine(io.stdout, line);
83
83
  }
84
84
  return 1;
@@ -119,7 +119,7 @@ export async function runScout(command, io) {
119
119
  if (applied.httpStatus === 403) {
120
120
  writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
121
121
  }
122
- for (const line of renderScoutBoard(payload))
122
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
123
123
  writeLine(io.stdout, line);
124
124
  return 1;
125
125
  }
@@ -71,9 +71,16 @@ export async function runSessions(command, io) {
71
71
  reason: sidecar.skipped_reason,
72
72
  })),
73
73
  }));
74
+ // BLI-3483: the scanners have always counted the folders and files they
75
+ // could not read, and this command has always thrown those counts away
76
+ // except for one field in `--json`. So "No sessions observed" was printed on
77
+ // a machine where the scan had been blocked from looking — the operator's
78
+ // one diagnostic tool answering the question with the failure removed.
79
+ const readFailures = countReadFailures(codex, claude);
74
80
  if (command.json) {
75
81
  writeLine(io.stdout, JSON.stringify({
76
82
  window,
83
+ read_failures: readFailures,
77
84
  ...(codex
78
85
  ? { codex: { counts: codex.counts, sessions: codexRows } }
79
86
  : {}),
@@ -101,10 +108,47 @@ export async function runSessions(command, io) {
101
108
  }
102
109
  }
103
110
  if (codexRows.length === 0 && claudeRows.length === 0) {
104
- writeLine(io.stdout, "No sessions observed in the scan window.");
111
+ writeLine(io.stdout, readFailures.total > 0
112
+ ? `No sessions listed, but ${readFailures.total} read failure(s) mean the scan could not see everywhere — this is not proof there were none.`
113
+ : "No sessions observed in the scan window.");
114
+ }
115
+ if (readFailures.total > 0) {
116
+ for (const line of readFailureLines(readFailures)) {
117
+ writeLine(io.stdout, line);
118
+ }
105
119
  }
106
120
  return 0;
107
121
  }
122
+ /**
123
+ * Everything the two scanners looked at and could not read. Counts only — the
124
+ * project-dir slug encodes a local path and never leaves this machine.
125
+ */
126
+ function countReadFailures(codex, claude) {
127
+ const failures = {
128
+ codex_directory_read_failed: codex?.directory_read_failed_count ?? 0,
129
+ codex_stat_failed: codex?.stat_failed_count ?? 0,
130
+ codex_secret_path_skipped: codex?.secret_path_skipped_count ?? 0,
131
+ claude_project_dir_read_failed: claude?.project_dir_read_failed_count ?? 0,
132
+ claude_project_dirs_skipped: claude?.project_dirs_skipped ?? 0,
133
+ claude_session_stat_failed: claude?.session_stat_failed_count ?? 0,
134
+ claude_sidecar_dir_read_failed: claude?.sidecar_dir_read_failed_count ?? 0,
135
+ claude_sidecar_stat_failed: claude?.sidecar_stat_failed_count ?? 0,
136
+ };
137
+ return {
138
+ total: Object.values(failures).reduce((sum, count) => sum + count, 0),
139
+ ...failures,
140
+ };
141
+ }
142
+ function readFailureLines(failures) {
143
+ const named = Object.entries(failures)
144
+ .filter(([field, count]) => field !== "total" && Number(count) > 0)
145
+ .map(([field, count]) => ` ${field}: ${count}`);
146
+ return [
147
+ `read failures during this scan (${failures.total}) — sessions under these are not listed:`,
148
+ ...named,
149
+ " a permission wall is the usual cause; on macOS grant Full Disk Access, on Windows check the folder ACL",
150
+ ];
151
+ }
108
152
  /** `--all`, an explicit `--since-days` capped at pairing, or the default window. */
109
153
  async function sessionsScanWindow(command, now) {
110
154
  if (command.all) {
@@ -8,7 +8,12 @@
8
8
  * Every one of these is defensive about shape on purpose. These bodies come off
9
9
  * the wire from a dashboard that may be a release ahead or behind the installed
10
10
  * CLI, and a missing field should cost one line of output, not the command.
11
+ *
12
+ * Columns are padded in SCREEN CELLS, not UTF-16 code units (BLI-3482) — a
13
+ * display name written in another script is exactly the row where `padEnd`
14
+ * silently stops lining up.
11
15
  */
16
+ import { padEndDisplay } from "./text-width.js";
12
17
  const INDENT = " ";
13
18
  function text(value, fallback = "—") {
14
19
  if (typeof value === "string" && value.trim())
@@ -82,7 +87,7 @@ export function renderTeamMembers(body) {
82
87
  for (const person of people) {
83
88
  const standing = text(person.standing, "");
84
89
  const name = text(person.displayName, text(person.email));
85
- lines.push(`${INDENT}${name.padEnd(28)} ${text(person.role, "—").padEnd(20)} ${standing}`);
90
+ lines.push(`${INDENT}${padEndDisplay(name, 28)} ${padEndDisplay(text(person.role, "—"), 20)} ${standing}`);
86
91
  if (typeof person.userId === "string") {
87
92
  lines.push(`${INDENT}${INDENT}id ${person.userId}`);
88
93
  }
@@ -101,7 +106,7 @@ export function renderSwitches(body) {
101
106
  const options = Array.isArray(entry.options)
102
107
  ? entry.options.map((option) => text(record(option).value)).join(" | ")
103
108
  : "";
104
- return `${INDENT}${text(entry.key).padEnd(24)} ${text(entry.value).padEnd(12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
109
+ return `${INDENT}${padEndDisplay(text(entry.key), 24)} ${padEndDisplay(text(entry.value), 12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
105
110
  });
106
111
  if (typeof body.readFailureReason === "string") {
107
112
  lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
@@ -114,7 +119,7 @@ export function renderModelRouting(body) {
114
119
  `${INDENT}memory ${text(body.memoryEffectiveLabel)} (${text(body.memorySource)}${typeof body.memoryVia === "string" ? ` via ${body.memoryVia}` : ""})`,
115
120
  ];
116
121
  for (const slot of list(body.displaySlots)) {
117
- lines.push(`${INDENT}${text(slot.name).padEnd(24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
122
+ lines.push(`${INDENT}${padEndDisplay(text(slot.name), 24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
118
123
  }
119
124
  if (typeof body.readFailureReason === "string") {
120
125
  lines.push(`${INDENT}settings could not be read: ${body.readFailureReason}`);
@@ -132,6 +137,6 @@ export function renderEnvBlobs(body) {
132
137
  const blobs = list(body.blobs);
133
138
  if (blobs.length === 0)
134
139
  return [`${INDENT}no env files stored`];
135
- return blobs.map((blob) => `${INDENT}${text(blob.project).padEnd(12)} ${text(blob.file_name).padEnd(16)} ` +
140
+ return blobs.map((blob) => `${INDENT}${padEndDisplay(text(blob.project), 12)} ${padEndDisplay(text(blob.file_name), 16)} ` +
136
141
  `updated ${text(blob.updated_at)} id ${text(blob.id)}`);
137
142
  }
@@ -213,6 +213,12 @@ async function setModels(command, tower, io) {
213
213
  return 0;
214
214
  }
215
215
  writeLine(io.stdout, `Saved: ${asList(body.saved).join(", ") || "nothing"}.`);
216
+ // BLI-3481: the save succeeded but the belt pre-flight could not reach the
217
+ // provider, so nobody has checked that the model just chosen will accept
218
+ // Tower's tool belt. A bare "Saved." there would be a silent success.
219
+ if (typeof body.warning === "string" && body.warning.length > 0) {
220
+ writeLine(io.stderr, body.warning);
221
+ }
216
222
  return 0;
217
223
  }
218
224
  // ── env files ─────────────────────────────────────────────────────────
@@ -0,0 +1,108 @@
1
+ /**
2
+ * How wide a string is ON SCREEN, in terminal cells (BLI-3482).
3
+ *
4
+ * `"".length` counts UTF-16 code units, which is the wrong number twice over
5
+ * for a column: an emoji costs two units and draws two cells, a CJK ideograph
6
+ * costs one unit and draws two, and a combining accent costs one unit and draws
7
+ * nothing. Padding by `.length` therefore ragged-edges any table whose cells are
8
+ * not plain ASCII — the failure a person sees is a column that no longer lines
9
+ * up, on exactly the rows that carry a name or a title in another script.
10
+ *
11
+ * This is a deliberately small wcwidth, not the real one, and its limits are
12
+ * honest:
13
+ *
14
+ * - The wide ranges below are the common East Asian Wide/Fullwidth blocks plus
15
+ * the main emoji planes. Ambiguous-width characters (box drawing, `⏺`, Greek,
16
+ * Cyrillic) are counted as ONE cell, which is what a Western-locale terminal
17
+ * draws; a CJK-locale terminal may draw some of them as two.
18
+ * - A ZWJ emoji sequence (👨‍👩‍👧) is measured as the sum of its parts, so it
19
+ * over-counts on terminals that draw the whole cluster in two cells. Nothing
20
+ * here inspects grapheme clusters; that needs `Intl.Segmenter` and a real
21
+ * emoji table, which is more machinery than a column deserves.
22
+ * - U+FE0F (emoji presentation) is counted as zero, so a text-default symbol
23
+ * promoted to emoji presentation is under-counted by one.
24
+ *
25
+ * Every one of those errors costs alignment, never content: nothing in this
26
+ * file may drop, cut or reorder text.
27
+ */
28
+ /** SGR colour sequences occupy no cells, so they are removed before counting. */
29
+ // eslint-disable-next-line no-control-regex
30
+ const ANSI_SGR = /\x1b\[[0-9;]*m/gu;
31
+ /** Zero-cell code points: combining marks, joiners, variation selectors. */
32
+ const ZERO_WIDTH = [
33
+ [0x0300, 0x036f], // combining diacritical marks
34
+ [0x0483, 0x0489],
35
+ [0x0591, 0x05bd],
36
+ [0x0610, 0x061a],
37
+ [0x064b, 0x065f],
38
+ [0x0670, 0x0670],
39
+ [0x06d6, 0x06dc],
40
+ [0x0e31, 0x0e31],
41
+ [0x0e34, 0x0e3a],
42
+ [0x0e47, 0x0e4e],
43
+ [0x1ab0, 0x1aff], // combining diacritical marks extended
44
+ [0x1dc0, 0x1dff], // combining diacritical marks supplement
45
+ [0x200b, 0x200f], // zero-width space through RTL mark (incl. ZWNJ, ZWJ)
46
+ [0x20d0, 0x20f0], // combining marks for symbols
47
+ [0xfe00, 0xfe0f], // variation selectors
48
+ [0xfe20, 0xfe2f], // combining half marks
49
+ [0xfeff, 0xfeff], // byte-order mark
50
+ ];
51
+ /** Two-cell code points: the common Wide / Fullwidth blocks and emoji planes. */
52
+ const WIDE = [
53
+ [0x1100, 0x115f], // Hangul Jamo
54
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
55
+ [0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
56
+ [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
57
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
58
+ [0xa000, 0xa4cf], // Yi
59
+ [0xa960, 0xa97f], // Hangul Jamo Extended-A
60
+ [0xac00, 0xd7a3], // Hangul syllables
61
+ [0xf900, 0xfaff], // CJK compatibility ideographs
62
+ [0xfe10, 0xfe19], // vertical forms
63
+ [0xfe30, 0xfe6f], // CJK compatibility forms
64
+ [0xff00, 0xff60], // fullwidth forms
65
+ [0xffe0, 0xffe6], // fullwidth signs
66
+ [0x1f300, 0x1f64f], // misc symbols and pictographs, emoticons
67
+ [0x1f680, 0x1f6ff], // transport and map symbols
68
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
69
+ [0x20000, 0x3fffd], // CJK Unified Ideographs Extensions B onward
70
+ ];
71
+ function inRanges(code, ranges) {
72
+ for (const [low, high] of ranges) {
73
+ if (code < low)
74
+ return false; // ranges are ascending
75
+ if (code <= high)
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /** Cells one code point draws: 0 for combining/invisible, 2 for wide, else 1. */
81
+ export function codePointWidth(code) {
82
+ if (code === 0x0a || code === 0x0d)
83
+ return 0;
84
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0))
85
+ return 0; // control characters
86
+ if (inRanges(code, ZERO_WIDTH))
87
+ return 0;
88
+ if (inRanges(code, WIDE))
89
+ return 2;
90
+ return 1;
91
+ }
92
+ /** Cells a string draws, ignoring any SGR colour sequences inside it. */
93
+ export function displayWidth(text) {
94
+ let total = 0;
95
+ for (const character of text.replace(ANSI_SGR, "")) {
96
+ total += codePointWidth(character.codePointAt(0) ?? 0);
97
+ }
98
+ return total;
99
+ }
100
+ /**
101
+ * `padEnd` that counts cells instead of code units. Like `padEnd`, a string
102
+ * already at or past the column is returned untouched — a column is a minimum,
103
+ * never a limit, because cutting a cell to fit loses content.
104
+ */
105
+ export function padEndDisplay(text, width) {
106
+ const missing = width - displayWidth(text);
107
+ return missing > 0 ? `${text}${" ".repeat(missing)}` : text;
108
+ }
@@ -10,11 +10,18 @@
10
10
  * summarises, reorders or paraphrases — it wraps, and it strips the emphasis
11
11
  * markers a reader did not ask for. `--markdown` bypasses this file entirely
12
12
  * and prints the server's bytes, which is what lets this layout be opinionated.
13
+ *
14
+ * Two things arrive as arguments rather than being read from the process, so
15
+ * these functions stay pure and their tests stay deterministic (BLI-3482):
16
+ * `width` (the wrap column) and `styled` (whether stdout is a terminal that
17
+ * asked for colour — `workbook.ts` decides it once with `colorEnabled(io)`).
18
+ * Columns are measured in SCREEN CELLS via `displayWidth`, not in UTF-16 code
19
+ * units, so a table whose cells carry CJK or emoji still lines up.
13
20
  */
14
- const DIM = "\x1b[2m";
15
- const RESET = "\x1b[0m";
21
+ import { dim } from "./cli-io.js";
22
+ import { displayWidth, padEndDisplay } from "./text-width.js";
16
23
  /** One compartment per project, its documents underneath, addressed as typed. */
17
- export function renderShelf(projects, width) {
24
+ export function renderShelf(projects, width, styled) {
18
25
  const docs = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
19
26
  const out = [
20
27
  `WORKBOOK · ${projects.length} project${projects.length === 1 ? "" : "s"} · ${docs} document${docs === 1 ? "" : "s"}`,
@@ -27,25 +34,25 @@ export function renderShelf(projects, width) {
27
34
  out.push(` ⏺ cockpit workbook ${project.slug ?? ""} ${doc.slug ?? ""}`);
28
35
  out.push(` ${doc.title ?? ""} · ${doc.kind ?? ""}`);
29
36
  if (doc.line) {
30
- out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`)));
37
+ out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`, styled)));
31
38
  }
32
- out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`));
39
+ out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`, styled));
33
40
  }
34
41
  }
35
42
  return out;
36
43
  }
37
44
  /** The document's own header, then its blocks laid out for `width`. */
38
- export function renderDocText(payload, markdown, width) {
45
+ export function renderDocText(payload, markdown, width, styled) {
39
46
  const doc = payload.doc ?? {};
40
47
  const out = [];
41
48
  if (doc.title)
42
49
  out.push(...wrap(doc.title.toUpperCase(), width));
43
50
  const meta = [doc.kind, payload.project?.title, doc.author, doc.date].filter(Boolean);
44
51
  if (meta.length > 0)
45
- out.push(...wrap(meta.join(" · "), width).map((line) => dim(line)));
52
+ out.push(...wrap(meta.join(" · "), width).map((line) => dim(line, styled)));
46
53
  if (out.length > 0)
47
54
  out.push("");
48
- out.push(...renderMarkdownText(markdown, width));
55
+ out.push(...renderMarkdownText(markdown, width, styled));
49
56
  return out;
50
57
  }
51
58
  /**
@@ -54,7 +61,7 @@ export function renderDocText(payload, markdown, width) {
54
61
  * paragraphs — so there is no "unknown block" case to guess at; anything else
55
62
  * falls through as a wrapped paragraph rather than being dropped.
56
63
  */
57
- export function renderMarkdownText(markdown, width) {
64
+ export function renderMarkdownText(markdown, width, styled) {
58
65
  const out = [];
59
66
  for (const block of markdown.split("\n\n")) {
60
67
  const text = block.trim();
@@ -80,7 +87,7 @@ export function renderMarkdownText(markdown, width) {
80
87
  .map((line) => plain(line.replace(/^>\s?/, "")))
81
88
  .join(" ")
82
89
  .trim();
83
- out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`)));
90
+ out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`, styled)));
84
91
  continue;
85
92
  }
86
93
  if (text.startsWith("- ")) {
@@ -102,6 +109,11 @@ export function renderMarkdownText(markdown, width) {
102
109
  * width, each row is printed as `header: cell` lines instead — narrower, and
103
110
  * still every cell. Dropping columns to fit is never an option: a table with a
104
111
  * column missing looks complete and is not.
112
+ *
113
+ * Column sizes are SCREEN CELLS (BLI-3482). `.length` counts UTF-16 units, so a
114
+ * name in Japanese under-padded by one cell per character and a column of
115
+ * emoji over-padded by one — the fits/does-not-fit decision was measured in
116
+ * the same wrong unit, which is how a table that fits chose the narrow layout.
105
117
  */
106
118
  function renderTable(block, width) {
107
119
  const rows = block
@@ -121,12 +133,12 @@ function renderTable(block, width) {
121
133
  const columns = Math.max(...rows.map((cells) => cells.length));
122
134
  const widths = [];
123
135
  for (let index = 0; index < columns; index += 1) {
124
- widths.push(Math.max(...rows.map((cells) => (cells[index] ?? "").length)));
136
+ widths.push(Math.max(...rows.map((cells) => displayWidth(cells[index] ?? ""))));
125
137
  }
126
138
  const tableWidth = widths.reduce((sum, value) => sum + value, 0) + 2 * (columns - 1);
127
139
  if (tableWidth <= width) {
128
140
  return rows.map((cells) => cells
129
- .map((cell, index) => cell.padEnd(index === columns - 1 ? 0 : (widths[index] ?? 0)))
141
+ .map((cell, index) => padEndDisplay(cell, index === columns - 1 ? 0 : (widths[index] ?? 0)))
130
142
  .join(" ")
131
143
  .trimEnd());
132
144
  }
@@ -160,7 +172,10 @@ export function sectionSlice(markdown, sections, sectionId) {
160
172
  return blocks.slice(start, end).join("\n\n").trim();
161
173
  }
162
174
  // ----------------------------------------------------------------- small parts
163
- /** Greedy word wrap. A word longer than the column keeps its own line, uncut. */
175
+ /**
176
+ * Greedy word wrap, measured in screen cells. A word longer than the column
177
+ * keeps its own line, uncut.
178
+ */
164
179
  export function wrap(text, width) {
165
180
  const limit = Math.max(20, width);
166
181
  const words = text.split(/\s+/u).filter(Boolean);
@@ -168,17 +183,22 @@ export function wrap(text, width) {
168
183
  return [];
169
184
  const lines = [];
170
185
  let line = "";
186
+ let lineWidth = 0;
171
187
  for (const word of words) {
188
+ const wordWidth = displayWidth(word);
172
189
  if (!line) {
173
190
  line = word;
191
+ lineWidth = wordWidth;
174
192
  continue;
175
193
  }
176
- if (line.length + 1 + word.length <= limit) {
194
+ if (lineWidth + 1 + wordWidth <= limit) {
177
195
  line = `${line} ${word}`;
196
+ lineWidth += 1 + wordWidth;
178
197
  }
179
198
  else {
180
199
  lines.push(line);
181
200
  line = word;
201
+ lineWidth = wordWidth;
182
202
  }
183
203
  }
184
204
  lines.push(line);
@@ -190,7 +210,4 @@ function plain(text) {
190
210
  .replace(/\*\*(.+?)\*\*/gu, "$1")
191
211
  .replace(/\*(.+?)\*/gu, "$1")
192
212
  .replace(/\\\|/gu, "|");
193
- }
194
- function dim(text) {
195
- return `${DIM}${text}${RESET}`;
196
213
  }
@@ -14,7 +14,7 @@
14
14
  * on the two supported host families; `cockpit workbook tower workbook | less`
15
15
  * is the person's decision to make, not this command's.
16
16
  */
17
- import { writeLine, writeRaw } from "./cli-io.js";
17
+ import { colorEnabled, writeLine, writeRaw } from "./cli-io.js";
18
18
  import { renderDocText, renderShelf, sectionSlice, } from "./workbook-render.js";
19
19
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
20
20
  const READ_DEADLINE_MS = 30_000;
@@ -52,6 +52,7 @@ export async function runWorkbook(command, io) {
52
52
  // -------------------------------------------------------------------- index
53
53
  function writeIndex(command, io, payload, width) {
54
54
  const projects = payload.projects ?? [];
55
+ const styled = colorEnabled(io);
55
56
  if (command.project) {
56
57
  const found = projects.find((project) => project.slug === command.project);
57
58
  if (!found) {
@@ -73,7 +74,7 @@ function writeIndex(command, io, payload, width) {
73
74
  writeLine(io.stdout, JSON.stringify({ ok: true, project: found }));
74
75
  }
75
76
  else {
76
- for (const line of renderShelf([found], width))
77
+ for (const line of renderShelf([found], width, styled))
77
78
  writeLine(io.stdout, line);
78
79
  }
79
80
  logIndexRead(io, [found]);
@@ -83,7 +84,7 @@ function writeIndex(command, io, payload, width) {
83
84
  writeLine(io.stdout, JSON.stringify({ ok: true, projects }));
84
85
  }
85
86
  else {
86
- for (const line of renderShelf(projects, width))
87
+ for (const line of renderShelf(projects, width, styled))
87
88
  writeLine(io.stdout, line);
88
89
  }
89
90
  logIndexRead(io, projects);
@@ -134,7 +135,8 @@ function writeDoc(command, io, payload, width) {
134
135
  writeRaw(io.stdout, selected);
135
136
  }
136
137
  else {
137
- for (const line of renderDocText(payload, selected, width))
138
+ const lines = renderDocText(payload, selected, width, colorEnabled(io));
139
+ for (const line of lines)
138
140
  writeLine(io.stdout, line);
139
141
  }
140
142
  writeLine(io.stderr, `[workbook cli] doc read ${JSON.stringify({
@@ -102,14 +102,39 @@ export async function uploadRawEvidenceFilesChunked(options) {
102
102
  // (after retries) covers a new dashboard whose ledger migration has not
103
103
  // been applied yet. Both still serve the legacy v1 route.
104
104
  if (begin.status === 404 || begin.status >= 500) {
105
+ // Until BLI-3483 this downgrade abandoned the entire chunked path
106
+ // without a word, and the comment above named two causes that the fleet
107
+ // had no way to tell apart — an old dashboard versus an unapplied ledger
108
+ // migration. This is the BLI-2528 shape exactly: the code knew, the
109
+ // operator did not. Once per run, because `break` leaves the loop.
110
+ console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
111
+ reason: begin.status === 404
112
+ ? "begin_route_absent"
113
+ : "begin_server_error_after_retries",
114
+ http_status: begin.status,
115
+ server_reason: safeFailureDetail(begin.body) ?? "none",
116
+ objects_in_batch: batch.length,
117
+ objects_unresolved: loaded.length - resolvedEntries.size,
118
+ }));
105
119
  beginUnavailable = true;
106
120
  break;
107
121
  }
108
122
  if (!begin.ok) {
123
+ // The server's own `{ reason }` sat unread in this body while the ledger
124
+ // recorded the transport status and nothing else (BLI-3483); the commit
125
+ // path has read it since BLI-2528 and this one now does the same.
126
+ const beginDetail = safeFailureDetail(begin.body);
127
+ const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
128
+ console.error("[evidence-upload] begin refused these objects", JSON.stringify({
129
+ reason: "begin_rejected",
130
+ http_status: begin.status,
131
+ server_reason: beginDetail ?? "none",
132
+ objects_in_batch: batch.length,
133
+ }));
109
134
  for (const entry of batch) {
110
- outcomes.push(failedOutcome(entry.file, `begin_failed_http_${begin.status}`));
135
+ outcomes.push(failedOutcome(entry.file, beginReason));
111
136
  for (const duplicate of entry.duplicates) {
112
- outcomes.push(failedOutcome(duplicate, `begin_failed_http_${begin.status}`));
137
+ outcomes.push(failedOutcome(duplicate, beginReason));
113
138
  }
114
139
  resolvedEntries.add(entry);
115
140
  }
@@ -286,7 +311,21 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
286
311
  content_base64: chunk.toString("base64"),
287
312
  });
288
313
  if (!chunkResponse.ok) {
289
- return failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}`, uploadedChunks);
314
+ // Same treatment the commit path has had since BLI-2528: the server's
315
+ // own reason rides on the label, so `chunk_3_failed_http_413` becomes
316
+ // `chunk_3_failed_http_413_object_too_large` and the ledger row names
317
+ // the cause instead of the transport (BLI-3483).
318
+ const chunkDetail = safeFailureDetail(chunkResponse.body);
319
+ console.error("[evidence-upload] chunk rejected", JSON.stringify({
320
+ reason: "chunk_rejected",
321
+ upload_id: disposition.upload_id,
322
+ http_status: chunkResponse.status,
323
+ server_reason: chunkDetail ?? "none",
324
+ chunk_index: index,
325
+ chunk_count: entry.chunkCount,
326
+ uploaded_chunk_count: uploadedChunks,
327
+ }));
328
+ return failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}${chunkDetail ? `_${chunkDetail}` : ""}`, uploadedChunks);
290
329
  }
291
330
  uploadedChunks += 1;
292
331
  }
@@ -10,6 +10,7 @@ import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
10
10
  import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
11
11
  import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
12
12
  import { describeError, isMissingFileFailure } from "./health-detail.js";
13
+ import { serverFailureDetail } from "./upload-http.js";
13
14
  const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
15
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
15
16
  ? localCollectorPackage.version
@@ -671,7 +672,7 @@ async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
671
672
  });
672
673
  const parsed = await readResponseJson(response);
673
674
  if (!response.ok) {
674
- throw new Error(responseErrorMessage(parsed, "Pair request failed"));
675
+ throw new Error(pairFailureMessage("Pair request failed", response, parsed));
675
676
  }
676
677
  return parsePairStartResponse(parsed);
677
678
  }
@@ -692,7 +693,7 @@ async function pollPairRequest(fetchImpl, dashboardUrl, options) {
692
693
  });
693
694
  const parsed = await readResponseJson(response);
694
695
  if (!response.ok) {
695
- throw new Error(responseErrorMessage(parsed, "Pair polling failed"));
696
+ throw new Error(pairFailureMessage("Pair polling failed", response, parsed));
696
697
  }
697
698
  const status = readStringField(parsed, "status");
698
699
  if (status === "approved") {
@@ -713,6 +714,30 @@ async function pollPairRequest(fetchImpl, dashboardUrl, options) {
713
714
  }
714
715
  throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
715
716
  }
717
+ /**
718
+ * What `cockpit login` tells the operator when pairing is refused.
719
+ *
720
+ * "Pair request failed" was the whole message — five words, while the status
721
+ * code sat in hand (BLI-3483). A 401 (this build's token is not accepted), a
722
+ * 403 (the dashboard knows the device and is refusing it), a 404 (wrong
723
+ * dashboard URL) and a 502 (something in front of the dashboard answered) are
724
+ * four different next actions, and the operator could not tell them apart.
725
+ * `upload.ts` has named its HTTP status since it was written; this is the same
726
+ * shape. The server's own words come first when it supplied any, and the status
727
+ * always rides at the end so it is never the thing that got dropped.
728
+ *
729
+ * Logged as well as thrown: `cockpit login` failures happen on a machine that
730
+ * is not collecting yet, so the terminal is the only receipt there is.
731
+ */
732
+ function pairFailureMessage(fallback, response, body) {
733
+ const serverWords = responseErrorMessage(body, fallback);
734
+ console.error("[local-state] pairing request refused", JSON.stringify({
735
+ reason: fallback === "Pair request failed" ? "pair_start_refused" : "pair_poll_refused",
736
+ http_status: response.status,
737
+ server_reason: serverFailureDetail(body) ?? "none",
738
+ }));
739
+ return `${serverWords} (HTTP ${response.status})`;
740
+ }
716
741
  async function readResponseJson(response) {
717
742
  const text = await response.text();
718
743
  if (!text)
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { AgentImageArtifactReportRequestSchema, } from "@bli-cockpit/telemetry-core";
14
14
  import { describeError } from "./health-detail.js";
15
- import { readResponseJson } from "./upload-http.js";
15
+ import { readResponseJson, serverFailureDetail } from "./upload-http.js";
16
16
  export async function reportAgentImageArtifacts(options) {
17
17
  const artifacts = agentArtifactsFromEvidence(options);
18
18
  if (artifacts.length === 0) {
@@ -37,7 +37,15 @@ export async function reportAgentImageArtifacts(options) {
37
37
  },
38
38
  body: JSON.stringify(payload),
39
39
  });
40
+ // Both non-2xx branches returned a label and said nothing (BLI-3483). An
41
+ // image that never gets reported is never redacted and never OCR'd, and
42
+ // the only place that fact existed was a return value the sync discards.
40
43
  if (response.status === 404) {
44
+ console.error("[agent-artifacts] the dashboard has no agent-artifact route; these images stay unreported", JSON.stringify({
45
+ reason: "agent_artifact_api_unavailable",
46
+ http_status: 404,
47
+ artifact_count: artifacts.length,
48
+ }));
41
49
  return {
42
50
  posted: false,
43
51
  reason: "agent_artifact_api_unavailable",
@@ -45,6 +53,13 @@ export async function reportAgentImageArtifacts(options) {
45
53
  };
46
54
  }
47
55
  if (!response.ok) {
56
+ const serverReason = serverFailureDetail(await readResponseJson(response));
57
+ console.error("[agent-artifacts] report refused", JSON.stringify({
58
+ reason: "report_failed",
59
+ http_status: response.status,
60
+ server_reason: serverReason ?? "none",
61
+ artifact_count: artifacts.length,
62
+ }));
48
63
  return {
49
64
  posted: false,
50
65
  reason: `report_failed_http_${response.status}`,
@@ -55,10 +70,20 @@ export async function reportAgentImageArtifacts(options) {
55
70
  const recordedCount = body && typeof body === "object"
56
71
  ? Number(body.recorded_count ?? 0)
57
72
  : 0;
73
+ const recorded = Number.isFinite(recordedCount) ? recordedCount : 0;
74
+ // The success branch too: "some images reported" and "every image
75
+ // reported" are different facts, and a count that is short of
76
+ // `artifact_count` is the only warning an operator would ever get.
77
+ console.error("[agent-artifacts] report recorded", JSON.stringify({
78
+ reason: "recorded",
79
+ http_status: response.status,
80
+ artifact_count: artifacts.length,
81
+ recorded_count: recorded,
82
+ }));
58
83
  return {
59
84
  posted: true,
60
85
  reason: "recorded",
61
- recorded_count: Number.isFinite(recordedCount) ? recordedCount : 0,
86
+ recorded_count: recorded,
62
87
  };
63
88
  }
64
89
  catch (error) {