@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.
- package/dist/adapters/local-sources.js +51 -5
- package/dist/autostart.js +105 -5
- package/dist/commands/brief.js +11 -10
- package/dist/commands/cli-io.js +29 -0
- package/dist/commands/correct.js +4 -5
- package/dist/commands/jarvis.js +7 -8
- package/dist/commands/local-discovery.js +20 -0
- package/dist/commands/local.js +34 -1
- package/dist/commands/notes.js +9 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +20 -18
- package/dist/commands/scout.js +4 -4
- package/dist/commands/sessions.js +45 -1
- package/dist/commands/settings-render.js +9 -4
- package/dist/commands/settings.js +6 -0
- package/dist/commands/text-width.js +108 -0
- package/dist/commands/workbook-render.js +34 -17
- package/dist/commands/workbook.js +6 -4
- package/dist/evidence-upload-client.js +42 -3
- package/dist/local-state.js +27 -2
- package/dist/upload-agent-artifacts.js +27 -2
- package/dist/upload-failure-reason.js +121 -0
- package/dist/upload-http.js +28 -0
- package/dist/upload.js +122 -21
- package/package.json +1 -1
package/dist/commands/scout.js
CHANGED
|
@@ -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,
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
15
|
-
|
|
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] ?? "")
|
|
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
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
135
|
+
outcomes.push(failedOutcome(entry.file, beginReason));
|
|
111
136
|
for (const duplicate of entry.duplicates) {
|
|
112
|
-
outcomes.push(failedOutcome(duplicate,
|
|
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
|
-
|
|
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
|
}
|
package/dist/local-state.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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:
|
|
86
|
+
recorded_count: recorded,
|
|
62
87
|
};
|
|
63
88
|
}
|
|
64
89
|
catch (error) {
|