@bli-cockpit/cli 0.2.45 → 0.2.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * What bucket a sync failure goes in, decided from structure — never from the
3
+ * words in a message.
4
+ *
5
+ * BLI-3551. The old classifier ran one regex,
6
+ * `/auth|token|session|unauthorized|forbidden|401|403/iu`, over the failure
7
+ * text. The sync's own reason label `session_report_unposted:no_successful_sync`
8
+ * contains the word "session", so a machine whose only problem was that it had
9
+ * nothing inside its approved roots reported `auth_failed` on every tick —
10
+ * 377 ticks in 38 hours on one device whose token was valid for another eleven
11
+ * weeks, 111 on another. The receipt did not merely lack detail; it named the
12
+ * wrong subsystem, and the operator's first move was to re-pair a device that
13
+ * was fine.
14
+ *
15
+ * So the bucket is now read off one of two structured things:
16
+ *
17
+ * - a **failure record** — the label the deciding branch wrote down, plus the
18
+ * HTTP status it observed, carried beside the rendered string rather than
19
+ * parsed back out of it;
20
+ * - a **typed error** — `SyncDeliveryError` (`reason` + `httpStatus`),
21
+ * `CollectionRootRequiredError`, or a Node error with a `code`/`name` the
22
+ * runtime set.
23
+ *
24
+ * `auth_failed` is reachable ONLY from an observed 401/403 or a named token
25
+ * failure. Anything unclassified is `sync_failed`, which is honest: an
26
+ * unrecognised failure is a failure whose subsystem nobody has named yet.
27
+ *
28
+ * Adding a reason label to the sync is adding an entry here — the class-lock
29
+ * test in `sync-health-class.test.ts` reads the reason literals out of
30
+ * `commands/session-sync.ts` and fails if one of them has no class.
31
+ */
32
+ import { CollectionRootRequiredError } from "./onboarding-roots.js";
33
+ import { SyncDeliveryError } from "./upload-failure-reason.js";
34
+ /** The coarse bucket a `sync_complete` receipt carries as its `error_code`. */
35
+ export const SYNC_HEALTH_ERROR_CODES = [
36
+ "auth_failed",
37
+ "network_failed",
38
+ "collection_root_failed",
39
+ "sync_failed",
40
+ ];
41
+ /**
42
+ * Every reason label a sync run is allowed to fail with.
43
+ *
44
+ * The rendered string a person reads may carry a scope or a nested cause
45
+ * (`bli-cockpit:ingest_rejected: http_500`, `raw_evidence:upload_failed`); the
46
+ * LABEL is what classification reads, and it is drawn from this list only.
47
+ */
48
+ export const SYNC_FAILURE_LABELS = [
49
+ // Delivery buckets, mirrored from SYNC_FAILURE_REASONS in
50
+ // upload-failure-reason.ts — a spooled worktree carries one of these.
51
+ "ingest_transport_error",
52
+ "ingest_rejected",
53
+ "ingest_receipt_not_202",
54
+ "ingest_receipt_incomplete",
55
+ "sync_failed_local",
56
+ /** A worktree finished in a state that is neither uploaded nor spooled. */
57
+ "upload_not_completed",
58
+ /** One or more raw-evidence objects failed to upload. */
59
+ "raw_evidence_upload_failed",
60
+ /** Raw evidence landed only partly and the gap is retryable. */
61
+ "raw_evidence_retry_required",
62
+ "deferred_byte_budget",
63
+ "deferred_object_budget",
64
+ "codex_session_limit_applied",
65
+ "claude_session_limit_applied",
66
+ "codex_session_read_failed",
67
+ "claude_session_read_failed",
68
+ /** The session store itself could not be read during the scan. */
69
+ "codex_scan_read_failed",
70
+ "claude_scan_read_failed",
71
+ /** Sessions were observed and their metadata report did not reach the door. */
72
+ "session_report_unposted",
73
+ /** The gate is real and this file forgot to say why — a bug, made visible. */
74
+ "sync_failed_reason_not_recorded",
75
+ ];
76
+ /**
77
+ * The class each label belongs to.
78
+ *
79
+ * `ingest_rejected` is deliberately NOT auth by default: a 500 from the ingest
80
+ * route and a 401 from it are the same label, and only the observed status
81
+ * tells them apart (see {@link classifySyncFailureRecords}).
82
+ */
83
+ export const SYNC_FAILURE_LABEL_CLASSES = {
84
+ // The request never reached an answer, or something in the path answered
85
+ // instead of the route. Both are the network, not this machine's state.
86
+ ingest_transport_error: "network_failed",
87
+ ingest_receipt_not_202: "network_failed",
88
+ // The route answered and refused, or answered and disagreed about what
89
+ // landed. Server-side; the status decides whether it was about credentials.
90
+ ingest_rejected: "sync_failed",
91
+ ingest_receipt_incomplete: "sync_failed",
92
+ // Everything below is this machine's own doing: budgets, caps, local reads,
93
+ // an unposted report. None of them is an authentication problem and none of
94
+ // them is a network problem.
95
+ sync_failed_local: "sync_failed",
96
+ upload_not_completed: "sync_failed",
97
+ raw_evidence_upload_failed: "sync_failed",
98
+ raw_evidence_retry_required: "sync_failed",
99
+ deferred_byte_budget: "sync_failed",
100
+ deferred_object_budget: "sync_failed",
101
+ codex_session_limit_applied: "sync_failed",
102
+ claude_session_limit_applied: "sync_failed",
103
+ codex_session_read_failed: "sync_failed",
104
+ claude_session_read_failed: "sync_failed",
105
+ codex_scan_read_failed: "sync_failed",
106
+ claude_scan_read_failed: "sync_failed",
107
+ session_report_unposted: "sync_failed",
108
+ sync_failed_reason_not_recorded: "sync_failed",
109
+ };
110
+ /** HTTP statuses that mean the credential, and nothing else. */
111
+ const AUTH_HTTP_STATUSES = new Set([401, 403]);
112
+ /**
113
+ * Reason labels that name a credential failure outright.
114
+ *
115
+ * Kept as an exact-match set, not a pattern: the word "token" appearing inside
116
+ * some other reason is what produced the lie this module exists to end.
117
+ */
118
+ const AUTH_FAILURE_LABELS = new Set([
119
+ "device_token_invalid",
120
+ "device_token_revoked",
121
+ "device_token_expired",
122
+ "device_not_paired",
123
+ "unauthorized",
124
+ "forbidden",
125
+ ]);
126
+ /** Node error codes that mean the request never got out or never got back. */
127
+ const NETWORK_ERROR_CODES = new Set([
128
+ "ENOTFOUND",
129
+ "ECONNREFUSED",
130
+ "ECONNRESET",
131
+ "EAI_AGAIN",
132
+ "EHOSTUNREACH",
133
+ "ENETUNREACH",
134
+ "ETIMEDOUT",
135
+ "EPIPE",
136
+ "UND_ERR_CONNECT_TIMEOUT",
137
+ "UND_ERR_HEADERS_TIMEOUT",
138
+ "UND_ERR_SOCKET",
139
+ ]);
140
+ /**
141
+ * The class one record belongs to.
142
+ *
143
+ * The status wins over the label where the two disagree, because a 401 is an
144
+ * observation and a label is a categorisation.
145
+ */
146
+ export function classifySyncFailureRecord(record) {
147
+ if (typeof record.http_status === "number" &&
148
+ AUTH_HTTP_STATUSES.has(record.http_status)) {
149
+ return "auth_failed";
150
+ }
151
+ return SYNC_FAILURE_LABEL_CLASSES[record.label] ?? "sync_failed";
152
+ }
153
+ /**
154
+ * The class a whole run belongs to.
155
+ *
156
+ * A run fails for several reasons at once more often than not, so the most
157
+ * actionable one wins: a credential problem is worth waking up for, a network
158
+ * one is worth waiting out, and a missing collection root is a setup step. The
159
+ * generic bucket is the floor, never a tie-breaker.
160
+ */
161
+ export function classifySyncFailureRecords(records) {
162
+ const classes = new Set(records.map(classifySyncFailureRecord));
163
+ for (const candidate of [
164
+ "auth_failed",
165
+ "collection_root_failed",
166
+ "network_failed",
167
+ ]) {
168
+ if (classes.has(candidate))
169
+ return candidate;
170
+ }
171
+ return "sync_failed";
172
+ }
173
+ /**
174
+ * The class a thrown error belongs to.
175
+ *
176
+ * Reads only structure the code itself set: the delivery error's own `reason`
177
+ * and `httpStatus`, the collection-root error's type, the runtime's `code` and
178
+ * `name`. A message is never inspected — that is the whole point of BLI-3551.
179
+ */
180
+ export function classifySyncHealthError(error) {
181
+ if (error instanceof CollectionRootRequiredError) {
182
+ return "collection_root_failed";
183
+ }
184
+ if (error instanceof SyncDeliveryError) {
185
+ return classifySyncFailureRecord({
186
+ label: error.reason,
187
+ rendered: error.message,
188
+ http_status: error.httpStatus,
189
+ });
190
+ }
191
+ const structured = structuredErrorFields(error);
192
+ if (typeof structured.httpStatus === "number" &&
193
+ AUTH_HTTP_STATUSES.has(structured.httpStatus)) {
194
+ return "auth_failed";
195
+ }
196
+ if (structured.reason && AUTH_FAILURE_LABELS.has(structured.reason)) {
197
+ return "auth_failed";
198
+ }
199
+ if (structured.reason &&
200
+ isSyncFailureLabel(structured.reason)) {
201
+ return SYNC_FAILURE_LABEL_CLASSES[structured.reason];
202
+ }
203
+ if (structured.name === "AbortError" || structured.name === "TimeoutError") {
204
+ return "network_failed";
205
+ }
206
+ if (structured.code && NETWORK_ERROR_CODES.has(structured.code)) {
207
+ return "network_failed";
208
+ }
209
+ // Nothing structured said anything. That is not evidence of an auth problem,
210
+ // a network problem or a setup problem — it is an unclassified failure, and
211
+ // the receipt says so instead of guessing from vocabulary.
212
+ return "sync_failed";
213
+ }
214
+ export function isSyncFailureLabel(value) {
215
+ return Object.hasOwn(SYNC_FAILURE_LABEL_CLASSES, value);
216
+ }
217
+ /**
218
+ * Fields an error carries as data rather than as prose.
219
+ *
220
+ * Duck-typed on purpose: an error crossing a package boundary (or rebuilt from
221
+ * a worker) loses its prototype but keeps its own enumerable fields, and a
222
+ * `reason` written by our code is still structure.
223
+ */
224
+ function structuredErrorFields(error) {
225
+ if (!error || typeof error !== "object") {
226
+ return { name: null, code: null, reason: null, httpStatus: null };
227
+ }
228
+ const record = error;
229
+ const status = typeof record["httpStatus"] === "number"
230
+ ? record["httpStatus"]
231
+ : typeof record["http_status"] === "number"
232
+ ? record["http_status"]
233
+ : typeof record["status"] === "number"
234
+ ? record["status"]
235
+ : null;
236
+ return {
237
+ name: typeof record["name"] === "string" ? record["name"] : null,
238
+ code: typeof record["code"] === "string" ? record["code"] : null,
239
+ reason: typeof record["reason"] === "string" ? record["reason"] : null,
240
+ httpStatus: status,
241
+ };
242
+ }
package/dist/upload.js CHANGED
@@ -170,6 +170,8 @@ export async function syncLocalAmbientEnvelope(options = {}) {
170
170
  source_scan_count: built.source_scan_count,
171
171
  risk_flag_count: built.risk_flag_count,
172
172
  failure_reason: spooledFailureReason,
173
+ failure_class: classified.reason,
174
+ failure_http_status: classified.http_status,
173
175
  spool_entry_id: entry.spool_id,
174
176
  retry_command: entry.retry_command,
175
177
  ...summarizeRawEvidenceDelivery(built, uploadOutcomes, uploadedChunkCount, cursor, staging, attemptedDate),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,6 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/telemetry-core": "0.1.25"
30
+ "@bli-cockpit/telemetry-core": "0.1.26"
31
31
  }
32
32
  }