@bli-cockpit/cli 0.2.51 → 0.2.52

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.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * What the sync tick does AFTER collection's own outcome is decided and
3
3
  * reported: keep this machine's CLI current on npm `latest` (BLI-2601), put a
4
- * broken scheduler registration back (BLI-2721), and keep BLI Memory
5
- * registered with both agent hosts (BLI-3580).
4
+ * broken scheduler registration back (BLI-2721), keep BLI Memory registered
5
+ * with both agent hosts (BLI-3580), and stop the laptop filling up (BLI-3619).
6
6
  *
7
7
  * Split out of commands/sync.ts (BLI-3578), moved verbatim. They belong
8
8
  * together because they share one rule, and it is the reason both are called
@@ -18,6 +18,7 @@ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./insta
18
18
  import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
19
19
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
20
20
  import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
21
+ import { runStagingPrune } from "../disk-prune.js";
21
22
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
22
23
  /**
23
24
  * BLI-2721: after the tick's collection and self-update are done and
@@ -183,6 +184,98 @@ function memoryInstallEvent(outcome) {
183
184
  }
184
185
  return { step: "memory_install", status: "ok", error_detail: detail };
185
186
  }
187
+ /**
188
+ * BLI-3619: staged raw evidence Tower has already accepted stops living on the
189
+ * laptop forever.
190
+ *
191
+ * Same shape as the memory install above and for the same reasons: it runs only
192
+ * once collection's own outcome is decided and reported, at most once a day, it
193
+ * cannot throw, and its outcome is its own named receipt rather than a sync
194
+ * failure. A machine that cannot prune is a machine short of disk, and that
195
+ * must never also be a machine that stops collecting.
196
+ *
197
+ * The rule itself is `disk-retention.ts`: only objects the upload ledger
198
+ * vouches for are ever deleted, and anything undelivered is counted, aged and
199
+ * named instead.
200
+ */
201
+ export async function runStagingPruneAfterSync(command, io, dashboardUrl, options = {}) {
202
+ let result;
203
+ try {
204
+ result = await runStagingPrune(getCollectorRuntimePaths(command.homeDir), {
205
+ env: io.env,
206
+ ...(options.now ? { now: options.now } : {}),
207
+ });
208
+ }
209
+ catch (error) {
210
+ // runStagingPrune already catches everything it can reach; this is the
211
+ // last-resort net so a prune crash truly cannot touch the sync result.
212
+ result = { ...prunedNothing(), reason: "prune_threw", status: "fail" };
213
+ console.error("[collector prune] the prune follow-up threw", JSON.stringify({
214
+ reason: "prune_followup_threw",
215
+ detail: redactedSyncErrorDetail(error),
216
+ }));
217
+ }
218
+ // The daily throttle is the steady state — reporting it would post a receipt
219
+ // on 95 of every 96 ticks for no new information.
220
+ if (result.reason === "throttled_recent_run")
221
+ return;
222
+ await reportInstallEventsBestEffort({
223
+ homeDir: command.homeDir,
224
+ dashboardUrl,
225
+ command: "sync",
226
+ events: [stagingPruneEvent(result)],
227
+ json: command.json,
228
+ io,
229
+ });
230
+ }
231
+ function prunedNothing() {
232
+ return {
233
+ status: "skipped",
234
+ reason: "prune_threw",
235
+ deleted_files: 0,
236
+ deleted_bytes: 0,
237
+ removed_packs: 0,
238
+ kept_uncommitted: 0,
239
+ kept_uncommitted_bytes: 0,
240
+ oldest_uncommitted_age_ms: 0,
241
+ kept_unknown: 0,
242
+ kept_unknown_bytes: 0,
243
+ kept_in_window: 0,
244
+ cap_bytes: 0,
245
+ bytes_before: 0,
246
+ bytes_after: 0,
247
+ cap_blocked_by_uncommitted: false,
248
+ cap_blocked_count: 0,
249
+ failed_deletions: 0,
250
+ };
251
+ }
252
+ /** Counts and byte totals only; no pack id and no path travels in a receipt. */
253
+ function stagingPruneEvent(result) {
254
+ const detail = [
255
+ `freed ${result.deleted_bytes}B in ${result.deleted_files} file(s)`,
256
+ `held ${result.kept_uncommitted_bytes}B uncommitted`,
257
+ result.cap_blocked_by_uncommitted
258
+ ? `staging_cap_blocked_by_uncommitted ${result.cap_blocked_count}`
259
+ : null,
260
+ result.failed_deletions > 0
261
+ ? `failed_deletions ${result.failed_deletions}`
262
+ : null,
263
+ ]
264
+ .filter((part) => Boolean(part))
265
+ .join("; ");
266
+ if (result.status === "fail") {
267
+ return {
268
+ step: "staging_prune",
269
+ status: "fail",
270
+ error_code: result.reason,
271
+ error_detail: detail,
272
+ };
273
+ }
274
+ if (result.status === "skipped") {
275
+ return { step: "staging_prune", status: "skipped", error_code: result.reason };
276
+ }
277
+ return { step: "staging_prune", status: "ok", error_detail: detail };
278
+ }
186
279
  /**
187
280
  * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
188
281
  * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
@@ -5,7 +5,7 @@ import { discoverCommandWorktrees } from "./local-discovery.js";
5
5
  import { sendCollectorHeartbeatBestEffort, } from "./heartbeat.js";
6
6
  import { classifySyncFailureRecords, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
7
7
  import { runAttributedWorktreeSync, } from "./session-sync.js";
8
- import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, } from "./sync-followups.js";
8
+ import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
9
9
  import { describeError } from "../health-detail.js";
10
10
  import { inspectBackfillLock } from "../backfill-lock.js";
11
11
  import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
@@ -52,6 +52,9 @@ export async function runSync(command, io) {
52
52
  // BLI-3580: BLI Memory's registration converges the same way — after
53
53
  // collection, at most once a day, its own receipt either way.
54
54
  await runMemoryInstallAfterSync(command, io, dashboardUrl);
55
+ // BLI-3619: and the disk stops growing without bound — same daily cadence,
56
+ // same rule that a follow-up never blocks or fails collection.
57
+ await runStagingPruneAfterSync(command, io, dashboardUrl);
55
58
  return result.exitCode;
56
59
  }
57
60
  catch (error) {
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Carrying out the retention plan, and saying what it did.
3
+ *
4
+ * BLI-3619. The decision is `disk-retention.ts` and the reading is
5
+ * `disk-usage.ts`; this file is the only one that removes a staged byte. Three
6
+ * properties it must keep:
7
+ *
8
+ * - **It never fails the tick.** Every path is caught. A prune that cannot run
9
+ * is a log line, never a reason collection did not happen — the same rule the
10
+ * log rotation and the two self-heal follow-ups already live under.
11
+ * - **It never deletes what the ledger did not vouch for.** The plan decides
12
+ * that; this file does not second-guess it, and re-reads nothing.
13
+ * - **It says so on both branches.** `[collector prune]` fires on the boring
14
+ * run too, because "0 deleted, 2.1 GB held, all committed" is the only
15
+ * evidence that the drain is still running at all (the BLI-2528 lesson: what
16
+ * only speaks on failure cannot answer "did anything work today?").
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.
22
+ */
23
+ import fs from "node:fs/promises";
24
+ import path from "node:path";
25
+ import { planStagingRetention, retentionOptionsFromEnv, } from "./disk-retention.js";
26
+ import { RAW_EVIDENCE_DIR, readStagingInventory } from "./disk-usage.js";
27
+ import { describeError } from "./health-detail.js";
28
+ 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
+ /**
32
+ * The prune the sync tick runs and `cockpit clean` / `cockpit doctor` reuse.
33
+ * Never throws.
34
+ */
35
+ export async function runStagingPrune(paths, options = {}) {
36
+ const env = options.env ?? process.env;
37
+ const now = options.now ?? new Date();
38
+ if (env["COCKPIT_DISABLE_GC"] === "1") {
39
+ return skippedResult("disabled");
40
+ }
41
+ if (!options.force && (await prunedRecently(paths, now))) {
42
+ return skippedResult("throttled_recent_run");
43
+ }
44
+ try {
45
+ if (!options.dryRun && !options.force)
46
+ await markPruned(paths, now);
47
+ const plan = await planFromDisk(paths, env, options, now);
48
+ const result = options.dryRun
49
+ ? dryRunResult(plan)
50
+ : await applyPlan(paths, plan);
51
+ reportPrune(result, { dryRun: Boolean(options.dryRun) });
52
+ return result;
53
+ }
54
+ catch (error) {
55
+ console.error("[collector prune] the prune could not run; nothing was deleted", JSON.stringify({ reason: "prune_threw", ...describeError(error) }));
56
+ return { ...skippedResult("prune_threw"), status: "fail" };
57
+ }
58
+ }
59
+ export async function planFromDisk(paths, env, options, now) {
60
+ const fromEnv = retentionOptionsFromEnv(env);
61
+ const inventory = await readStagingInventory(paths, now);
62
+ return planStagingRetention(inventory, {
63
+ retentionMs: options.retentionMs ?? fromEnv.retentionMs,
64
+ capBytes: options.capBytes ?? fromEnv.capBytes,
65
+ allCommitted: options.allCommitted,
66
+ });
67
+ }
68
+ async function applyPlan(paths, plan) {
69
+ const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
70
+ const tally = { files: 0, bytes: 0, packs: 0, failed: 0, hashes: [] };
71
+ const removedPackIds = await removeEmptyPacks(root, plan, tally);
72
+ await removeRemainingFiles(root, plan, removedPackIds, tally);
73
+ await forgetStagedObjects(paths, tally.hashes);
74
+ return {
75
+ ...dryRunResult(plan),
76
+ status: "ok",
77
+ reason: tally.files > 0 ? "pruned" : "nothing_eligible",
78
+ deleted_files: tally.files,
79
+ deleted_bytes: tally.bytes,
80
+ removed_packs: tally.packs,
81
+ failed_deletions: tally.failed,
82
+ };
83
+ }
84
+ /**
85
+ * A pack whose every payload file is going is removed whole, in one call, so
86
+ * `manifest.json` goes with it rather than surviving as a description of files
87
+ * that are not there.
88
+ */
89
+ async function removeEmptyPacks(root, plan, tally) {
90
+ const removed = new Set();
91
+ for (const pack of plan.empty_packs) {
92
+ const gone = await rmQuietly(path.join(root, pack.pack_id), true);
93
+ if (!gone) {
94
+ tally.failed += 1;
95
+ continue;
96
+ }
97
+ removed.add(pack.pack_id);
98
+ tally.packs += 1;
99
+ tally.bytes += pack.manifest_bytes;
100
+ }
101
+ return removed;
102
+ }
103
+ async function removeRemainingFiles(root, plan, removedPackIds, tally) {
104
+ const emptyPackIds = new Set(plan.empty_packs.map((pack) => pack.pack_id));
105
+ for (const entry of plan.delete) {
106
+ if (emptyPackIds.has(entry.pack_id)) {
107
+ // The pack removal above already took these bytes — or failed, in which
108
+ // case the file is still on disk and must be counted as such.
109
+ if (!removedPackIds.has(entry.pack_id))
110
+ tally.failed += 1;
111
+ else
112
+ countRemoved(tally, entry);
113
+ continue;
114
+ }
115
+ const file = path.join(root, entry.pack_id, ...entry.relative_path.split("/").filter(Boolean));
116
+ if (await rmQuietly(file, false))
117
+ countRemoved(tally, entry);
118
+ else
119
+ tally.failed += 1;
120
+ }
121
+ }
122
+ function countRemoved(tally, entry) {
123
+ tally.files += 1;
124
+ tally.bytes += entry.byte_size;
125
+ if (entry.content_hash)
126
+ tally.hashes.push(entry.content_hash);
127
+ }
128
+ async function rmQuietly(target, recursive) {
129
+ return fs.rm(target, { recursive, force: true }).then(() => true, () => false);
130
+ }
131
+ /**
132
+ * The staged-object index points at bytes that are no longer there. Left alone
133
+ * it self-heals — `resolveStagedObject` stats the file and drops the entry —
134
+ * but the index is capped at 5,000 rows, so stale entries evict live ones and
135
+ * a real staged copy stops being found. One atomic write clears them together.
136
+ */
137
+ async function forgetStagedObjects(paths, hashes) {
138
+ if (hashes.length === 0)
139
+ return;
140
+ try {
141
+ const staging = await readRawEvidenceStagingState(paths.state_dir);
142
+ let removed = 0;
143
+ for (const hash of hashes) {
144
+ if (staging.staged[hash]) {
145
+ delete staging.staged[hash];
146
+ removed += 1;
147
+ }
148
+ }
149
+ if (removed === 0)
150
+ return;
151
+ staging.updated_at = new Date().toISOString();
152
+ await writeRawEvidenceStagingState(paths.state_dir, staging);
153
+ }
154
+ catch (error) {
155
+ // Not fatal and not silent: the bytes are gone either way, and the index
156
+ // heals on the next read. What must not happen is nobody knowing the index
157
+ // is carrying rows for files that no longer exist.
158
+ console.error("[collector prune] staged index not updated after the prune", JSON.stringify({
159
+ reason: "staged_index_write_failed",
160
+ forgotten_count: hashes.length,
161
+ ...describeError(error),
162
+ }));
163
+ }
164
+ }
165
+ function dryRunResult(plan) {
166
+ return {
167
+ status: "ok",
168
+ reason: plan.delete.length > 0 ? "would_prune" : "nothing_eligible",
169
+ deleted_files: plan.delete.length,
170
+ deleted_bytes: plan.deleted_bytes + plan.empty_pack_manifest_bytes,
171
+ removed_packs: plan.empty_packs.length,
172
+ kept_uncommitted: plan.kept_uncommitted.count,
173
+ kept_uncommitted_bytes: plan.kept_uncommitted.bytes,
174
+ oldest_uncommitted_age_ms: plan.kept_uncommitted.oldest_age_ms,
175
+ kept_unknown: plan.kept_unknown.count,
176
+ kept_unknown_bytes: plan.kept_unknown.bytes,
177
+ kept_in_window: plan.kept_in_window.count,
178
+ cap_bytes: plan.cap_bytes,
179
+ bytes_before: plan.bytes_before,
180
+ bytes_after: plan.bytes_after,
181
+ cap_blocked_by_uncommitted: plan.cap_blocked_by_uncommitted,
182
+ cap_blocked_count: plan.cap_blocked_count,
183
+ failed_deletions: 0,
184
+ };
185
+ }
186
+ function skippedResult(reason) {
187
+ return {
188
+ status: "skipped",
189
+ reason,
190
+ deleted_files: 0,
191
+ deleted_bytes: 0,
192
+ removed_packs: 0,
193
+ kept_uncommitted: 0,
194
+ kept_uncommitted_bytes: 0,
195
+ oldest_uncommitted_age_ms: 0,
196
+ kept_unknown: 0,
197
+ kept_unknown_bytes: 0,
198
+ kept_in_window: 0,
199
+ cap_bytes: 0,
200
+ bytes_before: 0,
201
+ bytes_after: 0,
202
+ cap_blocked_by_uncommitted: false,
203
+ cap_blocked_count: 0,
204
+ failed_deletions: 0,
205
+ };
206
+ }
207
+ /**
208
+ * The receipt, on stderr, which launchd captures to `sync.err.log`. Counts,
209
+ * byte totals and reason labels only — never a pack id, never a path.
210
+ */
211
+ export function reportPrune(result, options = { dryRun: false }) {
212
+ console.error(options.dryRun ? "[collector prune] dry run" : "[collector prune] swept", JSON.stringify({
213
+ reason: result.reason,
214
+ deleted_files: result.deleted_files,
215
+ deleted_bytes: result.deleted_bytes,
216
+ removed_packs: result.removed_packs,
217
+ kept_uncommitted: result.kept_uncommitted,
218
+ staging_uncommitted_bytes: result.kept_uncommitted_bytes,
219
+ oldest_uncommitted_age_ms: result.oldest_uncommitted_age_ms,
220
+ kept_unknown: result.kept_unknown,
221
+ kept_in_window: result.kept_in_window,
222
+ cap_bytes: result.cap_bytes,
223
+ bytes_after: result.bytes_after,
224
+ failed_deletions: result.failed_deletions,
225
+ }));
226
+ if (!result.cap_blocked_by_uncommitted)
227
+ return;
228
+ console.error("[collector prune] still over the cap and nothing committed is left to take", JSON.stringify({
229
+ reason: "staging_cap_blocked_by_uncommitted",
230
+ blocked_object_count: result.cap_blocked_count,
231
+ staging_uncommitted_bytes: result.kept_uncommitted_bytes,
232
+ unknown_bytes: result.kept_unknown_bytes,
233
+ cap_bytes: result.cap_bytes,
234
+ bytes_after: result.bytes_after,
235
+ }));
236
+ }
237
+ async function prunedRecently(paths, now) {
238
+ const marker = path.join(paths.state_dir, STAGING_PRUNE_THROTTLE_MARKER);
239
+ const info = await fs.stat(marker).catch(() => null);
240
+ return Boolean(info && now.getTime() - info.mtimeMs < STAGING_PRUNE_MIN_INTERVAL_MS);
241
+ }
242
+ async function markPruned(paths, now) {
243
+ const marker = path.join(paths.state_dir, STAGING_PRUNE_THROTTLE_MARKER);
244
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
245
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
246
+ }
@@ -0,0 +1,157 @@
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;
3
+ /** Ceiling on everything under `raw-evidence`, manifests included. */
4
+ export const DEFAULT_STAGING_CAP_BYTES = 2 * 1024 * 1024 * 1024;
5
+ /** Local knobs, read from the environment like `COCKPIT_DISABLE_GC`. */
6
+ export const STAGING_CAP_ENV = "COCKPIT_STAGING_CAP_BYTES";
7
+ export const STAGING_RETENTION_ENV = "COCKPIT_STAGING_RETENTION_HOURS";
8
+ /** The two knobs, from the environment, falling back to the defaults above. */
9
+ export function retentionOptionsFromEnv(env) {
10
+ const hours = Number(env[STAGING_RETENTION_ENV]);
11
+ const cap = Number(env[STAGING_CAP_ENV]);
12
+ return {
13
+ retentionMs: Number.isFinite(hours) && hours >= 0
14
+ ? hours * 60 * 60 * 1000
15
+ : DEFAULT_STAGING_RETENTION_MS,
16
+ capBytes: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_STAGING_CAP_BYTES,
17
+ };
18
+ }
19
+ export function planStagingRetention(inventory, options = {}) {
20
+ const retentionMs = options.retentionMs ?? DEFAULT_STAGING_RETENTION_MS;
21
+ const capBytes = options.capBytes ?? DEFAULT_STAGING_CAP_BYTES;
22
+ const plan = emptyPlan(capBytes, retentionMs, inventory.total_bytes);
23
+ const committedInWindow = [];
24
+ for (const pack of inventory.packs) {
25
+ for (const object of pack.objects) {
26
+ if (object.state !== "committed") {
27
+ keepUnvouched(plan, object);
28
+ continue;
29
+ }
30
+ if (options.allCommitted || object.age_ms >= retentionMs) {
31
+ pushDeletion(plan, object, "committed_past_retry_window");
32
+ continue;
33
+ }
34
+ committedInWindow.push(object);
35
+ addToBucket(plan.kept_in_window, object);
36
+ }
37
+ }
38
+ applyByteCap(plan, committedInWindow, capBytes, inventory);
39
+ markEmptyPacks(plan, inventory.packs);
40
+ plan.bytes_after =
41
+ inventory.total_bytes - plan.deleted_bytes - plan.empty_pack_manifest_bytes;
42
+ // Over the cap is only "blocked by uncommitted" when uncommitted bytes are
43
+ // what is left. Over the cap with nothing unvouched on disk is a different
44
+ // sentence — the remaining manifests alone exceed it — and saying the wrong
45
+ // one would send an operator hunting evidence that is not missing.
46
+ const unvouched = plan.kept_uncommitted.count + plan.kept_unknown.count;
47
+ plan.cap_blocked_by_uncommitted = plan.bytes_after > capBytes && unvouched > 0;
48
+ plan.cap_blocked_count = plan.cap_blocked_by_uncommitted ? unvouched : 0;
49
+ return plan;
50
+ }
51
+ /**
52
+ * The oldest committed spares go until the total fits, newest kept last. Sorted
53
+ * by the ledger's `committed_at` age rather than a file mtime: mtime moves when
54
+ * a pack is refilled, and the question here is how long ago the bytes became
55
+ * safe elsewhere.
56
+ *
57
+ * It does not run at all when the floor is already above the cap. That branch
58
+ * is not theoretical: on the reference Mac (2026-09-05) 8.0 GB of staging is
59
+ * evidence no ledger vouches for, against a 2 GB cap, so spending the retry
60
+ * window on 1,027 committed spares would have deleted a day of local retry
61
+ * copies and still ended over the cap. The cap takes a spare only when taking
62
+ * it can actually get this machine under the cap.
63
+ */
64
+ function applyByteCap(plan, committedInWindow, capBytes, inventory) {
65
+ let projected = plan.bytes_before - plan.deleted_bytes;
66
+ if (projected <= capBytes)
67
+ return;
68
+ if (unremovableFloor(inventory) >= capBytes)
69
+ return;
70
+ const oldestFirst = [...committedInWindow].sort((a, b) => b.age_ms - a.age_ms);
71
+ for (const object of oldestFirst) {
72
+ if (projected <= capBytes)
73
+ break;
74
+ pushDeletion(plan, object, "cap_committed_oldest_first");
75
+ removeFromBucket(plan.kept_in_window, object);
76
+ projected -= object.byte_size;
77
+ }
78
+ }
79
+ /**
80
+ * Bytes no retention rule may take: every object the ledger does not vouch for,
81
+ * plus the `manifest.json` of each pack holding one, because that pack survives
82
+ * whatever else is deleted from it.
83
+ */
84
+ function unremovableFloor(inventory) {
85
+ let floor = 0;
86
+ for (const pack of inventory.packs) {
87
+ const unvouched = pack.objects.filter((object) => object.state !== "committed");
88
+ if (unvouched.length === 0)
89
+ continue;
90
+ floor += pack.manifest_bytes;
91
+ for (const object of unvouched)
92
+ floor += object.byte_size;
93
+ }
94
+ return floor;
95
+ }
96
+ /**
97
+ * A pack with no payload left is deleted whole, `manifest.json` included. The
98
+ * manifest is pack metadata — its only job is to describe files that are no
99
+ * longer there — and on the reference Mac those manifests were 836 MB across
100
+ * 1,612 packs, so leaving them behind would keep almost a gigabyte of
101
+ * descriptions of nothing.
102
+ */
103
+ function markEmptyPacks(plan, packs) {
104
+ const deleted = new Set(plan.delete.map((entry) => `${entry.pack_id}/${entry.relative_path}`));
105
+ for (const pack of packs) {
106
+ const survives = pack.objects.some((object) => !deleted.has(`${pack.pack_id}/${object.relative_path}`));
107
+ if (survives)
108
+ continue;
109
+ plan.empty_packs.push({
110
+ pack_id: pack.pack_id,
111
+ manifest_bytes: pack.manifest_bytes,
112
+ });
113
+ plan.empty_pack_manifest_bytes += pack.manifest_bytes;
114
+ }
115
+ }
116
+ function keepUnvouched(plan, object) {
117
+ const bucket = object.state === "unknown" ? plan.kept_unknown : plan.kept_uncommitted;
118
+ addToBucket(bucket, object);
119
+ }
120
+ function pushDeletion(plan, object, reason) {
121
+ plan.delete.push({
122
+ pack_id: object.pack_id,
123
+ relative_path: object.relative_path,
124
+ byte_size: object.byte_size,
125
+ content_hash: object.content_hash,
126
+ reason,
127
+ age_ms: object.age_ms,
128
+ });
129
+ plan.deleted_bytes += object.byte_size;
130
+ }
131
+ function addToBucket(bucket, object) {
132
+ bucket.count += 1;
133
+ bucket.bytes += object.byte_size;
134
+ bucket.oldest_age_ms = Math.max(bucket.oldest_age_ms, object.age_ms);
135
+ }
136
+ function removeFromBucket(bucket, object) {
137
+ bucket.count -= 1;
138
+ bucket.bytes -= object.byte_size;
139
+ }
140
+ function emptyPlan(capBytes, retentionMs, bytesBefore) {
141
+ const bucket = () => ({ count: 0, bytes: 0, oldest_age_ms: 0 });
142
+ return {
143
+ delete: [],
144
+ deleted_bytes: 0,
145
+ empty_packs: [],
146
+ empty_pack_manifest_bytes: 0,
147
+ kept_uncommitted: bucket(),
148
+ kept_unknown: bucket(),
149
+ kept_in_window: bucket(),
150
+ cap_bytes: capBytes,
151
+ retention_ms: retentionMs,
152
+ bytes_before: bytesBefore,
153
+ bytes_after: bytesBefore,
154
+ cap_blocked_by_uncommitted: false,
155
+ cap_blocked_count: 0,
156
+ };
157
+ }