@bli-cockpit/cli 0.2.154 → 0.2.156
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/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-collection-state.js +1 -0
- package/dist/adapters/raw-evidence-snapshot-supersede.js +29 -0
- package/dist/adapters/raw-evidence-transcript-file.js +13 -3
- package/dist/commands/backfill-checkpoint.js +12 -3
- package/dist/commands/backfill-report.js +12 -8
- package/dist/commands/backfill-scan.js +14 -0
- package/dist/commands/backfill-stub-reoffer.js +104 -0
- package/dist/commands/backfill-upload.js +4 -0
- package/dist/commands/backfill.js +5 -0
- package/dist/commands/clean.js +31 -13
- package/dist/commands/doctor-disk-words.js +81 -3
- package/dist/commands/doctor-pipeline.js +44 -22
- package/dist/commands/heartbeat.js +1 -1
- package/dist/commands/local-help-commands.js +7 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/status-disk-line.js +22 -0
- package/dist/commands/status.js +5 -2
- package/dist/commands/sync-followups-staging.js +13 -10
- package/dist/commands/sync.js +7 -4
- package/dist/cursors/raw-evidence-cursor.js +13 -0
- package/dist/disk-prune.js +19 -23
- package/dist/disk-retention.js +12 -9
- package/dist/disk-usage-footprint.js +10 -4
- package/dist/evidence-reconcile-client.js +2 -2
- package/dist/evidence-stubs-client.js +115 -0
- package/dist/log-rotation.js +31 -10
- package/dist/prune-receipt.js +87 -0
- package/dist/raw-evidence-gc.js +55 -15
- package/dist/state-dir-audit.js +138 -0
- package/dist/upload-envelope-build.js +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks `POST /api/ambient/evidence/stubs` which of this operator's stored
|
|
3
|
+
* transcripts are only the redaction-crash stub, and groups the answer by agent
|
|
4
|
+
* session (BLI-11102).
|
|
5
|
+
*
|
|
6
|
+
* A stub is a promise to come back, not a delivery: the local cursor recorded
|
|
7
|
+
* an upload, so nothing local would ever offer that session again. The server
|
|
8
|
+
* holds the label, so the server is asked. `cockpit backfill` re-offers every
|
|
9
|
+
* session this returns.
|
|
10
|
+
*
|
|
11
|
+
* Never throws. A door that cannot be asked (an older dashboard answers 404, a
|
|
12
|
+
* network error, a malformed page) returns `unavailable` with the reason, and
|
|
13
|
+
* the backfill runs exactly as it did before. That is also the release gate:
|
|
14
|
+
* the collector only ever sends a `stub_replaced` claim for a stub this door
|
|
15
|
+
* named, so it cannot send one to a server that does not know the rule.
|
|
16
|
+
*
|
|
17
|
+
* Bounded: pages of up to 500, at most `maxPages` of them, walked by the
|
|
18
|
+
* server's keyset cursor. The result says whether the list was exhausted,
|
|
19
|
+
* capped by `maxPages`, or cut short by a failed page after some landed.
|
|
20
|
+
*/
|
|
21
|
+
import { EVIDENCE_STUBS_MAX_PAGE, EVIDENCE_STUBS_SCHEMA_VERSION, EvidenceStubsResponseSchema, transcriptSessionFromObjectKey, } from "@bli-cockpit/telemetry-core";
|
|
22
|
+
import { describeError } from "./health-detail.js";
|
|
23
|
+
const STUBS_PAGE_TIMEOUT_MS = 15_000;
|
|
24
|
+
export const STUBS_DEFAULT_MAX_PAGES = 20;
|
|
25
|
+
export function stubSessionKey(source, sessionId) {
|
|
26
|
+
return `${source}:${sessionId}`;
|
|
27
|
+
}
|
|
28
|
+
export async function fetchStubbedSessions(options) {
|
|
29
|
+
const maxPages = options.maxPages ?? STUBS_DEFAULT_MAX_PAGES;
|
|
30
|
+
const sessions = new Map();
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
let stubCount = 0;
|
|
33
|
+
let unkeyed = 0;
|
|
34
|
+
let pages = 0;
|
|
35
|
+
let after = null;
|
|
36
|
+
for (;;) {
|
|
37
|
+
if (pages >= maxPages) {
|
|
38
|
+
return finish("capped", "ok");
|
|
39
|
+
}
|
|
40
|
+
const page = await requestPage({ ...options, after });
|
|
41
|
+
if (!page.ok) {
|
|
42
|
+
return finish(pages === 0 ? "unavailable" : "failed_partial", page.reason);
|
|
43
|
+
}
|
|
44
|
+
pages += 1;
|
|
45
|
+
for (const stub of page.stubs) {
|
|
46
|
+
stubCount += 1;
|
|
47
|
+
if (seen.has(stub.ref_id))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(stub.ref_id);
|
|
50
|
+
const session = transcriptSessionFromObjectKey(stub.object_key);
|
|
51
|
+
if (!session) {
|
|
52
|
+
unkeyed += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const key = stubSessionKey(session.source, session.session_id);
|
|
56
|
+
sessions.set(key, [...(sessions.get(key) ?? []), stub.raw_evidence_pointer_id]);
|
|
57
|
+
}
|
|
58
|
+
// Advance by what the server returned; a page that names no cursor is the last.
|
|
59
|
+
if (!page.nextAfter)
|
|
60
|
+
return finish("exhausted", "ok");
|
|
61
|
+
after = page.nextAfter;
|
|
62
|
+
}
|
|
63
|
+
function finish(status, reason) {
|
|
64
|
+
const result = {
|
|
65
|
+
status,
|
|
66
|
+
reason,
|
|
67
|
+
pages,
|
|
68
|
+
stub_count: stubCount,
|
|
69
|
+
unique_stub_count: seen.size,
|
|
70
|
+
unkeyed_stub_count: unkeyed,
|
|
71
|
+
sessions,
|
|
72
|
+
};
|
|
73
|
+
console.error("[collector stubs] redaction-crash stubs asked", JSON.stringify({
|
|
74
|
+
reason: status === "unavailable" ? `stub_door_unavailable:${reason}` : `stubs_${status}`,
|
|
75
|
+
pages,
|
|
76
|
+
stub_count: stubCount,
|
|
77
|
+
unique_stub_count: seen.size,
|
|
78
|
+
unkeyed_stub_count: unkeyed,
|
|
79
|
+
session_count: sessions.size,
|
|
80
|
+
...(status === "failed_partial" ? { failure: reason } : {}),
|
|
81
|
+
}));
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function requestPage(options) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timeout = setTimeout(() => controller.abort(), STUBS_PAGE_TIMEOUT_MS);
|
|
88
|
+
try {
|
|
89
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/stubs`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
Authorization: `Bearer ${options.deviceToken}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
schema_version: EVIDENCE_STUBS_SCHEMA_VERSION,
|
|
97
|
+
after: options.after,
|
|
98
|
+
limit: EVIDENCE_STUBS_MAX_PAGE,
|
|
99
|
+
}),
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok)
|
|
103
|
+
return { ok: false, reason: `http_${response.status}` };
|
|
104
|
+
const parsed = EvidenceStubsResponseSchema.safeParse(await response.json().catch(() => null));
|
|
105
|
+
if (!parsed.success)
|
|
106
|
+
return { ok: false, reason: "invalid_response" };
|
|
107
|
+
return { ok: true, stubs: parsed.data.stubs, nextAfter: parsed.data.next_after };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return { ok: false, reason: describeError(error).error_name };
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/log-rotation.js
CHANGED
|
@@ -37,9 +37,16 @@ import path from "node:path";
|
|
|
37
37
|
* of a file operators can read. The tail is capped rather than copied whole, so
|
|
38
38
|
* the archives cannot themselves become the disk problem — worst case on disk
|
|
39
39
|
* is (keep + 1) x maxBytes per stream.
|
|
40
|
+
*
|
|
41
|
+
* BLI-11106 tightened the numbers: at most 2 archives and 20 MB of archive per
|
|
42
|
+
* stream (2 x 10 MiB), so both streams together never pass 60 MiB, live logs
|
|
43
|
+
* included. It was 3 x 20 MiB per stream (160 MiB worst case) and the
|
|
44
|
+
* reference Mac held 147 MB of it on 2026-09-30. An archive above the new keep
|
|
45
|
+
* window (`sync.log.3` on every machine that ran the old numbers) is swept on
|
|
46
|
+
* the first tick by `sweepOrphanedLogArchives` below.
|
|
40
47
|
*/
|
|
41
|
-
export const DEFAULT_LOG_ROTATION_MAX_BYTES =
|
|
42
|
-
export const DEFAULT_LOG_ROTATION_KEEP =
|
|
48
|
+
export const DEFAULT_LOG_ROTATION_MAX_BYTES = 10 * 1024 * 1024;
|
|
49
|
+
export const DEFAULT_LOG_ROTATION_KEEP = 2;
|
|
43
50
|
/** The two files launchd writes for the scheduled tick. */
|
|
44
51
|
export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
|
|
45
52
|
/**
|
|
@@ -129,27 +136,41 @@ async function rotateOne(filePath, maxBytes, keep) {
|
|
|
129
136
|
* sentences and an operator is owed the second one.
|
|
130
137
|
*/
|
|
131
138
|
export async function sweepOrphanedLogArchives(paths, options) {
|
|
139
|
+
const swept = [];
|
|
140
|
+
for (const archive of await orphanedLogArchives(paths, options)) {
|
|
141
|
+
const removed = await fs
|
|
142
|
+
.rm(path.join(paths.state_dir, archive.name), { force: true })
|
|
143
|
+
.then(() => true, () => false);
|
|
144
|
+
if (removed)
|
|
145
|
+
swept.push(archive);
|
|
146
|
+
}
|
|
147
|
+
return swept;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The archives the sweep above WOULD remove, without removing them — what
|
|
151
|
+
* `cockpit clean --dry-run` prints (BLI-11106). Same rule, one reader.
|
|
152
|
+
*/
|
|
153
|
+
export async function orphanedLogArchives(paths, options = {
|
|
154
|
+
keep: DEFAULT_LOG_ROTATION_KEEP,
|
|
155
|
+
names: ROTATED_LOG_NAMES,
|
|
156
|
+
}) {
|
|
132
157
|
const entries = await fs
|
|
133
158
|
.readdir(paths.state_dir, { withFileTypes: true })
|
|
134
159
|
.catch(() => []);
|
|
135
|
-
const
|
|
160
|
+
const found = [];
|
|
136
161
|
for (const entry of entries) {
|
|
137
162
|
if (!entry.isFile())
|
|
138
163
|
continue;
|
|
139
164
|
const reason = orphanArchiveReason(entry.name, options);
|
|
140
165
|
if (!reason)
|
|
141
166
|
continue;
|
|
142
|
-
const file = path.join(paths.state_dir, entry.name);
|
|
143
167
|
const size = await fs
|
|
144
|
-
.stat(
|
|
168
|
+
.stat(path.join(paths.state_dir, entry.name))
|
|
145
169
|
.then((info) => info.size)
|
|
146
170
|
.catch(() => 0);
|
|
147
|
-
|
|
148
|
-
if (!removed)
|
|
149
|
-
continue;
|
|
150
|
-
swept.push({ name: entry.name, byte_size: size, reason });
|
|
171
|
+
found.push({ name: entry.name, byte_size: size, reason });
|
|
151
172
|
}
|
|
152
|
-
return
|
|
173
|
+
return found;
|
|
153
174
|
}
|
|
154
175
|
/**
|
|
155
176
|
* Which stream this file belongs to and why it is outside the keep window, or
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `last-prune.json`: when the staging prune last ran and what it freed
|
|
3
|
+
* (BLI-11106).
|
|
4
|
+
*
|
|
5
|
+
* The prune runs on every sync tick, so "did it run?" is answered by a file it
|
|
6
|
+
* overwrites, never by a file that grows. One small JSON object, bounded by
|
|
7
|
+
* construction. `cockpit doctor` and `cockpit status` read it for the
|
|
8
|
+
* footprint line ("last prune: <when>, freed <n> MB") without re-running the
|
|
9
|
+
* prune themselves.
|
|
10
|
+
*
|
|
11
|
+
* It also carries the last time a prune actually freed something, because on
|
|
12
|
+
* a healthy machine the newest run is usually `nothing_to_prune` and "freed
|
|
13
|
+
* 0 MB" on its own cannot tell a working drain from a stuck one.
|
|
14
|
+
*
|
|
15
|
+
* The old once-a-day throttle marker is removed on the first write: it gates
|
|
16
|
+
* nothing any more, and a file that gates nothing is exactly the kind of
|
|
17
|
+
* leftover this ticket exists to stop.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { describeError } from "./health-detail.js";
|
|
22
|
+
export const LAST_PRUNE_FILE = "last-prune.json";
|
|
23
|
+
/** The retired daily throttle marker; deleted on sight. */
|
|
24
|
+
export const LEGACY_STAGING_PRUNE_MARKER = ".last-staging-prune";
|
|
25
|
+
export async function readLastPruneReceipt(paths) {
|
|
26
|
+
const raw = await fs
|
|
27
|
+
.readFile(path.join(paths.state_dir, LAST_PRUNE_FILE), "utf8")
|
|
28
|
+
.catch(() => null);
|
|
29
|
+
if (!raw)
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (typeof parsed.at !== "string" || typeof parsed.reason !== "string") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
at: parsed.at,
|
|
38
|
+
reason: parsed.reason,
|
|
39
|
+
freed_bytes: numberOr0(parsed.freed_bytes),
|
|
40
|
+
deleted_files: numberOr0(parsed.deleted_files),
|
|
41
|
+
bytes_scanned: numberOr0(parsed.bytes_scanned),
|
|
42
|
+
bytes_after: numberOr0(parsed.bytes_after),
|
|
43
|
+
last_freed_at: typeof parsed.last_freed_at === "string" ? parsed.last_freed_at : null,
|
|
44
|
+
last_freed_bytes: numberOr0(parsed.last_freed_bytes),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A torn or hand-edited receipt reads as "never ran"; the next tick
|
|
49
|
+
// overwrites it. Doctor prints "last prune: never" rather than guessing.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Never throws: a receipt that cannot be written is a log line, not a failed tick. */
|
|
54
|
+
export async function writeLastPruneReceipt(paths, result, now) {
|
|
55
|
+
try {
|
|
56
|
+
const previous = await readLastPruneReceipt(paths);
|
|
57
|
+
const freed = result.deleted_bytes > 0;
|
|
58
|
+
const receipt = {
|
|
59
|
+
at: now.toISOString(),
|
|
60
|
+
reason: result.reason,
|
|
61
|
+
freed_bytes: result.deleted_bytes,
|
|
62
|
+
deleted_files: result.deleted_files,
|
|
63
|
+
bytes_scanned: result.bytes_before,
|
|
64
|
+
bytes_after: result.bytes_after,
|
|
65
|
+
last_freed_at: freed ? now.toISOString() : (previous?.last_freed_at ?? null),
|
|
66
|
+
last_freed_bytes: freed
|
|
67
|
+
? result.deleted_bytes
|
|
68
|
+
: (previous?.last_freed_bytes ?? 0),
|
|
69
|
+
};
|
|
70
|
+
await fs.mkdir(paths.state_dir, { recursive: true });
|
|
71
|
+
const file = path.join(paths.state_dir, LAST_PRUNE_FILE);
|
|
72
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
73
|
+
await fs.writeFile(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
74
|
+
await fs.rename(temp, file);
|
|
75
|
+
await fs
|
|
76
|
+
.rm(path.join(paths.state_dir, LEGACY_STAGING_PRUNE_MARKER), { force: true })
|
|
77
|
+
.catch(() => undefined);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error("[collector prune] could not record the last-prune receipt", JSON.stringify({ reason: "last_prune_write_failed", ...describeError(error) }));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function numberOr0(value) {
|
|
84
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
85
|
+
? value
|
|
86
|
+
: 0;
|
|
87
|
+
}
|
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -10,27 +10,33 @@ const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
10
10
|
* observed on the fleet is minutes, not hours.
|
|
11
11
|
*/
|
|
12
12
|
const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The retired once-a-day throttle marker (BLI-11106). The GC runs every tick
|
|
15
|
+
* now, and deletes this file on sight so it stops being state nobody reads.
|
|
16
|
+
*
|
|
17
|
+
* Why every tick is affordable: the GC used to hash every file of every old
|
|
18
|
+
* pack to confirm it was uploaded, which re-read gigabytes on a machine with
|
|
19
|
+
* unconfirmed evidence. It now asks the pack's own `manifest.json` first, and a
|
|
20
|
+
* pack whose manifest names ANY hash the ledger has not committed is skipped
|
|
21
|
+
* without reading a payload byte. Only a pack the manifest says is fully
|
|
22
|
+
* committed is hashed, once, on the way to being deleted.
|
|
23
|
+
*/
|
|
24
|
+
export const LEGACY_GC_THROTTLE_MARKER = ".last-raw-evidence-gc";
|
|
17
25
|
export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
|
|
18
26
|
if (env["COCKPIT_DISABLE_GC"] === "1") {
|
|
19
27
|
return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
|
|
20
28
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
|
|
25
|
-
}
|
|
26
|
-
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
27
|
-
await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
|
|
29
|
+
await fs
|
|
30
|
+
.rm(path.join(paths.state_dir, LEGACY_GC_THROTTLE_MARKER), { force: true })
|
|
31
|
+
.catch(() => undefined);
|
|
28
32
|
const cursor = await readRawEvidenceCursor(paths);
|
|
29
33
|
const uploadedHashes = new Set(Object.keys(cursor.objects));
|
|
30
34
|
const rawEvidenceRoot = path.join(paths.state_dir, "raw-evidence");
|
|
31
35
|
const entries = await fs.readdir(rawEvidenceRoot, { withFileTypes: true }).catch(() => []);
|
|
32
36
|
let removedDirs = 0;
|
|
33
37
|
let freedBytes = 0;
|
|
38
|
+
let scannedDirs = 0;
|
|
39
|
+
let skippedByManifest = 0;
|
|
34
40
|
for (const entry of entries) {
|
|
35
41
|
if (!entry.isDirectory() || !entry.name.startsWith("work-"))
|
|
36
42
|
continue;
|
|
@@ -40,6 +46,11 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
|
|
|
40
46
|
continue;
|
|
41
47
|
if (now.getTime() - dirStats.mtimeMs < RAW_EVIDENCE_RETENTION_MS)
|
|
42
48
|
continue;
|
|
49
|
+
scannedDirs += 1;
|
|
50
|
+
if (await manifestNamesUncommitted(dir, uploadedHashes)) {
|
|
51
|
+
skippedByManifest += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
43
54
|
const inspection = await inspectRawEvidenceDirForGc(dir, uploadedHashes);
|
|
44
55
|
if (!inspection.deletable)
|
|
45
56
|
continue;
|
|
@@ -50,12 +61,18 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
|
|
|
50
61
|
freedBytes += inspection.byteSize;
|
|
51
62
|
}
|
|
52
63
|
// Log capping used to live here, and that is why it did not work: this GC
|
|
53
|
-
//
|
|
64
|
+
// was throttled to once a day and is skipped entirely by COCKPIT_DISABLE_GC,
|
|
54
65
|
// while sync.log grows ~8 MB an hour on an active machine (measured on the
|
|
55
66
|
// reference Mac, 2026-09-04: 219,866,695 bytes with the last GC 19 hours
|
|
56
|
-
// earlier).
|
|
57
|
-
//
|
|
58
|
-
|
|
67
|
+
// earlier). One owner now, running in the tick preamble: log-rotation.ts
|
|
68
|
+
// (BLI-3553).
|
|
69
|
+
console.error("[raw-evidence] gc sweep", JSON.stringify({
|
|
70
|
+
reason: removedDirs > 0 ? "gc_removed" : "nothing_to_prune",
|
|
71
|
+
scanned_dirs: scannedDirs,
|
|
72
|
+
skipped_by_manifest: skippedByManifest,
|
|
73
|
+
removed_dirs: removedDirs,
|
|
74
|
+
freed_bytes: freedBytes,
|
|
75
|
+
}));
|
|
59
76
|
return {
|
|
60
77
|
skipped: false,
|
|
61
78
|
removed_dirs: removedDirs,
|
|
@@ -241,6 +258,29 @@ async function dirByteSize(dir) {
|
|
|
241
258
|
}
|
|
242
259
|
return total;
|
|
243
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* True when the pack's own manifest names a content hash the ledger has not
|
|
263
|
+
* committed, so the pack cannot be deleted and no payload needs reading. A
|
|
264
|
+
* missing or unreadable manifest answers false and falls through to the full
|
|
265
|
+
* hash check, which is the old, conservative path.
|
|
266
|
+
*/
|
|
267
|
+
async function manifestNamesUncommitted(dir, uploadedHashes) {
|
|
268
|
+
const manifest = await fs
|
|
269
|
+
.readFile(path.join(dir, "manifest.json"), "utf8")
|
|
270
|
+
.catch(() => null);
|
|
271
|
+
if (!manifest)
|
|
272
|
+
return false;
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(manifest);
|
|
275
|
+
return (parsed.files ?? []).some((file) => typeof file.content_hash_sha256 === "string" &&
|
|
276
|
+
!uploadedHashes.has(file.content_hash_sha256));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Not silent in effect: an unparseable manifest falls through to the full
|
|
280
|
+
// per-file hash check below, which decides on the bytes themselves.
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
244
284
|
async function inspectRawEvidenceDirForGc(dir, uploadedHashes) {
|
|
245
285
|
const files = await listFiles(dir).catch(() => null);
|
|
246
286
|
if (!files)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the collector's state directory holds, sorted into who owns it
|
|
3
|
+
* and what bounds it (BLI-11106).
|
|
4
|
+
*
|
|
5
|
+
* `~/.local/state/bli-cockpit` (Windows: `%USERPROFILE%\.local\state\bli-cockpit`,
|
|
6
|
+
* the same layout with backslashes) is where the collector keeps its state.
|
|
7
|
+
* By 2026-09-30 it also held a gigabyte of study artifacts written by agents
|
|
8
|
+
* running benches and experiments (`memory-bench`, `memory-extract-*`,
|
|
9
|
+
* `chat-model-bench`, …). Those are not the collector's, the collector never
|
|
10
|
+
* reads them, and counting them as its footprint made `cockpit doctor`
|
|
11
|
+
* alarm about bytes no prune could ever explain.
|
|
12
|
+
*
|
|
13
|
+
* The rule: study artifacts belong in the SIBLING root
|
|
14
|
+
* `~/.local/state/bli-cockpit-studies` (same parent, same layout on Windows),
|
|
15
|
+
* see `studiesStateDir`. Existing study folders already under the state dir are
|
|
16
|
+
* recognised by name, left exactly where they are, and not counted. Anything
|
|
17
|
+
* the table below does not recognise is counted as `other`, so a new
|
|
18
|
+
* collector file that nobody registered still shows up in the footprint
|
|
19
|
+
* rather than hiding.
|
|
20
|
+
*
|
|
21
|
+
* Every collector entry carries the one-line reason it is bounded; the
|
|
22
|
+
* runbook (`docs/runbooks/cockpit-launchd-sync.md`, "What the state
|
|
23
|
+
* directory holds") prints the same table. Metadata only: entry names are
|
|
24
|
+
* fixed strings, never a person's path.
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs/promises";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { directoryBytes } from "./disk-usage-files.js";
|
|
29
|
+
/** The sibling root where agent-written studies live, never under the collector. */
|
|
30
|
+
export const STUDIES_DIR_SUFFIX = "-studies";
|
|
31
|
+
/**
|
|
32
|
+
* The collector's own entries first (checked before the study prefixes), then
|
|
33
|
+
* the studies. Order matters only where a study prefix could shadow a
|
|
34
|
+
* collector name; none does today, and the test pins that.
|
|
35
|
+
*/
|
|
36
|
+
export const STATE_DIR_RULES = [
|
|
37
|
+
{ name: "raw-evidence", match: "exact", class: "staging", bound: "staged copies go the tick the server commits them; 256 MiB cap; uncommitted evidence is never deleted, only counted" },
|
|
38
|
+
{ name: "sync.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
|
|
39
|
+
{ name: "sync.err.log", match: "prefix", class: "logs", bound: "rotated every tick at 10 MiB, at most 2 archives (20 MB) per stream" },
|
|
40
|
+
{ name: "spool", match: "exact", class: "spool", bound: "upload queue; each entry is deleted once the ingest door accepts it" },
|
|
41
|
+
{ name: "cursors", match: "exact", class: "state", bound: "raw-evidence ledger and reconcile answers capped at 5,000 rows each; backfill cursors one small file each" },
|
|
42
|
+
{ name: "raw-evidence-staging.json", match: "exact", class: "state", bound: "staged-object index and delivery attempts capped at 5,000 rows each" },
|
|
43
|
+
{ name: "work-contexts", match: "exact", class: "state", bound: "one small file per worktree ever bound with `cockpit start`; overwritten, not pruned (a few KB each)" },
|
|
44
|
+
{ name: "active-work-context.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
45
|
+
{ name: "root-work-scan.json", match: "exact", class: "state", bound: "one file, overwritten by each root scan" },
|
|
46
|
+
{ name: "setup-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
47
|
+
{ name: "memory-install-receipt.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
48
|
+
{ name: "last-prune.json", match: "exact", class: "state", bound: "one file, overwritten every tick" },
|
|
49
|
+
{ name: "last-turn-trace.json", match: "exact", class: "state", bound: "one file, overwritten" },
|
|
50
|
+
{ name: "sync.lock", match: "exact", class: "state", bound: "lock file, removed when the tick ends" },
|
|
51
|
+
{ name: "backfill.lock", match: "exact", class: "state", bound: "lock file, removed when the backfill ends" },
|
|
52
|
+
{ name: ".last-", match: "prefix", class: "state", bound: "24-byte throttle markers for self-update, autostart repair and the memory install" },
|
|
53
|
+
{ name: "read-cache", match: "exact", class: "state", bound: "one response file per distinct Tower read, overwritten on refresh" },
|
|
54
|
+
{ name: "memory-hook-stats.json", match: "exact", class: "state", bound: "fixed-size histograms, overwritten (BLI Memory hooks)" },
|
|
55
|
+
{ name: "memory-hook-samples", match: "exact", class: "state", bound: "hourly content-free timing samples (BLI Memory hooks); not yet pruned, a few MB a week" },
|
|
56
|
+
{ name: "memory-recall-seen", match: "exact", class: "state", bound: "one file per agent session, deleted after 7 days (BLI Memory hooks)" },
|
|
57
|
+
{ name: "memory-stop-anchors.json", match: "exact", class: "state", bound: "one offset per transcript, overwritten (BLI Memory hooks)" },
|
|
58
|
+
{ name: "memory-daemon-spawn.json", match: "exact", class: "state", bound: "one spawn stamp, overwritten (BLI Memory daemon)" },
|
|
59
|
+
{ name: "memory-daemon.sock", match: "exact", class: "state", bound: "the daemon's socket, zero bytes" },
|
|
60
|
+
// Studies written by agents and scripts, not by the collector. Counted by
|
|
61
|
+
// name only, never sized, never deleted.
|
|
62
|
+
{ name: "memory-bench", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
63
|
+
{ name: "memory-extract-", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
64
|
+
{ name: "memory-questions", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
65
|
+
{ name: "memory-hook-context-sweep", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
66
|
+
{ name: "memory-anchor-probe", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
67
|
+
{ name: "memory-dup-threshold", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
68
|
+
{ name: "memory-judge-effort", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
69
|
+
{ name: "memory-recurrence-backfill", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
70
|
+
{ name: "memory-supersede-from-record", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
71
|
+
{ name: "memory-reconcile-precision", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
72
|
+
{ name: "memory-harvest-dedup", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
73
|
+
{ name: "chat-model-bench", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
74
|
+
{ name: "gpt6-bench-", match: "prefix", class: "study", bound: "agent study; belongs in bli-cockpit-studies" },
|
|
75
|
+
{ name: "manual-study-evidence-vault", match: "prefix", class: "study", bound: "hand-made evidence vault; `cockpit clean --all-committed` removes it once every file is committed" },
|
|
76
|
+
{ name: "issue-import", match: "exact", class: "study", bound: "one-off import receipts; belongs in bli-cockpit-studies" },
|
|
77
|
+
{ name: "analysis", match: "exact", class: "study", bound: "local harvest-analysis output; belongs in bli-cockpit-studies" },
|
|
78
|
+
{ name: "task-type-backlog", match: "prefix", class: "study", bound: "local harvest-analysis output; belongs in bli-cockpit-studies" },
|
|
79
|
+
];
|
|
80
|
+
/** Which rule an entry name falls under, or null for `other`. */
|
|
81
|
+
export function classifyStateEntry(name) {
|
|
82
|
+
for (const rule of STATE_DIR_RULES) {
|
|
83
|
+
if (rule.match === "exact" ? name === rule.name : name.startsWith(rule.name)) {
|
|
84
|
+
return rule;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The sibling studies root, on either host family: the state dir's own path
|
|
91
|
+
* with `-studies` appended, so `/Users/x/.local/state/bli-cockpit` becomes
|
|
92
|
+
* `/Users/x/.local/state/bli-cockpit-studies` and
|
|
93
|
+
* `C:\Users\x\.local\state\bli-cockpit` becomes
|
|
94
|
+
* `C:\Users\x\.local\state\bli-cockpit-studies`. No separator is joined, so
|
|
95
|
+
* neither layout can be spelled wrong.
|
|
96
|
+
*/
|
|
97
|
+
export function studiesStateDir(paths) {
|
|
98
|
+
return `${paths.state_dir.replace(/[\\/]+$/u, "")}${STUDIES_DIR_SUFFIX}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sizes the `state` and `other` entries of the state directory. Staging,
|
|
102
|
+
* logs and spool are measured by their own readers and skipped here; studies
|
|
103
|
+
* are counted but never walked, because a gigabyte of somebody's bench run is
|
|
104
|
+
* not worth a stat per file on every doctor call.
|
|
105
|
+
*/
|
|
106
|
+
export async function readStateDirFootprint(paths) {
|
|
107
|
+
const footprint = {
|
|
108
|
+
state_bytes: 0,
|
|
109
|
+
other_bytes: 0,
|
|
110
|
+
other_entries: 0,
|
|
111
|
+
study_entries: 0,
|
|
112
|
+
};
|
|
113
|
+
const entries = await fs
|
|
114
|
+
.readdir(paths.state_dir, { withFileTypes: true })
|
|
115
|
+
.catch(() => []);
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const rule = classifyStateEntry(entry.name);
|
|
118
|
+
const cls = rule?.class ?? "other";
|
|
119
|
+
if (cls === "staging" || cls === "logs" || cls === "spool")
|
|
120
|
+
continue;
|
|
121
|
+
if (cls === "study") {
|
|
122
|
+
footprint.study_entries += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const full = path.join(paths.state_dir, entry.name);
|
|
126
|
+
const bytes = entry.isDirectory()
|
|
127
|
+
? await directoryBytes(full)
|
|
128
|
+
: await fs.stat(full).then((info) => info.size, () => 0);
|
|
129
|
+
if (cls === "state") {
|
|
130
|
+
footprint.state_bytes += bytes;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
footprint.other_bytes += bytes;
|
|
134
|
+
footprint.other_entries += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return footprint;
|
|
138
|
+
}
|
|
@@ -125,6 +125,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
125
125
|
operatorId: session.operator_id,
|
|
126
126
|
workContextId: uploadContext.work_context_id,
|
|
127
127
|
})),
|
|
128
|
+
rawEvidenceStubReoffers: options.rawEvidenceStubReoffers,
|
|
128
129
|
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
129
130
|
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
130
131
|
rawEvidenceBudget: options.rawEvidenceBudget,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.156",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.42",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.76",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.54"
|
|
33
33
|
}
|
|
34
34
|
}
|