@bli-cockpit/cli 0.2.261 → 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.
- package/dist/adapters/transcript-read-memo.js +1 -1
- package/dist/command-guard.js +202 -0
- package/dist/commands/backfill.js +6 -1
- package/dist/commands/cli-io.js +3 -1
- package/dist/commands/doctor-disk-words.js +5 -1
- package/dist/commands/doctor.js +9 -2
- package/dist/commands/heartbeat.js +40 -0
- package/dist/commands/local-args-collector-status.js +4 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +7 -1
- package/dist/commands/local-help-commands.js +8 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions-forget.js +62 -0
- package/dist/commands/slack-privacy-args.js +28 -0
- package/dist/commands/slack-privacy.js +31 -0
- package/dist/commands/slack.js +3 -0
- package/dist/commands/sync-heartbeat.js +5 -1
- package/dist/commands/sync.js +9 -0
- package/dist/cursors/raw-evidence-cursor.js +1 -1
- package/dist/disk-usage-files.js +5 -5
- package/dist/evidence-upload-client.js +2 -1
- package/dist/evidence-upload-legacy.js +160 -0
- package/dist/evidence-upload-object.js +19 -10
- package/dist/evidence-upload-plan.js +20 -16
- package/dist/evidence-upload-staged.js +115 -0
- package/dist/evidence-upload-terminal.js +14 -152
- package/dist/http1-fetch.js +243 -0
- package/dist/local-state-files.js +4 -2
- package/dist/raw-evidence-gc.js +5 -5
- package/dist/spool/install-event-outbox.js +30 -0
- package/dist/staging-gate.js +6 -3
- package/dist/sync-tick-timing.js +4 -0
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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;
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -197,7 +197,9 @@ export function defaultIo() {
|
|
|
197
197
|
stdout: process.stdout,
|
|
198
198
|
stderr: process.stderr,
|
|
199
199
|
env: process.env,
|
|
200
|
-
|
|
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
|
-
|
|
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.`;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
@@ -19,6 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
|
|
|
19
19
|
import { parseSpendArgs } from "./spend.js";
|
|
20
20
|
import { parsePushArgs } from "./push.js";
|
|
21
21
|
import { parseRootsArgs } from "./roots.js";
|
|
22
|
+
import { parseSlackPrivacyArgs } from "./slack-privacy-args.js";
|
|
22
23
|
import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
|
|
23
24
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
24
25
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
@@ -67,7 +68,8 @@ const COMMAND_PARSERS = new Map([
|
|
|
67
68
|
["ci", (_command, rest) => parseCiArgs(rest)],
|
|
68
69
|
["delivery", (_command, rest) => parseDeliveryArgs(rest)],
|
|
69
70
|
["ops", (_command, rest) => parseOpsArgs(rest)],
|
|
70
|
-
|
|
71
|
+
// BLI-23244: `slack privacy` has its own flags and its own file.
|
|
72
|
+
["slack", (_command, rest) => (rest.includes("privacy") ? parseSlackPrivacyArgs(rest.filter((arg) => arg !== "privacy")) : parseSlackArgs(rest))],
|
|
71
73
|
["settings", (_command, rest) => parseSettingsArgs(rest)],
|
|
72
74
|
["team", (_command, rest) => parseTeamArgs(rest)],
|
|
73
75
|
["workbook", (_command, rest) => parseWorkbookArgs(rest)],
|
|
@@ -304,7 +304,13 @@ export const TOWER_COMMAND_HELP = [
|
|
|
304
304
|
[
|
|
305
305
|
"slack",
|
|
306
306
|
[
|
|
307
|
-
"Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full]] [--json]",
|
|
307
|
+
"Usage: cockpit slack [connect [--workspace bli|blue_pearl] | disconnect --workspace bli|blue_pearl | coverage [--workspace bli|blue_pearl] [--stale-only] [--list] | read [filters] [--format compact|full] | privacy] [--json]",
|
|
308
|
+
"",
|
|
309
|
+
" cockpit slack privacy (founders only)",
|
|
310
|
+
" Lists which Slack conversations Tower keeps out and lets in, and the founders' Slack ids.",
|
|
311
|
+
" A DM or group DM that includes a founder, and any channel a founder excludes, never enters",
|
|
312
|
+
" Tower unless a founder lets it in. Ids only (C…, D…, G…), never names. Changing a list needs",
|
|
313
|
+
" a signed-in browser session (ticket 23265), never this machine's token. Anyone else: owner_only.",
|
|
308
314
|
"",
|
|
309
315
|
" cockpit slack coverage",
|
|
310
316
|
" What Cockpit's Slack bot can see: how many channels it is in, what each",
|
|
@@ -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
|
[
|
package/dist/commands/local.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit slack privacy` arguments (BLI-23244): a founder LISTS their Slack privacy lists (which conversations Tower
|
|
3
|
+
* keeps out or lets in, and the founders' Slack identities). Ids only, never names.
|
|
4
|
+
*
|
|
5
|
+
* Read-only on purpose. Changing a list needs a signed-in browser session, so that an agent holding a founder's device
|
|
6
|
+
* token can never opt a founder's conversation in; the server refuses device-token writes. The browser card is
|
|
7
|
+
* ticket 23265. Split from `local-args-tower-admin.ts`, which is at this repo's readability band.
|
|
8
|
+
*/
|
|
9
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
10
|
+
const VALUE_FLAGS = ["--home", "--dashboard-url"];
|
|
11
|
+
const WRITE_FLAGS = ["--exclude", "--optin", "--remove", "--identity", "--no-account"];
|
|
12
|
+
const ALLOWED_FLAGS = [...VALUE_FLAGS, "--json"];
|
|
13
|
+
export const SLACK_PRIVACY_WRITE_REFUSAL = "slack privacy only lists. Changing a founder privacy list needs a signed-in browser session, so an agent holding this machine's token can never opt a founder's conversation in (the Settings card is ticket 23265).";
|
|
14
|
+
export function parseSlackPrivacyArgs(args) {
|
|
15
|
+
if (args.some((arg) => WRITE_FLAGS.includes(arg)))
|
|
16
|
+
throw new Error(SLACK_PRIVACY_WRITE_REFUSAL);
|
|
17
|
+
const values = parseNamedArgs(args, { allowedFlags: ALLOWED_FLAGS, valueFlags: VALUE_FLAGS });
|
|
18
|
+
if (values.positionals.length > 0)
|
|
19
|
+
throw new Error("slack privacy takes no arguments; it lists the founder privacy lists.");
|
|
20
|
+
return {
|
|
21
|
+
kind: "slack",
|
|
22
|
+
action: "privacy",
|
|
23
|
+
privacy: { op: "list" },
|
|
24
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
25
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
26
|
+
json: values.booleans.has("--json"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
|
|
3
|
+
export async function runSlackPrivacy(command, io) {
|
|
4
|
+
const tower = await openTower("slack", command, io);
|
|
5
|
+
const result = await callTower(tower, { path: "/api/slack/founder-privacy", label: "slack-privacy" });
|
|
6
|
+
if (!result.ok)
|
|
7
|
+
return writeCommandFailure(io, command.json, result);
|
|
8
|
+
writeLine(io.stderr, `[slack cli] privacy answered ${JSON.stringify({ op: "list", reason: "ok" })}`);
|
|
9
|
+
const body = asRecord(result.body);
|
|
10
|
+
if (command.json) {
|
|
11
|
+
writeLine(io.stdout, JSON.stringify(body));
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
for (const line of describe(body))
|
|
15
|
+
writeLine(io.stdout, line);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
/** Plain lines for a person. Exported for its test. */
|
|
19
|
+
export function describe(body) {
|
|
20
|
+
const rows = (key) => (Array.isArray(body[key]) ? body[key] : []);
|
|
21
|
+
const mark = (row) => ` ${String(row["conversationId"])} (${String(row["workspace"])}) since ${String(row["addedAt"]).slice(0, 10)}${row["addedByYou"] ? " — you" : " — the other founder"}`;
|
|
22
|
+
const identities = rows("identities");
|
|
23
|
+
return [
|
|
24
|
+
`Kept out (${rows("exclusions").length}):`, ...rows("exclusions").map(mark),
|
|
25
|
+
`Let in (${rows("optins").length}):`, ...rows("optins").map(mark),
|
|
26
|
+
`Founder Slack ids stated (${identities.length}):`,
|
|
27
|
+
...identities.map((row) => ` ${String(row["workspace"])}: ${row["slackUserId"] ? String(row["slackUserId"]) : "no account"}${row["yours"] ? " — you" : ""}`),
|
|
28
|
+
"Any DM or group DM that includes a founder is kept out unless it is on the let-in list.",
|
|
29
|
+
"To change a list, use Tower in the browser (ticket 23265); this command only lists.",
|
|
30
|
+
];
|
|
31
|
+
}
|
package/dist/commands/slack.js
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { colorEnabled, dim, writeLine } from "./cli-io.js";
|
|
24
24
|
import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
|
|
25
|
+
import { runSlackPrivacy } from "./slack-privacy.js";
|
|
25
26
|
export async function runSlack(command, io) {
|
|
27
|
+
if (command.action === "privacy")
|
|
28
|
+
return runSlackPrivacy(command, io);
|
|
26
29
|
const tower = await openTower("slack", command, io);
|
|
27
30
|
if (command.action === "connect" || command.action === "disconnect") {
|
|
28
31
|
const result = await callTower(tower, { path: `/api/slack/connections${command.action === "disconnect" ? `?workspace=${command.workspace}` : ""}`,
|
|
@@ -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) => {
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
|
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.
|