@bli-cockpit/cli 0.2.154 → 0.2.156
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/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +1 -0
- package/dist/adapters/raw-evidence-snapshot-supersede.js +29 -0
- package/dist/adapters/raw-evidence-transcript-file.js +13 -3
- package/dist/commands/backfill-checkpoint.js +12 -3
- package/dist/commands/backfill-report.js +12 -8
- package/dist/commands/backfill-scan.js +14 -0
- package/dist/commands/backfill-stub-reoffer.js +104 -0
- package/dist/commands/backfill-upload.js +4 -0
- package/dist/commands/backfill.js +5 -0
- package/dist/commands/clean.js +31 -13
- package/dist/commands/doctor-disk-words.js +81 -3
- package/dist/commands/doctor-pipeline.js +44 -22
- package/dist/commands/heartbeat.js +1 -1
- package/dist/commands/local-help-commands.js +7 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/status-disk-line.js +22 -0
- package/dist/commands/status.js +5 -2
- package/dist/commands/sync-followups-staging.js +13 -10
- package/dist/commands/sync.js +7 -4
- package/dist/cursors/raw-evidence-cursor.js +13 -0
- package/dist/disk-prune.js +19 -23
- package/dist/disk-retention.js +12 -9
- package/dist/disk-usage-footprint.js +10 -4
- package/dist/evidence-reconcile-client.js +2 -2
- package/dist/evidence-stubs-client.js +115 -0
- package/dist/log-rotation.js +31 -10
- package/dist/prune-receipt.js +87 -0
- package/dist/raw-evidence-gc.js +55 -15
- package/dist/state-dir-audit.js +138 -0
- package/dist/upload-envelope-build.js +1 -0
- package/package.json +4 -4
|
@@ -35,6 +35,7 @@ export async function runLocalSourceCollectors(options) {
|
|
|
35
35
|
claudeAttributionScan: options.rawEvidenceClaudeAttributionScan,
|
|
36
36
|
skipContentHashes: options.rawEvidenceSkipContentHashes,
|
|
37
37
|
priorSnapshots: options.rawEvidencePriorSnapshots,
|
|
38
|
+
stubReoffers: options.rawEvidenceStubReoffers,
|
|
38
39
|
byteBudget: options.rawEvidenceByteBudget,
|
|
39
40
|
objectBudget: options.rawEvidenceObjectBudget,
|
|
40
41
|
budget: options.rawEvidenceBudget,
|
|
@@ -74,6 +74,7 @@ export async function openCollection(context, options, places) {
|
|
|
74
74
|
caps: startingCaps({ byteBudget, objectBudget, budget: options.budget }),
|
|
75
75
|
skipContentHashes: options.skipContentHashes ?? new Set(),
|
|
76
76
|
priorSnapshots: options.priorSnapshots ?? new Map(),
|
|
77
|
+
stubReoffers: options.stubReoffers ?? new Map(),
|
|
77
78
|
budget: options.budget ?? {
|
|
78
79
|
remainingBytes: byteBudget,
|
|
79
80
|
remainingObjects: objectBudget,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* says so: `snapshot_rewritten`, both sizes, and the byte range the first
|
|
21
21
|
* difference falls in. Never content.
|
|
22
22
|
*/
|
|
23
|
+
import { REDACTION_STUB_LABEL } from "@bli-cockpit/telemetry-core";
|
|
23
24
|
import { firstDifferenceRange, isPrefixSuccessor, prefixCheckpoints, } from "@bli-cockpit/telemetry-core/snapshot-supersede";
|
|
24
25
|
/**
|
|
25
26
|
* The newest committed snapshot per source key, from the cursor's objects map,
|
|
@@ -79,4 +80,32 @@ export function supersedeClaimFor(options) {
|
|
|
79
80
|
first_differing_offset_below: range.below,
|
|
80
81
|
}));
|
|
81
82
|
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* BLI-11102: the claim a re-offered transcript makes when the server named its
|
|
86
|
+
* stored copy a redaction-crash stub. Only a main transcript that redacted
|
|
87
|
+
* cleanly this time claims; one that stubbed again claims nothing and keeps
|
|
88
|
+
* the ordinary path. Logs the decision either way.
|
|
89
|
+
*/
|
|
90
|
+
export function stubReplacementClaim(options) {
|
|
91
|
+
if (!options.stubPointerId || (options.kind !== "codex_jsonl" && options.kind !== "claude_jsonl"))
|
|
92
|
+
return null;
|
|
93
|
+
if (options.completenessLabel === REDACTION_STUB_LABEL) {
|
|
94
|
+
console.error("[evidence-upload] stubbed session re-offered but it stubbed again; no claim", JSON.stringify({
|
|
95
|
+
reason: "stub_reoffer_still_stubbed",
|
|
96
|
+
kind: options.kind,
|
|
97
|
+
byte_size: options.byteSize,
|
|
98
|
+
}));
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
console.error("[evidence-upload] stubbed session re-offered; claiming to replace the stub", JSON.stringify({
|
|
102
|
+
reason: "stub_reoffered",
|
|
103
|
+
rule: "stub_replaced",
|
|
104
|
+
kind: options.kind,
|
|
105
|
+
byte_size: options.byteSize,
|
|
106
|
+
// The same bytes may already be durable (a later sync healed it without
|
|
107
|
+
// knowing about the stub); they are staged anyway so the claim has a carrier.
|
|
108
|
+
already_durable: options.alreadyDurable,
|
|
109
|
+
}));
|
|
110
|
+
return { pointer_id: options.stubPointerId, rule: "stub_replaced" };
|
|
82
111
|
}
|
|
@@ -19,7 +19,7 @@ import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
|
|
|
19
19
|
import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
|
|
20
20
|
import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
|
|
21
21
|
import { evidenceEntry } from "./raw-evidence-manifest.js";
|
|
22
|
-
import { describeSnapshot, supersedeClaimFor } from "./raw-evidence-snapshot-supersede.js";
|
|
22
|
+
import { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
|
|
23
23
|
import { admitToBudget, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
|
|
24
24
|
/**
|
|
25
25
|
* Reads, secret-guards, content-addresses, budget-checks, and copies one
|
|
@@ -64,7 +64,16 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
64
64
|
secretLikeFileName: names.secretLikeFileName,
|
|
65
65
|
});
|
|
66
66
|
const contentHash = sha256(sanitized.bytes);
|
|
67
|
-
|
|
67
|
+
// BLI-11102: a session the server named a redaction stub is re-offered even
|
|
68
|
+
// when these bytes are already durable, because the stub is not a delivery.
|
|
69
|
+
const stubClaim = stubReplacementClaim({
|
|
70
|
+
kind: options.kind,
|
|
71
|
+
stubPointerId: collection.stubReoffers.get(options.sessionId),
|
|
72
|
+
completenessLabel: sanitized.completenessLabel,
|
|
73
|
+
byteSize: sanitized.bytes.byteLength,
|
|
74
|
+
alreadyDurable: collection.skipContentHashes.has(contentHash),
|
|
75
|
+
});
|
|
76
|
+
if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
|
|
68
77
|
collection.reused.push({
|
|
69
78
|
kind: options.kind,
|
|
70
79
|
label: names.evidenceLabel,
|
|
@@ -83,6 +92,7 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
83
92
|
names,
|
|
84
93
|
sanitized,
|
|
85
94
|
contentHash,
|
|
95
|
+
stubClaim,
|
|
86
96
|
});
|
|
87
97
|
return true;
|
|
88
98
|
}
|
|
@@ -139,7 +149,7 @@ async function stageOneTranscript(collection, file) {
|
|
|
139
149
|
// BLI-11088: remember this snapshot's shape, and claim the last one when
|
|
140
150
|
// these bytes rebuild it.
|
|
141
151
|
snapshot: describeSnapshot(file.sanitized.bytes),
|
|
142
|
-
supersedes: supersedeClaimFor({
|
|
152
|
+
supersedes: file.stubClaim ?? supersedeClaimFor({
|
|
143
153
|
bytes: file.sanitized.bytes,
|
|
144
154
|
contentHash: file.contentHash,
|
|
145
155
|
kind: file.kind,
|
|
@@ -21,6 +21,7 @@ import { indexDurableMainObjectKeys } from "./backfill-evidence-outcomes.js";
|
|
|
21
21
|
export async function recordBackfillDurableSessionPointers(options) {
|
|
22
22
|
const durableObjectBySession = indexDurableMainObjectKeys(options.syncResults);
|
|
23
23
|
let recordedCount = 0;
|
|
24
|
+
let stubsReplaced = 0;
|
|
24
25
|
for (const source of ["codex", "claude_code"]) {
|
|
25
26
|
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
26
27
|
const cursor = await readRawEvidenceCursor(options.paths, { filename });
|
|
@@ -31,8 +32,13 @@ export async function recordBackfillDurableSessionPointers(options) {
|
|
|
31
32
|
const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
|
|
32
33
|
const prior = cursor.sessions[candidate.session_id];
|
|
33
34
|
// No pointer, no prior entry, or already pointed: nothing this pass owes.
|
|
34
|
-
|
|
35
|
+
// BLI-11102: a pointer the server named a redaction stub is not a
|
|
36
|
+
// delivery, so a real pointer replaces it and the mark clears.
|
|
37
|
+
const replacesStub = Boolean(prior?.stub_reoffer) && prior?.uploaded_object_key !== objectKey;
|
|
38
|
+
if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
|
|
35
39
|
continue;
|
|
40
|
+
if (replacesStub)
|
|
41
|
+
stubsReplaced += 1;
|
|
36
42
|
cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
|
|
37
43
|
changed = true;
|
|
38
44
|
recordedCount += 1;
|
|
@@ -45,12 +51,12 @@ export async function recordBackfillDurableSessionPointers(options) {
|
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
if (recordedCount > 0) {
|
|
48
|
-
console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
|
|
54
|
+
console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount, stub_pointers_replaced: stubsReplaced }));
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
/** The cursor entry for a session backfill has just delivered in full. */
|
|
52
58
|
function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
53
|
-
|
|
59
|
+
const entry = {
|
|
54
60
|
...prior,
|
|
55
61
|
file_hash_sha256: candidate.content_hash_sha256,
|
|
56
62
|
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
@@ -65,6 +71,9 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
|
65
71
|
uploaded_byte_size: candidate.byte_size,
|
|
66
72
|
last_seen_at: now.toISOString(),
|
|
67
73
|
};
|
|
74
|
+
// The mark says "this pointer is a stub"; the pointer written above is not.
|
|
75
|
+
delete entry.stub_reoffer;
|
|
76
|
+
return entry;
|
|
68
77
|
}
|
|
69
78
|
export function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
70
79
|
if (!options.discoveryComplete)
|
|
@@ -7,6 +7,7 @@ import { advanceBackfillCursorThroughResolvedPrefix, recordBackfillDurableSessio
|
|
|
7
7
|
import { blockingScanIssues, cappedSidecarCount } from "./backfill-issues.js";
|
|
8
8
|
import { backfillResultBaseArgs, baseBackfillResult, emptyReport, } from "./backfill-result.js";
|
|
9
9
|
import { buildBackfillSessionReport } from "./backfill-session-report.js";
|
|
10
|
+
import { summarizeStubReoffer } from "./backfill-stub-reoffer.js";
|
|
10
11
|
/**
|
|
11
12
|
* REPORT: post the session report, record durable pointers, advance the
|
|
12
13
|
* cursor through the resolved contiguous prefix, decide completion, write the
|
|
@@ -37,14 +38,17 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
|
|
|
37
38
|
if (completion.status === "complete" && command.all) {
|
|
38
39
|
await writeAllHistoryCompletionMarker(ctx);
|
|
39
40
|
}
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
return {
|
|
42
|
+
...assembleBackfillResult({
|
|
43
|
+
command,
|
|
44
|
+
ctx,
|
|
45
|
+
upload,
|
|
46
|
+
posted,
|
|
47
|
+
completion,
|
|
48
|
+
blockedAt,
|
|
49
|
+
}),
|
|
50
|
+
stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable),
|
|
51
|
+
};
|
|
48
52
|
}
|
|
49
53
|
/**
|
|
50
54
|
* Tells the server what this run observed about every session, uploaded or
|
|
@@ -20,6 +20,7 @@ import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, cou
|
|
|
20
20
|
import { labelOutsideRootCandidates, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
|
|
21
21
|
import { reasonCountsFor } from "./backfill-reasons.js";
|
|
22
22
|
import { blockedBackfillResult } from "./backfill-result.js";
|
|
23
|
+
import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
|
|
23
24
|
import { resolveBackfillScope } from "./backfill-scope.js";
|
|
24
25
|
/**
|
|
25
26
|
* SCAN: is this machine paired, what roots/sources/window apply, and what did
|
|
@@ -47,6 +48,18 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
47
48
|
const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
|
|
48
49
|
const cursor = scopedCursor.cursor;
|
|
49
50
|
const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
|
|
51
|
+
// BLI-11102: a redaction-crash stub is not a delivery. The server names
|
|
52
|
+
// them; those sessions reopen exactly like pointer-less ones. A dry run
|
|
53
|
+
// keeps its one-request contract (the reachability probe) and does not ask.
|
|
54
|
+
const stubReoffer = command.dryRun
|
|
55
|
+
? emptyStubReofferPlan("dry_run")
|
|
56
|
+
: await loadStubReofferPlan({
|
|
57
|
+
paths,
|
|
58
|
+
dashboardUrl: scope.dashboardUrl,
|
|
59
|
+
sources,
|
|
60
|
+
fetchImpl: io.fetch,
|
|
61
|
+
});
|
|
62
|
+
reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
|
|
50
63
|
const scan = await scanBackfillSessions({
|
|
51
64
|
command,
|
|
52
65
|
homeDir: command.homeDir ?? os.homedir(),
|
|
@@ -73,6 +86,7 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
73
86
|
cursor,
|
|
74
87
|
scan,
|
|
75
88
|
reasonCounts,
|
|
89
|
+
stubReoffer,
|
|
76
90
|
oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
|
|
77
91
|
deterministicSkipCandidateKeys: deterministicSkipCandidateKeys(scan.candidates),
|
|
78
92
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
2
|
+
import { fetchStubbedSessions, stubSessionKey } from "../evidence-stubs-client.js";
|
|
3
|
+
import { describeError } from "../health-detail.js";
|
|
4
|
+
import { readLocalCollectorSessionFile } from "../local-state.js";
|
|
5
|
+
export function emptyStubReofferPlan(reason) {
|
|
6
|
+
return { status: "skipped", reason, sessions: new Map(), stub_count: 0, marked_local_sessions: 0 };
|
|
7
|
+
}
|
|
8
|
+
/** Ask the server which of this operator's pointers are stubs. Never throws; writes nothing. */
|
|
9
|
+
export async function loadStubReofferPlan(options) {
|
|
10
|
+
const session = await readLocalCollectorSessionFile(options.paths).catch(() => null);
|
|
11
|
+
if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
|
|
12
|
+
console.error("[backfill] redaction stubs not asked", JSON.stringify({ reason: "no_device_session" }));
|
|
13
|
+
return emptyStubReofferPlan("no_device_session");
|
|
14
|
+
}
|
|
15
|
+
const answer = await fetchStubbedSessions({
|
|
16
|
+
dashboardUrl: options.dashboardUrl,
|
|
17
|
+
deviceToken: session.device_token,
|
|
18
|
+
fetchImpl: options.fetchImpl,
|
|
19
|
+
});
|
|
20
|
+
const sessions = new Map([...answer.sessions].filter(([key]) => options.sources.some((source) => key.startsWith(`${source}:`))));
|
|
21
|
+
return {
|
|
22
|
+
status: answer.status,
|
|
23
|
+
reason: answer.reason,
|
|
24
|
+
sessions,
|
|
25
|
+
stub_count: answer.unique_stub_count,
|
|
26
|
+
marked_local_sessions: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Mark every stubbed session this machine tracks as not delivered. The caller
|
|
31
|
+
* holds the collection lock: sync writes the same cursor files.
|
|
32
|
+
*/
|
|
33
|
+
export async function markStubReoffers(paths, plan, now) {
|
|
34
|
+
if (plan.sessions.size === 0)
|
|
35
|
+
return;
|
|
36
|
+
plan.marked_local_sessions = await markStubbedSessions(paths, plan.sessions, now);
|
|
37
|
+
}
|
|
38
|
+
/** Reopen stubbed sessions exactly like pointer-less ones: selected past the cursor, sorted first. */
|
|
39
|
+
export function reopenStubbedSessions(reopen, plan) {
|
|
40
|
+
for (const key of plan.sessions.keys()) {
|
|
41
|
+
const at = key.indexOf(":");
|
|
42
|
+
const source = key.slice(0, at);
|
|
43
|
+
reopen[source]?.add(key.slice(at + 1));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Session id -> the stub pointer the new upload claims to replace (the server retires the rest). */
|
|
47
|
+
export function stubClaimsForCandidates(candidates, plan) {
|
|
48
|
+
const claims = new Map();
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
const pointer = plan.sessions.get(stubSessionKey(candidate.source, candidate.session_id))?.[0];
|
|
51
|
+
if (pointer)
|
|
52
|
+
claims.set(candidate.session_id, pointer);
|
|
53
|
+
}
|
|
54
|
+
return claims;
|
|
55
|
+
}
|
|
56
|
+
export function summarizeStubReoffer(plan, candidates) {
|
|
57
|
+
const reoffered = candidates.filter((candidate) => plan.sessions.has(stubSessionKey(candidate.source, candidate.session_id))).length;
|
|
58
|
+
const summary = {
|
|
59
|
+
status: plan.status,
|
|
60
|
+
reason: plan.reason,
|
|
61
|
+
stubs: plan.stub_count,
|
|
62
|
+
sessions: plan.sessions.size,
|
|
63
|
+
marked_local_sessions: plan.marked_local_sessions,
|
|
64
|
+
reoffered_sessions: reoffered,
|
|
65
|
+
};
|
|
66
|
+
if (plan.sessions.size > 0) {
|
|
67
|
+
console.error("[backfill] redaction-stubbed sessions re-offered", JSON.stringify({
|
|
68
|
+
...summary,
|
|
69
|
+
reason: "stub_reoffered",
|
|
70
|
+
stub_door: `${summary.status}:${summary.reason}`,
|
|
71
|
+
// Stubbed on the server, but no file for it in this run's scan window.
|
|
72
|
+
not_found_locally: plan.sessions.size - reoffered,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
return summary;
|
|
76
|
+
}
|
|
77
|
+
async function markStubbedSessions(paths, sessions, now) {
|
|
78
|
+
let marked = 0;
|
|
79
|
+
for (const source of ["codex", "claude_code"]) {
|
|
80
|
+
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
81
|
+
try {
|
|
82
|
+
const cursor = await readRawEvidenceCursor(paths, { filename });
|
|
83
|
+
let changed = false;
|
|
84
|
+
for (const [key, pointerIds] of sessions) {
|
|
85
|
+
if (!key.startsWith(`${source}:`))
|
|
86
|
+
continue;
|
|
87
|
+
const entry = cursor.sessions[key.slice(source.length + 1)];
|
|
88
|
+
if (!entry)
|
|
89
|
+
continue;
|
|
90
|
+
entry.stub_reoffer = { reason: "stub_reoffered", stub_pointer_ids: pointerIds, marked_at: now.toISOString() };
|
|
91
|
+
changed = true;
|
|
92
|
+
marked += 1;
|
|
93
|
+
}
|
|
94
|
+
if (changed) {
|
|
95
|
+
await writeRawEvidenceCursor(paths, cursor, { filename, sessionsOnly: source === "claude_code" });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// The mark is bookkeeping; the server's answer still drives this run.
|
|
100
|
+
console.error("[backfill] stub mark not written to the cursor", JSON.stringify({ reason: "stub_mark_write_failed", source, ...describeError(error) }));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return marked;
|
|
104
|
+
}
|
|
@@ -7,6 +7,7 @@ import { countSessionUploadFailures, deferredEvidenceCount, durableBackfillCandi
|
|
|
7
7
|
import { blockingScanIssues } from "./backfill-issues.js";
|
|
8
8
|
import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, } from "./backfill-outside-roots.js";
|
|
9
9
|
import { writeLine } from "./backfill-output.js";
|
|
10
|
+
import { stubClaimsForCandidates } from "./backfill-stub-reoffer.js";
|
|
10
11
|
const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
|
|
11
12
|
/**
|
|
12
13
|
* UPLOAD: sync every uploadable candidate in fixed-size batches, heartbeating
|
|
@@ -101,6 +102,7 @@ async function uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget
|
|
|
101
102
|
codexAttribution: ctx.scan.codexAttribution,
|
|
102
103
|
claudeAttribution: ctx.scan.claudeAttribution,
|
|
103
104
|
rawEvidenceBudget,
|
|
105
|
+
stubClaims: stubClaimsForCandidates(batch.candidates, ctx.stubReoffer),
|
|
104
106
|
fetchImpl: io.fetch,
|
|
105
107
|
});
|
|
106
108
|
const durableInBatch = durableBackfillCandidateKeys(batch, sync);
|
|
@@ -216,6 +218,8 @@ function backfillBatchSyncOptions(options) {
|
|
|
216
218
|
})),
|
|
217
219
|
claudeAttributionScan: options.claudeAttribution ?? undefined,
|
|
218
220
|
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
221
|
+
// BLI-11102: session id -> the redaction stub this upload replaces.
|
|
222
|
+
rawEvidenceStubReoffers: options.stubClaims,
|
|
219
223
|
// `cockpit backfill` is only ever an operator asking — by hand, through
|
|
220
224
|
// onboarding, through doctor, or by running the retry command Cockpit
|
|
221
225
|
// printed. The delivery-backoff window is the scheduler's cadence, so it
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* backfill-evidence-outcomes.ts what one upload achieved per session
|
|
26
26
|
* backfill-session-report.ts one attribution row per session seen
|
|
27
27
|
* backfill-checkpoint.ts the durable cursor and pointer writes
|
|
28
|
+
* backfill-stub-reoffer.ts redaction-crash stubs: re-offered, not delivered
|
|
28
29
|
* backfill-result.ts the `--json` payload
|
|
29
30
|
* backfill-output.ts every line and prompt a person sees
|
|
30
31
|
*
|
|
@@ -45,6 +46,7 @@ import { planBackfillRun } from "./backfill-plan.js";
|
|
|
45
46
|
import { reportBackfillOutcome } from "./backfill-report.js";
|
|
46
47
|
import { lockHeldResult } from "./backfill-result.js";
|
|
47
48
|
import { scanBackfillRun } from "./backfill-scan.js";
|
|
49
|
+
import { markStubReoffers } from "./backfill-stub-reoffer.js";
|
|
48
50
|
import { uploadBackfillBatches } from "./backfill-upload.js";
|
|
49
51
|
// The command's public surface. Callers (doctor, onboard, local, the CLI
|
|
50
52
|
// router, the BLI-3272 regression test) import these from `./backfill.js`;
|
|
@@ -135,6 +137,9 @@ export async function runBackfill(command, io) {
|
|
|
135
137
|
if (scanned.scopedCursor.reset) {
|
|
136
138
|
await writeBackfillCursor(paths, scanned.cursor);
|
|
137
139
|
}
|
|
140
|
+
// BLI-11102: mark server-named stubs as not delivered, under the lock
|
|
141
|
+
// because sync writes the same cursor files.
|
|
142
|
+
await markStubReoffers(paths, scanned.stubReoffer, now);
|
|
138
143
|
const upload = await uploadBackfillBatches(command, io, lock, scanned);
|
|
139
144
|
return await reportBackfillOutcome(command, io, scanned, upload);
|
|
140
145
|
}
|
package/dist/commands/clean.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
9
|
-
* committed,
|
|
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
|
|
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
|
-
:
|
|
97
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
}
|