@bli-cockpit/cli 0.2.94 → 0.2.95

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.
@@ -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.94");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.95");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,99 @@
1
+ import { resolveAutostartRoots } from "./autostart-command.js";
2
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
3
+ import { getCollectorRuntimePaths } from "../local-state.js";
4
+ import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
5
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
6
+ /**
7
+ * BLI-2721: after the tick's collection and self-update are done and
8
+ * reported, repair a broken/legacy autostart registration in place (Windows
9
+ * only — see autostart-self-heal.ts for why macOS is excluded). Every error
10
+ * path is swallowed like the self-update's: heal outcomes are their own
11
+ * receipts, never a sync failure.
12
+ */
13
+ export async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
14
+ let result;
15
+ try {
16
+ const rawExec = io.exec;
17
+ if (!rawExec) {
18
+ sayNoProcessRunner();
19
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, runnerUnavailableOutcome());
20
+ return;
21
+ }
22
+ result = await repairAutostartRegistration(command, io, rawExec);
23
+ }
24
+ catch (error) {
25
+ result = {
26
+ status: "fail",
27
+ reason: "autostart_self_heal_threw",
28
+ detail: redactedSyncErrorDetail(error),
29
+ };
30
+ }
31
+ // Steady state (healthy, absent, non-Windows, no roots) and the daily
32
+ // throttle are silent; an actual repair attempt reports either way.
33
+ if (!result || result.reason === "repair_throttled_recent_attempt")
34
+ return;
35
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
36
+ }
37
+ /**
38
+ * Attempt the repair with a runner that can find node.
39
+ *
40
+ * Every spawn in the scheduled path carries the running node's bin dir on
41
+ * PATH (`envWithNodeRuntimeOnPath`); the scheduler's stripped environment
42
+ * needs it. Returns null when there was nothing to repair.
43
+ */
44
+ async function repairAutostartRegistration(command, io, rawExec) {
45
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
46
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
47
+ return runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
48
+ homeDir: command.homeDir,
49
+ repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
50
+ dashboardUrl: command.dashboardUrl,
51
+ exec,
52
+ });
53
+ }
54
+ /**
55
+ * Say out loud that the repair was never attempted.
56
+ *
57
+ * BLI-3483: this was a bare `return`. On Windows the self-heal is the only
58
+ * thing that puts a broken scheduler back, so abandoning it here meant a
59
+ * machine could stop collecting forever and leave no receipt anywhere — the
60
+ * exact shape the fleet contract forbids. The packed CLI always supplies a
61
+ * runner (`commands/cli-io.ts`), so this fires only for an embedder that built
62
+ * its own `io`; it costs one line either way.
63
+ */
64
+ function sayNoProcessRunner() {
65
+ console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
66
+ reason: "runner_unavailable",
67
+ platform: process.platform,
68
+ next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
69
+ }));
70
+ }
71
+ /** The same fact as a receipt, so the fleet table can see it too. */
72
+ function runnerUnavailableOutcome() {
73
+ return {
74
+ status: "skipped",
75
+ reason: "runner_unavailable",
76
+ detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
77
+ };
78
+ }
79
+ /** One receipt for the repair, whichever branch above produced the outcome. */
80
+ async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
81
+ await reportInstallEventsBestEffort({
82
+ homeDir: command.homeDir,
83
+ dashboardUrl,
84
+ command: "sync",
85
+ events: [
86
+ {
87
+ // Windows repairs in place and keeps the name already in the receipts
88
+ // and the runbook; the macOS path only SCHEDULES a detached repair, so
89
+ // it reports under its own step (BLI-3553).
90
+ step: result.step ?? "autostart_repair",
91
+ status: result.status,
92
+ ...(result.status === "ok" ? {} : { error_code: result.reason }),
93
+ ...(result.detail ? { error_detail: result.detail } : {}),
94
+ },
95
+ ],
96
+ json: command.json,
97
+ io,
98
+ });
99
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Follow-up three: keep BLI Memory registered with both agent hosts (BLI-3580).
3
+ *
4
+ * Nobody is going to be asked to install a hook. `do-everything` registers it
5
+ * on the way through, and this puts it back if a host config is edited,
6
+ * replaced, or restored from a machine that never had it — at most once a day,
7
+ * because the steady state is "already current" and re-proving that every
8
+ * fifteen minutes is four file reads a tick for no new information.
9
+ *
10
+ * Same rule as the other follow-ups: it runs only once collection's own
11
+ * outcome has been decided and reported, it never throws, and its outcome is
12
+ * its own named receipt rather than a sync failure.
13
+ *
14
+ * Split out of `sync-followups.ts` (BLI-3988). The registration itself, and
15
+ * every target it writes, are `./memory-install.ts`.
16
+ */
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
20
+ import { installMemoryIntegration, } from "./memory-install.js";
21
+ import { getCollectorRuntimePaths, } from "../local-state.js";
22
+ export const MEMORY_INSTALL_THROTTLE_MARKER = ".last-memory-install";
23
+ const MEMORY_INSTALL_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
24
+ /**
25
+ * BLI-3580: BLI Memory's registration converges on its own, once a day.
26
+ *
27
+ * The daily cadence and the swallowed error paths are the whole contract; see
28
+ * this module's header for why each of them is what it is.
29
+ */
30
+ export async function runMemoryInstallAfterSync(command, io, dashboardUrl, options = {}) {
31
+ const paths = getCollectorRuntimePaths(command.homeDir);
32
+ const now = options.now ?? new Date();
33
+ if (await triedWithinTheLastDay(paths, now))
34
+ return;
35
+ await markInstallAttempted(paths, now);
36
+ const event = await installMemoryOrSayWhyNot(command, io, dashboardUrl);
37
+ await reportInstallEventsBestEffort({
38
+ homeDir: command.homeDir,
39
+ dashboardUrl,
40
+ command: "sync",
41
+ events: [event],
42
+ json: command.json,
43
+ io,
44
+ });
45
+ }
46
+ /** Has this machine already had its one attempt today? */
47
+ async function triedWithinTheLastDay(paths, now) {
48
+ const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
49
+ const lastAttempt = await fs.stat(marker).catch(() => null);
50
+ if (!lastAttempt)
51
+ return false;
52
+ return now.getTime() - lastAttempt.mtimeMs < MEMORY_INSTALL_MIN_INTERVAL_MS;
53
+ }
54
+ /**
55
+ * Spend today's attempt before making it.
56
+ *
57
+ * Written for the ATTEMPT, not the outcome — the same idiom the self-update
58
+ * and autostart repair use, so a machine that cannot write a host config does
59
+ * not retry it every fifteen minutes.
60
+ */
61
+ async function markInstallAttempted(paths, now) {
62
+ const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
63
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
64
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
65
+ }
66
+ /** The registration attempt, as a receipt either way; it cannot throw. */
67
+ async function installMemoryOrSayWhyNot(command, io, dashboardUrl) {
68
+ try {
69
+ const outcome = await installMemoryIntegration({
70
+ kind: "memory",
71
+ action: "install",
72
+ homeDir: command.homeDir,
73
+ dashboardUrl,
74
+ dryRun: false,
75
+ json: command.json,
76
+ }, io,
77
+ // Undefined on the real CLI (no io literal sets it); a test io can
78
+ // override the bin lookup so `bin_missing` is a fixture rather than a
79
+ // property of the machine the suite runs on (BLI-3630).
80
+ io.memoryInstallDeps);
81
+ return memoryInstallEvent(outcome);
82
+ }
83
+ catch (error) {
84
+ return {
85
+ step: "memory_install",
86
+ status: "fail",
87
+ error_code: "memory_install_threw",
88
+ error_detail: redactedSyncErrorDetail(error),
89
+ };
90
+ }
91
+ }
92
+ /**
93
+ * Target names and reason labels only. A target's `path` names a person's home
94
+ * directory and a `write_failed` detail can carry one, so neither travels: the
95
+ * receipt says `claude_hooks:read_back_mismatch`, which is the part an operator
96
+ * can act on.
97
+ */
98
+ function memoryInstallEvent(outcome) {
99
+ const detail = [
100
+ `source=${outcome.config_source}`,
101
+ ...outcome.targets.map((target) => `${target.target}:${target.status}/${target.reason}`),
102
+ ].join("; ");
103
+ if (outcome.status === "failed") {
104
+ return {
105
+ step: "memory_install",
106
+ status: "fail",
107
+ error_code: outcome.reason,
108
+ error_detail: detail,
109
+ };
110
+ }
111
+ if (outcome.status === "skipped") {
112
+ // Nothing was written, on purpose (`no_bin_no_write`). A fleet-wide
113
+ // `bin_missing` is the receipt that says the server package has not
114
+ // reached the machines yet — a fact, not a fault.
115
+ return {
116
+ step: "memory_install",
117
+ status: "skipped",
118
+ error_code: outcome.reason,
119
+ error_detail: detail,
120
+ };
121
+ }
122
+ return { step: "memory_install", status: "ok", error_detail: detail };
123
+ }
@@ -0,0 +1,127 @@
1
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
2
+ import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
3
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
4
+ import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
5
+ /**
6
+ * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
7
+ * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
8
+ * runs AFTER `runSync` has already decided and reported collection's own
9
+ * outcome above — a stuck or failing self-update can never block or delay
10
+ * collection, and a collection failure never blocks the chance to
11
+ * self-update. Every error path here is swallowed on purpose: a failure is
12
+ * reported as its own named `update` receipt, never surfaced as a `sync`
13
+ * failure or thrown from this function.
14
+ */
15
+ export async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
16
+ let event;
17
+ try {
18
+ event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
19
+ }
20
+ catch (error) {
21
+ // The throttle/probe/install machinery below is defensive already; this
22
+ // is the last-resort net so an update crash truly cannot touch the sync
23
+ // result above.
24
+ event = {
25
+ step: "update",
26
+ status: "fail",
27
+ error_code: "self_update_threw",
28
+ error_detail: redactedSyncErrorDetail(error),
29
+ };
30
+ }
31
+ if (!event)
32
+ return;
33
+ await reportInstallEventsBestEffort({
34
+ homeDir: command.homeDir,
35
+ dashboardUrl,
36
+ command: "update",
37
+ events: [event],
38
+ json: command.json,
39
+ io,
40
+ });
41
+ }
42
+ async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
43
+ const rawExec = io.exec;
44
+ if (!rawExec) {
45
+ // Only the real production `defaultIo()` supplies a process runner. A
46
+ // caller that omitted one gets a silent no-op rather than this reaching
47
+ // for a real npm binary it was never given — never observed in
48
+ // production, where `defaultIo()` always sets `exec`.
49
+ return null;
50
+ }
51
+ // Every spawn in the scheduled path carries the running node's bin dir on
52
+ // PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
53
+ // this; the scheduler's stripped environment does.
54
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
55
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
56
+ const scheduledIo = { ...io, exec };
57
+ const paths = getCollectorRuntimePaths(command.homeDir);
58
+ const result = await runScheduledSelfUpdate(paths, {
59
+ exec,
60
+ currentVersion: LOCAL_COLLECTOR_VERSION,
61
+ install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
62
+ }, { env: io.env, minVersion: minCliVersion });
63
+ return scheduledSelfUpdateInstallEvent(result);
64
+ }
65
+ async function attemptScheduledSelfUpdateInstall(io, tag) {
66
+ try {
67
+ // Reuses the exact npm-install machinery `cockpit doctor`'s
68
+ // `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
69
+ // knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
70
+ // this call never re-execs — see runScheduledSelfUpdate's doc comment.
71
+ await runSelfUpdate(io, { json: true, tag });
72
+ return { ok: true };
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof SelfUpdateError))
76
+ throw error;
77
+ return { ok: false, eacces: error.eacces };
78
+ }
79
+ }
80
+ function scheduledSelfUpdateInstallEvent(result) {
81
+ // The steady-state "already checked today" case is a pure no-op; reporting
82
+ // it would post a receipt on ~95 of every 96 sync ticks for no new
83
+ // information. Only a real attempt (ok, fail, or an explicit disable)
84
+ // produces a receipt.
85
+ if (result.reason === "throttled_recent_attempt")
86
+ return null;
87
+ // A forced attempt names its trigger in the receipt either way, so the
88
+ // ledger can tell "converged on the daily cadence" from "the floor pulled
89
+ // this machine forward" (BLI-2678).
90
+ const forcedDetail = result.forced && result.min_version
91
+ ? `forced_min_version ${result.min_version}`
92
+ : null;
93
+ if (result.status === "ok") {
94
+ // BLI-3551: this used to be `update ok` with an empty detail unless the
95
+ // floor forced it. One machine posted that receipt daily for nine releases
96
+ // while sitting on 0.2.37, and nobody could tell "already current" from
97
+ // "installed something" from "npm answered nothing" — three different
98
+ // situations wearing one word. The success branch names itself now.
99
+ const okDetail = [
100
+ forcedDetail,
101
+ result.reason === "updated" && result.previous_version && result.installed_version
102
+ ? `installed ${result.previous_version}→${result.installed_version}`
103
+ : result.reason,
104
+ result.target_version ? `target ${result.target_version}` : null,
105
+ ]
106
+ .filter((part) => Boolean(part))
107
+ .join("; ");
108
+ return {
109
+ step: "update",
110
+ status: "ok",
111
+ ...(okDetail ? { error_detail: okDetail } : {}),
112
+ };
113
+ }
114
+ const detail = [
115
+ forcedDetail,
116
+ result.target_version ? `target ${result.target_version}` : null,
117
+ result.installed_version ? `installed ${result.installed_version}` : null,
118
+ ]
119
+ .filter((part) => Boolean(part))
120
+ .join("; ");
121
+ return {
122
+ step: "update",
123
+ status: result.status,
124
+ error_code: result.reason,
125
+ ...(detail ? { error_detail: detail } : {}),
126
+ };
127
+ }
@@ -0,0 +1,202 @@
1
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
2
+ import { getCollectorRuntimePaths } from "../local-state.js";
3
+ import { runStagingPrune } from "../disk-prune.js";
4
+ import { runEvidenceReconcile, } from "../evidence-reconcile-client.js";
5
+ import { runEvidenceRedelivery, } from "../evidence-redelivery.js";
6
+ /** How many reconcile batches (of up to 500 hashes each) one sync tick may spend. */
7
+ const RECONCILE_BATCHES_PER_TICK = 1;
8
+ /**
9
+ * BLI-3619: staged raw evidence Tower has already accepted stops living on the
10
+ * laptop forever.
11
+ *
12
+ * Same shape as the memory install beside it and for the same reasons: it runs
13
+ * only once collection's own outcome is decided and reported, at most once a
14
+ * day, it cannot throw, and its outcome is its own named receipt rather than a
15
+ * sync failure. A machine that cannot prune is a machine short of disk, and
16
+ * that must never also be a machine that stops collecting.
17
+ *
18
+ * The rule itself is `disk-retention.ts`: only objects the upload ledger
19
+ * vouches for are ever deleted, and anything undelivered is counted, aged and
20
+ * named instead.
21
+ *
22
+ * BLI-3619's second half runs FIRST, every tick, bounded to
23
+ * `RECONCILE_BATCHES_PER_TICK` (one batch of up to 500 hashes): the local
24
+ * ledger's own memory is capped, so a laptop that keeps accumulating
25
+ * `unknown` state needs SOMETHING asking the server on a schedule, not only
26
+ * when a person happens to type `cockpit clean --reconcile`. One batch a tick
27
+ * is not throttled to once a day like the prune below — an `unknown` backlog
28
+ * drains at up to 500 hashes/15 min, and the ordinary case (nothing unknown)
29
+ * costs one cheap local read and no network call at all, so it never competes
30
+ * with collection for the tick's time. A reconcile failure never blocks the
31
+ * prune that follows it.
32
+ *
33
+ * BLI-3797 sits between them: the reconcile has just established which staged
34
+ * objects the SERVER says it never received, so the redelivery drain re-offers
35
+ * exactly those, bounded, before the prune runs and can delete whatever landed.
36
+ * Neither of the two can block the prune, and none of the three can fail a tick.
37
+ */
38
+ export async function runStagingPruneAfterSync(command, io, dashboardUrl, options = {}) {
39
+ const events = [];
40
+ const reconciled = await runReconcileFollowUp(command, io, dashboardUrl, options);
41
+ if (reconciled)
42
+ events.push(reconcileEvent(reconciled));
43
+ const redelivered = await runRedeliveryFollowUp(command, io, dashboardUrl, options);
44
+ if (redelivered)
45
+ events.push(redeliveryEvent(redelivered));
46
+ let result;
47
+ try {
48
+ result = await runStagingPrune(getCollectorRuntimePaths(command.homeDir), {
49
+ env: io.env,
50
+ ...(options.now ? { now: options.now } : {}),
51
+ });
52
+ }
53
+ catch (error) {
54
+ // runStagingPrune already catches everything it can reach; this is the
55
+ // last-resort net so a prune crash truly cannot touch the sync result.
56
+ result = { ...prunedNothing(), reason: "prune_threw", status: "fail" };
57
+ console.error("[collector prune] the prune follow-up threw", JSON.stringify({
58
+ reason: "prune_followup_threw",
59
+ detail: redactedSyncErrorDetail(error),
60
+ }));
61
+ }
62
+ // The daily throttle is the steady state — reporting it would post a receipt
63
+ // on 95 of every 96 ticks for no new information.
64
+ if (result.reason !== "throttled_recent_run") {
65
+ events.push(stagingPruneEvent(result));
66
+ }
67
+ if (events.length === 0)
68
+ return;
69
+ await reportInstallEventsBestEffort({
70
+ homeDir: command.homeDir,
71
+ dashboardUrl,
72
+ command: "sync",
73
+ events,
74
+ json: command.json,
75
+ io,
76
+ });
77
+ }
78
+ /**
79
+ * Never throws (`runEvidenceReconcile` already never does); returns `null`
80
+ * for the boring, common case — nothing this tick was `unknown` — so that
81
+ * case costs no receipt either, the same rule the prune's own daily throttle
82
+ * follows above.
83
+ */
84
+ async function runReconcileFollowUp(command, io, dashboardUrl, options) {
85
+ const result = await runEvidenceReconcile({
86
+ homeDir: command.homeDir,
87
+ dashboardUrl,
88
+ maxBatches: RECONCILE_BATCHES_PER_TICK,
89
+ fetch: io.fetch,
90
+ ...(options.now ? { now: options.now } : {}),
91
+ });
92
+ return result.reason === "nothing_unknown" ? null : result;
93
+ }
94
+ /**
95
+ * BLI-3797, between the reconcile above and the prune below, and in that order
96
+ * for a reason: reconcile turns `unknown` into a server-backed answer, this
97
+ * re-offers what that answer says never landed, and the prune then deletes
98
+ * whatever this just made durable. Running the drain first would ask the server
99
+ * about hashes it is about to be told the truth about; running it after the
100
+ * prune would leave a tick's worth of freed cap unused.
101
+ *
102
+ * Never throws (`runEvidenceRedelivery` already never does); returns `null` for
103
+ * the boring, common case — nothing on this disk is undelivered — so the steady
104
+ * state costs no receipt, the same rule the reconcile follow-up above and the
105
+ * prune's daily throttle below both follow.
106
+ */
107
+ async function runRedeliveryFollowUp(command, io, dashboardUrl, options) {
108
+ const result = await runEvidenceRedelivery({
109
+ homeDir: command.homeDir,
110
+ dashboardUrl,
111
+ env: io.env,
112
+ fetch: io.fetch,
113
+ ...(options.now ? { now: options.now } : {}),
114
+ });
115
+ return result.reason === "nothing_uncommitted" ? null : result;
116
+ }
117
+ /** Counts and byte totals only; no pack id and no hash travels in a receipt. */
118
+ function redeliveryEvent(result) {
119
+ const detail = [
120
+ `offered ${result.offered} object(s), ${result.offered_bytes}B, across ${result.packs} pack(s)`,
121
+ `uploaded ${result.uploaded} (${result.uploaded_bytes}B), reused ${result.reused}, failed ${result.failed}`,
122
+ `held ${result.held}, deferred ${result.deferred} (${result.deferred_bytes}B)`,
123
+ result.failure_reasons.length > 0
124
+ ? `failure_reasons ${result.failure_reasons.join(",")}`
125
+ : null,
126
+ ]
127
+ .filter((part) => Boolean(part))
128
+ .join("; ");
129
+ if (result.status === "fail") {
130
+ return {
131
+ step: "evidence_redelivery",
132
+ status: "fail",
133
+ error_code: result.reason,
134
+ error_detail: detail,
135
+ };
136
+ }
137
+ if (result.status === "skipped") {
138
+ return {
139
+ step: "evidence_redelivery",
140
+ status: "skipped",
141
+ error_code: result.reason,
142
+ error_detail: detail,
143
+ };
144
+ }
145
+ return { step: "evidence_redelivery", status: "ok", error_detail: detail };
146
+ }
147
+ function prunedNothing() {
148
+ return {
149
+ status: "skipped",
150
+ reason: "prune_threw",
151
+ deleted_files: 0,
152
+ deleted_bytes: 0,
153
+ removed_packs: 0,
154
+ kept_uncommitted: 0,
155
+ kept_uncommitted_bytes: 0,
156
+ oldest_uncommitted_age_ms: 0,
157
+ kept_unknown: 0,
158
+ kept_unknown_bytes: 0,
159
+ kept_in_window: 0,
160
+ cap_bytes: 0,
161
+ bytes_before: 0,
162
+ bytes_after: 0,
163
+ cap_blocked_by_uncommitted: false,
164
+ cap_blocked_count: 0,
165
+ failed_deletions: 0,
166
+ };
167
+ }
168
+ /** Counts and byte totals only; no pack id and no path travels in a receipt. */
169
+ function stagingPruneEvent(result) {
170
+ const detail = [
171
+ `freed ${result.deleted_bytes}B in ${result.deleted_files} file(s)`,
172
+ `held ${result.kept_uncommitted_bytes}B uncommitted`,
173
+ result.cap_blocked_by_uncommitted
174
+ ? `staging_cap_blocked_by_uncommitted ${result.cap_blocked_count}`
175
+ : null,
176
+ result.failed_deletions > 0
177
+ ? `failed_deletions ${result.failed_deletions}`
178
+ : null,
179
+ ]
180
+ .filter((part) => Boolean(part))
181
+ .join("; ");
182
+ if (result.status === "fail") {
183
+ return {
184
+ step: "staging_prune",
185
+ status: "fail",
186
+ error_code: result.reason,
187
+ error_detail: detail,
188
+ };
189
+ }
190
+ if (result.status === "skipped") {
191
+ return { step: "staging_prune", status: "skipped", error_code: result.reason };
192
+ }
193
+ return { step: "staging_prune", status: "ok", error_detail: detail };
194
+ }
195
+ /** Counts only; no hash and no pack id travels in a receipt. */
196
+ function reconcileEvent(result) {
197
+ const detail = `asked ${result.asked} hash(es) across ${result.batches}/${result.total_batches} batch(es); committed ${result.committed}, not_committed ${result.not_committed}, unknown_to_server ${result.unknown_to_server}, failed_batches ${result.failed_batches}`;
198
+ if (result.status === "fail") {
199
+ return { step: "evidence_reconcile", status: "fail", error_code: result.reason, error_detail: detail };
200
+ }
201
+ return { step: "evidence_reconcile", status: "ok", error_detail: detail };
202
+ }