@bli-cockpit/cli 0.2.168 → 0.2.170

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,43 @@
1
+ import { AUTOSTART_OPT_OUT_MARKER_FILE_NAME } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * BLI-12828. The tick now CREATES a missing scheduler registration (an absent
6
+ * LaunchAgent / Scheduled Task is how a Mac goes quiet after a reboot), so
7
+ * "absent" can no longer double as "the operator removed it on purpose". This
8
+ * marker is that deliberate act: `cockpit autostart uninstall` writes it,
9
+ * `cockpit autostart install` and `cockpit doctor` clear it, and the tick's
10
+ * create-when-absent repair refuses while it stands.
11
+ *
12
+ * One small file under the state dir; it holds a timestamp, never a path.
13
+ */
14
+ export const AUTOSTART_OPT_OUT_MARKER = AUTOSTART_OPT_OUT_MARKER_FILE_NAME;
15
+ function markerPath(paths) {
16
+ return path.join(paths.state_dir, AUTOSTART_OPT_OUT_MARKER);
17
+ }
18
+ export async function isAutostartOptedOut(paths) {
19
+ return fs
20
+ .stat(markerPath(paths))
21
+ .then(() => true)
22
+ .catch(() => false);
23
+ }
24
+ export async function markAutostartOptedOut(paths, now = new Date()) {
25
+ try {
26
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
27
+ await fs.writeFile(markerPath(paths), now.toISOString());
28
+ console.error("[autostart] opt-out recorded; the sync tick will not re-create the scheduler until `cockpit autostart install`", JSON.stringify({ reason: "operator_uninstalled" }));
29
+ }
30
+ catch (error) {
31
+ console.error("[autostart] opt-out could not be recorded; the tick may re-create the scheduler", JSON.stringify({
32
+ reason: "opt_out_write_failed",
33
+ error_name: error instanceof Error ? error.name : typeof error,
34
+ }));
35
+ }
36
+ }
37
+ export async function clearAutostartOptOut(paths) {
38
+ const had = await isAutostartOptedOut(paths);
39
+ await fs.rm(markerPath(paths), { force: true }).catch(() => undefined);
40
+ if (had) {
41
+ console.error("[autostart] opt-out cleared; background sync is wanted again", JSON.stringify({ reason: "operator_installed" }));
42
+ }
43
+ }
@@ -3,8 +3,12 @@ import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
5
5
  import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
6
+ import { isAutostartOptedOut } from "./autostart-opt-out.js";
6
7
  export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
7
- const AUTOSTART_REPAIR_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
8
+ // BLI-12828: the FIRST repair of a break never waits. The marker only spaces
9
+ // out RETRIES of a repair that keeps failing, and a healthy reading clears it,
10
+ // so the next break is repaired on the next tick, not a day later.
11
+ const AUTOSTART_REPAIR_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
8
12
  const DETAIL_MAX_CHARS = 300;
9
13
  export async function runAutostartSelfHeal(paths, options) {
10
14
  const platform = options.platform ?? process.platform;
@@ -20,9 +24,12 @@ export async function runAutostartSelfHeal(paths, options) {
20
24
  exec: options.exec,
21
25
  platform,
22
26
  });
23
- // Absent means the operator (or onboarding) owns the decision, not this tick.
24
- if (status.status === "absent")
27
+ const absent = status.status === "absent";
28
+ if (absent && (await isAutostartOptedOut(paths))) {
29
+ // Said on the tick's own log so "why is this Mac not ticking" is answerable.
30
+ console.error("[autostart-self-heal] scheduler absent; not re-creating it, the operator uninstalled it on purpose", JSON.stringify({ reason: "autostart_uninstalled_by_operator", platform }));
25
31
  return null;
32
+ }
26
33
  // A registration can read as "loaded" and still name a binary that no longer
27
34
  // exists — that is precisely what `brew upgrade node` leaves behind. Ask the
28
35
  // filesystem about the paths the SCHEDULER holds, not the ones this process
@@ -34,11 +41,16 @@ export async function runAutostartSelfHeal(paths, options) {
34
41
  platform,
35
42
  })
36
43
  : [];
37
- if (status.status !== "not_loaded" && runtimeProblems.length === 0) {
38
- // Healthy is the steady state and stays silent.
44
+ const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
45
+ if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0) {
46
+ // Healthy is the steady state and stays silent. A marker left by an
47
+ // earlier attempt is spent: that repair worked, so the next break starts
48
+ // fresh instead of waiting out a retry window.
49
+ await fs.rm(marker, { force: true }).catch(() => undefined);
39
50
  return null;
40
51
  }
41
52
  const problem = [
53
+ ...(absent ? ["registration absent"] : []),
42
54
  ...(status.message ? [status.message] : []),
43
55
  ...runtimeProblems,
44
56
  ]
@@ -59,15 +71,16 @@ export async function runAutostartSelfHeal(paths, options) {
59
71
  step: platform === "darwin" ? "autostart_heal" : "autostart_repair",
60
72
  };
61
73
  }
62
- // Attempts are throttled (marker mtime, written for the attempt not the
63
- // outcome) so a persistently failing repair cannot run every 15 minutes. The
74
+ // Only a RETRY waits (marker mtime, written for the attempt not the
75
+ // outcome, cleared by the next healthy reading): a repair that keeps failing
76
+ // is retried hourly instead of churning the scheduler every 15 minutes. The
64
77
  // status probe above still runs every tick — it is one launchctl/schtasks
65
78
  // query.
66
79
  const now = options.now ?? new Date();
67
- const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
68
80
  const lastAttempt = await fs.stat(marker).catch(() => null);
69
81
  if (lastAttempt &&
70
- now.getTime() - lastAttempt.mtimeMs < AUTOSTART_REPAIR_MIN_INTERVAL_MS) {
82
+ now.getTime() - lastAttempt.mtimeMs < AUTOSTART_REPAIR_RETRY_AFTER_FAILURE_MS) {
83
+ console.error("[autostart-self-heal] scheduler still broken; the last repair attempt was under an hour ago, retrying after that", JSON.stringify({ reason: "repair_retry_waiting", platform }));
71
84
  return { status: "skipped", reason: "repair_throttled_recent_attempt" };
72
85
  }
73
86
  await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
@@ -85,7 +98,7 @@ export async function runAutostartSelfHeal(paths, options) {
85
98
  if (repaired.loaded) {
86
99
  return {
87
100
  status: "ok",
88
- reason: "autostart_repaired",
101
+ reason: absent ? "autostart_created" : "autostart_repaired",
89
102
  detail: problem,
90
103
  step: "autostart_repair",
91
104
  };
@@ -1,6 +1,7 @@
1
1
  import { defaultExec, writeLine } from "./cli-io.js";
2
2
  import { runAutostartHealDetached } from "./autostart-heal.js";
3
3
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
4
+ import { clearAutostartOptOut, markAutostartOptedOut, } from "../autostart-opt-out.js";
4
5
  import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
5
6
  export async function runAutostart(command, io) {
6
7
  if (command.action === "heal-detached") {
@@ -37,6 +38,15 @@ export async function runAutostart(command, io) {
37
38
  intervalSeconds: command.intervalSeconds,
38
39
  exec,
39
40
  });
41
+ // BLI-12828: the tick re-creates an absent scheduler, so a deliberate
42
+ // uninstall has to leave a mark the tick can read.
43
+ const optOutPaths = getCollectorRuntimePaths(command.homeDir);
44
+ if (command.action === "uninstall" && result.status !== "unsupported") {
45
+ await markAutostartOptedOut(optOutPaths);
46
+ }
47
+ else if (command.action === "install" && result.loaded !== false) {
48
+ await clearAutostartOptOut(optOutPaths);
49
+ }
40
50
  if (command.json) {
41
51
  writeLine(io.stdout, JSON.stringify(result, null, 2));
42
52
  return autostartExitCode(command.action, result);
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import { AUTOSTART_LABEL, autostartStatus, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
4
+ import { isAutostartOptedOut } from "../autostart-opt-out.js";
4
5
  import { redactedHealthDetail } from "../health-detail.js";
5
6
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
6
7
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -72,9 +73,10 @@ async function healDetached(command, io, deps) {
72
73
  exec,
73
74
  platform,
74
75
  });
75
- if (before.status === "absent") {
76
- // Repair what exists, never create.
77
- return { status: "skipped", reason: "autostart_absent" };
76
+ if (before.status === "absent" &&
77
+ (await isAutostartOptedOut(getCollectorRuntimePaths(command.homeDir)))) {
78
+ // BLI-12828: absent is created now; only a deliberate uninstall stops it.
79
+ return { status: "skipped", reason: "autostart_uninstalled_by_operator" };
78
80
  }
79
81
  let written;
80
82
  try {
@@ -125,7 +127,7 @@ async function healDetached(command, io, deps) {
125
127
  }
126
128
  return {
127
129
  status: "ok",
128
- reason: "autostart_healed",
130
+ reason: before.status === "absent" ? "autostart_created" : "autostart_healed",
129
131
  detail: `node_path_reason=${written.node_path_reason}`,
130
132
  };
131
133
  }
@@ -8,6 +8,7 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConf
8
8
  import { normalizeCollectionRoots } from "../root-normalization.js";
9
9
  import { detectSecondCockpitInstall } from "../second-install.js";
10
10
  import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
11
+ import { SIGN_IN_ACTION } from "../sign-in-action.js";
11
12
  /**
12
13
  * The `authed`, `roots-ok`, and `single-install` check family: who is this
13
14
  * machine and where does it collect. Auth and roots are hard stops with no
@@ -31,7 +32,7 @@ export async function fixAuthState(context, state) {
31
32
  return checked;
32
33
  }
33
34
  if (!isInteractiveDoctorFix(context))
34
- return { ...state, nextAction: "cockpit login" };
35
+ return { ...state, nextAction: SIGN_IN_ACTION };
35
36
  const code = await context.deps.runLogin(context).catch((error) => {
36
37
  // Exit code 1 with no reason at all is what an operator saw when doctor
37
38
  // tried and failed to repair their auth — the same output as a login that
@@ -73,13 +74,10 @@ export async function readAuthState(context) {
73
74
  }
74
75
  return ok("authed", "device_token_present", "this machine is signed in");
75
76
  }
76
- return hardStop("authed", "pairing_required", [
77
- "device is not signed in.",
78
- "What you can do:",
79
- " 1) Run `cockpit login` and complete the email/device approval.",
80
- ` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
81
- " 3) Send this output to Edward if approval is blocked.",
82
- ].join("\n"));
77
+ return {
78
+ ...hardStop("authed", "pairing_required", `this machine is signed out. ${SIGN_IN_ACTION}`),
79
+ nextAction: SIGN_IN_ACTION,
80
+ };
83
81
  }
84
82
  export async function readRootState(context) {
85
83
  const paths = getCollectorRuntimePaths(context.command.homeDir);
@@ -92,13 +90,11 @@ export async function readRootState(context) {
92
90
  roots,
93
91
  };
94
92
  }
95
- return hardStop("roots-ok", "no_roots", [
96
- "no saved collection roots were found.",
97
- "What you can do:",
98
- ` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
99
- " 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
100
- " 3) Run `cockpit doctor` interactively to choose approved roots.",
101
- ].join("\n"));
93
+ const action = `Run \`${onboardOneLiner(context.command)}\` in a terminal from the folder where you work.`;
94
+ return {
95
+ ...hardStop("roots-ok", "no_roots", `no collection folders are saved yet. ${action}`),
96
+ nextAction: action,
97
+ };
102
98
  }
103
99
  /**
104
100
  * BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
@@ -164,10 +160,10 @@ function shellQuote(value) {
164
160
  * a gate on it, so a session sitting outside one is not a defect doctor found
165
161
  * and cannot repair. This row is therefore always informational (`ok`): it
166
162
  * reports how many of this scan's sessions were inside vs. outside, never
167
- * asks for approval, and attempts no fix. Widening the boundary still buys
168
- * the redacted transcript BYTES for those outside directories (those stay
169
- * root-gated), so the message still names `cockpit roots add` as something an
170
- * operator can choose to do, never something doctor is stuck waiting on. The
163
+ * asks for approval, and attempts no fix. Widening a root only adds the redacted transcript BYTES for those
164
+ * directories, so the message names `cockpit roots add` as an OPTIONAL step an
165
+ * operator can choose, never something doctor, or any person, is waiting on to
166
+ * approve. The
171
167
  * row only turns red the ordinary way every other doctor check does: the scan
172
168
  * itself throwing (`work_directory_scan_incomplete` from unreadable session
173
169
  * folders), which `runDoctorWithDeps`'s existing `check_threw` handling
@@ -182,9 +178,9 @@ export async function readRootsCoverWork(context) {
182
178
  const outsideCount = outside.reduce((sum, entry) => sum + entry.count, 0);
183
179
  const insideCount = scan.observed - outsideCount;
184
180
  if (!outsideCount)
185
- return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no known working directories outside approved roots. ${sessionCoverageLine(scan, outsideCount)}`);
181
+ return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no working folders outside your saved roots. ${sessionCoverageLine(scan, outsideCount)}`);
186
182
  return {
187
- ...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside approved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Their redacted transcript content stays unuploaded until a root widens (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
183
+ ...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Nothing needs approving. Optional: add a folder as a root to also upload its redacted transcript text (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
188
184
  nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
189
185
  };
190
186
  }
@@ -30,6 +30,12 @@
30
30
  * ledger with its reason label (`doctor-report.ts`'s `doctorEvent`
31
31
  * maps every other status to `ok`).
32
32
  *
33
+ * BLI-12828: a `fail` (nothing answered) now has a repair. The server ships
34
+ * inside the CLI and is wired in by `memory install`, so the fix re-registers,
35
+ * takes the newest CLI if that alone did not wake it, and asks again; the row's
36
+ * receipt shows the before and after. A `needs_fix` (Tower refused) has none:
37
+ * the refusal is the sign-in's, and the `authed` row owns that.
38
+ *
33
39
  * `skipped` when the bin is not on this machine at all, for the same reason
34
40
  * `memory-registered` skips: nothing to probe is not a broken machine.
35
41
  */
@@ -95,4 +101,25 @@ function probeEnv(context) {
95
101
  if (context.command.dashboardUrl)
96
102
  base[TOWER_DASHBOARD_URL_ENV] = context.command.dashboardUrl;
97
103
  return base;
104
+ }
105
+ /**
106
+ * The `mcp-answers` repair (BLI-12828): re-register, then take the newest CLI,
107
+ * asking the server again after each. Heavy (it spawns the server and may run
108
+ * the updater), so doctor runs it only on a `fail` and the tick leaves it to
109
+ * the daily memory install and the CLI floor, which do the same two things.
110
+ */
111
+ export async function fixMcpAnswersState(context, state) {
112
+ if (state.status !== "fail") {
113
+ console.error("[doctor] mcp-answers not repaired; Tower refused the call and the sign-in row owns that", JSON.stringify({ reason: state.code }));
114
+ return state;
115
+ }
116
+ await context.deps.fixMemory(context);
117
+ let checked = await context.deps.checkMcpAnswers(context);
118
+ console.error("[doctor] mcp-answers re-registered and asked again", JSON.stringify({ reason: checked.code, status: checked.status }));
119
+ if (checked.status === "fail") {
120
+ await context.deps.selfUpdate(context.io, { json: true, tag: context.command.updateTag });
121
+ checked = await context.deps.checkMcpAnswers(context);
122
+ console.error("[doctor] mcp-answers updated the CLI and asked again", JSON.stringify({ reason: checked.code, status: checked.status }));
123
+ }
124
+ return checked;
98
125
  }
@@ -1,6 +1,8 @@
1
1
  import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
2
2
  import { doctorRoots } from "./doctor-access.js";
3
3
  import { installAutostartAgent } from "../autostart.js";
4
+ import { clearAutostartOptOut } from "../autostart-opt-out.js";
5
+ import { getCollectorRuntimePaths } from "../local-state.js";
4
6
  import { fail, needsFix, ok, skipped } from "./doctor-report.js";
5
7
  import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
6
8
  import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
@@ -41,7 +43,7 @@ export async function fixAutostartState(context) {
41
43
  // leave the row red forever (BLI-3793).
42
44
  const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
43
45
  if (roots.length === 0)
44
- return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` to choose approved collection roots first.");
46
+ return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
45
47
  const result = await installAutostartAgent({
46
48
  ...(roots[0] ? { repoRoot: roots[0] } : {}),
47
49
  repoRoots: roots,
@@ -52,6 +54,10 @@ export async function fixAutostartState(context) {
52
54
  if (result.status === "unsupported") {
53
55
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
54
56
  }
57
+ if (result.loaded !== false) {
58
+ // BLI-12828: a person asked for background sync again; the tick may keep it.
59
+ await clearAutostartOptOut(getCollectorRuntimePaths(context.command.homeDir));
60
+ }
55
61
  if (result.loaded === false) {
56
62
  return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
57
63
  }
@@ -111,12 +117,12 @@ function memoryStepState(outcome, phase) {
111
117
  }
112
118
  export async function checkDoctorAgentRules(context) {
113
119
  const result = await inspectAgentRules({ homeDir: context.command.homeDir, scopePaths: await doctorRoots(context) });
114
- return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "run `cockpit agent-rules install`");
120
+ return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "agent rules are missing; `cockpit agent-rules install` writes them");
115
121
  }
116
122
  export async function fixDoctorAgentRules(context) {
117
123
  const roots = await doctorRoots(context);
118
124
  if (roots.length === 0)
119
- return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` to choose approved roots first.");
125
+ return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
120
126
  await installAgentRules({ homeDir: context.command.homeDir, scopePaths: roots });
121
127
  return checkDoctorAgentRules(context);
122
128
  }
@@ -70,6 +70,8 @@ setupReceipt, repairs = []) {
70
70
  writeLine(io.stderr, `${row.id}:`);
71
71
  writeLine(io.stderr, row.message.replaceAll("—", ";"));
72
72
  }
73
+ for (const line of repairReceiptLines(repairs))
74
+ writeLine(io.stdout, line);
73
75
  // BLI-3731. The invariant table says whether collection is healthy; this
74
76
  // says whether the machine is CONNECTED — the browser sign-in, the device
75
77
  // session, both agent hosts and the scheduler, every word read back off this
@@ -132,6 +134,32 @@ export function asRecord(value) {
132
134
  export function writeLine(stream, text) {
133
135
  stream.write(`${text}\n`);
134
136
  }
137
+ /** Fills each repair's before -> after receipt from the diagnosis and the final rows. */
138
+ export function finalizeDoctorRepairs(repairs, diagnosed, rows, attempted) {
139
+ for (const repair of repairs) {
140
+ const found = diagnosed.get(repair.step);
141
+ const final = rows.find((row) => row.id === repair.step);
142
+ if (found) {
143
+ repair.before = `${found.status}:${found.code}`;
144
+ repair.before_message = oneLine(found.message);
145
+ }
146
+ if (final) {
147
+ repair.after = `${final.status}:${final.code}`;
148
+ repair.after_message = oneLine(final.message);
149
+ }
150
+ repair.fix_attempted = attempted.has(repair.step);
151
+ }
152
+ }
153
+ /** The "Repairs:" block of the human table; empty when no fix ran. */
154
+ export function repairReceiptLines(repairs) {
155
+ const ran = repairs.filter((repair) => repair.fix_attempted);
156
+ if (ran.length === 0)
157
+ return [];
158
+ return [
159
+ "Repairs (before -> after):",
160
+ ...ran.map((repair) => ` ${repair.after?.startsWith("ok:") ? "fixed" : "still needs you"} ${repair.step}: ${repair.before_message ?? ""} -> ${repair.after_message ?? ""}`.replaceAll("—", ";")),
161
+ ];
162
+ }
135
163
  export function doctorNeedsPerson(rows, receipt) {
136
164
  const defaults = {
137
165
  "cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
@@ -4,11 +4,11 @@ import { describeError } from "../health-detail.js";
4
4
  import { withStage } from "../crash-guard.js";
5
5
  import { checkSingleInstallState, readRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
6
6
  import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
7
- import { checkMcpAnswersState } from "./doctor-mcp.js";
7
+ import { checkMcpAnswersState, fixMcpAnswersState } from "./doctor-mcp.js";
8
8
  import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
9
9
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
10
10
  import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
11
- import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
11
+ import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, finalizeDoctorRepairs, } from "./doctor-report.js";
12
12
  import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
13
13
  import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
14
14
  export async function runDoctor(command, io, hooks, overrides = {}) {
@@ -24,6 +24,7 @@ export async function runDoctorWithDeps(command, io, deps) {
24
24
  const repairs = [];
25
25
  const diagnoseOnly = command.dryRun || command.checkOnly;
26
26
  const diagnosed = new Map();
27
+ const attempted = new Set();
27
28
  // BLI-11101: every row says how long it took, so the next slow run names
28
29
  // the row without a profiler. A killed run still leaves every finished row.
29
30
  const checkMs = new Map();
@@ -54,6 +55,7 @@ export async function runDoctorWithDeps(command, io, deps) {
54
55
  repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
55
56
  }
56
57
  else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
58
+ attempted.add(invariant.id);
57
59
  row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
58
60
  repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
59
61
  // The replacement process owns the one final receipt, including JSON.
@@ -106,6 +108,8 @@ export async function runDoctorWithDeps(command, io, deps) {
106
108
  }
107
109
  }
108
110
  }
111
+ // BLI-12828: every repair's receipt says what it found and what it left.
112
+ finalizeDoctorRepairs(repairs, diagnosed, rows, attempted);
109
113
  const floor = await maybeReportDoctorEvents(context, rows);
110
114
  if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
111
115
  const index = rows.findIndex((row) => row.id === "cli-latest");
@@ -127,6 +131,15 @@ export async function runDoctorWithDeps(command, io, deps) {
127
131
  writeDoctorOutput(command, io, rows, receipt, repairs);
128
132
  return needsPerson === 0 ? 0 : 1;
129
133
  }
134
+ /**
135
+ * The table's row ids in order, and whether each one carries a repair.
136
+ * `doctor-repair-classes.test.ts` pins this against the runbook's audit table
137
+ * (`docs/runbooks/cockpit-doctor-repairs.md`) so a row cannot be added without
138
+ * either a repair or a named reason it has none.
139
+ */
140
+ export function doctorInvariantTable() {
141
+ return doctorInvariants().map((invariant) => ({ id: invariant.id, hasFix: Boolean(invariant.fix) }));
142
+ }
130
143
  function doctorInvariants() {
131
144
  return [
132
145
  { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
@@ -166,12 +179,14 @@ function doctorInvariants() {
166
179
  // BLI-3804. Right after the registration row, because it asks the second
167
180
  // half of the same question: `memory-registered` proves the entry exists
168
181
  // and names a bin, this one proves the server behind it starts, speaks the
169
- // protocol and reaches Tower. Deliberately has NO fix — re-registering a
170
- // server that is already registered repairs nothing, and the row names the
171
- // command a person should run instead (`doctor-mcp.ts`).
182
+ // protocol and reaches Tower. BLI-12828 gave it the repair the row always
183
+ // named in words: re-register, take the newest CLI, ask again. A Tower
184
+ // refusal is the sign-in row's, so that outcome is returned untouched
185
+ // (`doctor-mcp.ts`).
172
186
  {
173
187
  id: "mcp-answers",
174
188
  check: (context) => context.deps.checkMcpAnswers(context),
189
+ fix: fixMcpAnswersState,
175
190
  },
176
191
  // BLI-3884. Beside the two rows above because it is the same family — what
177
192
  // this machine's agents can reach — and, like `mcp-answers`, it has NO fix:
@@ -77,4 +77,18 @@ async function readTokenExpiry(response) {
77
77
  catch {
78
78
  return null;
79
79
  }
80
+ }
81
+ /**
82
+ * BLI-12828. The one fact the tick's sign-in heal needs from the check-in it
83
+ * just made: did the DOOR refuse this machine's token (401/403), as opposed to
84
+ * the network being down or the build being a checkout? A lapsed local date
85
+ * alone proves nothing offline. Process-scoped on purpose: one tick is one
86
+ * process, and `null` means "no refusal observed", never "accepted".
87
+ */
88
+ let lastRefusalStatus = null;
89
+ export function noteHeartbeatRefusal(status) {
90
+ lastRefusalStatus = status;
91
+ }
92
+ export function heartbeatRefusalStatus() {
93
+ return lastRefusalStatus;
80
94
  }
@@ -29,7 +29,7 @@ import { readStagingInventory } from "../disk-usage.js";
29
29
  import { describeError } from "../health-detail.js";
30
30
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
31
31
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
32
- import { readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
32
+ import { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
33
33
  import { classifySyncHealthError } from "../sync-health-class.js";
34
34
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
35
35
  import { readMemoryHookCounts } from "./memory-hook-counts.js";
@@ -284,6 +284,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
284
284
  body: JSON.stringify(heartbeat),
285
285
  signal: controller.signal,
286
286
  });
287
+ noteHeartbeatRefusal(response.ok ? null : response.status);
287
288
  if (!response.ok) {
288
289
  // A heartbeat is not retried, so the reason has to be said once, here.
289
290
  // BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.168");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.170");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The tick's machine-heal follow-ups that `cockpit doctor` also performs
3
+ * (BLI-12828). Doctor and the tick are meant to be the same repairs run by a
4
+ * person and run by the clock; `docs/runbooks/cockpit-doctor-repairs.md` is the
5
+ * table of which row each one answers.
6
+ *
7
+ * agent rules missing re-install them inspected every tick, a
8
+ * failed install retried hourly
9
+ * sign-in lapsed record the ONE human written when the heartbeat
10
+ * action, stop nagging could not renew, reported daily
11
+ *
12
+ * The rest of the table is already on the tick elsewhere: the CLI floor
13
+ * (`sync-followups-self-update.ts`), the scheduler incl. an ABSENT one
14
+ * (`sync-followups-autostart.ts` over `../autostart-self-heal.ts`), BLI Memory
15
+ * (`sync-followups-memory.ts`) and staging (`sync-followups-staging.ts`).
16
+ *
17
+ * Same rule as every follow-up: it runs after collection's outcome is decided,
18
+ * never throws, and its outcome is its own named receipt, never a sync failure.
19
+ * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
20
+ */
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
24
+ import { heartbeatRefusalStatus } from "./heartbeat-token.js";
25
+ import { SIGN_IN_ACTION } from "../sign-in-action.js";
26
+ import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
27
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
28
+ import { normalizeCollectionRoots } from "../root-normalization.js";
29
+ export const AGENT_RULES_REPAIR_MARKER = ".last-agent-rules-repair";
30
+ export const SESSION_NEEDS_LOGIN_MARKER = "session-needs-login.json";
31
+ const DAY_MS = 24 * 60 * 60 * 1000;
32
+ /** Only a RETRY of a failed install waits; the first install never does. */
33
+ const AGENT_RULES_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
34
+ export async function runMachineHealAfterSync(command, io, dashboardUrl, options = {}) {
35
+ const now = options.now ?? new Date();
36
+ const paths = getCollectorRuntimePaths(command.homeDir);
37
+ const events = [];
38
+ for (const heal of [
39
+ () => healAgentRules(command, paths, now),
40
+ () => noteSessionState(paths, heartbeatRefusalStatus(), now),
41
+ ]) {
42
+ try {
43
+ const event = await heal();
44
+ if (event)
45
+ events.push(event);
46
+ }
47
+ catch (error) {
48
+ console.error("[sync-heal] a machine-heal step threw and was dropped", JSON.stringify({ reason: "heal_step_threw", detail: redactedSyncErrorDetail(error) }));
49
+ }
50
+ }
51
+ if (events.length === 0)
52
+ return;
53
+ await reportInstallEventsBestEffort({
54
+ homeDir: command.homeDir,
55
+ dashboardUrl,
56
+ command: "sync",
57
+ events,
58
+ json: command.json,
59
+ io,
60
+ });
61
+ }
62
+ /** Agent rules: cheap to read, so read every tick; write only when missing. */
63
+ async function healAgentRules(command, paths, now) {
64
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
65
+ const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
66
+ if (roots.length === 0) {
67
+ console.error("[sync-heal] agent rules not checked; no saved roots to scope them to", JSON.stringify({ reason: "no_saved_roots" }));
68
+ return null;
69
+ }
70
+ const scope = { ...(command.homeDir ? { homeDir: command.homeDir } : {}), scopePaths: roots };
71
+ const marker = path.join(paths.state_dir, AGENT_RULES_REPAIR_MARKER);
72
+ const before = await inspectAgentRules(scope);
73
+ if (before.installed) {
74
+ // A marker from an earlier attempt is spent; the next removal is
75
+ // reinstalled on the next tick, not after a retry window.
76
+ await fs.rm(marker, { force: true }).catch(() => undefined);
77
+ return null;
78
+ }
79
+ const last = await fs.stat(marker).catch(() => null);
80
+ if (last && now.getTime() - last.mtimeMs < AGENT_RULES_RETRY_AFTER_FAILURE_MS) {
81
+ console.error("[sync-heal] agent rules still missing; the last install attempt was under an hour ago, retrying after that", JSON.stringify({ reason: "agent_rules_repair_throttled" }));
82
+ return null;
83
+ }
84
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
85
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
86
+ await installAgentRules(scope);
87
+ const after = await inspectAgentRules(scope);
88
+ console.error(after.installed
89
+ ? "[sync-heal] agent rules were missing and are installed"
90
+ : "[sync-heal] agent rules install ran but they still read missing", JSON.stringify({ reason: after.installed ? "agent_rules_installed" : "agent_rules_still_missing", before: "missing", after: after.installed ? "installed" : "missing" }));
91
+ return after.installed
92
+ ? { step: "agent_rules_install", status: "ok", error_detail: "before=missing; after=installed" }
93
+ : { step: "agent_rules_install", status: "fail", error_code: "agent_rules_still_missing", error_detail: "before=missing; after=missing" };
94
+ }
95
+ /**
96
+ * Sign-in: the heartbeat (which ran just before this) renews a lapsed token
97
+ * inside the server's grace window without a browser. If this machine is STILL
98
+ * signed out or the door refused the token (401/403), only a browser can fix
99
+ * it, so record the one action and say it once a day instead of every fifteen
100
+ * minutes. A lapsed local date with no refusal (offline, a checkout build)
101
+ * proves nothing, so it says nothing.
102
+ */
103
+ async function noteSessionState(paths, refusedStatus, now) {
104
+ const marker = path.join(paths.state_dir, SESSION_NEEDS_LOGIN_MARKER);
105
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
106
+ const state = session ? toSessionReference(session).session_state : "missing";
107
+ const noted = await readMarker(marker);
108
+ const refused = refusedStatus === 401 || refusedStatus === 403;
109
+ if (state === "valid" && !refused) {
110
+ if (!noted)
111
+ return null;
112
+ await fs.rm(marker, { force: true }).catch(() => undefined);
113
+ console.error("[sync-heal] sign-in is valid again; the needs-login note is cleared", JSON.stringify({ reason: "session_renewed", before: "needs_login", after: "valid" }));
114
+ return { step: "session_renewal", status: "ok", error_detail: "before=needs_login; after=valid" };
115
+ }
116
+ if (state !== "missing" && !refused)
117
+ return null;
118
+ const nowIso = now.toISOString();
119
+ if (noted && now.getTime() - Date.parse(noted.last_reported_at) < DAY_MS) {
120
+ return null;
121
+ }
122
+ const record = { since: noted?.since ?? nowIso, last_reported_at: nowIso, session_state: state, action: SIGN_IN_ACTION };
123
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
124
+ await fs.writeFile(marker, JSON.stringify(record)).catch(() => undefined);
125
+ console.error("[sync-heal] this machine is signed out and a browser sign-in is the only fix; saying so once a day", JSON.stringify({ reason: "browser_sign_in_required", session_state: state, next_action: SIGN_IN_ACTION }));
126
+ return {
127
+ step: "session_renewal",
128
+ status: "fail",
129
+ error_code: "browser_sign_in_required",
130
+ error_detail: `${state}; ${SIGN_IN_ACTION}`,
131
+ };
132
+ }
133
+ async function readMarker(file) {
134
+ try {
135
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
136
+ if (typeof parsed["since"] === "string" && typeof parsed["last_reported_at"] === "string") {
137
+ return { since: parsed["since"], last_reported_at: parsed["last_reported_at"] };
138
+ }
139
+ }
140
+ catch {
141
+ // No marker, or an unreadable one, reads as "never said".
142
+ }
143
+ return null;
144
+ }
@@ -11,7 +11,7 @@
11
11
  * collect behind both locks sync-receipt.ts
12
12
  * check in with the heartbeat door sync-heartbeat.ts
13
13
  * say how the tick went reportTickOutcome / reportTickThrew
14
- * converge the rest of the machine sync-followups.ts
14
+ * converge the rest of the machine sync-followups.ts, sync-heal.ts
15
15
  *
16
16
  * The supporting modules each own one thing those steps share:
17
17
  *
@@ -39,13 +39,18 @@ import { createInteractiveExecRunner } from "../process-runner.js";
39
39
  import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
40
40
  import { runMemoryExperienceAfterSync } from "./memory-log.js";
41
41
  import { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
42
+ import { runMachineHealAfterSync } from "./sync-heal.js";
42
43
  import { sendSyncHeartbeat } from "./sync-heartbeat.js";
43
44
  import { runSyncWithHealthReceipt } from "./sync-receipt.js";
44
45
  import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
45
46
  import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
47
+ import { stampSyncTick } from "../sync-tick-stamp.js";
46
48
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
47
49
  export async function runSync(command, io) {
48
50
  const paths = getCollectorRuntimePaths(command.homeDir);
51
+ // Ticket 12831: the memory prompt hook reads this stamp to tell a scheduler
52
+ // that stopped firing from one that is merely quiet.
53
+ await stampSyncTick(paths);
49
54
  // BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
50
55
  // rotated them before, and two months of a 15-minute tick left 211 MB of
51
56
  // sync.log on the reference Mac. Best-effort by construction — a rotation
@@ -165,5 +170,8 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
165
170
  async function convergeAfterTick(command, io, dashboardUrl) {
166
171
  await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
167
172
  await runMemoryInstallAfterSync(command, io, dashboardUrl);
173
+ // BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
174
+ // sign-in), so nobody has to be told to run it.
175
+ await runMachineHealAfterSync(command, io, dashboardUrl);
168
176
  await runMemoryExperienceAfterSync({ ...command, dashboardUrl }, io);
169
177
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The ONE sentence a person gets when only a browser can fix their sign-in
3
+ * (BLI-12828). Doctor's `authed` row, doctor's exit-code receipt and the sync
4
+ * tick's once-a-day note all say these exact words, so nobody is told two
5
+ * different things for the same problem.
6
+ */
7
+ export const SIGN_IN_ACTION = "Run `cockpit login` and approve the sign-in in your browser.";
@@ -0,0 +1,22 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { SYNC_TICK_STAMP_FILE_NAME } from "@bli-cockpit/telemetry-core";
4
+ /**
5
+ * Ticket 12831. Touched first thing in every `cockpit sync`, so "when did this
6
+ * machine last tick" is one file stat. The memory prompt hook reads it
7
+ * (`@bli-cockpit/memory-mcp` `hooks/sync-revive.ts`) and revives a scheduler
8
+ * that stopped firing. Best-effort: a stamp that cannot be written says so and
9
+ * never fails the tick (the worst case is one extra hourly revive attempt).
10
+ */
11
+ export async function stampSyncTick(paths, now = new Date()) {
12
+ try {
13
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
14
+ await fs.writeFile(path.join(paths.state_dir, SYNC_TICK_STAMP_FILE_NAME), now.toISOString());
15
+ }
16
+ catch (error) {
17
+ console.error("[sync-tick-stamp] tick stamp not written; the prompt hook may attempt a revive", JSON.stringify({
18
+ reason: "tick_stamp_write_failed",
19
+ error_name: error instanceof Error ? error.name : typeof error,
20
+ }));
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.168",
3
+ "version": "0.2.170",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.45",
31
- "@bli-cockpit/mcp": "0.1.86",
32
- "@bli-cockpit/telemetry-core": "0.1.57"
30
+ "@bli-cockpit/memory-mcp": "0.1.46",
31
+ "@bli-cockpit/mcp": "0.1.87",
32
+ "@bli-cockpit/telemetry-core": "0.1.58"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",