@bli-cockpit/cli 0.2.150 → 0.2.155

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.
Files changed (46) hide show
  1. package/dist/adapters/local-sources.js +1 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +1 -0
  3. package/dist/adapters/raw-evidence-facts.js +1 -0
  4. package/dist/adapters/raw-evidence-manifest.js +5 -0
  5. package/dist/adapters/raw-evidence-sanitize.js +82 -20
  6. package/dist/adapters/raw-evidence-snapshot-supersede.js +82 -0
  7. package/dist/adapters/raw-evidence-transcript-file.js +10 -0
  8. package/dist/agent-rules.js +3 -3
  9. package/dist/commands/backfill-lock-wait.js +49 -0
  10. package/dist/commands/backfill-outside-roots.js +97 -0
  11. package/dist/commands/backfill-reasons.js +17 -0
  12. package/dist/commands/backfill-scan.js +8 -3
  13. package/dist/commands/backfill-scope.js +14 -1
  14. package/dist/commands/backfill-session-report.js +3 -0
  15. package/dist/commands/backfill-upload.js +6 -0
  16. package/dist/commands/backfill.js +11 -5
  17. package/dist/commands/clean.js +31 -13
  18. package/dist/commands/doctor-budget.js +69 -0
  19. package/dist/commands/doctor-disk-words.js +81 -3
  20. package/dist/commands/doctor-lock-wait.js +12 -3
  21. package/dist/commands/doctor-pipeline-verdicts.js +2 -0
  22. package/dist/commands/doctor-pipeline.js +49 -24
  23. package/dist/commands/doctor.js +26 -4
  24. package/dist/commands/heartbeat.js +1 -1
  25. package/dist/commands/local-arg-values.js +9 -0
  26. package/dist/commands/local-args-collector-setup.js +4 -2
  27. package/dist/commands/local-args-collector-work.js +9 -1
  28. package/dist/commands/local-help-commands.js +11 -8
  29. package/dist/commands/local-help.js +1 -1
  30. package/dist/commands/public-root.js +1 -1
  31. package/dist/commands/status-disk-line.js +22 -0
  32. package/dist/commands/status.js +5 -2
  33. package/dist/commands/sync-followups-staging.js +13 -10
  34. package/dist/commands/sync.js +7 -4
  35. package/dist/cursors/raw-evidence-cursor.js +14 -0
  36. package/dist/disk-prune.js +19 -23
  37. package/dist/disk-retention.js +12 -9
  38. package/dist/disk-usage-footprint.js +10 -4
  39. package/dist/evidence-reconcile-client.js +2 -2
  40. package/dist/log-rotation.js +31 -10
  41. package/dist/prune-receipt.js +87 -0
  42. package/dist/raw-evidence-gc.js +55 -15
  43. package/dist/state-dir-audit.js +138 -0
  44. package/dist/upload-envelope-build.js +9 -0
  45. package/dist/upload-sync.js +10 -22
  46. package/package.json +5 -5
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * `cockpit clean` — what this machine is holding, why, and what goes.
3
3
  *
4
- * BLI-3619. The tick prunes on its own once a day; this is the door a person
5
- * opens when a laptop is full now. It prints the same buckets the retention
6
- * rule reasons in, one line each with its reason, and only then deletes:
4
+ * BLI-3619 / BLI-11106. The sync tick prunes on its own, every tick, and a
5
+ * staged copy goes the tick its commit is acknowledged; nobody should need
6
+ * this door. It is here for "show me" and for a machine that has been off.
7
+ * It prints the same buckets the retention rule reasons in, one line each with
8
+ * its reason, and only then deletes:
7
9
  *
8
- * committed, past the 48 h retry window goes
9
- * committed, still inside the window kept — unless the cap needs it
10
+ * committed on the server goes
11
+ * committed, inside an opted-in window kept — unless the cap needs it
12
+ * (only when COCKPIT_STAGING_RETENTION_HOURS is set)
10
13
  * uncommitted kept, always, and named
11
14
  * the ledger cannot say kept, always, and named apart
12
15
  *
13
- * `--dry-run` stops after the printing. `--all-committed` drops the retry
16
+ * `--dry-run` stops after the printing. `--all-committed` drops any opted-in
14
17
  * window — every committed spare goes now — and is also the only thing that
15
18
  * removes a `manual-study-evidence-vault-*` directory, and then only when every
16
19
  * object inside it is committed. Nothing here can delete evidence the upload
@@ -31,12 +34,13 @@ import crypto from "node:crypto";
31
34
  import fs from "node:fs/promises";
32
35
  import path from "node:path";
33
36
  import { writeLine } from "./cli-io.js";
37
+ import { footprintLine } from "./doctor-disk-words.js";
34
38
  import { planFromDisk, runStagingPrune } from "../disk-prune.js";
35
39
  import { MANUAL_VAULT_PREFIX, readDiskFootprint, } from "../disk-usage.js";
36
40
  import { readRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
37
41
  import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
38
42
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
39
- import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
43
+ import { DEFAULT_LOG_ROTATION_KEEP, orphanedLogArchives, rotateCollectorLogsBestEffort, } from "../log-rotation.js";
40
44
  export async function runClean(command, io) {
41
45
  const paths = getCollectorRuntimePaths(command.homeDir);
42
46
  const now = new Date();
@@ -53,8 +57,9 @@ export async function runClean(command, io) {
53
57
  const vaults = command.allCommitted
54
58
  ? await vaultsSafeToRemove(paths, footprint.vaults)
55
59
  : [];
60
+ const logArchives = await orphanedLogArchives(paths);
56
61
  if (!command.json) {
57
- for (const line of cleanLines(footprint, plan, vaults, command)) {
62
+ for (const line of cleanLines(footprint, plan, vaults, command, logArchives)) {
58
63
  writeLine(io.stdout, line);
59
64
  }
60
65
  }
@@ -67,7 +72,6 @@ export async function runClean(command, io) {
67
72
  const pruned = await runStagingPrune(paths, {
68
73
  env,
69
74
  now,
70
- force: true,
71
75
  allCommitted: command.allCommitted,
72
76
  reclaimTerminal: true,
73
77
  });
@@ -86,20 +90,32 @@ export async function runClean(command, io) {
86
90
  return pruned.status === "fail" ? 1 : 0;
87
91
  }
88
92
  /** The bucket table a person reads before anything is deleted. */
89
- export function cleanLines(footprint, plan, vaults, command) {
93
+ export function cleanLines(footprint, plan, vaults, command, logArchives = []) {
90
94
  const staging = footprint.staging;
91
95
  const lines = [
92
96
  command.dryRun ? "Tower clean (dry run)" : "Tower clean",
93
97
  ` staging ${mb(staging.total_bytes)} MB across ${staging.pack_count} pack(s), ${staging.object_count} file(s)`,
94
98
  ` going ${mb(plan.deleted_bytes + plan.empty_pack_manifest_bytes)} MB, ${plan.delete.length} file(s) — ${command.allCommitted
95
99
  ? "every committed copy (--all-committed)"
96
- : `committed and past the ${Math.round(plan.retention_ms / 3_600_000)} h retry window`}`,
97
- ` kept in window ${mb(plan.kept_in_window.bytes)} MB, ${plan.kept_in_window.count} file(s) — committed, still inside the retry window`,
100
+ : plan.retention_ms > 0
101
+ ? `committed and past the opted-in ${Math.round(plan.retention_ms / 3_600_000)} h window`
102
+ : "committed on the server; the source file is still on disk for any retry"}`,
103
+ ` kept in window ${mb(plan.kept_in_window.bytes)} MB, ${plan.kept_in_window.count} file(s) — committed, inside an opted-in retry window (none by default)`,
98
104
  ` kept uncommitted ${mb(plan.kept_uncommitted.bytes)} MB, ${plan.kept_uncommitted.count} file(s) — never delivered; retention will never delete these`,
99
105
  ` kept unknown ${mb(plan.kept_unknown.bytes)} MB, ${plan.kept_unknown.count} file(s) — the upload ledger no longer reaches back this far and cannot say`,
100
- ` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))`,
106
+ ` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))${logArchives.length > 0
107
+ ? `; ${logArchives.length} archive(s), ${mb(logArchives.reduce((sum, archive) => sum + archive.byte_size, 0))} MB, sit outside the ${DEFAULT_LOG_ROTATION_KEEP}-archive keep window and go`
108
+ : ""}`,
101
109
  ` spool ${mb(footprint.spool_bytes)} MB`,
110
+ ` state files ${mb(footprint.state.state_bytes)} MB — cursors, indexes and receipts, each bounded`,
102
111
  ];
112
+ if (footprint.state.other_bytes > 0) {
113
+ lines.push(` unrecognised ${mb(footprint.state.other_bytes)} MB across ${footprint.state.other_entries} entr(ies) — counted, never deleted by clean`);
114
+ }
115
+ if (footprint.state.study_entries > 0) {
116
+ lines.push(` studies ${footprint.state.study_entries} folder(s) an agent left here — not the collector's, not counted, never touched; new ones go in bli-cockpit-studies`);
117
+ }
118
+ lines.push(` ${footprintLine(footprint)}`);
103
119
  for (const vault of footprint.vaults) {
104
120
  const removable = vaults.some((entry) => entry.name === vault.name);
105
121
  lines.push(` ${vault.name} ${mb(vault.byte_size)} MB, ${vault.file_count} file(s) — ${removable
@@ -158,6 +174,8 @@ function cleanJson(footprint, plan, vaults, pruned, reconciled = null) {
158
174
  },
159
175
  rotated_log_bytes: footprint.logs.total_bytes,
160
176
  spool_bytes: footprint.spool_bytes,
177
+ state: footprint.state,
178
+ last_prune: footprint.last_prune,
161
179
  vaults: footprint.vaults,
162
180
  plan: {
163
181
  delete_count: plan.delete.length,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * BLI-11101: `cockpit doctor --json` answers within one bounded budget.
3
+ *
4
+ * A person at a terminal gets the long defaults (a 900-second catch-up and a
5
+ * 600-second wait for the collection lock) with progress every 30 seconds. An
6
+ * agent or script calling `--json` gets one answer: on Edward's Mac the long
7
+ * defaults ran past 9.5 minutes behind the 15-minute scheduled sync, and a
8
+ * caller that times out learns nothing. So `--json` without `--lock-wait` or
9
+ * `--backfill-budget` runs every repair under one deadline measured from the
10
+ * start of the run, and a repair that does not fit says so by name instead of
11
+ * queueing. Passing either flag opts back into the long run.
12
+ */
13
+ export const DOCTOR_JSON_BUDGET_SECONDS = 45;
14
+ export const DEFAULT_LOCK_WAIT_SECONDS = 600;
15
+ export const DEFAULT_BACKFILL_BUDGET_SECONDS = 900;
16
+ /** A repair that execs a whole sync or catch-up chunk is not started with less than this left. */
17
+ export const SLOW_FIX_MIN_SECONDS = {
18
+ "backfill-complete": 10,
19
+ "sync-fresh": 15,
20
+ };
21
+ /** The run deadline in epoch ms, or `null` when the caller gets the long defaults. */
22
+ export function doctorRunDeadline(command, startedMs) {
23
+ if (!command.json)
24
+ return null;
25
+ if (command.lockWaitSeconds !== undefined || command.backfillBudgetSeconds !== undefined)
26
+ return null;
27
+ return startedMs + DOCTOR_JSON_BUDGET_SECONDS * 1000;
28
+ }
29
+ function remainingSeconds(context, now) {
30
+ if (context.deadlineMs === undefined || context.deadlineMs === null)
31
+ return null;
32
+ return Math.max(0, (context.deadlineMs - now) / 1000);
33
+ }
34
+ /** How long a repair may wait for the collection lock. */
35
+ export function doctorLockWaitSeconds(context, now = Date.now()) {
36
+ if (context.command.lockWaitSeconds !== undefined)
37
+ return context.command.lockWaitSeconds;
38
+ const remaining = remainingSeconds(context, now);
39
+ return remaining === null ? DEFAULT_LOCK_WAIT_SECONDS : Math.min(DEFAULT_LOCK_WAIT_SECONDS, remaining);
40
+ }
41
+ /** How long the catch-up repair may run, lock waiting included. */
42
+ export function doctorBackfillBudgetSeconds(context, now = Date.now()) {
43
+ if (context.command.backfillBudgetSeconds !== undefined)
44
+ return context.command.backfillBudgetSeconds;
45
+ const remaining = remainingSeconds(context, now);
46
+ return remaining === null ? DEFAULT_BACKFILL_BUDGET_SECONDS : Math.min(DEFAULT_BACKFILL_BUDGET_SECONDS, remaining);
47
+ }
48
+ /**
49
+ * The row a slow repair returns instead of starting when the run budget cannot
50
+ * hold it; `null` means go ahead. Logged either way the budget decides.
51
+ */
52
+ export function budgetSpentRow(context, row, now = Date.now()) {
53
+ const floor = SLOW_FIX_MIN_SECONDS[row.id];
54
+ const remaining = remainingSeconds(context, now);
55
+ if (floor === undefined || remaining === null)
56
+ return null;
57
+ if (remaining >= floor) {
58
+ console.error("[doctor] repair fits the json budget", JSON.stringify({ row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor }));
59
+ return null;
60
+ }
61
+ console.error("[doctor] repair skipped, json budget spent", JSON.stringify({ reason: "doctor_budget_spent", row: row.id, remaining_seconds: Math.floor(remaining), floor_seconds: floor, budget_seconds: DOCTOR_JSON_BUDGET_SECONDS }));
62
+ return {
63
+ ...row,
64
+ status: "needs_fix",
65
+ code: "doctor_budget_spent",
66
+ nextAction: "cockpit doctor",
67
+ message: `\`cockpit doctor --json\` answers within ${DOCTOR_JSON_BUDGET_SECONDS}s and this repair needs at least ${floor}s; ${Math.floor(remaining)}s were left (was: ${row.code}). Run \`cockpit doctor\` to let it finish.`,
68
+ };
69
+ }
@@ -28,9 +28,85 @@ export function redeliveryLine(result) {
28
28
  }
29
29
  return "the next `cockpit sync` re-offers them";
30
30
  }
31
+ /**
32
+ * Over this, the whole collector footprint is a needs-fix row (BLI-11106).
33
+ * Delete-on-commit plus the 256 MiB staging cap and the log caps put a healthy
34
+ * machine far under it; crossing it means something is holding bytes, and the
35
+ * row names what.
36
+ */
37
+ export const FOOTPRINT_NEEDS_FIX_BYTES = 512 * 1024 * 1024;
38
+ /**
39
+ * Every bucket the collector owns, biggest first. Study folders an agent left
40
+ * under the state dir are not here: they are not the collector's, and no
41
+ * prune would ever explain them (they belong in `bli-cockpit-studies`).
42
+ */
43
+ export function footprintBuckets(footprint) {
44
+ const staging = footprint.staging;
45
+ const buckets = [
46
+ { label: "staging, committed (goes on the next tick)", bytes: staging.committed_bytes },
47
+ {
48
+ label: "staging, uncommitted (evidence the server has not accepted yet)",
49
+ bytes: staging.uncommitted_bytes + staging.unknown_bytes,
50
+ },
51
+ { label: "staging, refused by the server", bytes: staging.terminal_bytes ?? 0 },
52
+ { label: "staging, pack manifests", bytes: stagingOverheadBytes(footprint) },
53
+ { label: "sync logs", bytes: footprint.logs.total_bytes },
54
+ { label: "collector state files", bytes: footprint.state.state_bytes },
55
+ { label: "upload spool", bytes: footprint.spool_bytes },
56
+ { label: "unrecognised files in the state dir", bytes: footprint.state.other_bytes },
57
+ ];
58
+ return buckets.filter((bucket) => bucket.bytes > 0).sort((a, b) => b.bytes - a.bytes);
59
+ }
60
+ export function footprintTotalBytes(footprint) {
61
+ return footprintBuckets(footprint).reduce((sum, bucket) => sum + bucket.bytes, 0);
62
+ }
63
+ /**
64
+ * The one footprint line doctor and status both print: staging committed /
65
+ * uncommitted, logs, state, and when the prune last ran and what it freed.
66
+ */
67
+ export function footprintLine(footprint) {
68
+ const staging = footprint.staging;
69
+ const parts = [
70
+ `footprint ${mib(footprintTotalBytes(footprint))} MB`,
71
+ `staging ${mib(staging.committed_bytes)} MB committed / ${mib(staging.uncommitted_bytes + staging.unknown_bytes)} MB uncommitted`,
72
+ `logs ${mib(footprint.logs.total_bytes)} MB`,
73
+ `state ${mib(footprint.state.state_bytes + footprint.spool_bytes)} MB`,
74
+ ];
75
+ if (footprint.state.other_bytes > 0) {
76
+ parts.push(`unrecognised ${mib(footprint.state.other_bytes)} MB`);
77
+ }
78
+ parts.push(lastPrunePhrase(footprint));
79
+ return parts.join(", ");
80
+ }
81
+ /** `last prune: <when>, freed <n> MB`, plus the last time it freed anything. */
82
+ export function lastPrunePhrase(footprint) {
83
+ const last = footprint.last_prune;
84
+ if (!last)
85
+ return "last prune: never";
86
+ const base = `last prune: ${minute(last.at)}, freed ${mib(last.freed_bytes)} MB`;
87
+ if (last.freed_bytes > 0 || !last.last_freed_at)
88
+ return base;
89
+ return `${base} (last freed ${mib(last.last_freed_bytes)} MB at ${minute(last.last_freed_at)})`;
90
+ }
91
+ /** The biggest bucket, named, for the needs-fix sentence; null when empty. */
92
+ export function footprintHolder(footprint) {
93
+ return footprintBuckets(footprint)[0] ?? null;
94
+ }
95
+ function stagingOverheadBytes(footprint) {
96
+ const staging = footprint.staging;
97
+ const payload = staging.committed_bytes +
98
+ staging.uncommitted_bytes +
99
+ staging.unknown_bytes +
100
+ (staging.terminal_bytes ?? 0);
101
+ return Math.max(0, staging.total_bytes - payload);
102
+ }
103
+ function minute(iso) {
104
+ return iso.length >= 16 ? `${iso.slice(0, 16)}Z` : iso;
105
+ }
31
106
  export function diskRowMessage(footprint, capBytes) {
32
107
  const staging = footprint.staging;
33
108
  const parts = [
109
+ footprintLine(footprint),
34
110
  `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
35
111
  ];
36
112
  // BLI-3797: "1,916.9 uncommitted" is a number, not a finding. The row now
@@ -51,9 +127,11 @@ export function diskRowMessage(footprint, capBytes) {
51
127
  if (staging.unknown_count > 0) {
52
128
  parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
53
129
  }
54
- parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
55
- for (const vault of footprint.vaults) {
56
- parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
130
+ // BLI-11106: study folders an agent left under the state dir are named as a
131
+ // count, never sized or added to the footprint. They are not the
132
+ // collector's, and new ones belong in the sibling `bli-cockpit-studies`.
133
+ if (footprint.state.study_entries > 0) {
134
+ parts.push(`${footprint.state.study_entries} study folder(s) under the state dir are not counted (new studies go in bli-cockpit-studies beside it)`);
57
135
  }
58
136
  return parts.join("; ");
59
137
  }
@@ -1,12 +1,13 @@
1
1
  import { inspectBackfillLock } from "../backfill-lock.js";
2
2
  import { getCollectorRuntimePaths } from "../local-state.js";
3
3
  import { inspectSyncLock } from "../sync-lock.js";
4
- const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
4
+ import { doctorLockWaitSeconds } from "./doctor-budget.js";
5
+ const BUSY = new Set(["sync_already_running", "sync_lock_held", "backfill_already_running", "live_sync_paused_during_backfill"]);
5
6
  /** Retry the operation itself: its existing exclusive lock acquisition owns the
6
7
  * handoff. Merely observing a free lock never grants permission to collect. */
7
8
  export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
8
9
  const started = timing.now();
9
- const limit = (context.command.lockWaitSeconds ?? 600) * 1000;
10
+ const limit = doctorLockWaitSeconds(context) * 1000;
10
11
  let nextProgress = 0;
11
12
  let row = await run();
12
13
  while (BUSY.has(row.code)) {
@@ -27,7 +28,15 @@ export async function withDoctorLockWait(context, run, timing = { now: () => Dat
27
28
  if (result.code === 0 && result.stdout.trim())
28
29
  name = result.stdout.trim().split("\n")[0] ?? name;
29
30
  }
30
- return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
31
+ // BLI-11101: the scheduled sync holding the lock is not a fault; it is
32
+ // doing the upload this row would prove. Say so by name.
33
+ const syncOwns = !backfill.held && Boolean(sync?.held);
34
+ const code = syncOwns ? "sync_running" : "lock_wait_timeout";
35
+ console.error(`[doctor] ${row.id} lock wait ended`, JSON.stringify({ reason: code, owner_pid: pid ?? null, waited_seconds: Math.round(elapsed / 1000), limit_seconds: Math.round(limit / 1000) }));
36
+ const message = syncOwns
37
+ ? `The background sync (pid ${pid ?? "unknown"}, ${name}) is running and holds the collection lock; waited ${Math.round(elapsed / 1000)}s. Run \`cockpit doctor\` after it finishes, or \`cockpit doctor --lock-wait 600\` to wait for it.`
38
+ : `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.`;
39
+ return { ...row, status: "needs_fix", code, nextAction: "cockpit doctor --lock-wait 600", message };
31
40
  }
32
41
  if (elapsed >= nextProgress) {
33
42
  const seconds = Math.floor(elapsed / 1000);
@@ -43,6 +43,8 @@ const COLLECTION_BUSY_REASONS = new Set([
43
43
  "live_sync_paused_during_backfill",
44
44
  // `cockpit backfill` stood aside for another backfill.
45
45
  "backfill_already_running",
46
+ // `cockpit backfill` waited for the collection lock and gave up (BLI-11091).
47
+ "sync_lock_held",
46
48
  ]);
47
49
  export function isCollectionBusyReason(reason) {
48
50
  return typeof reason === "string" && COLLECTION_BUSY_REASONS.has(reason);
@@ -1,6 +1,5 @@
1
1
  import { withDoctorLockWait } from "./doctor-lock-wait.js";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
2
+ import { doctorBackfillBudgetSeconds, doctorLockWaitSeconds } from "./doctor-budget.js";
4
3
  import { inspectBackfillLock } from "../backfill-lock.js";
5
4
  import { readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
6
5
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
@@ -11,7 +10,7 @@ import { runEvidenceRedelivery } from "../evidence-redelivery.js";
11
10
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
12
11
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
13
12
  import { runBackfillCommand } from "./backfill.js";
14
- import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
13
+ import { FOOTPRINT_NEEDS_FIX_BYTES, diskRowMessage, footprintHolder, footprintTotalBytes, mib, redeliveryLine, } from "./doctor-disk-words.js";
15
14
  import { doctorRoots } from "./doctor-access.js";
16
15
  import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, parseDoctorSyncJson, syncFailureMessage, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
17
16
  import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
@@ -31,7 +30,6 @@ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
31
30
  * `doctor-pipeline-verdicts.ts`, and every one of them is re-exported from
32
31
  * here so callers keep one address.
33
32
  */
34
- const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
35
33
  export async function checkBackfillState(context) {
36
34
  const paths = getCollectorRuntimePaths(context.command.homeDir);
37
35
  const roots = await doctorRoots(context);
@@ -48,7 +46,7 @@ export async function checkBackfillState(context) {
48
46
  }
49
47
  export async function fixBackfillState(context) {
50
48
  const started = Date.now();
51
- const deadline = started + (context.command.backfillBudgetSeconds ?? 900) * 1000;
49
+ const deadline = started + doctorBackfillBudgetSeconds(context, started) * 1000;
52
50
  let caughtUp = 0;
53
51
  let chunks = 0;
54
52
  let live = null;
@@ -79,7 +77,7 @@ export async function fixBackfillState(context) {
79
77
  // Every chunk reacquires the collector's locks. Bound lock waiting by
80
78
  // the overall budget too; never abandon an in-flight cursor write.
81
79
  const chunkContext = { ...context, command: { ...context.command,
82
- lockWaitSeconds: Math.min(context.command.lockWaitSeconds ?? 600, (deadline - Date.now()) / 1000),
80
+ lockWaitSeconds: Math.min(doctorLockWaitSeconds(context), (deadline - Date.now()) / 1000),
83
81
  } };
84
82
  const row = await withDoctorLockWait(chunkContext, async () => {
85
83
  if (Date.now() >= deadline)
@@ -91,6 +89,8 @@ export async function fixBackfillState(context) {
91
89
  homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
92
90
  all: true, dryRun: false, yes: true, json: true,
93
91
  deadlineMs: deadline,
92
+ // Doctor's own `withDoctorLockWait` owns the waiting (BLI-11091).
93
+ syncLockWaitMs: 0,
94
94
  onProgress: (progress) => { live = progress; },
95
95
  }, capture.io);
96
96
  const parsed = parseDoctorBackfillJson(capture.stdout());
@@ -134,18 +134,14 @@ export async function checkGcState(context) {
134
134
  if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
135
135
  return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
136
136
  }
137
- const paths = getCollectorRuntimePaths(context.command.homeDir);
138
- const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
139
- const info = await fs.stat(marker).catch(() => null);
140
- if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
141
- return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
142
- }
143
- return needsFix("gc-checked", "due", "cleanup is due");
137
+ // BLI-11106: the GC runs on every sync tick and costs one manifest read per
138
+ // old pack, so there is no "already ran today" to report; doctor runs it too.
139
+ return needsFix("gc-checked", "due", "cleanup runs on every sync; running it now");
144
140
  }
145
141
  export async function fixGcState(context) {
146
142
  const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(context.command.homeDir), context.io.env);
147
143
  if (result.skipped) {
148
- return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
144
+ return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
149
145
  }
150
146
  if (result.removed_dirs === 0) {
151
147
  return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
@@ -153,21 +149,47 @@ export async function fixGcState(context) {
153
149
  return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
154
150
  }
155
151
  /**
156
- * BLI-3619: what Cockpit is holding on this machine, in one line.
152
+ * BLI-3619 / BLI-11106: what Tower is holding on this machine, in one line.
157
153
  *
158
- * Green means the staging tree fits under the cap. Over the cap is `needs_fix`
159
- * and the fix is the same prune the tick runs, forced past its daily throttle;
160
- * if it is still over afterwards, the message names the one command that goes
161
- * further. Every number here is metadata — byte counts and file counts, never
162
- * a path.
154
+ * Green means the staging tree fits under the cap AND the whole collector
155
+ * footprint (staging, logs, state, spool) is under 512 MB. Either one crossed
156
+ * is `needs_fix`, and the message names the bucket holding the bytes. The fix
157
+ * is the same prune the tick runs; if it is still over afterwards, the message
158
+ * names the one command that goes further. Every number here is metadata —
159
+ * byte counts and file counts, never a path.
163
160
  */
164
161
  export async function checkDiskState(context) {
165
162
  const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
166
163
  const { capBytes } = retentionOptionsFromEnv(context.io.env);
167
164
  const message = diskRowMessage(footprint, capBytes);
168
- return footprint.staging.total_bytes - (footprint.staging.terminal_bytes ?? 0) > capBytes
169
- ? needsFix("disk-bounded", "over_staging_cap", message)
170
- : ok("disk-bounded", "within_staging_cap", message);
165
+ if (footprint.staging.total_bytes - (footprint.staging.terminal_bytes ?? 0) > capBytes) {
166
+ return needsFix("disk-bounded", "over_staging_cap", message);
167
+ }
168
+ const over = footprintOverSentence(footprint);
169
+ if (over)
170
+ return needsFix("disk-bounded", "footprint_over_512mb", `${over}; ${message}`);
171
+ return ok("disk-bounded", "within_staging_cap", message);
172
+ }
173
+ /**
174
+ * "812.0 MB held, over the 512 MB line; most of it is sync logs (600.0 MB)",
175
+ * or null when the footprint is under the line. Logged either way, because a
176
+ * row that only speaks when it is red cannot say the check ran.
177
+ */
178
+ export function footprintOverSentence(footprint) {
179
+ const total = footprintTotalBytes(footprint);
180
+ const holder = footprintHolder(footprint);
181
+ const over = total > FOOTPRINT_NEEDS_FIX_BYTES;
182
+ console.error("[cockpit-doctor] collector footprint", JSON.stringify({
183
+ reason: over ? "footprint_over_512mb" : "footprint_within_512mb",
184
+ total_bytes: total,
185
+ limit_bytes: FOOTPRINT_NEEDS_FIX_BYTES,
186
+ holder: holder?.label ?? null,
187
+ holder_bytes: holder?.bytes ?? 0,
188
+ study_entries_not_counted: footprint.state.study_entries,
189
+ }));
190
+ if (!over)
191
+ return null;
192
+ return `${mib(total)} MB held, over the ${mib(FOOTPRINT_NEEDS_FIX_BYTES)} MB line; most of it is ${holder?.label ?? "unknown"} (${mib(holder?.bytes ?? 0)} MB)`;
171
193
  }
172
194
  export async function fixDiskState(context) {
173
195
  const paths = getCollectorRuntimePaths(context.command.homeDir);
@@ -185,7 +207,6 @@ export async function fixDiskState(context) {
185
207
  const redelivered = redeliveryLine(redelivery);
186
208
  const pruned = await runStagingPrune(paths, {
187
209
  env: context.io.env,
188
- force: true,
189
210
  });
190
211
  if (pruned.status === "fail") {
191
212
  return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
@@ -195,6 +216,10 @@ export async function fixDiskState(context) {
195
216
  }
196
217
  const footprint = await readDiskFootprint(paths);
197
218
  const message = diskRowMessage(footprint, pruned.cap_bytes);
219
+ const over = pruned.cap_blocked_by_uncommitted ? null : footprintOverSentence(footprint);
220
+ if (over) {
221
+ return needsFix("disk-bounded", "footprint_over_512mb", `freed ${mib(pruned.deleted_bytes)} MB; ${over}; ${message}`);
222
+ }
198
223
  if (!pruned.cap_blocked_by_uncommitted) {
199
224
  return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
200
225
  }
@@ -5,6 +5,7 @@ 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
7
  import { checkMcpAnswersState } from "./doctor-mcp.js";
8
+ import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
8
9
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
9
10
  import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
10
11
  import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
@@ -15,12 +16,19 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
15
16
  return runDoctorWithDeps(command, io, deps);
16
17
  }
17
18
  export async function runDoctorWithDeps(command, io, deps) {
18
- const context = { command, io, deps };
19
+ const runStarted = Date.now();
20
+ const deadlineMs = doctorRunDeadline(command, runStarted);
21
+ const context = { command, io, deps, deadlineMs };
22
+ console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
19
23
  const rows = [];
20
24
  const repairs = [];
21
25
  const diagnoseOnly = command.dryRun || command.checkOnly;
22
26
  const diagnosed = new Map();
27
+ // BLI-11101: every row says how long it took, so the next slow run names
28
+ // the row without a profiler. A killed run still leaves every finished row.
29
+ const checkMs = new Map();
23
30
  for (const invariant of doctorInvariants()) {
31
+ const started = Date.now();
24
32
  try {
25
33
  diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
26
34
  }
@@ -28,16 +36,24 @@ export async function runDoctorWithDeps(command, io, deps) {
28
36
  console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
29
37
  diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
30
38
  }
39
+ checkMs.set(invariant.id, Date.now() - started);
40
+ console.error("[doctor] row checked", JSON.stringify({ row: invariant.id, elapsed_ms: checkMs.get(invariant.id), verdict: diagnosed.get(invariant.id)?.status, reason: diagnosed.get(invariant.id)?.code }));
31
41
  }
32
42
  for (const invariant of doctorInvariants()) {
33
43
  let row;
44
+ const fixStarted = Date.now();
34
45
  try {
35
46
  row = diagnosed.get(invariant.id);
36
47
  const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
37
48
  const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
38
49
  const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
39
50
  (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
40
- if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
51
+ const spent = row.status !== "ok" && row.status !== "skipped" && canFix ? budgetSpentRow(context, row) : null;
52
+ if (spent) {
53
+ row = spent;
54
+ repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
55
+ }
56
+ else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
41
57
  row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
42
58
  repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
43
59
  // The replacement process owns the one final receipt, including JSON.
@@ -57,6 +73,9 @@ export async function runDoctorWithDeps(command, io, deps) {
57
73
  repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
58
74
  }
59
75
  console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
76
+ const fixMs = Date.now() - fixStarted;
77
+ const rowCheckMs = checkMs.get(invariant.id) ?? 0;
78
+ console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
60
79
  rows.push(row);
61
80
  }
62
81
  // Re-read host state after repairs. Sync is proven by its upload receipts;
@@ -68,10 +87,11 @@ export async function runDoctorWithDeps(command, io, deps) {
68
87
  const index = rows.findIndex((row) => row.id === invariant.id);
69
88
  if (rows[index]?.status === "fail")
70
89
  continue;
90
+ const verifyStarted = Date.now();
71
91
  try {
72
92
  const checked = await invariant.check(context);
73
93
  rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
74
- console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
94
+ console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status, elapsed_ms: Date.now() - verifyStarted }));
75
95
  if (checked.status === "needs_fix" || checked.status === "fail") {
76
96
  const repair = repairs.find((entry) => entry.step === invariant.id);
77
97
  if (repair) {
@@ -102,8 +122,10 @@ export async function runDoctorWithDeps(command, io, deps) {
102
122
  rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
103
123
  }
104
124
  }
125
+ const needsPerson = doctorNeedsPerson(rows, receipt).length;
126
+ console.error("[doctor] run done", JSON.stringify({ elapsed_ms: Date.now() - runStarted, rows: rows.length, needs_person: needsPerson, check_only: Boolean(diagnoseOnly) }));
105
127
  writeDoctorOutput(command, io, rows, receipt, repairs);
106
- return doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
128
+ return needsPerson === 0 ? 0 : 1;
107
129
  }
108
130
  function doctorInvariants() {
109
131
  return [
@@ -133,7 +133,7 @@ export function buildCollectorHeartbeat(options) {
133
133
  * that a green, syncing, up-to-date laptop was sitting on 1.8 GB of evidence the
134
134
  * server had never received, and a fleet-wide failure nobody can see is the
135
135
  * BLI-2528 shape exactly. The walk is bounded by the pack tree the prune keeps
136
- * under a 2 GB cap, and it is best-effort: a read that fails returns nulls and
136
+ * under a 256 MiB cap, and it is best-effort: a read that fails returns nulls and
137
137
  * says so, so the heartbeat omits both fields rather than claiming zero.
138
138
  */
139
139
  export async function readHeartbeatStagingFacts(options) {
@@ -105,6 +105,15 @@ export function optionalPositiveInteger(value, flag) {
105
105
  }
106
106
  return parsed;
107
107
  }
108
+ export function optionalNonNegativeInteger(value, flag) {
109
+ if (value === undefined)
110
+ return undefined;
111
+ const parsed = Number(value);
112
+ if (!Number.isInteger(parsed) || parsed < 0) {
113
+ throw new Error(`${flag} must be a whole number of 0 or more.`);
114
+ }
115
+ return parsed;
116
+ }
108
117
  export function optionalConfidence(value, flag) {
109
118
  if (value === undefined)
110
119
  return undefined;
@@ -142,8 +142,10 @@ export function parseDoctorArgs(alias, args) {
142
142
  return {
143
143
  kind: "doctor",
144
144
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
- backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget") ?? 900,
146
- lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
145
+ // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
+ // and `--json` without either flag runs under one bounded budget.
147
+ backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget"),
148
+ lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait"),
147
149
  alias,
148
150
  homeDir: optionalNonEmpty(values.flags.get("--home")),
149
151
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),