@bli-cockpit/cli 0.2.28 → 0.2.29

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.
@@ -1163,6 +1163,13 @@ async function syncBackfillBatch(options) {
1163
1163
  })),
1164
1164
  claudeAttributionScan: options.claudeAttribution ?? undefined,
1165
1165
  rawEvidenceBudget: options.rawEvidenceBudget,
1166
+ // `cockpit backfill` is only ever an operator asking — by hand, through
1167
+ // onboarding, through doctor, or by running the retry command Cockpit
1168
+ // printed. The delivery-backoff window is the scheduler's cadence, so it
1169
+ // does not gate this pass: honouring it here made the retry that follows a
1170
+ // failed commit a 15-minute no-op that reported
1171
+ // `durable_session_pointer_missing` and never named the hold (BLI-3118).
1172
+ evidenceDeliveryMode: "operator_retry",
1166
1173
  fetch: options.fetchImpl,
1167
1174
  };
1168
1175
  try {
@@ -0,0 +1,92 @@
1
+ import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
2
+ export function writeLine(stream, text) {
3
+ stream.write(`${text}\n`);
4
+ }
5
+ /** Writes text through unchanged, adding only the trailing newline it lacks. */
6
+ export function writeRaw(stream, text) {
7
+ if (!text)
8
+ return;
9
+ stream.write(text);
10
+ if (!text.endsWith("\n"))
11
+ stream.write("\n");
12
+ }
13
+ export function errorMessage(error) {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+ export function writeExecOutput(io, result, options) {
17
+ if (options.stdout)
18
+ writeRaw(io.stdout, result.stdout);
19
+ if (options.stderr)
20
+ writeRaw(io.stderr, result.stderr);
21
+ }
22
+ export function isInteractiveStdin(io) {
23
+ return Boolean(io.stdin.isTTY);
24
+ }
25
+ /**
26
+ * Reads one line from stdin after writing a prompt. Shared by the onboard email
27
+ * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
28
+ * headless / piped / spawned runs never block on input.
29
+ */
30
+ export async function readLine(io, prompt) {
31
+ io.stdout.write(prompt);
32
+ io.stdin.setEncoding("utf8");
33
+ return new Promise((resolve) => {
34
+ const onData = (chunk) => {
35
+ io.stdin.removeListener("data", onData);
36
+ io.stdin.pause();
37
+ resolve(chunk);
38
+ };
39
+ io.stdin.resume();
40
+ io.stdin.on("data", onData);
41
+ });
42
+ }
43
+ /** Enter means yes: only an explicit `n`/`no` declines. */
44
+ export function yesByDefault(raw) {
45
+ const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
46
+ return answer !== "n" && answer !== "no";
47
+ }
48
+ /** Collects what a nested command writes instead of letting it reach the user. */
49
+ export function bufferedWritable(chunks) {
50
+ return {
51
+ write(chunk) {
52
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
53
+ return true;
54
+ },
55
+ };
56
+ }
57
+ export function replayCaptured(stream, chunks) {
58
+ for (const chunk of chunks)
59
+ stream.write(chunk);
60
+ }
61
+ /** Captured `--json` stdout, parsed when it is JSON and handed back raw when it is not. */
62
+ export function parseCapturedJson(chunks) {
63
+ const text = chunks.join("").trim();
64
+ if (!text)
65
+ return null;
66
+ try {
67
+ return JSON.parse(text);
68
+ }
69
+ catch {
70
+ return text;
71
+ }
72
+ }
73
+ export function defaultExec() {
74
+ return createCapturedExecRunner();
75
+ }
76
+ export function defaultInteractiveExec() {
77
+ return createInteractiveExecRunner();
78
+ }
79
+ export function defaultIo() {
80
+ if (!globalThis.fetch) {
81
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
82
+ }
83
+ return {
84
+ stdin: process.stdin,
85
+ stdout: process.stdout,
86
+ stderr: process.stderr,
87
+ env: process.env,
88
+ fetch: globalThis.fetch.bind(globalThis),
89
+ exec: defaultExec(),
90
+ interactiveExec: defaultInteractiveExec(),
91
+ };
92
+ }
@@ -0,0 +1,135 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { matchesLiveSyncWorktree, } from "./session-sync.js";
3
+ export function shortSha(value) {
4
+ return value ? value.slice(0, 12) : "unknown";
5
+ }
6
+ export function displayTicketId(ticketId) {
7
+ return ticketId ?? "general ambient";
8
+ }
9
+ export function displayWorkLabel(status) {
10
+ if (status.work_label && status.work_id)
11
+ return `${status.work_label} (${status.work_id})`;
12
+ return status.work_label ?? status.work_id ?? "no active work context";
13
+ }
14
+ function sourceFunnelLine(label, counts) {
15
+ const readFailures = counts.read_failures > 0 ? `, read_failures ${counts.read_failures}` : "";
16
+ return `${label} sessions: attributed ${counts.attributed}, fallback ${counts.attributed_fallback}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}${readFailures}`;
17
+ }
18
+ function attributionReportLine(summary) {
19
+ return summary.report_posted
20
+ ? "Attribution report: recorded"
21
+ : `Attribution report: skipped (${summary.report_reason})`;
22
+ }
23
+ /**
24
+ * One funnel line per source, an anomaly diagnostics line only when something
25
+ * is nonzero (clean syncs stay one line per source), and the report line.
26
+ * Counts only — project-dir slugs encode full local paths and never print
27
+ * (B.4 §5).
28
+ */
29
+ export function writeAgentSessionSummary(io, summary) {
30
+ writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
31
+ writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
32
+ const claudeDiagnostics = claudeDiagnosticsLine(summary);
33
+ if (claudeDiagnostics)
34
+ writeLine(io.stdout, claudeDiagnostics);
35
+ writeLine(io.stdout, attributionReportLine(summary));
36
+ }
37
+ function claudeDiagnosticsLine(summary) {
38
+ // Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
39
+ // counters and must NOT trigger this line, or a healthy orchestrated sync
40
+ // prints it 48×/day in launchd logs. Clean syncs stay one line per source.
41
+ const claude = summary.claude;
42
+ const parts = [];
43
+ if (claude.sidecars_skipped)
44
+ parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
45
+ if (claude.sidecars_capped)
46
+ parts.push(`sidecars_capped ${claude.sidecars_capped}`);
47
+ if (claude.sidecars_failed)
48
+ parts.push(`sidecars_failed ${claude.sidecars_failed}`);
49
+ if (claude.mains_oversized)
50
+ parts.push(`mains_oversized ${claude.mains_oversized}`);
51
+ if (claude.oversized_lines_skipped)
52
+ parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
53
+ if (claude.project_dirs_skipped)
54
+ parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
55
+ if (claude.sessions_schema_drift)
56
+ parts.push(`schema_drift ${claude.sessions_schema_drift}`);
57
+ if (claude.growth_damped)
58
+ parts.push(`growth_damped ${claude.growth_damped}`);
59
+ if (claude.first_run_backfill)
60
+ parts.push("first_run_backfill");
61
+ if (summary.files_deferred_byte_budget)
62
+ parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
63
+ if (summary.files_deferred_object_budget)
64
+ parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
65
+ return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
66
+ }
67
+ export function rawEvidenceSyncLine(sync) {
68
+ const failures = sync.raw_evidence_failure_reasons.length > 0
69
+ ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
70
+ : "";
71
+ const retries = sync.raw_evidence_retry_reasons.length > 0
72
+ ? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
73
+ : "";
74
+ // A held object and a nine-day-old first failure both belong on this line.
75
+ // Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
76
+ // invisible (BLI-3066).
77
+ const held = sync.raw_evidence_delivery_held_count > 0
78
+ ? ` held: ${sync.raw_evidence_delivery_held_count}`
79
+ : "";
80
+ const stuck = sync.raw_evidence_stuck_object_count > 0
81
+ ? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
82
+ : "";
83
+ return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
84
+ }
85
+ export function cursorStatusLine(sync) {
86
+ return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
87
+ }
88
+ /**
89
+ * One line that cannot say "fine" while an object has never been accepted.
90
+ * Named reasons, worst attempt count, and the date it started.
91
+ */
92
+ export function stuckEvidenceLine(status) {
93
+ if (status.stuck_evidence_object_count === 0)
94
+ return "none";
95
+ return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
96
+ }
97
+ /** The `--json` row for one synced worktree, shared by onboard and sync. */
98
+ export function worktreeSyncRow(outcome, run) {
99
+ const { worktree, context, sync } = outcome;
100
+ const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
101
+ const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
102
+ const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
103
+ const attributedSessionCount = codexSessionCount + claudeSessionCount;
104
+ return {
105
+ repo_label: context?.repo_label ?? worktree.repo_label,
106
+ repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
107
+ worktree_label: context?.worktree_label ?? worktree.worktree_label,
108
+ worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
109
+ branch: context?.branch ?? worktree.branch,
110
+ head_sha: sync.head_sha ?? worktree.head_sha,
111
+ work_context_id: context?.work_context_id ?? sync.work_context_id,
112
+ upload_status: sync.status,
113
+ raw_evidence_file_count: sync.raw_evidence_file_count,
114
+ raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
115
+ raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
116
+ raw_evidence_reused_count: sync.raw_evidence_reused_count,
117
+ raw_evidence_failed_count: sync.raw_evidence_failed_count,
118
+ raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
119
+ raw_evidence_retry_required: sync.raw_evidence_retry_required,
120
+ raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
121
+ attributed_session_count: attributedSessionCount,
122
+ codex_session_count: codexSessionCount,
123
+ claude_session_count: claudeSessionCount,
124
+ cursor_tracked_object_count: sync.cursor_tracked_object_count,
125
+ failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
126
+ };
127
+ }
128
+ /** Green only when the whole run is green; an accepted upload with partial collection is `partial`. */
129
+ export function attributedSyncRunStatus(run) {
130
+ if (run.ok)
131
+ return "uploaded";
132
+ return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
133
+ ? "partial"
134
+ : "spooled";
135
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The folders this machine is allowed to collect from: resolving them, saving
3
+ * them, and reading them back to prove setup actually worked.
4
+ *
5
+ * Widening the boundary is a consent decision a person makes, never a fix an
6
+ * agent applies — so everything here either asks or reports, and the read-back
7
+ * fails loudly rather than handing over a machine that will never collect.
8
+ * Split out of commands/local.ts (BLI-3104); moved verbatim.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { stat } from "node:fs/promises";
13
+ import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
14
+ import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
15
+ import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
16
+ import { collectionRootPathAliases } from "../repo-identity.js";
17
+ import { normalizeCollectionRoots } from "../root-normalization.js";
18
+ export function onboardingRootPrompt(io) {
19
+ return {
20
+ confirm: async (message) => yesByDefault(await readLine(io, message)),
21
+ input: (message) => readLine(io, message),
22
+ message: (message) => writeLine(io.stdout, message),
23
+ };
24
+ }
25
+ export async function resolveOnboardingRootsForCommand(command, io) {
26
+ const paths = getCollectorRuntimePaths(command.homeDir);
27
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
28
+ const interactive = !command.json && isInteractiveStdin(io);
29
+ const rootsResult = await resolveOnboardingRoots({
30
+ homeDir: command.homeDir,
31
+ explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
32
+ config: existingConfig,
33
+ interactive,
34
+ allowHomeRoot: command.allowHomeRoot,
35
+ prompt: interactive ? onboardingRootPrompt(io) : undefined,
36
+ });
37
+ const collectionRoots = rootsResult.roots;
38
+ const primaryRoot = collectionRoots[0];
39
+ if (!primaryRoot) {
40
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
41
+ }
42
+ const replaceRepoRoots = rootsResult.source === "prompt" &&
43
+ !command.collectionRoots?.length &&
44
+ (existingConfig?.default_repo_paths.length ?? 0) > 0;
45
+ return {
46
+ existingConfig,
47
+ rootsResult,
48
+ collectionRoots,
49
+ primaryRoot,
50
+ replaceRepoRoots,
51
+ };
52
+ }
53
+ export async function persistOnboardingRootConfig(command, resolution) {
54
+ const result = await installLocalCollector({
55
+ homeDir: command.homeDir,
56
+ repoRoot: resolution.primaryRoot,
57
+ repoRoots: resolution.collectionRoots,
58
+ replaceRepoRoots: resolution.replaceRepoRoots,
59
+ dashboardUrl: command.dashboardUrl,
60
+ deviceName: command.deviceName,
61
+ });
62
+ await assertCollectionRootPersisted(command.homeDir);
63
+ return result;
64
+ }
65
+ /**
66
+ * Setup does not get to claim success on its own say-so.
67
+ *
68
+ * Onboarding used to write the config and report success without ever reading
69
+ * it back. Savina's onboard did exactly that, persisted nothing, and every
70
+ * scheduled sync afterwards threw `collection_root_required` into a log nobody
71
+ * reads — twelve consecutive failures, six days at 3 uploaded of 195, found
72
+ * only by hand-querying the database. BLI-1986 fixed one path into that state;
73
+ * this closes the state itself.
74
+ *
75
+ * So we read the config back through the SAME resolution the scheduled sync
76
+ * will use, and fail here — in front of a human who can still fix it — rather
77
+ * than silently handing back a machine that will never collect.
78
+ */
79
+ export async function assertCollectionRootPersisted(homeDir) {
80
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
81
+ const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
82
+ if (saved.length === 0) {
83
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
84
+ }
85
+ // Present in the file is not the same as usable. A root that no longer
86
+ // exists on disk resolves to nothing at sync time, which is the same silent
87
+ // dead end arriving one step later.
88
+ const usable = [];
89
+ for (const root of saved) {
90
+ if (await directoryExists(root))
91
+ usable.push(root);
92
+ }
93
+ if (usable.length === 0) {
94
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
95
+ }
96
+ return usable;
97
+ }
98
+ /**
99
+ * Keep both spellings when an existing root is reached through a filesystem
100
+ * alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
101
+ * realpathed later, so either transcript spelling must remain inside the same
102
+ * operator-approved physical root.
103
+ */
104
+ export async function collectionRootConsentAliases(roots) {
105
+ return normalizeCollectionRoots(await collectionRootPathAliases(roots));
106
+ }
107
+ // Placeholders like <path-to-your-work-folder> make a person stop and think.
108
+ // These print real, paste-able commands with this machine's actual paths in
109
+ // them, so the fix is a copy away rather than a puzzle.
110
+ function collectionRootNotPersistedMessage(homeDir) {
111
+ const home = path.resolve(homeDir ?? os.homedir());
112
+ return [
113
+ "Setup finished without saving a collection root, so this machine would never collect anything.",
114
+ "Nothing was saved, so nothing is broken — setup just did not finish.",
115
+ "",
116
+ "Fix it by running ONE of these:",
117
+ "",
118
+ " # Sync everything on this machine (what most people want on a work laptop)",
119
+ " cockpit do-everything --allow-home-root",
120
+ "",
121
+ " # Or sync one folder — replace the path with where your projects live",
122
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
123
+ "",
124
+ " # Or answer the folder question interactively",
125
+ " cockpit do-everything",
126
+ "",
127
+ "Then check it worked:",
128
+ " cockpit status",
129
+ ].join("\n");
130
+ }
131
+ function collectionRootMissingOnDiskMessage(saved, homeDir) {
132
+ const home = path.resolve(homeDir ?? os.homedir());
133
+ return [
134
+ "Cockpit is set up to collect from a folder that is not on this machine:",
135
+ ...saved.map((root) => ` ${root}`),
136
+ "",
137
+ "That usually means the folder was renamed, moved, or deleted since setup.",
138
+ "",
139
+ "Fix it by running ONE of these:",
140
+ "",
141
+ " # Point Cockpit at where your projects actually live now",
142
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
143
+ "",
144
+ " # Or sync everything on this machine and stop worrying about the path",
145
+ " cockpit do-everything --allow-home-root",
146
+ "",
147
+ "Not sure where your projects are? This lists the folders Cockpit can see:",
148
+ " cockpit status",
149
+ ].join("\n");
150
+ }
151
+ async function directoryExists(dir) {
152
+ return stat(dir).then((stats) => stats.isDirectory(), () => false);
153
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Receipts: what this machine tells the dashboard about how a command went.
3
+ *
4
+ * Every step of `install`, `onboard`, `update` and every `sync` tick records a
5
+ * named step result here, and the outbox delivers them best-effort. A failure
6
+ * that names itself is the whole point — "it failed" is not a result, and a
7
+ * receipt nobody can act on is why BLI-2526 and BLI-2542 exist.
8
+ *
9
+ * Split out of commands/local.ts (BLI-3104) — moved verbatim; the step names,
10
+ * error codes and redaction rules are a server-side contract.
11
+ */
12
+ import os from "node:os";
13
+ import { errorMessage, writeLine } from "./cli-io.js";
14
+ import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
+ import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
+ import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
18
+ import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
19
+ export function addInstallEvent(events, step, status, errorCode,
20
+ // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
21
+ // pass it; it is redacted at this boundary, not at the call site.
22
+ errorMessage) {
23
+ const detail = errorMessage ? redactedHealthDetail(errorMessage) : "";
24
+ const code = errorCode ? sanitizeInstallErrorCode(errorCode) : undefined;
25
+ events.push({
26
+ step,
27
+ status,
28
+ ...(code ? { error_code: code } : {}),
29
+ ...(detail && detail !== code ? { error_detail: detail } : {}),
30
+ });
31
+ }
32
+ export function sanitizeInstallErrorCode(value) {
33
+ const normalized = value
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9_]+/gu, "_")
37
+ .replace(/^_+|_+$/gu, "")
38
+ .slice(0, 120);
39
+ return normalized || "unknown";
40
+ }
41
+ /**
42
+ * Posts pending install events. Also the collector's only per-tick listening
43
+ * post: the response carries the server-published `min_cli_version` floor
44
+ * (BLI-2678), so the last one observed is returned for the scheduled
45
+ * self-update step to act on. Every early-out returns null — no receipt, no
46
+ * floor.
47
+ */
48
+ export async function reportInstallEventsBestEffort(options) {
49
+ if (options.events.length === 0)
50
+ return null;
51
+ const paths = getCollectorRuntimePaths(options.homeDir);
52
+ try {
53
+ await enqueueInstallEventEntry(paths, {
54
+ dashboardUrl: options.dashboardUrl,
55
+ cliVersion: LOCAL_COLLECTOR_VERSION,
56
+ command: options.command,
57
+ osPlatform: os.platform(),
58
+ events: options.events.map((event) => ({
59
+ step: event.step.trim().slice(0, 120),
60
+ status: event.status,
61
+ ...(event.error_code
62
+ ? { error_code: sanitizeInstallErrorCode(event.error_code) }
63
+ : {}),
64
+ // Already redacted and capped at the point it was produced; bounded
65
+ // again here because this mapping is what the server contract sees.
66
+ ...(event.error_detail
67
+ ? {
68
+ error_detail: event.error_detail
69
+ .trim()
70
+ .slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
71
+ }
72
+ : {}),
73
+ ...(event.at ? { at: event.at } : {}),
74
+ })),
75
+ });
76
+ }
77
+ catch {
78
+ if (options.json) {
79
+ writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
80
+ }
81
+ return null;
82
+ }
83
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
84
+ if (!session ||
85
+ session.session_state !== "valid" ||
86
+ typeof session.device_token !== "string" ||
87
+ !session.device_token) {
88
+ return null;
89
+ }
90
+ const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
91
+ const failures = [];
92
+ let observedMinCliVersion = null;
93
+ for (let offset = 0; offset < pending.length; offset += 5) {
94
+ await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
95
+ const controller = new AbortController();
96
+ const timeout = setTimeout(() => controller.abort(), 5_000);
97
+ try {
98
+ const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ Authorization: `Bearer ${session.device_token}`,
103
+ },
104
+ body: JSON.stringify({
105
+ cli_version: entry.cli_version,
106
+ command: entry.command,
107
+ os_platform: entry.os_platform,
108
+ events: entry.events,
109
+ }),
110
+ signal: controller.signal,
111
+ });
112
+ if (!response.ok) {
113
+ throw new Error(`http_${response.status}`);
114
+ }
115
+ const receipt = (await response
116
+ .json()
117
+ .catch(() => null));
118
+ if (typeof receipt?.min_cli_version === "string" &&
119
+ receipt.min_cli_version.trim()) {
120
+ observedMinCliVersion = receipt.min_cli_version.trim();
121
+ }
122
+ await removeInstallEventEntry(paths, entry.outbox_id);
123
+ }
124
+ catch (error) {
125
+ const failureReason = classifyInstallTelemetryError(error);
126
+ failures.push(failureReason);
127
+ await recordInstallEventAttemptFailure(paths, entry, {
128
+ attemptedAt: new Date().toISOString(),
129
+ failureReason,
130
+ }).catch(() => undefined);
131
+ }
132
+ finally {
133
+ clearTimeout(timeout);
134
+ }
135
+ }));
136
+ }
137
+ if (options.json && failures.length > 0) {
138
+ writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
139
+ }
140
+ return observedMinCliVersion;
141
+ }
142
+ function classifyInstallTelemetryError(error) {
143
+ if (error instanceof Error && error.name === "AbortError") {
144
+ return "timeout";
145
+ }
146
+ const message = errorMessage(error);
147
+ const status = message.match(/http_(\d{3})/i)?.[1];
148
+ if (status)
149
+ return `http_${status}`;
150
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
151
+ return "network";
152
+ return "failed";
153
+ }
154
+ export function classifySyncHealthError(error) {
155
+ const message = errorMessage(error);
156
+ if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
157
+ return "auth_failed";
158
+ }
159
+ if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
160
+ return "network_failed";
161
+ }
162
+ // Anchored on the code the collector actually throws rather than on loose
163
+ // vocabulary. The old test matched /collection.root|workspace|repo|worktree/
164
+ // against the message, so any failure that merely mentioned a repo was filed
165
+ // as a collection-root failure and the real reason was lost (BLI-2492).
166
+ if (message.includes(COLLECTION_ROOT_REQUIRED) ||
167
+ /collection root/iu.test(message)) {
168
+ return "collection_root_failed";
169
+ }
170
+ return "sync_failed";
171
+ }
172
+ // The bucket above is for aggregation. This is the reason — the actual message,
173
+ // redacted on the machine that produced it, before it ever leaves.
174
+ //
175
+ // Error text can carry absolute paths and, on some auth failures, token-shaped
176
+ // fragments. It goes through the same deterministic redaction the collector
177
+ // already applies to evidence, and is capped so one pathological stack trace
178
+ // cannot dominate a health receipt.
179
+ export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
180
+ export function redactedSyncErrorDetail(error) {
181
+ const message = errorMessage(error).replace(/\s+/gu, " ").trim();
182
+ const { text } = redactSecretLikeContent(message, {
183
+ appliedBy: "local_collector",
184
+ });
185
+ // BLI-2542: the comment above always said this text can carry absolute paths,
186
+ // and until now nothing removed them — secret redaction matches token shapes,
187
+ // not filesystem paths. Same masking the doctor receipts use, so one boundary
188
+ // rule covers every health receipt.
189
+ const masked = maskLocalIdentifiers(text);
190
+ return masked.length > SYNC_ERROR_DETAIL_MAX_CHARS
191
+ ? `${masked.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
192
+ : masked;
193
+ }