@bli-cockpit/cli 0.2.46 → 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.
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
5
5
  import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
6
6
  const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
- const SYNC_LOG_MAX_BYTES = 50 * 1024 * 1024;
8
7
  /**
9
8
  * A staging directory belongs to one in-flight collection pass. Anything this
10
9
  * old is the remains of a crash or a kill, never live work — the longest sync
@@ -17,22 +16,12 @@ const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
17
16
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
17
  export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
19
18
  if (env["COCKPIT_DISABLE_GC"] === "1") {
20
- return {
21
- skipped: true,
22
- removed_dirs: 0,
23
- freed_bytes: 0,
24
- rotated_sync_log: false,
25
- };
19
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
26
20
  }
27
21
  const throttleMarker = path.join(paths.state_dir, ".last-raw-evidence-gc");
28
22
  const lastRun = await fs.stat(throttleMarker).catch(() => null);
29
23
  if (lastRun && now.getTime() - lastRun.mtimeMs < GC_MIN_INTERVAL_MS) {
30
- return {
31
- skipped: true,
32
- removed_dirs: 0,
33
- freed_bytes: 0,
34
- rotated_sync_log: false,
35
- };
24
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
36
25
  }
37
26
  await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
38
27
  await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
@@ -60,11 +49,17 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
60
49
  removedDirs += 1;
61
50
  freedBytes += inspection.byteSize;
62
51
  }
52
+ // Log capping used to live here, and that is why it did not work: this GC
53
+ // is throttled to once a day and skipped entirely by COCKPIT_DISABLE_GC,
54
+ // while sync.log grows ~8 MB an hour on an active machine (measured on the
55
+ // reference Mac, 2026-09-04: 219,866,695 bytes with the last GC 19 hours
56
+ // earlier). A daily truncate-to-zero of a file that reaches ~190 MB a day is
57
+ // not a cap, and sync.err.log had no cap at all. One owner now, running in
58
+ // the tick preamble: log-rotation.ts (BLI-3553).
63
59
  return {
64
60
  skipped: false,
65
61
  removed_dirs: removedDirs,
66
62
  freed_bytes: freedBytes,
67
- rotated_sync_log: await rotateSyncLog(paths),
68
63
  };
69
64
  }
70
65
  export function rawEvidenceGcSummary(result) {
@@ -288,15 +283,6 @@ async function listFiles(root) {
288
283
  }
289
284
  return files;
290
285
  }
291
- async function rotateSyncLog(paths) {
292
- const syncLog = path.join(paths.state_dir, "sync.log");
293
- const info = await fs.stat(syncLog).catch(() => null);
294
- if (!info?.isFile() || info.size <= SYNC_LOG_MAX_BYTES)
295
- return false;
296
- await fs.truncate(syncLog, 0).catch(() => undefined);
297
- const after = await fs.stat(syncLog).catch(() => null);
298
- return after?.isFile() === true && after.size === 0;
299
- }
300
286
  async function exists(target) {
301
287
  return fs.stat(target).then(() => true, () => false);
302
288
  }
@@ -127,6 +127,7 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
127
127
  reason: "updated",
128
128
  target_version: targetVersion ?? installedVersion,
129
129
  installed_version: installedVersion,
130
+ previous_version: deps.currentVersion,
130
131
  ...forcedFields,
131
132
  };
132
133
  }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
4
+ const NOT_CHECKED = {
5
+ detected: false,
6
+ reason: "probe_unavailable",
7
+ other_bin: null,
8
+ install_count: 0,
9
+ paths: [],
10
+ };
11
+ export async function detectSecondCockpitInstall(options) {
12
+ const platform = options.platform ?? process.platform;
13
+ const lookup = platform === "win32"
14
+ ? { cmd: "where.exe", args: ["cockpit"] }
15
+ : platform === "darwin"
16
+ ? { cmd: DARWIN_LOGIN_SHELL, args: ["-lc", "which -a cockpit"] }
17
+ : null;
18
+ if (!lookup)
19
+ return NOT_CHECKED;
20
+ const result = await options
21
+ .exec(lookup.cmd, lookup.args)
22
+ .catch(() => null);
23
+ if (!result) {
24
+ return { ...NOT_CHECKED, reason: "probe_failed" };
25
+ }
26
+ const candidates = parseCandidatePaths(result.stdout, platform);
27
+ if (candidates.length === 0) {
28
+ // `which`/`where` found nothing: this process was started by an absolute
29
+ // path (the scheduler always does) and the operator's PATH has no cockpit
30
+ // at all. Nothing to compare against, and nothing to complain about.
31
+ return {
32
+ detected: false,
33
+ reason: result.code === 0 ? "probe_failed" : "single_install",
34
+ other_bin: null,
35
+ install_count: 0,
36
+ paths: [],
37
+ };
38
+ }
39
+ const byDirectory = new Map();
40
+ for (const candidate of candidates) {
41
+ const key = directoryKey(candidate, platform);
42
+ if (!byDirectory.has(key))
43
+ byDirectory.set(key, candidate);
44
+ }
45
+ const installs = [...byDirectory.values()];
46
+ if (installs.length > 1) {
47
+ const other = await pickOther(installs, options, platform);
48
+ return {
49
+ detected: true,
50
+ reason: "second_install_detected",
51
+ other_bin: platformBasename(other ?? installs[installs.length - 1] ?? "", platform),
52
+ install_count: installs.length,
53
+ paths: installs,
54
+ };
55
+ }
56
+ // Exactly one on PATH. It can still be a DIFFERENT install from the one
57
+ // running now — the scheduler holds an absolute path to whichever prefix it
58
+ // was registered with. Only decidable where the bin is a symlink into the
59
+ // package (npm's POSIX layout); a Windows `.cmd` shim is a generated script,
60
+ // not a link, so there is nothing to resolve and the check stays quiet
61
+ // rather than guessing.
62
+ const only = installs[0];
63
+ if (!only || platform === "win32") {
64
+ return single(installs);
65
+ }
66
+ const ownEntryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
67
+ if (!ownEntryPoint)
68
+ return single(installs);
69
+ const isLink = await (options.isSymbolicLink ?? defaultIsSymbolicLink)(only);
70
+ if (!isLink)
71
+ return single(installs);
72
+ const resolve = options.realpath ?? ((candidate) => realpath(candidate));
73
+ const [linked, own] = await Promise.all([
74
+ resolve(only).catch(() => null),
75
+ resolve(ownEntryPoint).catch(() => ownEntryPoint),
76
+ ]);
77
+ if (!linked || linked === own)
78
+ return single(installs);
79
+ return {
80
+ detected: true,
81
+ reason: "second_install_detected",
82
+ other_bin: platformBasename(only, platform),
83
+ install_count: 2,
84
+ paths: [only, ownEntryPoint],
85
+ };
86
+ }
87
+ function single(installs) {
88
+ return {
89
+ detected: false,
90
+ reason: "single_install",
91
+ other_bin: null,
92
+ install_count: installs.length,
93
+ paths: installs,
94
+ };
95
+ }
96
+ /**
97
+ * Which of several installs is NOT the one this process came from.
98
+ *
99
+ * npm lays a global install out as `<prefix>/bin/cockpit` beside
100
+ * `<prefix>/lib/node_modules/...`, and on Windows as `<prefix>\cockpit.cmd`
101
+ * beside `<prefix>\node_modules\...`. Either way the bin's GRANDPARENT is the
102
+ * prefix and our own entry point lives under it, so anything under a different
103
+ * prefix is the other install. If nothing matches — an unusual layout — name
104
+ * the last candidate rather than nothing: an operator can act on a named bin
105
+ * and cannot act on silence.
106
+ */
107
+ async function pickOther(installs, options, platform) {
108
+ const entryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
109
+ const other = installs.find((candidate) => !isUnderPrefixOf(candidate, entryPoint, platform));
110
+ return other ?? installs[installs.length - 1] ?? null;
111
+ }
112
+ function isUnderPrefixOf(candidateBin, entryPoint, platform) {
113
+ if (!entryPoint)
114
+ return false;
115
+ const platformPath = platform === "win32" ? path.win32 : path.posix;
116
+ const prefix = platformPath.dirname(platformPath.dirname(candidateBin));
117
+ if (!prefix || prefix === "." || prefix === platformPath.sep)
118
+ return false;
119
+ const normalize = (value) => platform === "win32" ? value.toLowerCase() : value;
120
+ const bounded = prefix.endsWith(platformPath.sep)
121
+ ? prefix
122
+ : prefix + platformPath.sep;
123
+ return normalize(entryPoint).startsWith(normalize(bounded));
124
+ }
125
+ function platformBasename(candidate, platform) {
126
+ return platform === "win32"
127
+ ? path.win32.basename(candidate)
128
+ : path.posix.basename(candidate);
129
+ }
130
+ function parseCandidatePaths(stdout, platform) {
131
+ return stdout
132
+ .split(/\r?\n/u)
133
+ .map((line) => line.trim())
134
+ .filter((line) => {
135
+ if (!line)
136
+ return false;
137
+ // zsh's `which -a` also prints shell-function bodies and
138
+ // "cockpit not found"; only absolute paths are installs.
139
+ return platform === "win32"
140
+ ? /^[A-Za-z]:\\/u.test(line)
141
+ : line.startsWith("/");
142
+ });
143
+ }
144
+ function directoryKey(candidate, platform) {
145
+ if (!candidate)
146
+ return "";
147
+ const directory = platform === "win32"
148
+ ? path.win32.dirname(candidate).toLowerCase()
149
+ : path.posix.dirname(candidate);
150
+ return directory;
151
+ }
152
+ async function defaultIsSymbolicLink(candidate) {
153
+ return lstat(candidate).then((info) => info.isSymbolicLink(), () => false);
154
+ }
155
+ /** One line for a receipt or a log: named, path-free. */
156
+ export function secondInstallReason(finding) {
157
+ return finding.detected
158
+ ? `second_install_detected ${finding.other_bin ?? "cockpit"}`
159
+ : finding.reason;
160
+ }
@@ -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.46",
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
  }