@bli-cockpit/cli 0.2.46 → 0.2.48
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/raw-evidence-git-diff.js +50 -0
- package/dist/adapters/raw-evidence-keys.js +4 -1
- package/dist/adapters/raw-evidence-pack-store.js +13 -4
- package/dist/adapters/raw-evidence.js +43 -1
- package/dist/autostart-node-path.js +141 -0
- package/dist/autostart-self-heal.js +115 -11
- package/dist/autostart.js +213 -41
- package/dist/commands/autostart-heal.js +162 -0
- package/dist/commands/collection-roots.js +4 -4
- package/dist/commands/doctor.js +47 -1
- package/dist/commands/heartbeat.js +193 -0
- package/dist/commands/install-receipts.js +45 -19
- package/dist/commands/install-update.js +3 -3
- package/dist/commands/local-args.js +7 -1
- package/dist/commands/local.js +116 -10
- package/dist/commands/ops-render.js +29 -0
- package/dist/commands/ops.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +146 -25
- package/dist/dev-build.js +186 -0
- package/dist/evidence-upload-client.js +112 -4
- package/dist/evidence-upload-rekey.js +40 -0
- package/dist/log-rotation.js +144 -0
- package/dist/onboarding-roots.js +23 -6
- package/dist/raw-evidence-gc.js +9 -23
- package/dist/scheduled-self-update.js +1 -0
- package/dist/second-install.js +160 -0
- package/dist/sync-health-class.js +242 -0
- package/dist/upload.js +2 -0
- package/package.json +3 -3
|
@@ -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.
|
|
3
|
+
"version": "0.2.48",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"pretypecheck": "npm run build",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "npm run build",
|
|
27
|
-
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
|
+
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/telemetry-core": "0.1.26"
|
|
31
31
|
}
|
|
32
32
|
}
|