@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.
@@ -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
- 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}${failures}${retries}`;
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.26");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.28");
19
19
  return 0;
20
20
  }
21
21
 
@@ -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
  }
@@ -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
+ }