@bli-cockpit/cli 0.2.262 → 0.2.263

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.
@@ -66,7 +66,7 @@ export async function openTranscriptReadMemo(stateDir, name) {
66
66
  const before = entries.size;
67
67
  const kept = trimEntries(entries, now, PREPARE_MEMO_MAX_ENTRIES);
68
68
  try {
69
- await writeJsonFileAtomic(transcriptReadMemoPath(stateDir, name), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) });
69
+ await writeJsonFileAtomic(transcriptReadMemoPath(stateDir, name), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) }, { compact: true });
70
70
  dirty = false;
71
71
  written = true;
72
72
  }
@@ -0,0 +1,202 @@
1
+ import os from "node:os";
2
+ import { Worker } from "node:worker_threads";
3
+ import { enqueueInstallEventEntrySyncForExit, installEventOutboxDirectory } from "./spool/install-event-outbox.js";
4
+ import { LOCAL_COLLECTOR_VERSION } from "./local-state-config.js";
5
+ import { releaseHeldSyncLocksOnExit } from "./sync-lock.js";
6
+ /**
7
+ * Two ceilings a long-lived collector command stops itself at (BLI-23313).
8
+ *
9
+ * Why: a 15-minute `cockpit sync` tick was caught at 55-69 GB of NATIVE memory
10
+ * (166 GB on a 16 GB Mac), growing ~1 GB/s, while `ps` RSS read ~1 GB because
11
+ * the pages were already swapped and compressed. The growth is off the JS heap,
12
+ * so `--max-old-space-size` cannot cap it. Other ticks simply held the
13
+ * collection lock for 16 minutes to 5.4 hours. A command that cannot finish
14
+ * must end itself, say why, and give the lock back.
15
+ *
16
+ * - Memory ceiling: a 2 s `unref`ed timer reads `rss`, `external`,
17
+ * `arrayBuffers` and the sticky peak `maxRSS` (which survives pages being
18
+ * swapped out). Default 3 GB for sync/doctor (a normal tick peaks ~400 MB).
19
+ * `COCKPIT_MEMORY_CEILING_MB` overrides it.
20
+ * - Wall-clock deadline: sync only, 30 min by default. 12 min killed a healthy
21
+ * catch-up tick in its prune step after it had uploaded 42 envelopes; the
22
+ * memory ceiling is what stops a runaway, this only ends multi-hour hangs.
23
+ * The collection lock keeps ticks from overlapping meanwhile.
24
+ * `COCKPIT_TICK_DEADLINE_MS` overrides it.
25
+ * - A worker-thread backstop checks every second at 1.25x the ceiling (or 30 s
26
+ * past the deadline). The observed runaway sat 100% inside one native call on
27
+ * the main thread, where no main-thread timer can fire; the worker writes the
28
+ * receipt and hard-kills the process. The dead pid lets the next tick take
29
+ * the lock over at once.
30
+ *
31
+ * On a trip: log `[cockpit <command>] <reason>` with sizes only, queue a
32
+ * `sync_complete` failure receipt in the install-event outbox (the next tick
33
+ * delivers it), release the collection lock, `process.exit` non-zero. Both
34
+ * exits are portable: no POSIX signal is needed on the main-thread path.
35
+ */
36
+ export const EXIT_MEMORY_CEILING = 71;
37
+ export const EXIT_TICK_DEADLINE = 72;
38
+ export const MEMORY_CEILING_REASON = "memory_ceiling_exceeded";
39
+ export const TICK_DEADLINE_REASON = "tick_deadline_exceeded";
40
+ export const DEFAULT_MEMORY_CEILING_MB = { sync: 3072, doctor: 3072, backfill: 6144 };
41
+ export const DEFAULT_TICK_DEADLINE_MS = 30 * 60_000;
42
+ const DEFAULT_INTERVAL_MS = 2_000;
43
+ const BACKSTOP_FACTOR = 1.25;
44
+ const BACKSTOP_DEADLINE_GRACE_MS = 30_000;
45
+ const MB = 1024 * 1024;
46
+ function positive(raw) {
47
+ if (!raw)
48
+ return null;
49
+ const value = Number(raw);
50
+ return Number.isFinite(value) && value > 0 ? value : null;
51
+ }
52
+ export function resolveCeilingMb(command, env) {
53
+ return positive(env["COCKPIT_MEMORY_CEILING_MB"]) ?? DEFAULT_MEMORY_CEILING_MB[command];
54
+ }
55
+ export function resolveDeadlineMs(command, env) {
56
+ if (command !== "sync")
57
+ return null;
58
+ return positive(env["COCKPIT_TICK_DEADLINE_MS"]) ?? DEFAULT_TICK_DEADLINE_MS;
59
+ }
60
+ export function readMemory() {
61
+ const usage = process.memoryUsage();
62
+ let maxRssKb = 0;
63
+ try {
64
+ maxRssKb = process.resourceUsage().maxRSS; // kilobytes on every platform
65
+ }
66
+ catch {
67
+ maxRssKb = 0;
68
+ }
69
+ return {
70
+ rss_mb: Math.round(usage.rss / MB),
71
+ max_rss_mb: Math.round(maxRssKb / 1024),
72
+ external_mb: Math.round(usage.external / MB),
73
+ array_buffers_mb: Math.round(usage.arrayBuffers / MB),
74
+ };
75
+ }
76
+ export function exceedsCeiling(reading, ceilingMb) {
77
+ return Math.max(reading.rss_mb, reading.max_rss_mb, reading.external_mb, reading.array_buffers_mb) > ceilingMb;
78
+ }
79
+ /** Starts the guard; the returned function stops it (call it in a `finally`). */
80
+ export function startCommandGuard(options) {
81
+ const env = options.env ?? process.env;
82
+ const exit = options.exit ?? ((code) => process.exit(code));
83
+ const ceilingMb = resolveCeilingMb(options.command, env);
84
+ const deadlineMs = options.deadlineMs === undefined ? resolveDeadlineMs(options.command, env) : options.deadlineMs;
85
+ const intervalMs = positive(env["COCKPIT_GUARD_INTERVAL_MS"]) ?? DEFAULT_INTERVAL_MS;
86
+ let tripped = false;
87
+ const trip = (reason, code, label, extra) => {
88
+ if (tripped)
89
+ return;
90
+ tripped = true;
91
+ const fields = { reason, command: options.command, phase: options.phase?.() ?? null, ...extra };
92
+ console.error(`[cockpit ${options.command}] ${label}`, JSON.stringify(fields));
93
+ try {
94
+ const outboxId = enqueueInstallEventEntrySyncForExit(options.paths, {
95
+ dashboardUrl: options.dashboardUrl,
96
+ cliVersion: LOCAL_COLLECTOR_VERSION,
97
+ command: options.command,
98
+ osPlatform: os.platform(),
99
+ events: [{ step: `${options.command}_complete`, status: "fail", error_code: reason, error_detail: JSON.stringify(fields) }],
100
+ });
101
+ console.error(`[cockpit ${options.command}] failure receipt queued`, JSON.stringify({ reason, outbox_id: outboxId }));
102
+ }
103
+ catch (error) {
104
+ console.error(`[cockpit ${options.command}] failure receipt not queued`, JSON.stringify({ reason: "receipt_write_failed", trip_reason: reason, error_name: error instanceof Error ? error.name : typeof error }));
105
+ }
106
+ try {
107
+ const released = releaseHeldSyncLocksOnExit();
108
+ console.error(`[cockpit ${options.command}] collection lock released before exit`, JSON.stringify({ reason, released }));
109
+ }
110
+ catch (error) {
111
+ console.error(`[cockpit ${options.command}] lock release threw`, JSON.stringify({ reason: "lock_release_threw", trip_reason: reason, error_name: error instanceof Error ? error.name : typeof error }));
112
+ }
113
+ exit(code);
114
+ };
115
+ const memoryTimer = setInterval(() => {
116
+ const reading = readMemory();
117
+ if (exceedsCeiling(reading, ceilingMb)) {
118
+ trip(MEMORY_CEILING_REASON, EXIT_MEMORY_CEILING, "memory ceiling exceeded", { ...reading, ceiling_mb: ceilingMb });
119
+ }
120
+ }, intervalMs);
121
+ memoryTimer.unref();
122
+ const startedAt = Date.now();
123
+ const deadlineTimer = deadlineMs
124
+ ? setTimeout(() => {
125
+ trip(TICK_DEADLINE_REASON, EXIT_TICK_DEADLINE, "tick deadline exceeded", { deadline_ms: deadlineMs, elapsed_ms: Date.now() - startedAt, ...readMemory() });
126
+ }, deadlineMs)
127
+ : null;
128
+ deadlineTimer?.unref();
129
+ const worker = options.backstop === false ? null : startBackstop(options, ceilingMb, deadlineMs, startedAt);
130
+ console.error(`[cockpit ${options.command}] guard armed`, JSON.stringify({ ceiling_mb: ceilingMb, deadline_ms: deadlineMs, interval_ms: intervalMs, backstop: worker !== null }));
131
+ return () => {
132
+ clearInterval(memoryTimer);
133
+ if (deadlineTimer)
134
+ clearTimeout(deadlineTimer);
135
+ void worker?.terminate();
136
+ };
137
+ }
138
+ const BACKSTOP_SOURCE = `
139
+ const { workerData: d } = require("node:worker_threads");
140
+ const fs = require("node:fs");
141
+ const path = require("node:path");
142
+ const crypto = require("node:crypto");
143
+ setInterval(() => {
144
+ let reason = null;
145
+ const rssMb = process.memoryUsage.rss() / 1048576;
146
+ let maxMb = 0;
147
+ try { maxMb = process.resourceUsage().maxRSS / 1024; } catch (error) { maxMb = 0; /* resourceUsage unavailable: rss alone decides */ }
148
+ if (Math.max(rssMb, maxMb) > d.hardCeilingMb) reason = "memory_ceiling_exceeded";
149
+ else if (d.hardDeadlineAt && Date.now() > d.hardDeadlineAt) reason = "tick_deadline_exceeded";
150
+ if (!reason) return;
151
+ const fields = { reason, command: d.command, backstop: "worker", rss_mb: Math.round(rssMb), max_rss_mb: Math.round(maxMb), ceiling_mb: d.ceilingMb, phase: "main_thread_unresponsive" };
152
+ // Direct fd write: worker console output goes through the (stuck) main thread.
153
+ const say = (line) => { try { fs.writeSync(2, line + "\\n"); } catch (error) { /* stderr is closed; the receipt file below still records the trip */ } };
154
+ say("[cockpit " + d.command + "] " + (reason === "memory_ceiling_exceeded" ? "memory ceiling exceeded" : "tick deadline exceeded") + " " + JSON.stringify(fields));
155
+ try {
156
+ const now = new Date().toISOString();
157
+ const id = "install-event-" + crypto.randomUUID();
158
+ fs.mkdirSync(d.outboxDir, { recursive: true, mode: 0o700 });
159
+ const entry = { schema_version: "cockpit-install-event-outbox.v1", outbox_id: id, created_at: now, last_attempt_at: null, retry_count: 0, last_failure_reason: null, dashboard_url: d.dashboardUrl, cli_version: d.cliVersion, command: d.command, os_platform: d.osPlatform, events: [{ step: d.command + "_complete", status: "fail", error_code: reason, error_detail: JSON.stringify(fields), at: now }] };
160
+ fs.writeFileSync(path.join(d.outboxDir, id.replace(/[^a-z0-9_-]/gi, "") + ".json"), JSON.stringify(entry, null, 2) + "\\n", { mode: 0o600 });
161
+ } catch (error) {
162
+ say("[cockpit " + d.command + "] failure receipt not queued " + JSON.stringify({ reason: "receipt_write_failed", trip_reason: reason, error_name: error && error.name }));
163
+ }
164
+ process.kill(process.pid, "SIGKILL");
165
+ }, 1000);
166
+ `;
167
+ function startBackstop(options, ceilingMb, deadlineMs, startedAt) {
168
+ try {
169
+ const worker = new Worker(BACKSTOP_SOURCE, {
170
+ eval: true,
171
+ workerData: {
172
+ command: options.command,
173
+ ceilingMb,
174
+ hardCeilingMb: Math.ceil(ceilingMb * BACKSTOP_FACTOR),
175
+ hardDeadlineAt: deadlineMs ? startedAt + deadlineMs + BACKSTOP_DEADLINE_GRACE_MS : null,
176
+ outboxDir: installEventOutboxDirectory(options.paths),
177
+ dashboardUrl: options.dashboardUrl,
178
+ cliVersion: LOCAL_COLLECTOR_VERSION,
179
+ osPlatform: os.platform(),
180
+ },
181
+ });
182
+ worker.unref();
183
+ worker.on("error", (error) => {
184
+ console.error(`[cockpit ${options.command}] guard backstop crashed`, JSON.stringify({ reason: "backstop_worker_error", error_name: error.name }));
185
+ });
186
+ return worker;
187
+ }
188
+ catch (error) {
189
+ console.error(`[cockpit ${options.command}] guard backstop not started`, JSON.stringify({ reason: "backstop_worker_unavailable", error_name: error instanceof Error ? error.name : typeof error }));
190
+ return null;
191
+ }
192
+ }
193
+ /** Runs `body` with the guard armed, always disarming it afterwards. */
194
+ export async function withCommandGuard(options, body) {
195
+ const stop = startCommandGuard(options);
196
+ try {
197
+ return await body();
198
+ }
199
+ finally {
200
+ stop();
201
+ }
202
+ }
@@ -53,6 +53,9 @@ import { uploadBackfillBatches } from "./backfill-upload.js";
53
53
  // which sibling implements one is this module's business, not theirs.
54
54
  export { DEFAULT_BACKFILL_SINCE_DAYS, backfillRetryCommand } from "./backfill-command-line.js";
55
55
  export { buildBackfillSessionReport } from "./backfill-session-report.js";
56
+ import { withCommandGuard } from "../command-guard.js";
57
+ import { installHttp1GlobalFetch } from "../http1-fetch.js";
58
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
56
59
  export async function runBackfillCommand(command, io) {
57
60
  // A bare `cockpit backfill` used to refuse and print three lines telling the
58
61
  // operator to pick a window. That put a mandatory flag on the command that
@@ -78,7 +81,9 @@ export async function runBackfillCommand(command, io) {
78
81
  }
79
82
  return 1;
80
83
  }
81
- const result = await runBackfill(effective, io);
84
+ // BLI-23313: same HTTP/1.1 transport and memory ceiling as the sync tick.
85
+ installHttp1GlobalFetch();
86
+ const result = await withCommandGuard({ command: "backfill", paths: getCollectorRuntimePaths(effective.homeDir), dashboardUrl: DEFAULT_DASHBOARD_URL }, () => runBackfill(effective, io));
82
87
  if (effective.json) {
83
88
  writeLine(io.stdout, JSON.stringify(result, null, 2));
84
89
  return result.status === "complete" ? 0 : 1;
@@ -197,7 +197,9 @@ export function defaultIo() {
197
197
  stdout: process.stdout,
198
198
  stderr: process.stderr,
199
199
  env: process.env,
200
- fetch: globalThis.fetch.bind(globalThis),
200
+ // Late-bound (BLI-23313): `installHttp1GlobalFetch()` swaps globalThis.fetch for the
201
+ // HTTP/1.1 transport when a long-lived command starts; a bound copy would miss it.
202
+ fetch: ((input, init) => globalThis.fetch(input, init)),
201
203
  exec: defaultExec(),
202
204
  interactiveExec: defaultInteractiveExec(),
203
205
  };
@@ -92,7 +92,11 @@ export function footprintBuckets(footprint) {
92
92
  */
93
93
  export function stagingPausedWords(gate, holder) {
94
94
  if (gate.reason === "low_disk") {
95
- return `Tower paused copying new evidence: the disk has ${gib(gate.free_bytes ?? 0)} GB free. Nothing is lost; it resumes when space returns.`;
95
+ // BLI-23181: the size of the disk, the floor it is under, and Tower's own
96
+ // share, so "is it us?" is answered on the row that raises it.
97
+ const of = typeof gate.total_bytes === "number" ? ` of ${gib(gate.total_bytes)} GB` : "";
98
+ const floor = typeof gate.floor_bytes === "number" ? `, under the ${gib(gate.floor_bytes)} GB Tower leaves free` : "";
99
+ return `Tower paused copying new evidence: the disk has ${gib(gate.free_bytes ?? 0)} GB free${of}${floor}; Tower itself keeps ${mib(gate.collector_bytes)} MB here. Nothing is lost; it resumes when space returns.`;
96
100
  }
97
101
  if (gate.reason === "state_over_ceiling") {
98
102
  return `Tower paused copying new evidence: everything it keeps here is ${mib(gate.collector_bytes)} MB, over its ${mib(gate.ceiling_bytes)} MB ceiling${holder ? `, mostly ${holder.label} (${mib(holder.bytes)} MB)` : ""}. Nothing is lost; it resumes when that shrinks.`;
@@ -1,6 +1,8 @@
1
1
  import { checkSlackConnected } from "./doctor-slack.js";
2
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
3
- import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
4
+ import { withCommandGuard } from "../command-guard.js";
5
+ import { installHttp1GlobalFetch } from "../http1-fetch.js";
4
6
  import { isSemverBelow } from "../scheduled-self-update.js";
5
7
  import { describeError } from "../health-detail.js";
6
8
  import { withStage } from "../crash-guard.js";
@@ -17,7 +19,12 @@ import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
17
19
  import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
18
20
  export async function runDoctor(command, io, hooks, overrides = {}) {
19
21
  const deps = { ...defaultDoctorDeps(hooks), ...overrides };
20
- return runDoctorWithDeps(command, io, deps);
22
+ // BLI-23313: memory ceiling only (doctor has its own run deadline); a test
23
+ // that injects deps is not guarded.
24
+ if (Object.keys(overrides).length > 0)
25
+ return runDoctorWithDeps(command, io, deps);
26
+ installHttp1GlobalFetch(io.env);
27
+ return withCommandGuard({ command: "doctor", paths: getCollectorRuntimePaths(command.homeDir), dashboardUrl: command.dashboardUrl ?? DEFAULT_DASHBOARD_URL, env: io.env }, () => runDoctorWithDeps(command, io, deps));
21
28
  }
22
29
  export async function runDoctorWithDeps(command, io, deps) {
23
30
  const runStarted = Date.now();
@@ -27,6 +27,7 @@ import path from "node:path";
27
27
  import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
28
28
  import { readCachedSetupReceipt } from "./setup-receipt.js";
29
29
  import { readStagingInventory } from "../disk-usage.js";
30
+ import { readStagingGate } from "../staging-gate.js";
30
31
  import { describeError } from "../health-detail.js";
31
32
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
32
33
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
@@ -123,6 +124,7 @@ export function buildCollectorHeartbeat(options) {
123
124
  ...(options.facts.stagingUncommittedReason
124
125
  ? { staging_uncommitted_reason: options.facts.stagingUncommittedReason }
125
126
  : {}),
127
+ ...(options.facts.disk ? { disk: options.facts.disk } : {}),
126
128
  ...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
127
129
  ...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
128
130
  };
@@ -155,6 +157,44 @@ export async function readHeartbeatStagingFacts(options) {
155
157
  return { bytes: null, reason: null };
156
158
  }
157
159
  }
160
+ /**
161
+ * BLI-23181. "Is it us filling their disk?" in six numbers the server keeps.
162
+ *
163
+ * One live read of the same gate the tick already runs (`staging-gate.ts`):
164
+ * the volume holding the state dir, its floor, Tower's own bytes on it and
165
+ * whether copying is paused. The board used to learn only the label
166
+ * `staging_paused_low_disk`, which cannot tell a 99%-full laptop from a floor
167
+ * that is wrong for that disk. Best-effort: a failure returns null and says
168
+ * so, and the heartbeat omits the field rather than reporting a zero.
169
+ */
170
+ export async function readHeartbeatDiskFacts(options) {
171
+ const paths = getCollectorRuntimePaths(options.homeDir);
172
+ try {
173
+ const gate = await readStagingGate(paths.state_dir, options.env, options.deps);
174
+ const reading = {
175
+ free_bytes: wholeBytes(gate.free_bytes),
176
+ total_bytes: wholeBytes(gate.total_bytes),
177
+ floor_bytes: wholeBytes(gate.floor_bytes),
178
+ collector_bytes: wholeBytes(gate.collector_bytes) ?? 0,
179
+ staging_bytes: wholeBytes(gate.staging_bytes) ?? 0,
180
+ gate: gate.reason ?? "open",
181
+ };
182
+ console.error("[heartbeat] carrying the disk reading", JSON.stringify({
183
+ reason: reading.free_bytes === null ? "disk_free_unreadable" : `disk_gate_${reading.gate}`,
184
+ ...reading,
185
+ }));
186
+ return reading;
187
+ }
188
+ catch (error) {
189
+ console.error("[heartbeat] the disk could not be measured; this tick reports no disk reading", JSON.stringify({ reason: "disk_reading_failed", ...describeError(error) }));
190
+ return null;
191
+ }
192
+ }
193
+ function wholeBytes(value) {
194
+ if (value === null || !Number.isFinite(value) || value < 0)
195
+ return null;
196
+ return Math.min(Math.floor(value), Number.MAX_SAFE_INTEGER);
197
+ }
158
198
  /**
159
199
  * The cached memory receipt for this tick, or null with a named reason
160
200
  * (BLI-3729).
@@ -12,6 +12,7 @@ import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
12
12
  import { assertNoPositionals, normalizeUrl, optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, workRootFlagValue, } from "./local-arg-values.js";
13
13
  import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
14
14
  import { MEMORY_STORE_ACTIONS, parseMemoryStoreArgs, } from "./local-args-tower-memory.js";
15
+ import { parseSessionsForgetArgs } from "./sessions-forget.js";
15
16
  export function parseStatusArgs(args) {
16
17
  const values = parseNamedArgs(args, {
17
18
  allowedFlags: [
@@ -41,6 +42,9 @@ export function parseStatusArgs(args) {
41
42
  };
42
43
  }
43
44
  export function parseSessionsArgs(args) {
45
+ // BLI-23245: `cockpit sessions forget <id>` talks to Tower; plain `cockpit sessions` stays the local scan.
46
+ if (args[0] === "forget")
47
+ return parseSessionsForgetArgs(args.slice(1));
44
48
  const values = parseNamedArgs(args, {
45
49
  allowedFlags: [
46
50
  "--home",
@@ -323,6 +323,14 @@ const COMMAND_HELP = new Map([
323
323
  "missing?\" locally — counts and labels only, never paths or content.",
324
324
  "--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
325
325
  "`--repo <path>` remains supported as a backward-compatible alias.",
326
+ "",
327
+ "Usage: cockpit sessions forget <session-id> [--apply] [--json]",
328
+ "",
329
+ "Founders only: removes one of YOUR OWN sessions from Tower everywhere it was copied",
330
+ "(transcript files, digests, summaries, search rows, usage counts, memories learned from it)",
331
+ "and stops Tower ever taking it again. Without --apply it is a dry run: counts per place,",
332
+ "nothing changed. --apply deletes, then counts again to prove zero. It cannot be undone.",
333
+ "The MCP tool sessions_forget is the dry run only; only this command, typed by you, removes.",
326
334
  ],
327
335
  ],
328
336
  [
@@ -1,6 +1,7 @@
1
1
  import { runOpsCommand } from "./local-run-ops.js";
2
2
  import { runGlossary } from "./glossary.js";
3
3
  import { runSpend } from "./spend.js";
4
+ import { runSessionsForget } from "./sessions-forget.js";
4
5
  import { runPush } from "./push.js";
5
6
  import { defaultIo, writeLine } from "./cli-io.js";
6
7
  import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
@@ -198,6 +199,7 @@ async function runTowerCommand(command, io) {
198
199
  case "correct": return await runCorrect(command, io);
199
200
  case "notes": return await runNotes(command, io);
200
201
  case "glossary": return await runGlossary(command, io);
202
+ case "sessions-forget": return await runSessionsForget(command, io);
201
203
  case "spend": return await runSpend(command, io);
202
204
  case "push": return await runPush(command, io);
203
205
  case "docs": return await runDocs(command, io);
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.262");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.263");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,62 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
3
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
4
+ export const SESSIONS_FORGET_USAGE = "Usage: cockpit sessions forget <session-id> [--apply] [--json]";
5
+ /** Session ids are the Claude/Codex transcript ids: letters, digits and dashes. Anything else is a typo, not an id. */
6
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
7
+ export function parseSessionsForgetArgs(args) {
8
+ const values = parseNamedArgs(args, {
9
+ allowedFlags: ["--apply", "--json", "--home", "--dashboard-url"],
10
+ valueFlags: ["--home", "--dashboard-url"],
11
+ });
12
+ const [sessionId, ...rest] = values.positionals;
13
+ if (!sessionId || rest.length > 0)
14
+ throw new Error(SESSIONS_FORGET_USAGE);
15
+ if (!SESSION_ID.test(sessionId))
16
+ throw new Error(`"${sessionId}" is not a session id. ${SESSIONS_FORGET_USAGE}`);
17
+ return {
18
+ kind: "sessions-forget",
19
+ sessionId,
20
+ apply: values.booleans.has("--apply"),
21
+ json: values.booleans.has("--json"),
22
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
23
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
24
+ };
25
+ }
26
+ function isForgetAnswer(body) {
27
+ const value = body;
28
+ return Boolean(value && value.ok === true && Array.isArray(value.places) && typeof value.message === "string");
29
+ }
30
+ /** The words a founder reads. Only places with something in them, then the one-line verdict from Tower. */
31
+ function printAnswer(io, answer) {
32
+ const found = answer.places.filter((place) => place.before > 0 || (place.after ?? 0) > 0);
33
+ writeLine(io.stdout, answer.mode === "apply" ? `Forgot session ${answer.session_id}.` : `Dry run for session ${answer.session_id}. Nothing was changed.`);
34
+ if (found.length === 0)
35
+ writeLine(io.stdout, " Tower holds nothing for this session.");
36
+ for (const place of found) {
37
+ const after = answer.mode === "apply" ? ` -> ${place.after ?? "?"} left` : "";
38
+ writeLine(io.stdout, ` ${place.label}: ${place.before}${after}`);
39
+ }
40
+ writeLine(io.stdout, answer.message);
41
+ if (answer.mode === "dry_run" && answer.total_before > 0) {
42
+ writeLine(io.stdout, `To remove it for good: cockpit sessions forget ${answer.session_id} --apply`);
43
+ }
44
+ }
45
+ export async function runSessionsForget(command, io) {
46
+ const door = await openAgentDoor("sessions forget", command, io);
47
+ const answer = await askAgentDoor(door, {
48
+ path: "/api/sessions/forget",
49
+ method: "POST",
50
+ label: "sessions forget",
51
+ // An apply removes stored files one batch at a time after the database transaction; give it room.
52
+ timeoutMs: 120_000,
53
+ body: { session_id: command.sessionId, apply: command.apply },
54
+ });
55
+ if (!answer.ok)
56
+ return failAgentDoor(door, "[sessions forget cli]", answer.reason, answer.detail);
57
+ if (command.json || !isForgetAnswer(answer.body))
58
+ return emitAgentDoor(door, answer.body);
59
+ printAnswer(io, answer.body);
60
+ // An apply that could not prove zero is not a success, and the exit code says so.
61
+ return answer.body.mode === "apply" && (answer.body.total_after !== 0 || answer.body.files_left !== 0) ? 1 : 0;
62
+ }
@@ -1,4 +1,4 @@
1
- import { sendCollectorHeartbeatBestEffort, readHeartbeatStagingFacts, } from "./heartbeat.js";
1
+ import { sendCollectorHeartbeatBestEffort, readHeartbeatDiskFacts, readHeartbeatStagingFacts, } from "./heartbeat.js";
2
2
  import { resolveSyncCollectionRoots } from "./sync-roots.js";
3
3
  import { describeError } from "../health-detail.js";
4
4
  /**
@@ -17,6 +17,9 @@ export async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
17
17
  const staging = await readHeartbeatStagingFacts({
18
18
  homeDir: command.homeDir,
19
19
  }).catch(() => ({ bytes: null, reason: null }));
20
+ // BLI-23181: free, total and floor bytes beside Tower's own footprint, so the
21
+ // board can say whether a low-disk pause is a full disk or Tower.
22
+ const disk = await readHeartbeatDiskFacts({ homeDir: command.homeDir, env: io.env }).catch(() => null);
20
23
  await sendCollectorHeartbeatBestEffort({
21
24
  homeDir: command.homeDir,
22
25
  dashboardUrl,
@@ -25,6 +28,7 @@ export async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
25
28
  ...facts,
26
29
  stagingUncommittedBytes: staging.bytes,
27
30
  stagingUncommittedReason: staging.reason,
31
+ disk,
28
32
  },
29
33
  io,
30
34
  }).catch((error) => {
@@ -47,6 +47,8 @@ import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledS
47
47
  import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
48
48
  import { settleTickImageModeBestEffort } from "../image-mode-state.js";
49
49
  import { stampSyncTick } from "../sync-tick-stamp.js";
50
+ import { withCommandGuard } from "../command-guard.js";
51
+ import { installHttp1GlobalFetch } from "../http1-fetch.js";
50
52
  import { startTickTimer } from "../sync-tick-timing.js";
51
53
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
52
54
  /**
@@ -55,7 +57,14 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConf
55
57
  * steps themselves are `runSyncTick`, below.
56
58
  */
57
59
  export async function runSync(command, io) {
60
+ // BLI-23313: HTTP/1.1 only (Node's h2 session grew native memory without
61
+ // bound), then a memory ceiling and a whole-tick deadline.
62
+ installHttp1GlobalFetch(io.env);
58
63
  const tick = startTickTimer();
64
+ const paths = getCollectorRuntimePaths(command.homeDir);
65
+ return withCommandGuard({ command: "sync", paths, dashboardUrl: await tickDashboardUrl(command, paths), env: io.env, phase: () => tick.current() }, () => runTimedSync(command, io, tick));
66
+ }
67
+ async function runTimedSync(command, io, tick) {
59
68
  try {
60
69
  const exitCode = await runSyncTick(command, io, tick);
61
70
  tick.finish(exitCode);
@@ -66,7 +66,7 @@ export async function writeRawEvidenceCursor(paths, state, options = {}) {
66
66
  // Invariant enforcement (D4): the Claude cursor never owns objects.
67
67
  pruned.objects = {};
68
68
  }
69
- const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
69
+ const serialized = `${JSON.stringify(pruned)}\n`; // compact (BLI-23317); readers accept either
70
70
  // Atomic write: a launchd sync and a manual sync can race the same cursor, so
71
71
  // write a sibling temp file, fsync it, and rename over the target. A crash
72
72
  // mid-write leaves either the old or the new state, never a truncated file.
@@ -8,9 +8,9 @@
8
8
  * pack already wrote, and the one path that must actually read bytes
9
9
  * (`hashUnnamedFile`) spends a budget and stops.
10
10
  */
11
- import crypto from "node:crypto";
12
11
  import fs from "node:fs/promises";
13
12
  import path from "node:path";
13
+ import { hashFileStreaming } from "./evidence-upload-staged.js";
14
14
  import { describeError } from "./health-detail.js";
15
15
  /** Bytes this inventory may spend hashing files no manifest names. */
16
16
  export const UNNAMED_FILE_HASH_BUDGET_BYTES = 256 * 1024 * 1024;
@@ -23,11 +23,11 @@ export const UNNAMED_FILE_HASH_BUDGET_BYTES = 256 * 1024 * 1024;
23
23
  export async function hashUnnamedFile(file, size, budget) {
24
24
  if (size > budget.remaining)
25
25
  return null;
26
- const bytes = await fs.readFile(file).catch(() => null);
27
- if (!bytes)
26
+ const hashed = await hashFileStreaming(file);
27
+ if (!hashed)
28
28
  return null;
29
- budget.remaining -= bytes.byteLength;
30
- return crypto.createHash("sha256").update(bytes).digest("hex");
29
+ budget.remaining -= hashed.byteSize;
30
+ return hashed.digest;
31
31
  }
32
32
  /**
33
33
  * A pack's own record of what it staged: pack-relative path -> content hash.
@@ -1,6 +1,7 @@
1
1
  import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, } from "@bli-cockpit/telemetry-core";
2
2
  import { batches, loadPlannedEntries, resolveBeginBatch } from "./evidence-upload-plan.js";
3
- import { summarizeOutcomes, uploadWithLegacyFallback } from "./evidence-upload-terminal.js";
3
+ import { uploadWithLegacyFallback } from "./evidence-upload-legacy.js";
4
+ import { summarizeOutcomes } from "./evidence-upload-terminal.js";
4
5
  import { NON_JSON_RESPONSE_BODY_KEY } from "./evidence-upload-transport.js";
5
6
  export async function uploadRawEvidenceFilesChunked(options) {
6
7
  const outcomes = [];