@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.
- 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 +175 -0
- package/dist/commands/install-receipts.js +11 -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/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 +2 -2
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -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.
|
|
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.
|
|
30
|
+
"@bli-cockpit/telemetry-core": "0.1.26"
|
|
31
31
|
}
|
|
32
32
|
}
|