@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
@@ -8,7 +8,7 @@
8
8
  * defaults, same refusal sentences.
9
9
  */
10
10
  import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
11
- import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
11
+ import { assertNoPositionals, optionalConfidence, optionalNonEmpty, optionalNonNegativeInteger, optionalPositiveInteger, optionalSchemaValue, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
12
12
  export function parseStartArgs(args) {
13
13
  const values = parseNamedArgs(args, {
14
14
  allowedFlags: [
@@ -134,6 +134,7 @@ export function parseBackfillArgs(args) {
134
134
  "--max-files",
135
135
  "--max-depth",
136
136
  "--max-repos",
137
+ "--lock-wait",
137
138
  "--yes",
138
139
  "--json",
139
140
  ],
@@ -146,6 +147,7 @@ export function parseBackfillArgs(args) {
146
147
  "--max-files",
147
148
  "--max-depth",
148
149
  "--max-repos",
150
+ "--lock-wait",
149
151
  ],
150
152
  });
151
153
  assertNoPositionals(values.positionals, "backfill");
@@ -169,7 +171,13 @@ export function parseBackfillArgs(args) {
169
171
  maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
170
172
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
171
173
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
174
+ ...lockWaitMs(values.flags.get("--lock-wait")),
172
175
  yes: values.booleans.has("--yes"),
173
176
  json: values.booleans.has("--json"),
174
177
  };
178
+ }
179
+ /** `--lock-wait <seconds>` (0 = try once); absent means the 10-minute default (BLI-11091). */
180
+ function lockWaitMs(value) {
181
+ const seconds = optionalNonNegativeInteger(value, "--lock-wait");
182
+ return seconds === undefined ? {} : { syncLockWaitMs: seconds * 1000 };
175
183
  }
@@ -95,6 +95,7 @@ export function localSubcommandHelp(command) {
95
95
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
96
96
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
97
97
  "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
98
+ "--json without either flag answers within 45 seconds; a repair that does not fit reads doctor_budget_spent.",
98
99
  "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
99
100
  "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
100
101
  ],
@@ -267,7 +268,7 @@ export function localSubcommandHelp(command) {
267
268
  [
268
269
  "backfill",
269
270
  [
270
- "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
271
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
271
272
  "",
272
273
  "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
273
274
  "Omit --source to scan both sources; there is no --source all literal.",
@@ -276,6 +277,8 @@ export function localSubcommandHelp(command) {
276
277
  "Repo discovery scans 3 folder levels and up to 50 repos by default.",
277
278
  "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
278
279
  "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
280
+ "Sessions outside every saved root upload too, labelled outside; only an explicit opt-out withholds a session.",
281
+ "When the scheduled sync holds the collection lock, backfill waits up to --lock-wait seconds (default 600) and exits 1 with sync_lock_held if it never frees.",
279
282
  ],
280
283
  ],
281
284
  [
@@ -345,11 +348,11 @@ export function localSubcommandHelp(command) {
345
348
  "Usage: cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
346
349
  "",
347
350
  "Reclaims this machine's disk. Prints what it would delete and why, per bucket,",
348
- "then deletes: staged evidence Tower has already accepted goes after a 48-hour",
349
- "retry window, and anything the upload ledger has NOT vouched for is kept,",
351
+ "then deletes: a staged copy Tower has already accepted goes (a retry re-reads",
352
+ "the source file), and anything the upload ledger has NOT vouched for is kept,",
350
353
  "counted and named — retention never deletes undelivered evidence.",
351
- "--dry-run stops after the printing. --all-committed drops the retry window,",
352
- "so every already-accepted copy goes now, and is the only thing that removes a",
354
+ "--dry-run stops after the printing. --all-committed drops any retry window set",
355
+ "with COCKPIT_STAGING_RETENTION_HOURS, so every already-accepted copy goes now, and is the only thing that removes a",
353
356
  "manual-study-evidence-vault folder (and only when every file in it is accepted).",
354
357
  "--reconcile asks Tower's own upload ledger about every object this machine's",
355
358
  "local memory can no longer classify (\"unknown\") before the buckets print — a",
@@ -358,9 +361,9 @@ export function localSubcommandHelp(command) {
358
361
  "object instead of \"unknown\". A server failure never blocks the rest of clean",
359
362
  "and is printed on its own line. Combine with --dry-run to ask and print",
360
363
  "without deleting.",
361
- "The scheduled sync does the same prune once a day (reconciling one batch first",
362
- "when there is anything unknown); this is the door for a laptop that is full",
363
- "right now.",
364
+ "The scheduled sync does the same prune on every tick (reconciling one batch first",
365
+ "when there is anything unknown); this is the door for \"show me\" and for a",
366
+ "laptop that has been off.",
364
367
  ],
365
368
  ],
366
369
  [
@@ -109,7 +109,7 @@ export function localCommandHelp(command) {
109
109
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
110
110
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
111
111
  " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
112
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
112
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
113
113
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
114
114
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
115
115
  " cockpit serve [--port <port>] [--workspace <path>]",
@@ -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.150");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.155");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `cockpit status`'s `Disk:` line (BLI-11106): the same footprint line
3
+ * `cockpit doctor`'s disk row opens with, so a person who pastes `status` into
4
+ * Slack also pastes what the collector holds on disk and when it last cleaned
5
+ * up. Human output only; `status --json` is unchanged. Split out of
6
+ * `status.ts` to keep that file under the readability ratchet.
7
+ */
8
+ import { footprintLine } from "./doctor-disk-words.js";
9
+ import { readDiskFootprint } from "../disk-usage.js";
10
+ import { describeError } from "../health-detail.js";
11
+ import { getCollectorRuntimePaths } from "../local-state.js";
12
+ /** Never throws: an unreadable footprint is a named line, not a failed status. */
13
+ export async function statusDiskLine(homeDir) {
14
+ try {
15
+ const footprint = await readDiskFootprint(getCollectorRuntimePaths(homeDir));
16
+ return `Disk: ${footprintLine(footprint)}`;
17
+ }
18
+ catch (error) {
19
+ console.error("[cockpit-status] could not read the disk footprint", JSON.stringify({ reason: "footprint_read_failed", ...describeError(error) }));
20
+ return "Disk: could not be read (footprint_read_failed)";
21
+ }
22
+ }
@@ -22,6 +22,7 @@ import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from
22
22
  import { discoverCommandWorktrees } from "./local-discovery.js";
23
23
  import { refreshSetupReceipt } from "./setup-receipt.js";
24
24
  import { setupReceiptBlock } from "./setup-receipt-lines.js";
25
+ import { statusDiskLine } from "./status-disk-line.js";
25
26
  import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
26
27
  import { describeError } from "../health-detail.js";
27
28
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
@@ -77,6 +78,7 @@ async function writeMultiRepoStatus(command, io, backfillCursor, setupReceipt, w
77
78
  return writeMultiRepoJson(io, backfillCursor, setupReceipt, statuses);
78
79
  writeLine(io.stdout, "Tower status — every repo below this folder");
79
80
  writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
81
+ writeLine(io.stdout, await statusDiskLine(command.homeDir));
80
82
  for (const status of statuses) {
81
83
  writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
82
84
  }
@@ -108,14 +110,14 @@ async function writeSingleRepoStatus(command, io, backfillCursor, setupReceipt)
108
110
  });
109
111
  if (command.json)
110
112
  return writeSingleRepoJson(io, status, backfillCursor, setupReceipt);
111
- writeSingleRepoLines(io, status, backfillCursor, setupReceipt);
113
+ writeSingleRepoLines(io, status, backfillCursor, setupReceipt, await statusDiskLine(command.homeDir));
112
114
  return 0;
113
115
  }
114
116
  function writeSingleRepoJson(io, status, backfillCursor, setupReceipt) {
115
117
  writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor, setup_receipt: setupReceipt?.receipt ?? null }, null, 2));
116
118
  return 0;
117
119
  }
118
- function writeSingleRepoLines(io, status, backfillCursor, setupReceipt) {
120
+ function writeSingleRepoLines(io, status, backfillCursor, setupReceipt, disk) {
119
121
  writeLine(io.stdout, "Tower status");
120
122
  writeLine(io.stdout, `Installed: ${status.installed ? "yes" : "no"}`);
121
123
  writeLine(io.stdout, `Signed in: ${signedInLine(status.session_state)}`);
@@ -142,6 +144,7 @@ function writeSingleRepoLines(io, status, backfillCursor, setupReceipt) {
142
144
  writeLine(io.stdout, `Last health note problem: ${status.last_health_receipt_failure_reason ?? "none"}`);
143
145
  writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
144
146
  writeLine(io.stdout, `Stuck files: ${stuckEvidenceLine(status)}`);
147
+ writeLine(io.stdout, disk);
145
148
  for (const detail of status.details)
146
149
  writeLine(io.stdout, `- ${detail}`);
147
150
  writeConnectedBlock(io, setupReceipt);
@@ -10,9 +10,11 @@ const RECONCILE_BATCHES_PER_TICK = 1;
10
10
  * laptop forever.
11
11
  *
12
12
  * Same shape as the memory install beside it and for the same reasons: it runs
13
- * only once collection's own outcome is decided and reported, at most once a
14
- * day, it cannot throw, and its outcome is its own named receipt rather than a
15
- * sync failure. A machine that cannot prune is a machine short of disk, and
13
+ * only once collection's own outcome is decided and reported, it cannot throw,
14
+ * and its outcome is its own named receipt rather than a sync failure. Unlike
15
+ * the memory install it runs on EVERY tick (BLI-11106): the upload above has
16
+ * just recorded which objects the server committed, and their staged copies
17
+ * go in this same tick rather than a day or two later. A machine that cannot prune is a machine short of disk, and
16
18
  * that must never also be a machine that stops collecting.
17
19
  *
18
20
  * The rule itself is `disk-retention.ts`: only objects the upload ledger
@@ -24,7 +26,7 @@ const RECONCILE_BATCHES_PER_TICK = 1;
24
26
  * ledger's own memory is capped, so a laptop that keeps accumulating
25
27
  * `unknown` state needs SOMETHING asking the server on a schedule, not only
26
28
  * when a person happens to type `cockpit clean --reconcile`. One batch a tick
27
- * is not throttled to once a day like the prune below — an `unknown` backlog
29
+ * is bounded to one batch rather than drained whole — an `unknown` backlog
28
30
  * drains at up to 500 hashes/15 min, and the ordinary case (nothing unknown)
29
31
  * costs one cheap local read and no network call at all, so it never competes
30
32
  * with collection for the tick's time. A reconcile failure never blocks the
@@ -59,9 +61,10 @@ export async function runStagingPruneAfterSync(command, io, dashboardUrl, option
59
61
  detail: redactedSyncErrorDetail(error),
60
62
  }));
61
63
  }
62
- // The daily throttle is the steady state — reporting it would post a receipt
63
- // on 95 of every 96 ticks for no new information.
64
- if (result.reason !== "throttled_recent_run") {
64
+ // A tick with nothing to prune is the steady state: `[collector prune]`
65
+ // already logged it locally with the bytes scanned, and posting a receipt on
66
+ // 95 of every 96 ticks would say nothing new.
67
+ if (result.reason !== "nothing_to_prune") {
65
68
  events.push(stagingPruneEvent(result));
66
69
  }
67
70
  if (events.length === 0)
@@ -78,8 +81,8 @@ export async function runStagingPruneAfterSync(command, io, dashboardUrl, option
78
81
  /**
79
82
  * Never throws (`runEvidenceReconcile` already never does); returns `null`
80
83
  * for the boring, common case — nothing this tick was `unknown` — so that
81
- * case costs no receipt either, the same rule the prune's own daily throttle
82
- * follows above.
84
+ * case costs no receipt either, the same rule the prune's own
85
+ * `nothing_to_prune` follows above.
83
86
  */
84
87
  async function runReconcileFollowUp(command, io, dashboardUrl, options) {
85
88
  const result = await runEvidenceReconcile({
@@ -102,7 +105,7 @@ async function runReconcileFollowUp(command, io, dashboardUrl, options) {
102
105
  * Never throws (`runEvidenceRedelivery` already never does); returns `null` for
103
106
  * the boring, common case — nothing on this disk is undelivered — so the steady
104
107
  * state costs no receipt, the same rule the reconcile follow-up above and the
105
- * prune's daily throttle below both follow.
108
+ * prune's `nothing_to_prune` below both follow.
106
109
  */
107
110
  async function runRedeliveryFollowUp(command, io, dashboardUrl, options) {
108
111
  const result = await runEvidenceRedelivery({
@@ -24,8 +24,10 @@
24
24
  *
25
25
  * Both arms below do the same things in the same order, because a tick that
26
26
  * THREW is still a tick a machine survived and the fleet has to be able to tell
27
- * that from silence (BLI-3551). The one difference is the staging prune, which
28
- * the throwing arm has never run.
27
+ * that from silence (BLI-3551). Both arms also run the staging prune: a tick
28
+ * that threw after uploading still leaves committed copies the prune may take,
29
+ * and waiting for the next clean tick is how a sick machine fills its disk
30
+ * (BLI-11106).
29
31
  *
30
32
  * Split out of commands/local.ts (BLI-3578), then into the siblings above
31
33
  * (BLI-3982). The scan and upload engine underneath is `./session-sync.ts`;
@@ -78,8 +80,8 @@ export async function runSync(command, io) {
78
80
  await sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat);
79
81
  await reportTickOutcome(command, io, dashboardUrl, result.completion);
80
82
  await convergeAfterTick(command, io, dashboardUrl);
81
- // BLI-3619: and the disk stops growing without bound — same daily cadence,
82
- // same rule that a follow-up never blocks or fails collection.
83
+ // BLI-3619 / BLI-11106: every tick, the staged copies of whatever the
84
+ // server just committed go; a follow-up never blocks or fails collection.
83
85
  await runStagingPruneAfterSync(command, io, dashboardUrl);
84
86
  return result.exitCode;
85
87
  }
@@ -94,6 +96,7 @@ export async function runSync(command, io) {
94
96
  });
95
97
  await reportTickThrew(command, io, dashboardUrl, error, errorCode);
96
98
  await convergeAfterTick(command, io, dashboardUrl);
99
+ await runStagingPruneAfterSync(command, io, dashboardUrl);
97
100
  throw error;
98
101
  }
99
102
  }
@@ -165,10 +165,14 @@ function parseObjectEntry(value) {
165
165
  const committedAt = optionalString(record["committed_at"]);
166
166
  if (!objectKey || !committedAt)
167
167
  return null;
168
+ const sourceKey = optionalString(record["source_key"]);
168
169
  return {
169
170
  object_key: objectKey,
170
171
  byte_size: optionalNumber(record["byte_size"]),
171
172
  committed_at: committedAt,
173
+ ...(sourceKey
174
+ ? { source_key: sourceKey, checkpoints: parseCheckpoints(record["checkpoints"]) }
175
+ : {}),
172
176
  };
173
177
  }
174
178
  function parseSessionEntry(value) {
@@ -193,6 +197,16 @@ function parseSessionEntry(value) {
193
197
  last_seen_at: lastSeenAt,
194
198
  };
195
199
  }
200
+ function parseCheckpoints(value) {
201
+ if (!value || typeof value !== "object")
202
+ return null;
203
+ const out = {};
204
+ for (const [offset, hash] of Object.entries(value)) {
205
+ if (/^\d+$/.test(offset) && typeof hash === "string")
206
+ out[offset] = hash;
207
+ }
208
+ return out;
209
+ }
196
210
  function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
197
211
  return path.join(paths.cursors_dir, filename);
198
212
  }
@@ -15,19 +15,25 @@
15
15
  * evidence that the drain is still running at all (the BLI-2528 lesson: what
16
16
  * only speaks on failure cannot answer "did anything work today?").
17
17
  *
18
- * Throttled to once a day by a marker file, the same idiom the raw-evidence GC,
19
- * the scheduled self-update and the BLI Memory install use. Walking a pack tree
20
- * is cheap; doing it every fifteen minutes for a decision that changes on the
21
- * scale of hours is not.
18
+ * Runs on EVERY tick (BLI-11106). It used to sit behind a once-a-day marker
19
+ * file, and that is how 534 MB of committed, past-window copies were still on
20
+ * the reference Mac when it was measured: the drain existed and had not run
21
+ * yet. The inventory reads each pack's small `manifest.json` rather than its
22
+ * payload, so a tick with nothing to delete costs one directory walk and logs
23
+ * `nothing_to_prune` with the bytes it scanned. `COCKPIT_DISABLE_GC=1` is the
24
+ * only off switch.
25
+ *
26
+ * Every non-dry run leaves `last-prune.json` (one small object, overwritten)
27
+ * so `cockpit doctor` and `cockpit status` can say when it last ran and what
28
+ * it freed without re-running it.
22
29
  */
23
30
  import fs from "node:fs/promises";
24
31
  import path from "node:path";
25
32
  import { planStagingRetention, retentionOptionsFromEnv, } from "./disk-retention.js";
26
33
  import { RAW_EVIDENCE_DIR, readStagingInventory } from "./disk-usage.js";
27
34
  import { describeError } from "./health-detail.js";
35
+ import { writeLastPruneReceipt } from "./prune-receipt.js";
28
36
  import { readRawEvidenceStagingState, writeRawEvidenceStagingState, } from "./raw-evidence-staging.js";
29
- export const STAGING_PRUNE_THROTTLE_MARKER = ".last-staging-prune";
30
- export const STAGING_PRUNE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
31
37
  /**
32
38
  * The prune the sync tick runs and `cockpit clean` / `cockpit doctor` reuse.
33
39
  * Never throws.
@@ -38,17 +44,14 @@ export async function runStagingPrune(paths, options = {}) {
38
44
  if (env["COCKPIT_DISABLE_GC"] === "1") {
39
45
  return skippedResult("disabled");
40
46
  }
41
- if (!options.force && (await prunedRecently(paths, now))) {
42
- return skippedResult("throttled_recent_run");
43
- }
44
47
  try {
45
- if (!options.dryRun && !options.force)
46
- await markPruned(paths, now);
47
48
  const plan = await planFromDisk(paths, env, options, now);
48
49
  const result = options.dryRun
49
50
  ? dryRunResult(plan)
50
51
  : await applyPlan(paths, plan);
51
52
  reportPrune(result, { dryRun: Boolean(options.dryRun) });
53
+ if (!options.dryRun)
54
+ await writeLastPruneReceipt(paths, result, now);
52
55
  return result;
53
56
  }
54
57
  catch (error) {
@@ -75,7 +78,7 @@ async function applyPlan(paths, plan) {
75
78
  return {
76
79
  ...dryRunResult(plan),
77
80
  status: "ok",
78
- reason: tally.files > 0 ? "pruned" : "nothing_eligible",
81
+ reason: tally.files > 0 || tally.packs > 0 ? "pruned" : "nothing_to_prune",
79
82
  deleted_files: tally.files,
80
83
  deleted_bytes: tally.bytes,
81
84
  removed_packs: tally.packs,
@@ -166,7 +169,9 @@ async function forgetStagedObjects(paths, hashes) {
166
169
  function dryRunResult(plan) {
167
170
  return {
168
171
  status: "ok",
169
- reason: plan.delete.length > 0 ? "would_prune" : "nothing_eligible",
172
+ reason: plan.delete.length > 0 || plan.empty_packs.length > 0
173
+ ? "would_prune"
174
+ : "nothing_to_prune",
170
175
  deleted_files: plan.delete.length,
171
176
  deleted_bytes: plan.deleted_bytes + plan.empty_pack_manifest_bytes,
172
177
  removed_packs: plan.empty_packs.length,
@@ -221,6 +226,7 @@ export function reportPrune(result, options = { dryRun: false }) {
221
226
  kept_unknown: result.kept_unknown,
222
227
  kept_in_window: result.kept_in_window,
223
228
  cap_bytes: result.cap_bytes,
229
+ bytes_scanned: result.bytes_before,
224
230
  bytes_after: result.bytes_after,
225
231
  failed_deletions: result.failed_deletions,
226
232
  }));
@@ -234,14 +240,4 @@ export function reportPrune(result, options = { dryRun: false }) {
234
240
  cap_bytes: result.cap_bytes,
235
241
  bytes_after: result.bytes_after,
236
242
  }));
237
- }
238
- async function prunedRecently(paths, now) {
239
- const marker = path.join(paths.state_dir, STAGING_PRUNE_THROTTLE_MARKER);
240
- const info = await fs.stat(marker).catch(() => null);
241
- return Boolean(info && now.getTime() - info.mtimeMs < STAGING_PRUNE_MIN_INTERVAL_MS);
242
- }
243
- async function markPruned(paths, now) {
244
- const marker = path.join(paths.state_dir, STAGING_PRUNE_THROTTLE_MARKER);
245
- await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
246
- await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
247
243
  }
@@ -1,7 +1,10 @@
1
- /** How long a committed spare is kept so a retry still has local bytes. */
2
- export const DEFAULT_STAGING_RETENTION_MS = 48 * 60 * 60 * 1000;
1
+ /**
2
+ * How long a committed spare is kept: zero (BLI-11106). A retry re-reads the
3
+ * source file, so the staged copy of a committed object is pure duplication.
4
+ */
5
+ export const DEFAULT_STAGING_RETENTION_MS = 0;
3
6
  /** Ceiling on everything under `raw-evidence`, manifests included. */
4
- export const DEFAULT_STAGING_CAP_BYTES = 2 * 1024 * 1024 * 1024;
7
+ export const DEFAULT_STAGING_CAP_BYTES = 256 * 1024 * 1024;
5
8
  /** Local knobs, read from the environment like `COCKPIT_DISABLE_GC`. */
6
9
  export const STAGING_CAP_ENV = "COCKPIT_STAGING_CAP_BYTES";
7
10
  export const STAGING_RETENTION_ENV = "COCKPIT_STAGING_RETENTION_HOURS";
@@ -33,7 +36,7 @@ export function planStagingRetention(inventory, options = {}) {
33
36
  continue;
34
37
  }
35
38
  if (options.allCommitted || object.age_ms >= retentionMs) {
36
- pushDeletion(plan, object, "committed_past_retry_window");
39
+ pushDeletion(plan, object, "committed_on_server");
37
40
  continue;
38
41
  }
39
42
  committedInWindow.push(object);
@@ -61,11 +64,11 @@ export function planStagingRetention(inventory, options = {}) {
61
64
  * safe elsewhere.
62
65
  *
63
66
  * It does not run at all when the floor is already above the cap. That branch
64
- * is not theoretical: on the reference Mac (2026-09-05) 8.0 GB of staging is
65
- * evidence no ledger vouches for, against a 2 GB cap, so spending the retry
66
- * window on 1,027 committed spares would have deleted a day of local retry
67
- * copies and still ended over the cap. The cap takes a spare only when taking
68
- * it can actually get this machine under the cap.
67
+ * is not theoretical: on the reference Mac (2026-09-05) 8.0 GB of staging was
68
+ * evidence no ledger vouched for, against the cap of the day. With the default
69
+ * zero window it rarely matters (committed spares are already gone); it keeps
70
+ * an opted-in window honest by taking a spare only when that can actually get
71
+ * this machine under the cap.
69
72
  */
70
73
  function applyByteCap(plan, committedInWindow, capBytes, inventory) {
71
74
  let projected = plan.bytes_before - plan.deleted_bytes;
@@ -1,9 +1,11 @@
1
1
  /**
2
- * The whole-machine read: staging, rotated logs, the spool, and the vaults a
3
- * person made by hand — the four places Cockpit ever writes bytes on a laptop.
2
+ * The whole-machine read: staging, rotated logs, the spool, the vaults a
3
+ * person made by hand, and (BLI-11106) the rest of the state directory plus
4
+ * the last prune's receipt.
4
5
  *
5
- * The staging half is the rest of this family; what lives here is the other
6
- * three and the one function that puts all four together.
6
+ * The staging half is the rest of this family; the state-dir audit is
7
+ * `state-dir-audit.ts` and the receipt `prune-receipt.ts`. What lives here is
8
+ * the one function that puts them together.
7
9
  */
8
10
  import fs from "node:fs/promises";
9
11
  import path from "node:path";
@@ -11,6 +13,8 @@ import { MANUAL_VAULT_PREFIX, } from "./disk-usage-facts.js";
11
13
  import { directoryBytes, listPackFiles } from "./disk-usage-files.js";
12
14
  import { readStagingInventory } from "./disk-usage-scan.js";
13
15
  import { rotatedLogFootprint } from "./log-rotation.js";
16
+ import { readLastPruneReceipt } from "./prune-receipt.js";
17
+ import { readStateDirFootprint } from "./state-dir-audit.js";
14
18
  /**
15
19
  * Everything on this machine Cockpit put there, in one read. Used by the sync
16
20
  * tick's prune, by `cockpit clean` and by `cockpit doctor`'s disk row, so all
@@ -22,6 +26,8 @@ export async function readDiskFootprint(paths, now = new Date()) {
22
26
  logs: await rotatedLogFootprint(paths),
23
27
  spool_bytes: await directoryBytes(paths.spool_dir),
24
28
  vaults: await readManualVaults(paths),
29
+ state: await readStateDirFootprint(paths),
30
+ last_prune: await readLastPruneReceipt(paths),
25
31
  };
26
32
  }
27
33
  /** The hand-made vaults: counted and named so nothing deletes them silently. */
@@ -35,8 +35,8 @@ export async function runEvidenceReconcile(options) {
35
35
  if (hashes.length === 0) {
36
36
  // No stderr line here on purpose: the sync tick calls this every 15
37
37
  // minutes and the steady state is "nothing unknown" — logging that
38
- // every tick forever is exactly the receipt-on-95-of-96-ticks spam the
39
- // daily prune throttle exists to avoid. `cockpit clean --reconcile`
38
+ // every tick forever is exactly the receipt-on-95-of-96-ticks spam that
39
+ // the follow-ups skip on their `nothing_*` reasons. `cockpit clean --reconcile`
40
40
  // still tells a person this on stdout (`reconcileLines` in clean.ts).
41
41
  return emptyResult("nothing_unknown");
42
42
  }
@@ -37,9 +37,16 @@ import path from "node:path";
37
37
  * of a file operators can read. The tail is capped rather than copied whole, so
38
38
  * the archives cannot themselves become the disk problem — worst case on disk
39
39
  * is (keep + 1) x maxBytes per stream.
40
+ *
41
+ * BLI-11106 tightened the numbers: at most 2 archives and 20 MB of archive per
42
+ * stream (2 x 10 MiB), so both streams together never pass 60 MiB, live logs
43
+ * included. It was 3 x 20 MiB per stream (160 MiB worst case) and the
44
+ * reference Mac held 147 MB of it on 2026-09-30. An archive above the new keep
45
+ * window (`sync.log.3` on every machine that ran the old numbers) is swept on
46
+ * the first tick by `sweepOrphanedLogArchives` below.
40
47
  */
41
- export const DEFAULT_LOG_ROTATION_MAX_BYTES = 20 * 1024 * 1024;
42
- export const DEFAULT_LOG_ROTATION_KEEP = 3;
48
+ export const DEFAULT_LOG_ROTATION_MAX_BYTES = 10 * 1024 * 1024;
49
+ export const DEFAULT_LOG_ROTATION_KEEP = 2;
43
50
  /** The two files launchd writes for the scheduled tick. */
44
51
  export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
45
52
  /**
@@ -129,27 +136,41 @@ async function rotateOne(filePath, maxBytes, keep) {
129
136
  * sentences and an operator is owed the second one.
130
137
  */
131
138
  export async function sweepOrphanedLogArchives(paths, options) {
139
+ const swept = [];
140
+ for (const archive of await orphanedLogArchives(paths, options)) {
141
+ const removed = await fs
142
+ .rm(path.join(paths.state_dir, archive.name), { force: true })
143
+ .then(() => true, () => false);
144
+ if (removed)
145
+ swept.push(archive);
146
+ }
147
+ return swept;
148
+ }
149
+ /**
150
+ * The archives the sweep above WOULD remove, without removing them — what
151
+ * `cockpit clean --dry-run` prints (BLI-11106). Same rule, one reader.
152
+ */
153
+ export async function orphanedLogArchives(paths, options = {
154
+ keep: DEFAULT_LOG_ROTATION_KEEP,
155
+ names: ROTATED_LOG_NAMES,
156
+ }) {
132
157
  const entries = await fs
133
158
  .readdir(paths.state_dir, { withFileTypes: true })
134
159
  .catch(() => []);
135
- const swept = [];
160
+ const found = [];
136
161
  for (const entry of entries) {
137
162
  if (!entry.isFile())
138
163
  continue;
139
164
  const reason = orphanArchiveReason(entry.name, options);
140
165
  if (!reason)
141
166
  continue;
142
- const file = path.join(paths.state_dir, entry.name);
143
167
  const size = await fs
144
- .stat(file)
168
+ .stat(path.join(paths.state_dir, entry.name))
145
169
  .then((info) => info.size)
146
170
  .catch(() => 0);
147
- const removed = await fs.rm(file, { force: true }).then(() => true, () => false);
148
- if (!removed)
149
- continue;
150
- swept.push({ name: entry.name, byte_size: size, reason });
171
+ found.push({ name: entry.name, byte_size: size, reason });
151
172
  }
152
- return swept;
173
+ return found;
153
174
  }
154
175
  /**
155
176
  * Which stream this file belongs to and why it is outside the keep window, or
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `last-prune.json`: when the staging prune last ran and what it freed
3
+ * (BLI-11106).
4
+ *
5
+ * The prune runs on every sync tick, so "did it run?" is answered by a file it
6
+ * overwrites, never by a file that grows. One small JSON object, bounded by
7
+ * construction. `cockpit doctor` and `cockpit status` read it for the
8
+ * footprint line ("last prune: <when>, freed <n> MB") without re-running the
9
+ * prune themselves.
10
+ *
11
+ * It also carries the last time a prune actually freed something, because on
12
+ * a healthy machine the newest run is usually `nothing_to_prune` and "freed
13
+ * 0 MB" on its own cannot tell a working drain from a stuck one.
14
+ *
15
+ * The old once-a-day throttle marker is removed on the first write: it gates
16
+ * nothing any more, and a file that gates nothing is exactly the kind of
17
+ * leftover this ticket exists to stop.
18
+ */
19
+ import fs from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { describeError } from "./health-detail.js";
22
+ export const LAST_PRUNE_FILE = "last-prune.json";
23
+ /** The retired daily throttle marker; deleted on sight. */
24
+ export const LEGACY_STAGING_PRUNE_MARKER = ".last-staging-prune";
25
+ export async function readLastPruneReceipt(paths) {
26
+ const raw = await fs
27
+ .readFile(path.join(paths.state_dir, LAST_PRUNE_FILE), "utf8")
28
+ .catch(() => null);
29
+ if (!raw)
30
+ return null;
31
+ try {
32
+ const parsed = JSON.parse(raw);
33
+ if (typeof parsed.at !== "string" || typeof parsed.reason !== "string") {
34
+ return null;
35
+ }
36
+ return {
37
+ at: parsed.at,
38
+ reason: parsed.reason,
39
+ freed_bytes: numberOr0(parsed.freed_bytes),
40
+ deleted_files: numberOr0(parsed.deleted_files),
41
+ bytes_scanned: numberOr0(parsed.bytes_scanned),
42
+ bytes_after: numberOr0(parsed.bytes_after),
43
+ last_freed_at: typeof parsed.last_freed_at === "string" ? parsed.last_freed_at : null,
44
+ last_freed_bytes: numberOr0(parsed.last_freed_bytes),
45
+ };
46
+ }
47
+ catch {
48
+ // A torn or hand-edited receipt reads as "never ran"; the next tick
49
+ // overwrites it. Doctor prints "last prune: never" rather than guessing.
50
+ return null;
51
+ }
52
+ }
53
+ /** Never throws: a receipt that cannot be written is a log line, not a failed tick. */
54
+ export async function writeLastPruneReceipt(paths, result, now) {
55
+ try {
56
+ const previous = await readLastPruneReceipt(paths);
57
+ const freed = result.deleted_bytes > 0;
58
+ const receipt = {
59
+ at: now.toISOString(),
60
+ reason: result.reason,
61
+ freed_bytes: result.deleted_bytes,
62
+ deleted_files: result.deleted_files,
63
+ bytes_scanned: result.bytes_before,
64
+ bytes_after: result.bytes_after,
65
+ last_freed_at: freed ? now.toISOString() : (previous?.last_freed_at ?? null),
66
+ last_freed_bytes: freed
67
+ ? result.deleted_bytes
68
+ : (previous?.last_freed_bytes ?? 0),
69
+ };
70
+ await fs.mkdir(paths.state_dir, { recursive: true });
71
+ const file = path.join(paths.state_dir, LAST_PRUNE_FILE);
72
+ const temp = `${file}.${process.pid}.tmp`;
73
+ await fs.writeFile(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
74
+ await fs.rename(temp, file);
75
+ await fs
76
+ .rm(path.join(paths.state_dir, LEGACY_STAGING_PRUNE_MARKER), { force: true })
77
+ .catch(() => undefined);
78
+ }
79
+ catch (error) {
80
+ console.error("[collector prune] could not record the last-prune receipt", JSON.stringify({ reason: "last_prune_write_failed", ...describeError(error) }));
81
+ }
82
+ }
83
+ function numberOr0(value) {
84
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
85
+ ? value
86
+ : 0;
87
+ }