@bli-cockpit/cli 0.2.51 → 0.2.53
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.
- package/dist/commands/clean.js +244 -0
- package/dist/commands/doctor.js +73 -0
- package/dist/commands/jarvis.js +101 -9
- package/dist/commands/local-args-collector.js +30 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +26 -0
- package/dist/commands/local.js +3 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-attribution.js +55 -0
- package/dist/commands/session-sync-failures.js +140 -0
- package/dist/commands/session-sync-health.js +102 -0
- package/dist/commands/session-sync-plan.js +81 -0
- package/dist/commands/session-sync-record.js +279 -0
- package/dist/commands/session-sync-scan.js +209 -0
- package/dist/commands/session-sync-types.js +12 -0
- package/dist/commands/session-sync-upload.js +215 -0
- package/dist/commands/session-sync.js +44 -987
- package/dist/commands/sync-followups.js +140 -2
- package/dist/commands/sync.js +4 -1
- package/dist/cursors/raw-evidence-reconcile-cursor.js +132 -0
- package/dist/disk-prune.js +246 -0
- package/dist/disk-retention.js +157 -0
- package/dist/disk-usage.js +392 -0
- package/dist/evidence-reconcile-client.js +224 -0
- package/dist/log-rotation.js +106 -2
- package/dist/tower-stream.js +5 -1
- package/package.json +3 -3
|
@@ -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),
|
|
5
|
-
*
|
|
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,7 +18,11 @@ 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";
|
|
22
|
+
import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
|
|
21
23
|
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
24
|
+
/** How many reconcile batches (of up to 500 hashes each) one sync tick may spend. */
|
|
25
|
+
const RECONCILE_BATCHES_PER_TICK = 1;
|
|
22
26
|
/**
|
|
23
27
|
* BLI-2721: after the tick's collection and self-update are done and
|
|
24
28
|
* reported, repair a broken/legacy autostart registration in place (Windows
|
|
@@ -183,6 +187,140 @@ function memoryInstallEvent(outcome) {
|
|
|
183
187
|
}
|
|
184
188
|
return { step: "memory_install", status: "ok", error_detail: detail };
|
|
185
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* BLI-3619: staged raw evidence Tower has already accepted stops living on the
|
|
192
|
+
* laptop forever.
|
|
193
|
+
*
|
|
194
|
+
* Same shape as the memory install above and for the same reasons: it runs only
|
|
195
|
+
* once collection's own outcome is decided and reported, at most once a day, it
|
|
196
|
+
* cannot throw, and its outcome is its own named receipt rather than a sync
|
|
197
|
+
* failure. A machine that cannot prune is a machine short of disk, and that
|
|
198
|
+
* must never also be a machine that stops collecting.
|
|
199
|
+
*
|
|
200
|
+
* The rule itself is `disk-retention.ts`: only objects the upload ledger
|
|
201
|
+
* vouches for are ever deleted, and anything undelivered is counted, aged and
|
|
202
|
+
* named instead.
|
|
203
|
+
*
|
|
204
|
+
* BLI-3619's second half runs FIRST, every tick, bounded to
|
|
205
|
+
* `RECONCILE_BATCHES_PER_TICK` (one batch of up to 500 hashes): the local
|
|
206
|
+
* ledger's own memory is capped, so a laptop that keeps accumulating
|
|
207
|
+
* `unknown` state needs SOMETHING asking the server on a schedule, not only
|
|
208
|
+
* when a person happens to type `cockpit clean --reconcile`. One batch a tick
|
|
209
|
+
* is not throttled to once a day like the prune below — an `unknown` backlog
|
|
210
|
+
* drains at up to 500 hashes/15 min, and the ordinary case (nothing unknown)
|
|
211
|
+
* costs one cheap local read and no network call at all, so it never competes
|
|
212
|
+
* with collection for the tick's time. A reconcile failure never blocks the
|
|
213
|
+
* prune that follows it.
|
|
214
|
+
*/
|
|
215
|
+
export async function runStagingPruneAfterSync(command, io, dashboardUrl, options = {}) {
|
|
216
|
+
const events = [];
|
|
217
|
+
const reconciled = await runReconcileFollowUp(command, io, dashboardUrl, options);
|
|
218
|
+
if (reconciled)
|
|
219
|
+
events.push(reconcileEvent(reconciled));
|
|
220
|
+
let result;
|
|
221
|
+
try {
|
|
222
|
+
result = await runStagingPrune(getCollectorRuntimePaths(command.homeDir), {
|
|
223
|
+
env: io.env,
|
|
224
|
+
...(options.now ? { now: options.now } : {}),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// runStagingPrune already catches everything it can reach; this is the
|
|
229
|
+
// last-resort net so a prune crash truly cannot touch the sync result.
|
|
230
|
+
result = { ...prunedNothing(), reason: "prune_threw", status: "fail" };
|
|
231
|
+
console.error("[collector prune] the prune follow-up threw", JSON.stringify({
|
|
232
|
+
reason: "prune_followup_threw",
|
|
233
|
+
detail: redactedSyncErrorDetail(error),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
// The daily throttle is the steady state — reporting it would post a receipt
|
|
237
|
+
// on 95 of every 96 ticks for no new information.
|
|
238
|
+
if (result.reason !== "throttled_recent_run") {
|
|
239
|
+
events.push(stagingPruneEvent(result));
|
|
240
|
+
}
|
|
241
|
+
if (events.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
await reportInstallEventsBestEffort({
|
|
244
|
+
homeDir: command.homeDir,
|
|
245
|
+
dashboardUrl,
|
|
246
|
+
command: "sync",
|
|
247
|
+
events,
|
|
248
|
+
json: command.json,
|
|
249
|
+
io,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Never throws (`runEvidenceReconcile` already never does); returns `null`
|
|
254
|
+
* for the boring, common case — nothing this tick was `unknown` — so that
|
|
255
|
+
* case costs no receipt either, the same rule the prune's own daily throttle
|
|
256
|
+
* follows above.
|
|
257
|
+
*/
|
|
258
|
+
async function runReconcileFollowUp(command, io, dashboardUrl, options) {
|
|
259
|
+
const result = await runEvidenceReconcile({
|
|
260
|
+
homeDir: command.homeDir,
|
|
261
|
+
dashboardUrl,
|
|
262
|
+
maxBatches: RECONCILE_BATCHES_PER_TICK,
|
|
263
|
+
fetch: io.fetch,
|
|
264
|
+
...(options.now ? { now: options.now } : {}),
|
|
265
|
+
});
|
|
266
|
+
return result.reason === "nothing_unknown" ? null : result;
|
|
267
|
+
}
|
|
268
|
+
function prunedNothing() {
|
|
269
|
+
return {
|
|
270
|
+
status: "skipped",
|
|
271
|
+
reason: "prune_threw",
|
|
272
|
+
deleted_files: 0,
|
|
273
|
+
deleted_bytes: 0,
|
|
274
|
+
removed_packs: 0,
|
|
275
|
+
kept_uncommitted: 0,
|
|
276
|
+
kept_uncommitted_bytes: 0,
|
|
277
|
+
oldest_uncommitted_age_ms: 0,
|
|
278
|
+
kept_unknown: 0,
|
|
279
|
+
kept_unknown_bytes: 0,
|
|
280
|
+
kept_in_window: 0,
|
|
281
|
+
cap_bytes: 0,
|
|
282
|
+
bytes_before: 0,
|
|
283
|
+
bytes_after: 0,
|
|
284
|
+
cap_blocked_by_uncommitted: false,
|
|
285
|
+
cap_blocked_count: 0,
|
|
286
|
+
failed_deletions: 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** Counts and byte totals only; no pack id and no path travels in a receipt. */
|
|
290
|
+
function stagingPruneEvent(result) {
|
|
291
|
+
const detail = [
|
|
292
|
+
`freed ${result.deleted_bytes}B in ${result.deleted_files} file(s)`,
|
|
293
|
+
`held ${result.kept_uncommitted_bytes}B uncommitted`,
|
|
294
|
+
result.cap_blocked_by_uncommitted
|
|
295
|
+
? `staging_cap_blocked_by_uncommitted ${result.cap_blocked_count}`
|
|
296
|
+
: null,
|
|
297
|
+
result.failed_deletions > 0
|
|
298
|
+
? `failed_deletions ${result.failed_deletions}`
|
|
299
|
+
: null,
|
|
300
|
+
]
|
|
301
|
+
.filter((part) => Boolean(part))
|
|
302
|
+
.join("; ");
|
|
303
|
+
if (result.status === "fail") {
|
|
304
|
+
return {
|
|
305
|
+
step: "staging_prune",
|
|
306
|
+
status: "fail",
|
|
307
|
+
error_code: result.reason,
|
|
308
|
+
error_detail: detail,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (result.status === "skipped") {
|
|
312
|
+
return { step: "staging_prune", status: "skipped", error_code: result.reason };
|
|
313
|
+
}
|
|
314
|
+
return { step: "staging_prune", status: "ok", error_detail: detail };
|
|
315
|
+
}
|
|
316
|
+
/** Counts only; no hash and no pack id travels in a receipt. */
|
|
317
|
+
function reconcileEvent(result) {
|
|
318
|
+
const detail = `asked ${result.asked} hash(es) across ${result.batches}/${result.total_batches} batch(es); committed ${result.committed}, not_committed ${result.not_committed}, unknown_to_server ${result.unknown_to_server}, failed_batches ${result.failed_batches}`;
|
|
319
|
+
if (result.status === "fail") {
|
|
320
|
+
return { step: "evidence_reconcile", status: "fail", error_code: result.reason, error_detail: detail };
|
|
321
|
+
}
|
|
322
|
+
return { step: "evidence_reconcile", status: "ok", error_detail: detail };
|
|
323
|
+
}
|
|
186
324
|
/**
|
|
187
325
|
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
188
326
|
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
package/dist/commands/sync.js
CHANGED
|
@@ -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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's own answer, kept durably on this machine.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3619. `cursors/raw-evidence.json` `objects` answers "did this content
|
|
5
|
+
* hash commit?" but is capped at 5,000 rows and prunes the oldest, so a pack
|
|
6
|
+
* staged before its own memory begins reads `unknown` — the ledger cannot say,
|
|
7
|
+
* not that it says no. `cockpit clean --reconcile` asks the dashboard once per
|
|
8
|
+
* hash and this file is where that answer lives afterward, so the same
|
|
9
|
+
* question is never asked twice for the same hash and a `--dry-run` run can
|
|
10
|
+
* read a reconciled state from an earlier `--reconcile` run without spending
|
|
11
|
+
* another round trip.
|
|
12
|
+
*
|
|
13
|
+
* Keyed on `content_hash_sha256`, same as the commit ledger and for the same
|
|
14
|
+
* reason: `pack_id` is a local directory name the server has never seen.
|
|
15
|
+
*
|
|
16
|
+
* Metadata only: hashes, a verdict label, and two timestamps. Never a path,
|
|
17
|
+
* never a byte of content.
|
|
18
|
+
*/
|
|
19
|
+
import crypto from "node:crypto";
|
|
20
|
+
import fs from "node:fs/promises";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
23
|
+
export const RECONCILE_CURSOR_FILENAME = "raw-evidence-reconcile.json";
|
|
24
|
+
// Same order of magnitude as the commit ledger's own cap (D4): entries are
|
|
25
|
+
// small, and the question this file answers is bounded by how many hashes a
|
|
26
|
+
// laptop can hold `unknown` at once, which the byte cap already keeps small.
|
|
27
|
+
const MAX_TRACKED_RESULTS = 5_000;
|
|
28
|
+
export function emptyReconcileCursorState() {
|
|
29
|
+
return {
|
|
30
|
+
schema_version: "cockpit-raw-evidence-reconcile-cursor.v1",
|
|
31
|
+
updated_at: null,
|
|
32
|
+
results: {},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function readReconcileCursor(paths) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(await fs.readFile(reconcileCursorPath(paths), "utf8"));
|
|
38
|
+
return parseReconcileCursorState(raw);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!isMissingFileFailure(error)) {
|
|
42
|
+
console.error("[collector reconcile] reconcile cursor unreadable, starting from empty", JSON.stringify({
|
|
43
|
+
reason: "reconcile_cursor_unreadable",
|
|
44
|
+
...describeError(error),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
return emptyReconcileCursorState();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function writeReconcileCursor(paths, state) {
|
|
51
|
+
const filePath = reconcileCursorPath(paths);
|
|
52
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
53
|
+
const pruned = pruneReconcileState(state);
|
|
54
|
+
const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
|
|
55
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
56
|
+
let handle = null;
|
|
57
|
+
try {
|
|
58
|
+
handle = await fs.open(tempPath, "w", 0o600);
|
|
59
|
+
await handle.writeFile(serialized);
|
|
60
|
+
await handle.sync();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle?.close();
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await fs.rename(tempPath, filePath);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (process.platform !== "win32") {
|
|
73
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function recordReconcileResult(state, contentHash, entry) {
|
|
77
|
+
state.results[contentHash] = entry;
|
|
78
|
+
}
|
|
79
|
+
function pruneReconcileState(state) {
|
|
80
|
+
const entries = Object.entries(state.results);
|
|
81
|
+
if (entries.length <= MAX_TRACKED_RESULTS)
|
|
82
|
+
return state;
|
|
83
|
+
entries.sort((a, b) => b[1].checked_at.localeCompare(a[1].checked_at));
|
|
84
|
+
return {
|
|
85
|
+
...state,
|
|
86
|
+
results: Object.fromEntries(entries.slice(0, MAX_TRACKED_RESULTS)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function reconcileCursorPath(paths) {
|
|
90
|
+
return path.join(paths.cursors_dir, RECONCILE_CURSOR_FILENAME);
|
|
91
|
+
}
|
|
92
|
+
function parseReconcileCursorState(value) {
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
return emptyReconcileCursorState();
|
|
95
|
+
const record = value;
|
|
96
|
+
const results = {};
|
|
97
|
+
const rawResults = record["results"];
|
|
98
|
+
if (rawResults && typeof rawResults === "object") {
|
|
99
|
+
for (const [hash, entry] of Object.entries(rawResults)) {
|
|
100
|
+
const parsed = parseReconcileEntry(entry);
|
|
101
|
+
if (parsed)
|
|
102
|
+
results[hash] = parsed;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
schema_version: "cockpit-raw-evidence-reconcile-cursor.v1",
|
|
107
|
+
updated_at: optionalString(record["updated_at"]),
|
|
108
|
+
results,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function parseReconcileEntry(value) {
|
|
112
|
+
if (!value || typeof value !== "object")
|
|
113
|
+
return null;
|
|
114
|
+
const record = value;
|
|
115
|
+
const verdict = record["verdict"];
|
|
116
|
+
const checkedAt = optionalString(record["checked_at"]);
|
|
117
|
+
if (!checkedAt)
|
|
118
|
+
return null;
|
|
119
|
+
if (verdict !== "committed" &&
|
|
120
|
+
verdict !== "not_committed" &&
|
|
121
|
+
verdict !== "unknown_to_server") {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
verdict,
|
|
126
|
+
checked_at: checkedAt,
|
|
127
|
+
server_committed_at: optionalString(record["server_committed_at"]),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function optionalString(value) {
|
|
131
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
132
|
+
}
|
|
@@ -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
|
+
}
|