@bli-cockpit/cli 0.2.40 → 0.2.42

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.
@@ -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) {
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The vocabulary a failed sync is allowed to use, and the bounded cause beside
3
+ * it.
4
+ *
5
+ * Before BLI-3483 the delivery path threw English sentences and the catch in
6
+ * `upload.ts` copied `error.message` straight into the spool row's
7
+ * `failure_reason`. Two consequences, both bad:
8
+ *
9
+ * - **Unbounded cardinality.** Every distinct server message became a distinct
10
+ * "reason", so nothing downstream could count how many machines were failing
11
+ * the same way. A reason label is only useful if it is drawn from a closed
12
+ * set.
13
+ * - **Opposite causes wearing one name.** "Ambient ingest returned an incomplete
14
+ * durable receipt." was the answer for a proxy that returned its own 200 AND
15
+ * for a dashboard that genuinely persisted fewer rows than were sent. The
16
+ * first is a network appliance in the way; the second is data loss. Same
17
+ * sentence, opposite repairs.
18
+ *
19
+ * So a failure now carries two things: a `SyncFailureReason` from the list
20
+ * below, which is the bucket, and a detail of at most
21
+ * {@link SYNC_FAILURE_DETAIL_MAX_CHARS} characters, which is the observed cause
22
+ * — the status, the field that disagreed, the error code. The detail is
23
+ * redacted with the same scrubber the health receipts use, because a caught
24
+ * error on this machine can carry an absolute path or a token-shaped fragment.
25
+ *
26
+ * Spool rows are local JSON, not database rows, so nothing migrates: a row
27
+ * written by an older CLI keeps its prose string and still parses and renders,
28
+ * it simply does not participate in the new buckets.
29
+ */
30
+ import { describeError, redactedHealthDetail } from "./health-detail.js";
31
+ export const SYNC_FAILURE_REASONS = [
32
+ /** The request never got an answer: DNS, TLS, a reset, an offline machine. */
33
+ "ingest_transport_error",
34
+ /** The ingest route answered and refused the envelope. */
35
+ "ingest_rejected",
36
+ /**
37
+ * Something answered 2xx, but not the ingest route's 202. A proxy, a captive
38
+ * portal, or a gateway that swallowed the real response.
39
+ */
40
+ "ingest_receipt_not_202",
41
+ /** A 202 from the route whose receipt does not match what was submitted. */
42
+ "ingest_receipt_incomplete",
43
+ /** The sync threw before or after delivery — filesystem, cursor, spool. */
44
+ "sync_failed_local",
45
+ ];
46
+ export const SYNC_FAILURE_DETAIL_MAX_CHARS = 250;
47
+ /**
48
+ * A delivery failure that already knows its own bucket.
49
+ *
50
+ * `message` is `reason: detail` so the existing consumers that only ever print
51
+ * `error.message` — `cockpit status`, the health-receipt classifier — keep
52
+ * working and get strictly more than they had.
53
+ */
54
+ export class SyncDeliveryError extends Error {
55
+ reason;
56
+ detail;
57
+ httpStatus;
58
+ constructor(reason, options = {}) {
59
+ const detail = boundedFailureDetail(options.detail);
60
+ super(composeFailureReason(reason, detail), options.cause === undefined ? undefined : { cause: options.cause });
61
+ this.name = "SyncDeliveryError";
62
+ this.reason = reason;
63
+ this.detail = detail;
64
+ this.httpStatus = options.httpStatus ?? null;
65
+ }
66
+ }
67
+ /**
68
+ * Redacts, collapses and caps an observed cause.
69
+ *
70
+ * `redactedHealthDetail` is the boundary rule for anything that leaves this
71
+ * machine (secrets, absolute paths, the hostname, the account name); the cap
72
+ * here is tighter than the health receipt's because this string is also the
73
+ * spool row a person reads in one terminal line.
74
+ */
75
+ export function boundedFailureDetail(value) {
76
+ if (!value)
77
+ return null;
78
+ const redacted = redactedHealthDetail(value);
79
+ if (!redacted)
80
+ return null;
81
+ return redacted.length > SYNC_FAILURE_DETAIL_MAX_CHARS
82
+ ? `${redacted.slice(0, SYNC_FAILURE_DETAIL_MAX_CHARS - 1)}…`
83
+ : redacted;
84
+ }
85
+ /** The single string the spool row stores: the bucket, then the cause. */
86
+ export function composeFailureReason(reason, detail) {
87
+ return detail ? `${reason}: ${detail}` : reason;
88
+ }
89
+ /**
90
+ * Turns anything thrown inside a sync into a bucket plus a cause.
91
+ *
92
+ * A `SyncDeliveryError` already decided; everything else is local — a
93
+ * filesystem error writing the cursor, a spool write, a throw from an adapter —
94
+ * and is described by its error name, code and syscall rather than by its
95
+ * message, which is where the paths live.
96
+ */
97
+ export function classifySyncFailure(error) {
98
+ if (error instanceof SyncDeliveryError) {
99
+ return {
100
+ reason: error.reason,
101
+ detail: error.detail,
102
+ failure_reason: composeFailureReason(error.reason, error.detail),
103
+ http_status: error.httpStatus,
104
+ };
105
+ }
106
+ const described = describeError(error);
107
+ const parts = [
108
+ described.error_name,
109
+ described.error_code,
110
+ described.error_syscall,
111
+ described.error_detail,
112
+ described.cause_code ?? described.cause_name,
113
+ ].filter((part) => Boolean(part));
114
+ const detail = boundedFailureDetail(parts.join(" "));
115
+ return {
116
+ reason: "sync_failed_local",
117
+ detail,
118
+ failure_reason: composeFailureReason("sync_failed_local", detail),
119
+ http_status: null,
120
+ };
121
+ }
@@ -6,6 +6,7 @@
6
6
  * server sent back without letting a non-JSON body throw, and turn a normal
7
7
  * dashboard URL into one that can be concatenated with a path.
8
8
  */
9
+ import { boundedFailureDetail } from "./upload-failure-reason.js";
9
10
  /**
10
11
  * Reads a reply body as JSON, and never throws doing it.
11
12
  *
@@ -34,6 +35,33 @@ export async function readResponseJson(response) {
34
35
  return { message: text };
35
36
  }
36
37
  }
38
+ /**
39
+ * The server's own reason for refusing, as something safe to log.
40
+ *
41
+ * The dashboard's error envelopes answer `{ code, message, reason? }`. A
42
+ * `reason`/`code` that already looks like a label travels verbatim — that is
43
+ * the bucket the server chose, and reproducing it is how "storage rejected 116
44
+ * MB" reaches an operator instead of "upload failed" (BLI-2528). Anything else
45
+ * falls back to the message, redacted and capped, because a proxy's error page
46
+ * is unbounded and a route's message can carry a path.
47
+ *
48
+ * `null` means the body said nothing usable — which is itself worth logging as
49
+ * `"none"`, since "the server gave no reason" and "we never looked" are
50
+ * different facts.
51
+ */
52
+ export function serverFailureDetail(value) {
53
+ if (!value || typeof value !== "object")
54
+ return null;
55
+ const record = value;
56
+ for (const key of ["reason", "code"]) {
57
+ const candidate = record[key];
58
+ if (typeof candidate === "string" && /^[a-z0-9_:.-]{1,80}$/iu.test(candidate)) {
59
+ return candidate;
60
+ }
61
+ }
62
+ const message = record["message"] ?? record["error"];
63
+ return typeof message === "string" ? boundedFailureDetail(message) : null;
64
+ }
37
65
  /** The server's own words when it supplied any, otherwise our fallback. */
38
66
  export function responseErrorMessage(value, fallback) {
39
67
  if (value && typeof value === "object") {