@bli-cockpit/cli 0.2.26 → 0.2.28
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/raw-evidence.js +327 -30
- package/dist/autostart.js +99 -13
- package/dist/commands/local.js +33 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/local-state.js +12 -1
- package/dist/raw-evidence-gc.js +178 -0
- package/dist/raw-evidence-staging.js +309 -0
- package/dist/upload.js +141 -15
- package/package.json +1 -1
package/dist/commands/local.js
CHANGED
|
@@ -20,7 +20,7 @@ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } fro
|
|
|
20
20
|
import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
|
|
21
21
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
22
22
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
23
|
-
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
23
|
+
import { rawEvidenceDedupSummary, rawEvidenceGcSummary, runRawEvidenceLocalGc, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
|
|
24
24
|
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
25
25
|
import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
|
|
26
26
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
@@ -1808,7 +1808,16 @@ function rawEvidenceSyncLine(sync) {
|
|
|
1808
1808
|
const retries = sync.raw_evidence_retry_reasons.length > 0
|
|
1809
1809
|
? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
|
|
1810
1810
|
: "";
|
|
1811
|
-
|
|
1811
|
+
// A held object and a nine-day-old first failure both belong on this line.
|
|
1812
|
+
// Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
|
|
1813
|
+
// invisible (BLI-3066).
|
|
1814
|
+
const held = sync.raw_evidence_delivery_held_count > 0
|
|
1815
|
+
? ` held: ${sync.raw_evidence_delivery_held_count}`
|
|
1816
|
+
: "";
|
|
1817
|
+
const stuck = sync.raw_evidence_stuck_object_count > 0
|
|
1818
|
+
? ` stuck: ${sync.raw_evidence_stuck_object_count} (worst ${sync.raw_evidence_max_delivery_attempts} attempt(s) since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
|
|
1819
|
+
: "";
|
|
1820
|
+
return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${held}${stuck}${failures}${retries}`;
|
|
1812
1821
|
}
|
|
1813
1822
|
function cursorStatusLine(sync) {
|
|
1814
1823
|
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
@@ -2488,6 +2497,15 @@ function syncResult(run) {
|
|
|
2488
2497
|
}
|
|
2489
2498
|
async function runSyncLocked(command, io) {
|
|
2490
2499
|
const collectionRoots = await resolveSyncCollectionRoots(command);
|
|
2500
|
+
// Before anything is collected: collapse byte-identical staged packs. It runs
|
|
2501
|
+
// first, unconditionally and unthrottled, because a machine that already
|
|
2502
|
+
// holds 559 copies of one rollout needs the disk back before it stages
|
|
2503
|
+
// anything else (BLI-3066). Safe by construction — a duplicate is identical
|
|
2504
|
+
// by content hash to the survivor.
|
|
2505
|
+
const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
2506
|
+
if (!dedup.skipped && dedup.removed_dirs > 0) {
|
|
2507
|
+
writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
|
|
2508
|
+
}
|
|
2491
2509
|
const worktrees = await discoverCommandWorktrees(collectionRoots, {
|
|
2492
2510
|
maxDepth: command.maxDepth,
|
|
2493
2511
|
maxRepos: command.maxRepos,
|
|
@@ -2515,6 +2533,7 @@ async function runSyncLocked(command, io) {
|
|
|
2515
2533
|
repos: rows,
|
|
2516
2534
|
codex_sessions: run.summary,
|
|
2517
2535
|
raw_evidence_gc: gc,
|
|
2536
|
+
raw_evidence_dedup: dedup,
|
|
2518
2537
|
}, null, 2));
|
|
2519
2538
|
return syncResult(run);
|
|
2520
2539
|
}
|
|
@@ -2546,6 +2565,7 @@ async function runSyncLocked(command, io) {
|
|
|
2546
2565
|
collection_complete: run.ok,
|
|
2547
2566
|
codex_sessions: run.summary,
|
|
2548
2567
|
raw_evidence_gc: gc,
|
|
2568
|
+
raw_evidence_dedup: dedup,
|
|
2549
2569
|
}, null, 2));
|
|
2550
2570
|
return syncResult(run);
|
|
2551
2571
|
}
|
|
@@ -2568,6 +2588,7 @@ async function runSyncLocked(command, io) {
|
|
|
2568
2588
|
collection_complete: run.ok,
|
|
2569
2589
|
codex_sessions: run.summary,
|
|
2570
2590
|
raw_evidence_gc: gc,
|
|
2591
|
+
raw_evidence_dedup: dedup,
|
|
2571
2592
|
}, null, 2));
|
|
2572
2593
|
return syncResult(run);
|
|
2573
2594
|
}
|
|
@@ -2801,10 +2822,20 @@ async function runStatus(command, io) {
|
|
|
2801
2822
|
writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
|
|
2802
2823
|
writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
|
|
2803
2824
|
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
2825
|
+
writeLine(io.stdout, `stuck_evidence: ${stuckEvidenceLine(status)}`);
|
|
2804
2826
|
for (const detail of status.details)
|
|
2805
2827
|
writeLine(io.stdout, `- ${detail}`);
|
|
2806
2828
|
return 0;
|
|
2807
2829
|
}
|
|
2830
|
+
/**
|
|
2831
|
+
* One line that cannot say "fine" while an object has never been accepted.
|
|
2832
|
+
* Named reasons, worst attempt count, and the date it started.
|
|
2833
|
+
*/
|
|
2834
|
+
function stuckEvidenceLine(status) {
|
|
2835
|
+
if (status.stuck_evidence_object_count === 0)
|
|
2836
|
+
return "none";
|
|
2837
|
+
return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
|
|
2838
|
+
}
|
|
2808
2839
|
function displayTicketId(ticketId) {
|
|
2809
2840
|
return ticketId ?? "general ambient";
|
|
2810
2841
|
}
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.28");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
package/dist/local-state.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOr
|
|
|
8
8
|
import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
9
9
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
10
|
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
|
+
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
11
12
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
13
|
export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
|
|
13
14
|
? localCollectorPackage.version
|
|
@@ -340,10 +341,12 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
340
341
|
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
341
342
|
const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
|
|
342
343
|
const branch = options.branch ?? identity.branch;
|
|
343
|
-
const [uploadSpool, healthOutbox] = await Promise.all([
|
|
344
|
+
const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
|
|
344
345
|
summarizeLocalUploadSpool(paths),
|
|
345
346
|
summarizeInstallEventOutbox(paths),
|
|
347
|
+
readRawEvidenceStagingState(paths.state_dir),
|
|
346
348
|
]);
|
|
349
|
+
const stuckEvidence = summarizeStuckEvidence(stagingState, now);
|
|
347
350
|
const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
|
|
348
351
|
const uploadState = !config
|
|
349
352
|
? "not_installed"
|
|
@@ -377,6 +380,9 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
377
380
|
if (healthOutbox.pending_count > 0) {
|
|
378
381
|
details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
|
|
379
382
|
}
|
|
383
|
+
if (stuckEvidence.stuck_object_count > 0) {
|
|
384
|
+
details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
|
|
385
|
+
}
|
|
380
386
|
return {
|
|
381
387
|
installed: Boolean(config),
|
|
382
388
|
config_file: paths.config_file,
|
|
@@ -405,6 +411,11 @@ export async function inspectLocalCollectorStatus(options = {}) {
|
|
|
405
411
|
pending_health_receipt_count: healthOutbox.pending_count,
|
|
406
412
|
oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
|
|
407
413
|
last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
|
|
414
|
+
stuck_evidence_object_count: stuckEvidence.stuck_object_count,
|
|
415
|
+
stuck_evidence_held_count: stuckEvidence.held_object_count,
|
|
416
|
+
stuck_evidence_max_attempts: stuckEvidence.max_attempts,
|
|
417
|
+
stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
|
|
418
|
+
stuck_evidence_reasons: stuckEvidence.reasons,
|
|
408
419
|
details,
|
|
409
420
|
};
|
|
410
421
|
}
|
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -2,8 +2,15 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
|
|
5
|
+
import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
|
|
5
6
|
const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
7
|
const SYNC_LOG_MAX_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* A staging directory belongs to one in-flight collection pass. Anything this
|
|
10
|
+
* old is the remains of a crash or a kill, never live work — the longest sync
|
|
11
|
+
* observed on the fleet is minutes, not hours.
|
|
12
|
+
*/
|
|
13
|
+
const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
|
|
7
14
|
// GC hashes every file in every old-but-kept dir to confirm uploads. Running
|
|
8
15
|
// that on each 15-min sync would re-read the same gigabytes ~96x/day on
|
|
9
16
|
// machines with unconfirmed evidence, so GC is throttled to once per day.
|
|
@@ -63,6 +70,177 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
|
|
|
63
70
|
export function rawEvidenceGcSummary(result) {
|
|
64
71
|
return `raw-evidence GC: removed ${result.removed_dirs} dirs, freed ~${formatMb(result.freed_bytes)} MB`;
|
|
65
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Collapse byte-identical staged packs down to one survivor.
|
|
75
|
+
*
|
|
76
|
+
* Runs at the START of every sync, unthrottled and with no age requirement,
|
|
77
|
+
* because it is the only thing that drains a machine that already holds 559
|
|
78
|
+
* copies of the same rollout (BLI-3066). The GC proper cannot: it only deletes
|
|
79
|
+
* packs whose every file is already committed, and these were never committed —
|
|
80
|
+
* that is precisely why they piled up.
|
|
81
|
+
*
|
|
82
|
+
* Deleting an uncommitted copy is safe here and only here: the survivor holds
|
|
83
|
+
* the identical bytes, by content hash, so no evidence is lost. Identity comes
|
|
84
|
+
* from the manifest's recorded file hashes — cheap (one small JSON per pack)
|
|
85
|
+
* and exact. A pack with no readable manifest is counted and left alone rather
|
|
86
|
+
* than guessed at.
|
|
87
|
+
*
|
|
88
|
+
* The survivor is the newest directory: it is the one the current content-keyed
|
|
89
|
+
* pack id resolves to, so a fleet machine converges in one extra sync instead
|
|
90
|
+
* of oscillating between an old name and a new one.
|
|
91
|
+
*/
|
|
92
|
+
export async function sweepDuplicateStagedRawEvidence(paths, env = process.env, now = new Date()) {
|
|
93
|
+
const empty = {
|
|
94
|
+
skipped: false,
|
|
95
|
+
duplicate_groups: 0,
|
|
96
|
+
removed_dirs: 0,
|
|
97
|
+
freed_bytes: 0,
|
|
98
|
+
removed_staging_dirs: 0,
|
|
99
|
+
unfingerprintable_dirs: 0,
|
|
100
|
+
};
|
|
101
|
+
if (env["COCKPIT_DISABLE_GC"] === "1") {
|
|
102
|
+
return { ...empty, skipped: true };
|
|
103
|
+
}
|
|
104
|
+
const rawEvidenceRoot = path.join(paths.state_dir, "raw-evidence");
|
|
105
|
+
const dirEntries = await fs
|
|
106
|
+
.readdir(rawEvidenceRoot, { withFileTypes: true })
|
|
107
|
+
.catch(() => []);
|
|
108
|
+
// Packs the staged-object index points into are preferred survivors. Keeping
|
|
109
|
+
// one of those means the next collection reuses it instead of writing a fresh
|
|
110
|
+
// copy and re-deleting the old one on the sync after — a two-step oscillation
|
|
111
|
+
// that would look like the sweep working while the bytes moved every cycle.
|
|
112
|
+
const staging = await readRawEvidenceStagingState(paths.state_dir);
|
|
113
|
+
const referencedPackIds = new Set(Object.values(staging.staged).map((entry) => entry.pack_id));
|
|
114
|
+
let removedStagingDirs = 0;
|
|
115
|
+
const packs = [];
|
|
116
|
+
for (const entry of dirEntries) {
|
|
117
|
+
if (!entry.isDirectory())
|
|
118
|
+
continue;
|
|
119
|
+
const dir = path.join(rawEvidenceRoot, entry.name);
|
|
120
|
+
if (entry.name.startsWith(".staging-")) {
|
|
121
|
+
const info = await fs.stat(dir).catch(() => null);
|
|
122
|
+
if (!info)
|
|
123
|
+
continue;
|
|
124
|
+
if (now.getTime() - info.mtimeMs < STAGING_ORPHAN_MS)
|
|
125
|
+
continue;
|
|
126
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
127
|
+
if (!(await exists(dir)))
|
|
128
|
+
removedStagingDirs += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!entry.name.startsWith("work-"))
|
|
132
|
+
continue;
|
|
133
|
+
const info = await fs.stat(dir).catch(() => null);
|
|
134
|
+
if (!info?.isDirectory())
|
|
135
|
+
continue;
|
|
136
|
+
packs.push({
|
|
137
|
+
dir,
|
|
138
|
+
mtimeMs: info.mtimeMs,
|
|
139
|
+
preferred: referencedPackIds.has(entry.name),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
let unfingerprintable = 0;
|
|
144
|
+
for (const pack of packs) {
|
|
145
|
+
const fingerprint = await packContentFingerprint(pack.dir);
|
|
146
|
+
if (!fingerprint) {
|
|
147
|
+
unfingerprintable += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const group = groups.get(fingerprint);
|
|
151
|
+
if (group) {
|
|
152
|
+
group.push(pack);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
groups.set(fingerprint, [pack]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
let duplicateGroups = 0;
|
|
159
|
+
let removedDirs = 0;
|
|
160
|
+
let freedBytes = 0;
|
|
161
|
+
for (const group of groups.values()) {
|
|
162
|
+
if (group.length < 2)
|
|
163
|
+
continue;
|
|
164
|
+
duplicateGroups += 1;
|
|
165
|
+
group.sort((a, b) => Number(b.preferred) - Number(a.preferred) || b.mtimeMs - a.mtimeMs);
|
|
166
|
+
for (const duplicate of group.slice(1)) {
|
|
167
|
+
const byteSize = await dirByteSize(duplicate.dir);
|
|
168
|
+
await fs
|
|
169
|
+
.rm(duplicate.dir, { recursive: true, force: true })
|
|
170
|
+
.catch(() => undefined);
|
|
171
|
+
if (await exists(duplicate.dir))
|
|
172
|
+
continue;
|
|
173
|
+
removedDirs += 1;
|
|
174
|
+
freedBytes += byteSize;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const result = {
|
|
178
|
+
skipped: false,
|
|
179
|
+
duplicate_groups: duplicateGroups,
|
|
180
|
+
removed_dirs: removedDirs,
|
|
181
|
+
freed_bytes: freedBytes,
|
|
182
|
+
removed_staging_dirs: removedStagingDirs,
|
|
183
|
+
unfingerprintable_dirs: unfingerprintable,
|
|
184
|
+
};
|
|
185
|
+
// stderr, which launchd captures to `sync.err.log`. Logged on every sweep,
|
|
186
|
+
// including the boring one: "0 duplicates today" is the only evidence that
|
|
187
|
+
// the drain is still running at all.
|
|
188
|
+
console.error("[raw-evidence] dedup sweep", JSON.stringify({
|
|
189
|
+
reason: removedDirs > 0 ? "dedup_removed" : "dedup_clean",
|
|
190
|
+
pack_count: packs.length,
|
|
191
|
+
duplicate_groups: duplicateGroups,
|
|
192
|
+
removed_dirs: removedDirs,
|
|
193
|
+
freed_bytes: freedBytes,
|
|
194
|
+
removed_staging_dirs: removedStagingDirs,
|
|
195
|
+
unfingerprintable_dirs: unfingerprintable,
|
|
196
|
+
}));
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
export function rawEvidenceDedupSummary(result) {
|
|
200
|
+
return `raw-evidence dedup: removed ${result.removed_dirs} duplicate pack(s) across ${result.duplicate_groups} group(s), freed ~${formatMb(result.freed_bytes)} MB`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Content identity of a pack, read from the manifest it already carries.
|
|
204
|
+
*
|
|
205
|
+
* Only the file content hashes are used — not `pack_id`, not `created_at`, not
|
|
206
|
+
* the branch or ticket labels. Two packs of the same transcript minted 15
|
|
207
|
+
* minutes apart differ in all of those and in none of these.
|
|
208
|
+
*/
|
|
209
|
+
async function packContentFingerprint(dir) {
|
|
210
|
+
const manifestBytes = await fs
|
|
211
|
+
.readFile(path.join(dir, "manifest.json"), "utf8")
|
|
212
|
+
.catch(() => null);
|
|
213
|
+
if (!manifestBytes)
|
|
214
|
+
return null;
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(manifestBytes);
|
|
217
|
+
const hashes = (parsed.files ?? [])
|
|
218
|
+
.filter((file) => typeof file.content_hash_sha256 === "string" &&
|
|
219
|
+
file.content_hash_sha256.length === 64)
|
|
220
|
+
.map((file) => `${String(file.content_hash_sha256)}:${typeof file.byte_size === "number" ? file.byte_size : 0}`)
|
|
221
|
+
.sort();
|
|
222
|
+
if (hashes.length === 0)
|
|
223
|
+
return null;
|
|
224
|
+
return crypto
|
|
225
|
+
.createHash("sha256")
|
|
226
|
+
.update(hashes.join(","), "utf8")
|
|
227
|
+
.digest("hex");
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function dirByteSize(dir) {
|
|
234
|
+
const files = await listFiles(dir).catch(() => null);
|
|
235
|
+
if (!files)
|
|
236
|
+
return 0;
|
|
237
|
+
let total = 0;
|
|
238
|
+
for (const file of files) {
|
|
239
|
+
const info = await fs.stat(file).catch(() => null);
|
|
240
|
+
total += info?.isFile() ? info.size : 0;
|
|
241
|
+
}
|
|
242
|
+
return total;
|
|
243
|
+
}
|
|
66
244
|
async function inspectRawEvidenceDirForGc(dir, uploadedHashes) {
|
|
67
245
|
const files = await listFiles(dir).catch(() => null);
|
|
68
246
|
if (!files)
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
|
|
6
|
+
*
|
|
7
|
+
* Two questions this file answers, and BLI-3066 is what it costs when nothing
|
|
8
|
+
* does:
|
|
9
|
+
*
|
|
10
|
+
* 1. "Have I already staged these exact bytes?" — the pack id used to fold
|
|
11
|
+
* `now` into its hash, so every 15-minute sync minted a new directory and
|
|
12
|
+
* copied the same transcript into it again. One Codex rollout reached 559
|
|
13
|
+
* byte-identical copies, 182 GiB, on one laptop.
|
|
14
|
+
* 2. "How many times have I already offered this object, and how did that go?"
|
|
15
|
+
* — nothing counted, so the same commit failed 1,030 times in nine days at
|
|
16
|
+
* full 15-minute cadence, and every attempt looked like the first.
|
|
17
|
+
*
|
|
18
|
+
* The cursor (`cursors/raw-evidence.json`) answers the *committed* question and
|
|
19
|
+
* deliberately stays that way: an entry there means the object is durable
|
|
20
|
+
* remotely. This file is the pre-commit side — staged but not yet acknowledged —
|
|
21
|
+
* so a cursor reader can never mistake "on this disk" for "safe in the bucket".
|
|
22
|
+
*
|
|
23
|
+
* Metadata only: content hashes, byte sizes, pack ids, pack-relative paths,
|
|
24
|
+
* reason labels, timestamps. Never content, never an absolute path.
|
|
25
|
+
*/
|
|
26
|
+
export const RAW_EVIDENCE_STAGING_FILENAME = "raw-evidence-staging.json";
|
|
27
|
+
/**
|
|
28
|
+
* Backoff schedule for an object whose delivery keeps failing.
|
|
29
|
+
*
|
|
30
|
+
* 15 min (the scheduler's own cadence, so the first retry is simply the next
|
|
31
|
+
* sync), doubling to a 6 h ceiling. Nine days of failure is then ~40 attempts
|
|
32
|
+
* instead of 1,030, and the object is still retried four times a day — a server
|
|
33
|
+
* fix lands within hours, not on the next reinstall.
|
|
34
|
+
*/
|
|
35
|
+
export const EVIDENCE_DELIVERY_BACKOFF_BASE_MS = 15 * 60 * 1000;
|
|
36
|
+
export const EVIDENCE_DELIVERY_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
|
|
37
|
+
/** The reason label written when an object is being held by backoff. */
|
|
38
|
+
export const DELIVERY_BACKOFF_HOLDING_REASON = "delivery_backoff_holding";
|
|
39
|
+
const MAX_TRACKED_STAGED_OBJECTS = 5_000;
|
|
40
|
+
const MAX_TRACKED_DELIVERY_ATTEMPTS = 5_000;
|
|
41
|
+
export function emptyRawEvidenceStagingState() {
|
|
42
|
+
return {
|
|
43
|
+
schema_version: "cockpit-raw-evidence-staging.v1",
|
|
44
|
+
updated_at: null,
|
|
45
|
+
staged: {},
|
|
46
|
+
delivery_attempts: {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function rawEvidenceStagingStatePath(stateDir) {
|
|
50
|
+
return path.join(stateDir, RAW_EVIDENCE_STAGING_FILENAME);
|
|
51
|
+
}
|
|
52
|
+
export async function readRawEvidenceStagingState(stateDir) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
|
|
55
|
+
return parseStagingState(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return emptyRawEvidenceStagingState();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Atomic write, same shape as the cursor: temp sibling, fsync, rename. A
|
|
63
|
+
* launchd sync and a hand-run sync can race, and a truncated state file would
|
|
64
|
+
* lose every attempt count at once — which is exactly the thing that must not
|
|
65
|
+
* be losable.
|
|
66
|
+
*/
|
|
67
|
+
export async function writeRawEvidenceStagingState(stateDir, state) {
|
|
68
|
+
const filePath = rawEvidenceStagingStatePath(stateDir);
|
|
69
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
70
|
+
const pruned = pruneStagingState(state);
|
|
71
|
+
const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
|
|
72
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
73
|
+
let handle = null;
|
|
74
|
+
try {
|
|
75
|
+
handle = await fs.open(tempPath, "w", 0o600);
|
|
76
|
+
await handle.writeFile(serialized);
|
|
77
|
+
await handle.sync();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await handle?.close();
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await fs.rename(tempPath, filePath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (process.platform !== "win32") {
|
|
90
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function recordStagedObject(state, contentHash, entry) {
|
|
94
|
+
state.staged[contentHash] = entry;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The absolute path of an already-staged copy of these bytes, or null.
|
|
98
|
+
*
|
|
99
|
+
* Verified against the filesystem, not trusted from the index: a dedup sweep,
|
|
100
|
+
* a GC pass, or an operator with `rm -rf` can all have removed the file since
|
|
101
|
+
* it was recorded. A stale entry is dropped so the caller re-stages rather than
|
|
102
|
+
* handing the uploader a path that is not there.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveStagedObject(rawEvidenceRoot, state, contentHash) {
|
|
105
|
+
const entry = state.staged[contentHash];
|
|
106
|
+
if (!entry)
|
|
107
|
+
return null;
|
|
108
|
+
const localPath = stagedObjectPath(rawEvidenceRoot, entry);
|
|
109
|
+
const info = await fs.stat(localPath).catch(() => null);
|
|
110
|
+
if (!info?.isFile() || info.size !== entry.byte_size) {
|
|
111
|
+
delete state.staged[contentHash];
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { local_path: localPath, entry };
|
|
115
|
+
}
|
|
116
|
+
export function stagedObjectPath(rawEvidenceRoot, entry) {
|
|
117
|
+
return path.join(rawEvidenceRoot, entry.pack_id, ...entry.relative_path.split("/").filter(Boolean));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Delay before the next attempt on an object that has failed `attempts` times.
|
|
121
|
+
* `attempts` is 1-based: the first failure waits one scheduler cadence.
|
|
122
|
+
*/
|
|
123
|
+
export function evidenceDeliveryBackoffMs(attempts) {
|
|
124
|
+
const safeAttempts = Math.max(1, Math.floor(attempts));
|
|
125
|
+
const exponent = Math.min(safeAttempts - 1, 32);
|
|
126
|
+
const delay = EVIDENCE_DELIVERY_BACKOFF_BASE_MS * 2 ** exponent;
|
|
127
|
+
return Math.min(delay, EVIDENCE_DELIVERY_BACKOFF_MAX_MS);
|
|
128
|
+
}
|
|
129
|
+
export function recordDeliveryFailure(state, contentHash, options) {
|
|
130
|
+
const previous = state.delivery_attempts[contentHash];
|
|
131
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
132
|
+
const attemptedAtIso = options.attemptedAt.toISOString();
|
|
133
|
+
const entry = {
|
|
134
|
+
attempts,
|
|
135
|
+
first_failed_at: previous?.first_failed_at ?? attemptedAtIso,
|
|
136
|
+
last_attempt_at: attemptedAtIso,
|
|
137
|
+
last_reason: options.reason,
|
|
138
|
+
next_attempt_at: new Date(options.attemptedAt.getTime() + evidenceDeliveryBackoffMs(attempts)).toISOString(),
|
|
139
|
+
byte_size: options.byteSize ?? previous?.byte_size ?? 0,
|
|
140
|
+
source_key: options.sourceKey ?? previous?.source_key ?? null,
|
|
141
|
+
};
|
|
142
|
+
state.delivery_attempts[contentHash] = entry;
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
/** An object that landed clears its history; the next failure starts at one. */
|
|
146
|
+
export function clearDeliveryAttempt(state, contentHash) {
|
|
147
|
+
if (!state.delivery_attempts[contentHash])
|
|
148
|
+
return false;
|
|
149
|
+
delete state.delivery_attempts[contentHash];
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
export function deliveryHold(state, contentHash, now) {
|
|
153
|
+
if (!contentHash)
|
|
154
|
+
return null;
|
|
155
|
+
const entry = state.delivery_attempts[contentHash];
|
|
156
|
+
if (!entry)
|
|
157
|
+
return null;
|
|
158
|
+
return Date.parse(entry.next_attempt_at) > now.getTime() ? entry : null;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Source keys currently held by backoff.
|
|
162
|
+
*
|
|
163
|
+
* Collection consults this BEFORE reading a file, so a held 350 MB transcript
|
|
164
|
+
* costs nothing at all this cycle — no read, no hash, no copy, no upload. The
|
|
165
|
+
* hold is still a named, retryable gap, never a silent drop.
|
|
166
|
+
*/
|
|
167
|
+
export function heldSourceKeys(state, now) {
|
|
168
|
+
const held = new Set();
|
|
169
|
+
for (const entry of Object.values(state.delivery_attempts)) {
|
|
170
|
+
if (!entry.source_key)
|
|
171
|
+
continue;
|
|
172
|
+
if (Date.parse(entry.next_attempt_at) > now.getTime()) {
|
|
173
|
+
held.add(entry.source_key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return held;
|
|
177
|
+
}
|
|
178
|
+
export function evidenceSourceKey(options) {
|
|
179
|
+
const source = options.sourcePath
|
|
180
|
+
? shortHash(options.sourcePath)
|
|
181
|
+
: options.label
|
|
182
|
+
? shortHash(options.label)
|
|
183
|
+
: "unknown";
|
|
184
|
+
return `${options.kind}:${options.sessionId ?? "none"}:${source}`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* What `cockpit status` and the health receipt need in order to be unable to
|
|
188
|
+
* read green while an object has been failing for nine days.
|
|
189
|
+
*/
|
|
190
|
+
export function summarizeStuckEvidence(state, now) {
|
|
191
|
+
const entries = Object.values(state.delivery_attempts);
|
|
192
|
+
const reasons = new Set();
|
|
193
|
+
let heldCount = 0;
|
|
194
|
+
let maxAttempts = 0;
|
|
195
|
+
let oldest = null;
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
reasons.add(entry.last_reason);
|
|
198
|
+
if (Date.parse(entry.next_attempt_at) > now.getTime())
|
|
199
|
+
heldCount += 1;
|
|
200
|
+
maxAttempts = Math.max(maxAttempts, entry.attempts);
|
|
201
|
+
if (!oldest || entry.first_failed_at.localeCompare(oldest) < 0) {
|
|
202
|
+
oldest = entry.first_failed_at;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
stuck_object_count: entries.length,
|
|
207
|
+
held_object_count: heldCount,
|
|
208
|
+
max_attempts: maxAttempts,
|
|
209
|
+
oldest_first_failed_at: oldest,
|
|
210
|
+
reasons: [...reasons].sort(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Pack id derived from CONTENT, never from the clock.
|
|
215
|
+
*
|
|
216
|
+
* The manifest is excluded from the hash on purpose: its own object key
|
|
217
|
+
* contains the pack id, so folding it in would be circular. Hashes are sorted,
|
|
218
|
+
* so a change in collection order alone does not mint a new pack.
|
|
219
|
+
*/
|
|
220
|
+
export function contentKeyedRawEvidencePackId(options) {
|
|
221
|
+
const material = [...options.contentHashes].sort().join(",");
|
|
222
|
+
return `${options.workContextId}-${shortHash(`${options.workContextId}:${material}`)}`;
|
|
223
|
+
}
|
|
224
|
+
function pruneStagingState(state) {
|
|
225
|
+
return {
|
|
226
|
+
...state,
|
|
227
|
+
staged: pruneNewest(state.staged, MAX_TRACKED_STAGED_OBJECTS, (entry) => entry.staged_at),
|
|
228
|
+
delivery_attempts: pruneNewest(state.delivery_attempts, MAX_TRACKED_DELIVERY_ATTEMPTS, (entry) => entry.last_attempt_at),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function pruneNewest(record, max, sortKey) {
|
|
232
|
+
const entries = Object.entries(record);
|
|
233
|
+
if (entries.length <= max)
|
|
234
|
+
return record;
|
|
235
|
+
entries.sort((a, b) => sortKey(b[1]).localeCompare(sortKey(a[1])));
|
|
236
|
+
return Object.fromEntries(entries.slice(0, max));
|
|
237
|
+
}
|
|
238
|
+
function parseStagingState(value) {
|
|
239
|
+
if (!value || typeof value !== "object") {
|
|
240
|
+
return emptyRawEvidenceStagingState();
|
|
241
|
+
}
|
|
242
|
+
const record = value;
|
|
243
|
+
return {
|
|
244
|
+
schema_version: "cockpit-raw-evidence-staging.v1",
|
|
245
|
+
updated_at: optionalString(record["updated_at"]),
|
|
246
|
+
staged: parseRecord(record["staged"], parseStagedEntry),
|
|
247
|
+
delivery_attempts: parseRecord(record["delivery_attempts"], parseAttemptEntry),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function parseRecord(value, parseEntry) {
|
|
251
|
+
if (!value || typeof value !== "object")
|
|
252
|
+
return {};
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
255
|
+
const parsed = parseEntry(entry);
|
|
256
|
+
if (parsed)
|
|
257
|
+
out[key] = parsed;
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
function parseStagedEntry(value) {
|
|
262
|
+
if (!value || typeof value !== "object")
|
|
263
|
+
return null;
|
|
264
|
+
const record = value;
|
|
265
|
+
const packId = optionalString(record["pack_id"]);
|
|
266
|
+
const relativePath = optionalString(record["relative_path"]);
|
|
267
|
+
const stagedAt = optionalString(record["staged_at"]);
|
|
268
|
+
if (!packId || !relativePath || !stagedAt)
|
|
269
|
+
return null;
|
|
270
|
+
return {
|
|
271
|
+
pack_id: packId,
|
|
272
|
+
relative_path: relativePath,
|
|
273
|
+
byte_size: optionalNumber(record["byte_size"]),
|
|
274
|
+
source_key: optionalString(record["source_key"]),
|
|
275
|
+
staged_at: stagedAt,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function parseAttemptEntry(value) {
|
|
279
|
+
if (!value || typeof value !== "object")
|
|
280
|
+
return null;
|
|
281
|
+
const record = value;
|
|
282
|
+
const lastAttemptAt = optionalString(record["last_attempt_at"]);
|
|
283
|
+
const nextAttemptAt = optionalString(record["next_attempt_at"]);
|
|
284
|
+
if (!lastAttemptAt || !nextAttemptAt)
|
|
285
|
+
return null;
|
|
286
|
+
const attempts = optionalNumber(record["attempts"]);
|
|
287
|
+
return {
|
|
288
|
+
attempts: attempts > 0 ? attempts : 1,
|
|
289
|
+
first_failed_at: optionalString(record["first_failed_at"]) ?? lastAttemptAt,
|
|
290
|
+
last_attempt_at: lastAttemptAt,
|
|
291
|
+
last_reason: optionalString(record["last_reason"]) ?? "unknown",
|
|
292
|
+
next_attempt_at: nextAttemptAt,
|
|
293
|
+
byte_size: optionalNumber(record["byte_size"]),
|
|
294
|
+
source_key: optionalString(record["source_key"]),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function optionalString(value) {
|
|
298
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
299
|
+
}
|
|
300
|
+
function optionalNumber(value) {
|
|
301
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
302
|
+
}
|
|
303
|
+
function shortHash(value) {
|
|
304
|
+
return crypto
|
|
305
|
+
.createHash("sha256")
|
|
306
|
+
.update(value, "utf8")
|
|
307
|
+
.digest("hex")
|
|
308
|
+
.slice(0, 12);
|
|
309
|
+
}
|