@bli-cockpit/cli 0.2.207 → 0.2.210
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/raw-evidence-sanitize.js +12 -2
- package/dist/commands/install-receipts.js +27 -16
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/settings-render.js +4 -0
- package/dist/commands/sources-check.js +15 -4
- package/dist/commands/sources-verdict.js +125 -14
- package/dist/commands/sync.js +8 -1
- package/dist/image-description-session.js +41 -5
- package/dist/image-description-state.js +47 -3
- package/dist/image-mode-state.js +154 -0
- package/dist/local-state-files.js +20 -0
- package/package.json +4 -4
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { sha256 } from "./raw-evidence-keys.js";
|
|
3
3
|
import { describeError } from "../health-detail.js";
|
|
4
|
+
/**
|
|
5
|
+
* The one line a whole file becomes when masking its secret-bearing lines still leaves something
|
|
6
|
+
* secret-looking. It carries none of the file's content, so a reader that compares sizes or hashes
|
|
7
|
+
* against it (`cockpit sources check`) must treat it as "no redacted form", never as the file.
|
|
8
|
+
*/
|
|
9
|
+
export const WHOLE_FILE_MASK_LINE = "[REDACTED_LINE:secret_redaction_failed]\n";
|
|
10
|
+
/** True when these upload bytes are the whole-file mask, not a redacted copy of the content. */
|
|
11
|
+
export function isWholeFileMask(bytes) {
|
|
12
|
+
return Buffer.from(bytes).toString("utf8") === WHOLE_FILE_MASK_LINE;
|
|
13
|
+
}
|
|
4
14
|
/** BLI-3238: a crashed redactor has to say so; see the catch below. */
|
|
5
15
|
export function sanitizeTextEvidenceForUpload(options) {
|
|
6
16
|
const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
|
|
@@ -75,7 +85,7 @@ function maskFileWithSecretLikeName(input) {
|
|
|
75
85
|
? input.redactionResult.text
|
|
76
86
|
: input.text;
|
|
77
87
|
const safeText = containsSecretLikeContent(sanitizedText)
|
|
78
|
-
?
|
|
88
|
+
? WHOLE_FILE_MASK_LINE
|
|
79
89
|
: sanitizedText;
|
|
80
90
|
const sanitizedBytes = Buffer.from(safeText, "utf8");
|
|
81
91
|
return {
|
|
@@ -101,7 +111,7 @@ function maskSecretBearingLinesOnly(input) {
|
|
|
101
111
|
});
|
|
102
112
|
let maskedText = maskSecretBearingLines(input.text, redaction);
|
|
103
113
|
if (containsSecretLikeContent(maskedText)) {
|
|
104
|
-
maskedText =
|
|
114
|
+
maskedText = WHOLE_FILE_MASK_LINE;
|
|
105
115
|
}
|
|
106
116
|
const sanitizedBytes = Buffer.from(maskedText, "utf8");
|
|
107
117
|
return {
|
|
@@ -136,11 +136,15 @@ async function deliverPendingInstallEvents(options, paths, deviceToken) {
|
|
|
136
136
|
let delivered = 0;
|
|
137
137
|
let observedMinCliVersion = null;
|
|
138
138
|
for (let offset = 0; offset < pending.length; offset += 5) {
|
|
139
|
-
await Promise.all(pending.slice(offset, offset + 5).map((entry) => deliverInstallEventEntry(options.io, paths, entry, deviceToken, failures).then((
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
if (floor
|
|
143
|
-
|
|
139
|
+
await Promise.all(pending.slice(offset, offset + 5).map((entry) => deliverInstallEventEntry(options.io, paths, entry, deviceToken, failures).then((receipt) => {
|
|
140
|
+
if (receipt === undefined)
|
|
141
|
+
return;
|
|
142
|
+
if (receipt.floor != null)
|
|
143
|
+
observedMinCliVersion = receipt.floor;
|
|
144
|
+
if (receipt.imageHandlingMode !== undefined && options.observed) {
|
|
145
|
+
options.observed.imageHandlingMode = receipt.imageHandlingMode;
|
|
146
|
+
}
|
|
147
|
+
delivered += 1;
|
|
144
148
|
})));
|
|
145
149
|
}
|
|
146
150
|
if (delivered > 0) {
|
|
@@ -165,9 +169,9 @@ async function deliverInstallEventEntry(io, paths, entry, deviceToken, failures)
|
|
|
165
169
|
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
166
170
|
try {
|
|
167
171
|
const response = await postInstallEventEntry(io, entry, deviceToken, controller.signal);
|
|
168
|
-
const
|
|
172
|
+
const receipt = await readReceiptReply(response);
|
|
169
173
|
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
170
|
-
return
|
|
174
|
+
return receipt;
|
|
171
175
|
}
|
|
172
176
|
catch (error) {
|
|
173
177
|
await keepFailedInstallEventEntry(paths, entry, error, failures);
|
|
@@ -193,18 +197,25 @@ async function postInstallEventEntry(io, entry, deviceToken, signal) {
|
|
|
193
197
|
throw new Error(`http_${response.status}`);
|
|
194
198
|
return response;
|
|
195
199
|
}
|
|
196
|
-
|
|
200
|
+
/**
|
|
201
|
+
* The reply carries two things the tick listens for: the server-published `min_cli_version` floor
|
|
202
|
+
* (BLI-2678) and this machine's `image_handling_mode` (BLI-17273; undefined when the reply names
|
|
203
|
+
* none, validated by the caller and never here).
|
|
204
|
+
*/
|
|
205
|
+
async function readReceiptReply(response) {
|
|
197
206
|
const receipt = (await response.json().catch((error) => {
|
|
198
|
-
//
|
|
199
|
-
// (
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({ reason: "receipt_body_unreadable", http_status: response.status, ...describeError(error) }));
|
|
207
|
+
// A body that will not parse means the floor and the image mode are not observed on this tick
|
|
208
|
+
// (the forced-update path silently does nothing, the machine keeps its cached mode) while the
|
|
209
|
+
// 2xx above says the receipt landed fine.
|
|
210
|
+
console.error("[install-receipts] receipt body unreadable; no min_cli_version or image_handling_mode observed", JSON.stringify({ reason: "receipt_body_unreadable", http_status: response.status, ...describeError(error) }));
|
|
203
211
|
return null;
|
|
204
212
|
}));
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
return {
|
|
214
|
+
floor: typeof receipt?.min_cli_version === "string" && receipt.min_cli_version.trim()
|
|
215
|
+
? receipt.min_cli_version.trim()
|
|
216
|
+
: null,
|
|
217
|
+
imageHandlingMode: receipt?.image_handling_mode,
|
|
218
|
+
};
|
|
208
219
|
}
|
|
209
220
|
async function keepFailedInstallEventEntry(paths, entry, error, failures) {
|
|
210
221
|
const failureReason = classifyInstallTelemetryError(error);
|
|
@@ -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.210");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -138,6 +138,10 @@ export function renderSwitches(body) {
|
|
|
138
138
|
if (typeof body.readFailureReason === "string") {
|
|
139
139
|
lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
|
|
140
140
|
}
|
|
141
|
+
// "No machine is on slim" must never hide "could not ask" (BLI-17273).
|
|
142
|
+
if (typeof body.collectorImageModeReadFailureReason === "string") {
|
|
143
|
+
lines.push(`${INDENT}machine image modes could not be listed: ${body.collectorImageModeReadFailureReason}`);
|
|
144
|
+
}
|
|
141
145
|
return lines;
|
|
142
146
|
}
|
|
143
147
|
export function renderModelRouting(body) {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { writeLine } from "./cli-io.js";
|
|
2
2
|
import { readDescribedImageKeys } from "../image-description-state.js";
|
|
3
|
+
import { resolveImageHandlingMode } from "../image-mode-state.js";
|
|
3
4
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
4
5
|
import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
|
|
5
6
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
6
7
|
import { discoverLocalTranscripts } from "./sources-discovery.js";
|
|
7
8
|
import { fileFacts, judgeSource } from "./sources-verdict.js";
|
|
8
|
-
const VERDICTS = ["stored_full", "stored_partial", "not_stored"];
|
|
9
|
+
const VERDICTS = ["stored_full", "stored_slim", "stored_partial", "not_stored"];
|
|
9
10
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
11
|
export async function runSourcesCheck(command, io, now = new Date()) {
|
|
11
12
|
const startedAt = Date.now();
|
|
@@ -30,13 +31,18 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
30
31
|
claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
|
|
31
32
|
};
|
|
32
33
|
const describedKeys = new Set(await readDescribedImageKeys(paths.state_dir));
|
|
34
|
+
// BLI-17273: judge against THIS machine's mode (the cache the sync-start receipt wrote, else the
|
|
35
|
+
// fleet default), never the bare constant. A machine that runs or ever ran slim holds slim server
|
|
36
|
+
// copies, which must never read as safe to delete.
|
|
37
|
+
const imageMode = await resolveImageHandlingMode(paths.state_dir);
|
|
38
|
+
console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
|
|
33
39
|
const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
|
|
34
40
|
for (const file of local.files) {
|
|
35
41
|
const judged = await judgeSource({
|
|
36
42
|
file,
|
|
37
43
|
remotes: remote.files.get(file.key) ?? [],
|
|
38
44
|
cursorEntry: file.kind === "claude_subagent" ? null : (cursors[file.source].sessions[file.session_id] ?? null),
|
|
39
|
-
facts: fileFacts(file, undefined, { describedKeys }),
|
|
45
|
+
facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
|
|
40
46
|
}).catch((error) => {
|
|
41
47
|
report.local.unreadable += 1;
|
|
42
48
|
console.error("[collector sources] a transcript could not be judged; left out of the answer", JSON.stringify({ reason: "file_unreadable", kind: file.kind, byte_size: file.byte_size, error_name: error?.name ?? "Error" }));
|
|
@@ -71,6 +77,7 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
71
77
|
newer_skipped: report.local.newer_skipped,
|
|
72
78
|
unreadable: report.local.unreadable,
|
|
73
79
|
stored_full: report.counts.stored_full,
|
|
80
|
+
stored_slim: report.counts.stored_slim,
|
|
74
81
|
stored_partial: report.counts.stored_partial,
|
|
75
82
|
not_stored: report.counts.not_stored,
|
|
76
83
|
reasons: report.reasons,
|
|
@@ -93,6 +100,7 @@ function emptyReport(command, remote, newerSkipped, unreadable) {
|
|
|
93
100
|
local: { examined: 0, newer_skipped: newerSkipped, unreadable },
|
|
94
101
|
counts: {
|
|
95
102
|
stored_full: { files: 0, bytes: 0 },
|
|
103
|
+
stored_slim: { files: 0, bytes: 0 },
|
|
96
104
|
stored_partial: { files: 0, bytes: 0 },
|
|
97
105
|
not_stored: { files: 0, bytes: 0 },
|
|
98
106
|
},
|
|
@@ -115,7 +123,10 @@ export function sourcesCheckLines(report) {
|
|
|
115
123
|
const lines = [
|
|
116
124
|
`Tower sources check: ${scope} on this machine`,
|
|
117
125
|
` stored in full ${report.counts.stored_full.files} file(s), ${mb(report.counts.stored_full.bytes)} MB`,
|
|
118
|
-
|
|
126
|
+
...(report.counts.stored_slim.files > 0
|
|
127
|
+
? [` stored slim ${report.counts.stored_slim.files} file(s), ${mb(report.counts.stored_slim.bytes)} MB (KEEP: Tower holds the slim form, the image bytes are only here)`]
|
|
128
|
+
: []),
|
|
129
|
+
` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
|
|
119
130
|
` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
|
|
120
131
|
];
|
|
121
132
|
const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
|
|
@@ -129,7 +140,7 @@ export function sourcesCheckLines(report) {
|
|
|
129
140
|
if (report.status === "incomplete") {
|
|
130
141
|
lines.push(` INCOMPLETE Tower's list was cut short (${report.reason}); "not stored" and "stored in part" may be understated. Run it again.`);
|
|
131
142
|
}
|
|
132
|
-
lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand; see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
|
|
143
|
+
lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand (never \"stored slim\"); see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
|
|
133
144
|
return lines;
|
|
134
145
|
}
|
|
135
146
|
function mb(bytes) {
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
* grow after its upload, else `remote_smaller`), or its size
|
|
19
19
|
* is not recorded (`remote_size_unknown`).
|
|
20
20
|
*
|
|
21
|
+
* stored_slim (BLI-17273) the copy Tower holds is the SLIM form of the file: images
|
|
22
|
+
* described and replaced by a marker, duplicate copies and bookkeeping
|
|
23
|
+
* stripped. Complete for every reader, but the local file holds the only
|
|
24
|
+
* copy of the image bytes, so a slim copy is NEVER `stored_full`: keep the
|
|
25
|
+
* file. Only judged for a machine that runs, or ever ran, slim
|
|
26
|
+
* (`image-mode-state.ts`); every other machine reads exactly as before.
|
|
27
|
+
* The rules are on `judgeWithSlimCopy` below.
|
|
28
|
+
*
|
|
21
29
|
* A file too large to redact in memory is compared by its raw size
|
|
22
30
|
* (`remote_at_least_file_size`). A file whose redaction would crash to the stub
|
|
23
31
|
* is compared the same way, never by the stub's 42 bytes.
|
|
@@ -28,7 +36,7 @@ import crypto from "node:crypto";
|
|
|
28
36
|
import fs from "node:fs/promises";
|
|
29
37
|
import path from "node:path";
|
|
30
38
|
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, REDACTION_STUB_SHA256, } from "@bli-cockpit/telemetry-core";
|
|
31
|
-
import { sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
|
|
39
|
+
import { isWholeFileMask, sanitizeTextEvidenceForUpload } from "../adapters/raw-evidence-sanitize.js";
|
|
32
40
|
import { isSecretLikePath } from "../adapters/raw-evidence-keys.js";
|
|
33
41
|
import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
|
|
34
42
|
import { uploadFormOfTranscript } from "../image-description-session.js";
|
|
@@ -47,7 +55,15 @@ export async function judgeSource(input) {
|
|
|
47
55
|
reason,
|
|
48
56
|
remote_byte_size: best,
|
|
49
57
|
});
|
|
50
|
-
|
|
58
|
+
const split = input.facts.slimSplit ? await input.facts.slimSplit() : null;
|
|
59
|
+
if (split)
|
|
60
|
+
return judgeWithSlimCopy({ file, cursorEntry, facts: input.facts, held, best, split });
|
|
61
|
+
// A slim-aware machine with a null split may have lost its described-image list: the server copy
|
|
62
|
+
// can be slim while this side sees nothing to slim. The cursor cannot tell, so judge only against
|
|
63
|
+
// the whole form below (a slim copy then reads stored_partial/remote_smaller: keep the file).
|
|
64
|
+
const trustCursor = input.facts.slimAware !== true;
|
|
65
|
+
if (trustCursor &&
|
|
66
|
+
cursorEntry &&
|
|
51
67
|
!cursorEntry.stubbed &&
|
|
52
68
|
cursorEntry.uploaded_object_key &&
|
|
53
69
|
held.some((remote) => remote.object_key === cursorEntry.uploaded_object_key) &&
|
|
@@ -56,7 +72,12 @@ export async function judgeSource(input) {
|
|
|
56
72
|
cursorEntry.file_hash_sha256 === (await input.facts.rawSha256())) {
|
|
57
73
|
return full("unchanged_since_upload");
|
|
58
74
|
}
|
|
59
|
-
|
|
75
|
+
if (!trustCursor) {
|
|
76
|
+
console.error("[sources-verdict] slim-aware machine, no slim split: cursor shortcut skipped", JSON.stringify({ reason: "slim_aware_split_null", has_cursor: cursorEntry !== null }));
|
|
77
|
+
}
|
|
78
|
+
// Slim-aware with no split: only the WHOLE form is evidence of a whole server copy. When it
|
|
79
|
+
// stubs (null) the raw size below answers, so a slim-only copy is never stored_full.
|
|
80
|
+
const sanitized = !trustCursor && input.facts.wholeForm ? await input.facts.wholeForm() : await input.facts.sanitized();
|
|
60
81
|
if (sanitized) {
|
|
61
82
|
if (held.some((remote) => remote.content_hash_sha256 === sanitized.sha256))
|
|
62
83
|
return full("content_hash_match");
|
|
@@ -76,16 +97,91 @@ export async function judgeSource(input) {
|
|
|
76
97
|
cursorEntry.uploaded_byte_size < file.byte_size;
|
|
77
98
|
return partial(grewAfterUpload ? "changed_since_upload" : "remote_smaller");
|
|
78
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* BLI-17273. The file has two redacted forms (whole and slim) and this machine has run slim, so a
|
|
102
|
+
* copy Tower holds may be either. The rule that keeps local image bytes safe: only evidence that the
|
|
103
|
+
* WHOLE form is on the server makes `stored_full`; a copy that is the slim form, or cannot be told
|
|
104
|
+
* apart from it, is `stored_slim` (keep the file).
|
|
105
|
+
*
|
|
106
|
+
* hash equals the whole form stored_full content_hash_match
|
|
107
|
+
* hash equals the slim form stored_slim content_hash_match
|
|
108
|
+
* largest copy is at least the whole form's size stored_full remote_at_least_sanitized_size
|
|
109
|
+
* cursor says its upload is current, file unchanged stored_slim unchanged_since_upload
|
|
110
|
+
* (the upload may be either form and its hash matches neither, so it is not proven whole)
|
|
111
|
+
* largest copy is at least the slim form's size stored_slim remote_at_least_slim_size
|
|
112
|
+
* size never recorded stored_partial remote_size_unknown
|
|
113
|
+
* otherwise stored_partial changed_since_upload / remote_smaller
|
|
114
|
+
*
|
|
115
|
+
* A stale whole copy cannot outrank the slim size: a copy smaller than the whole form is never
|
|
116
|
+
* `stored_full` here, however it compares to the slim form.
|
|
117
|
+
*/
|
|
118
|
+
async function judgeWithSlimCopy(input) {
|
|
119
|
+
const { file, cursorEntry, held, best, split } = input;
|
|
120
|
+
const result = (verdict, reason) => ({
|
|
121
|
+
verdict,
|
|
122
|
+
reason,
|
|
123
|
+
remote_byte_size: best,
|
|
124
|
+
});
|
|
125
|
+
if (held.some((remote) => remote.content_hash_sha256 === split.full.sha256))
|
|
126
|
+
return result("stored_full", "content_hash_match");
|
|
127
|
+
if (held.some((remote) => remote.content_hash_sha256 === split.slim.sha256))
|
|
128
|
+
return result("stored_slim", "content_hash_match");
|
|
129
|
+
if (best !== null && best >= split.full.byte_size)
|
|
130
|
+
return result("stored_full", "remote_at_least_sanitized_size");
|
|
131
|
+
if (cursorEntry &&
|
|
132
|
+
!cursorEntry.stubbed &&
|
|
133
|
+
cursorEntry.uploaded_object_key &&
|
|
134
|
+
held.some((remote) => remote.object_key === cursorEntry.uploaded_object_key) &&
|
|
135
|
+
cursorEntry.uploaded_byte_size === file.byte_size &&
|
|
136
|
+
cursorEntry.file_hash_sha256 &&
|
|
137
|
+
cursorEntry.file_hash_sha256 === (await input.facts.rawSha256())) {
|
|
138
|
+
return result("stored_slim", "unchanged_since_upload");
|
|
139
|
+
}
|
|
140
|
+
if (best === null)
|
|
141
|
+
return result("stored_partial", "remote_size_unknown");
|
|
142
|
+
if (best >= split.slim.byte_size)
|
|
143
|
+
return result("stored_slim", "remote_at_least_slim_size");
|
|
144
|
+
const grewAfterUpload = cursorEntry?.uploaded_object_key != null &&
|
|
145
|
+
cursorEntry.uploaded_byte_size != null &&
|
|
146
|
+
cursorEntry.uploaded_byte_size < file.byte_size;
|
|
147
|
+
return result("stored_partial", grewAfterUpload ? "changed_since_upload" : "remote_smaller");
|
|
148
|
+
}
|
|
79
149
|
/**
|
|
80
150
|
* Reads the file at most once, on first ask, and redacts it exactly as the
|
|
81
151
|
* upload does (`raw-evidence-transcript-file.ts`): same sanitizer, same
|
|
82
152
|
* secret-like-name rule, same byte cap.
|
|
83
153
|
*/
|
|
84
154
|
export function fileFacts(file, maxBytes = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
85
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* `mode` is this machine's mode (`image-mode-state.ts`; absent means the fleet default
|
|
157
|
+
* `IMAGE_HANDLING_MODE`). `describedKeys` is the collector's described-image state. `slimAware`
|
|
158
|
+
* (BLI-17273) is true for a machine that runs or ever ran slim: its server copies may be slim, so
|
|
159
|
+
* `slimSplit()` answers; absent it defaults to "the mode is drop".
|
|
160
|
+
*/
|
|
86
161
|
upload = {}) {
|
|
87
162
|
const mode = upload.mode ?? IMAGE_HANDLING_MODE;
|
|
88
163
|
const describedKeys = upload.describedKeys ?? new Set();
|
|
164
|
+
const slimAware = upload.slimAware ?? mode === "describe_and_drop";
|
|
165
|
+
const redactForUpload = (text, original) => {
|
|
166
|
+
const result = sanitizeTextEvidenceForUpload({
|
|
167
|
+
text,
|
|
168
|
+
originalBytes: text === original.toString("utf8") ? original : Buffer.from(text, "utf8"),
|
|
169
|
+
redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
|
|
170
|
+
secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
|
|
171
|
+
});
|
|
172
|
+
const hash = sha256(result.bytes);
|
|
173
|
+
if (hash === REDACTION_STUB_SHA256)
|
|
174
|
+
return null;
|
|
175
|
+
// A whole-file mask is one 40-byte line, not the content: every copy Tower holds would look
|
|
176
|
+
// "at least as large" and read stored_full. No redacted form, so the raw size answers instead.
|
|
177
|
+
if (isWholeFileMask(result.bytes)) {
|
|
178
|
+
console.error("[sources-verdict] redacted form is the whole-file mask: judged by raw size", JSON.stringify({ reason: "whole_file_mask", source: file.source, file_bytes: file.byte_size }));
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return { sha256: hash, byte_size: result.bytes.byteLength };
|
|
182
|
+
};
|
|
183
|
+
let split = null;
|
|
184
|
+
let whole = null;
|
|
89
185
|
let bytes = null;
|
|
90
186
|
const read = () => {
|
|
91
187
|
bytes ??= file.byte_size > maxBytes ? Promise.resolve(null) : fs.readFile(file.file_path);
|
|
@@ -94,6 +190,7 @@ upload = {}) {
|
|
|
94
190
|
let rawHash = null;
|
|
95
191
|
let sanitized = null;
|
|
96
192
|
return {
|
|
193
|
+
slimAware,
|
|
97
194
|
rawSha256() {
|
|
98
195
|
rawHash ??= read().then(async (buffer) => (buffer ? sha256(buffer) : await streamSha256(file.file_path)));
|
|
99
196
|
return rawHash;
|
|
@@ -104,19 +201,33 @@ upload = {}) {
|
|
|
104
201
|
return null;
|
|
105
202
|
// Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
|
|
106
203
|
const uploadText = uploadFormOfTranscript(buffer.toString("utf8"), file.source === "codex" ? "codex" : "claude_code", mode, describedKeys);
|
|
107
|
-
|
|
108
|
-
text: uploadText,
|
|
109
|
-
originalBytes: uploadText === buffer.toString("utf8") ? buffer : Buffer.from(uploadText, "utf8"),
|
|
110
|
-
redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
|
|
111
|
-
secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
|
|
112
|
-
});
|
|
113
|
-
const hash = sha256(result.bytes);
|
|
114
|
-
if (hash === REDACTION_STUB_SHA256)
|
|
115
|
-
return null;
|
|
116
|
-
return { sha256: hash, byte_size: result.bytes.byteLength };
|
|
204
|
+
return redactForUpload(uploadText, buffer);
|
|
117
205
|
});
|
|
118
206
|
return sanitized;
|
|
119
207
|
},
|
|
208
|
+
wholeForm() {
|
|
209
|
+
whole ??= read().then((buffer) => (buffer ? redactForUpload(buffer.toString("utf8"), buffer) : null));
|
|
210
|
+
return whole;
|
|
211
|
+
},
|
|
212
|
+
async slimSplit() {
|
|
213
|
+
if (!slimAware)
|
|
214
|
+
return null;
|
|
215
|
+
split ??= read().then((buffer) => {
|
|
216
|
+
if (!buffer)
|
|
217
|
+
return null;
|
|
218
|
+
const text = buffer.toString("utf8");
|
|
219
|
+
const slimText = uploadFormOfTranscript(text, file.source === "codex" ? "codex" : "claude_code", "describe_and_drop", describedKeys);
|
|
220
|
+
// Slimming changes nothing: one form, and `sanitized()` is the answer.
|
|
221
|
+
if (slimText === text)
|
|
222
|
+
return null;
|
|
223
|
+
const whole = redactForUpload(text, buffer);
|
|
224
|
+
const slim = redactForUpload(slimText, buffer);
|
|
225
|
+
if (!whole || !slim || whole.sha256 === slim.sha256)
|
|
226
|
+
return null;
|
|
227
|
+
return { full: whole, slim };
|
|
228
|
+
});
|
|
229
|
+
return split;
|
|
230
|
+
},
|
|
120
231
|
};
|
|
121
232
|
}
|
|
122
233
|
function sha256(buffer) {
|
package/dist/commands/sync.js
CHANGED
|
@@ -45,6 +45,7 @@ import { sendSyncHeartbeat } from "./sync-heartbeat.js";
|
|
|
45
45
|
import { runSyncWithHealthReceipt } from "./sync-receipt.js";
|
|
46
46
|
import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
|
|
47
47
|
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
48
|
+
import { settleTickImageModeBestEffort } from "../image-mode-state.js";
|
|
48
49
|
import { stampSyncTick } from "../sync-tick-stamp.js";
|
|
49
50
|
import { startTickTimer } from "../sync-tick-timing.js";
|
|
50
51
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
@@ -137,14 +138,20 @@ async function tickDashboardUrl(command, paths) {
|
|
|
137
138
|
* one to fall back on (BLI-2678).
|
|
138
139
|
*/
|
|
139
140
|
async function reportTickStarted(command, io, dashboardUrl) {
|
|
140
|
-
|
|
141
|
+
const observed = {};
|
|
142
|
+
const minCliVersion = await reportInstallEventsBestEffort({
|
|
141
143
|
homeDir: command.homeDir,
|
|
142
144
|
dashboardUrl,
|
|
143
145
|
command: "sync",
|
|
144
146
|
events: [{ step: "sync_started", status: "ok" }],
|
|
145
147
|
json: command.json,
|
|
146
148
|
io,
|
|
149
|
+
observed,
|
|
147
150
|
});
|
|
151
|
+
// BLI-17273: the reply also names this machine's image-handling mode. Put it on file BEFORE
|
|
152
|
+
// collection starts (the uploader reads the file); an offline tick keeps the last value on file.
|
|
153
|
+
await settleTickImageModeBestEffort(getCollectorRuntimePaths(command.homeDir).state_dir, observed.imageHandlingMode);
|
|
154
|
+
return minCliVersion;
|
|
148
155
|
}
|
|
149
156
|
/** Post the receipt the run earned, whatever it says. */
|
|
150
157
|
async function reportTickOutcome(command, io, dashboardUrl, completion) {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* the describe door, a few at a time, inside a per-sync count and time budget. The state file
|
|
10
10
|
* remembers what was sent, so a screenshot that appears in 40 turns, or in 3 sessions, is sent
|
|
11
11
|
* once. A failure never fails the transcript.
|
|
12
|
-
* 3. REPLACE, only when
|
|
12
|
+
* 3. REPLACE, only when this machine's mode (set per machine from Tower, kept on file by
|
|
13
|
+
* `image-mode-state.ts`; the fleet default is `IMAGE_HANDLING_MODE` in telemetry-core) says
|
|
13
14
|
* `describe_and_drop`: every image whose description is stored becomes its short marker. An
|
|
14
15
|
* image with no stored description is KEPT. In `describe_and_keep` (the default) the text
|
|
15
16
|
* comes back byte for byte as it went in.
|
|
@@ -22,8 +23,9 @@
|
|
|
22
23
|
*/
|
|
23
24
|
import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
|
|
24
25
|
import { postImageToDescribeDoor } from "./image-describe-client.js";
|
|
26
|
+
import { resolveImageHandlingMode } from "./image-mode-state.js";
|
|
25
27
|
import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
|
|
26
|
-
import { imageStateKey, readDescribedImageKeys,
|
|
28
|
+
import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
|
|
27
29
|
/** New images sent to the door in one sync (all worktrees together). */
|
|
28
30
|
export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
29
31
|
/** After this long in one sync, remaining images wait for the next tick. */
|
|
@@ -35,6 +37,8 @@ export async function openImageDescriptions(options) {
|
|
|
35
37
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
36
38
|
const described = new Set(await readDescribedImageKeys(options.stateDir));
|
|
37
39
|
const orderedKeys = [...described];
|
|
40
|
+
const failedImages = await readFailedImages(options.stateDir);
|
|
41
|
+
let stateDirty = false;
|
|
38
42
|
const settledThisRun = new Map();
|
|
39
43
|
const now = options.now ?? Date.now;
|
|
40
44
|
const startedAt = now();
|
|
@@ -59,12 +63,33 @@ export async function openImageDescriptions(options) {
|
|
|
59
63
|
source: input.source,
|
|
60
64
|
session_id: input.sessionId.slice(0, 200),
|
|
61
65
|
});
|
|
66
|
+
const key = imageStateKey(image.sha256);
|
|
62
67
|
if (outcome.ok) {
|
|
63
68
|
markDescribed(image.sha256);
|
|
64
69
|
settledThisRun.set(image.sha256, outcome.status);
|
|
70
|
+
if (failedImages[key]) {
|
|
71
|
+
delete failedImages[key];
|
|
72
|
+
stateDirty = true;
|
|
73
|
+
}
|
|
65
74
|
return;
|
|
66
75
|
}
|
|
67
76
|
settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
|
|
77
|
+
rememberFailure(key, outcome);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* BLI-16979: the door answered about THIS image and could not describe it (a 4xx such as
|
|
81
|
+
* `describer_rejected_image`, or a 5xx). Wait before sending the same bytes again, instead of
|
|
82
|
+
* re-sending them every sync. A transport error, a 401 and a 429 say nothing about the image, so
|
|
83
|
+
* they are not remembered.
|
|
84
|
+
*/
|
|
85
|
+
function rememberFailure(key, outcome) {
|
|
86
|
+
if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
|
|
87
|
+
return;
|
|
88
|
+
const attempts = (failedImages[key]?.attempts ?? 0) + 1;
|
|
89
|
+
const delay = imageRetryDelayMs(attempts, outcome.permanent);
|
|
90
|
+
failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
|
|
91
|
+
stateDirty = true;
|
|
92
|
+
console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
|
|
68
93
|
}
|
|
69
94
|
async function describeAndReplace(input) {
|
|
70
95
|
const images = findEmbeddedImages(input.text);
|
|
@@ -74,7 +99,7 @@ export async function openImageDescriptions(options) {
|
|
|
74
99
|
for (const image of images)
|
|
75
100
|
if (!unique.has(image.sha256))
|
|
76
101
|
unique.set(image.sha256, image);
|
|
77
|
-
const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0 };
|
|
102
|
+
const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
|
|
78
103
|
const failedReasons = {};
|
|
79
104
|
const toSend = [];
|
|
80
105
|
for (const image of unique.values()) {
|
|
@@ -90,6 +115,11 @@ export async function openImageDescriptions(options) {
|
|
|
90
115
|
counts.too_large += 1;
|
|
91
116
|
continue;
|
|
92
117
|
}
|
|
118
|
+
const earlierFailure = failedImages[imageStateKey(image.sha256)];
|
|
119
|
+
if (earlierFailure && earlierFailure.retry_after > now()) {
|
|
120
|
+
counts.backing_off += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
93
123
|
toSend.push(image);
|
|
94
124
|
}
|
|
95
125
|
let next = 0;
|
|
@@ -116,8 +146,10 @@ export async function openImageDescriptions(options) {
|
|
|
116
146
|
failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
|
|
117
147
|
}
|
|
118
148
|
}
|
|
119
|
-
if (counts.described_now + counts.known > 0)
|
|
120
|
-
await
|
|
149
|
+
if (counts.described_now + counts.known > 0 || stateDirty) {
|
|
150
|
+
await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
|
|
151
|
+
stateDirty = false;
|
|
152
|
+
}
|
|
121
153
|
const replaced = mode === "describe_and_drop"
|
|
122
154
|
? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
|
|
123
155
|
: { text: input.text, replacedCount: 0, charsSaved: 0 };
|
|
@@ -174,11 +206,15 @@ export async function openImageStepForSync(options) {
|
|
|
174
206
|
if (options.budget?.images)
|
|
175
207
|
return options.budget.images;
|
|
176
208
|
try {
|
|
209
|
+
// BLI-17273: the mode is PER MACHINE: the last value Tower sent (kept on file so an offline
|
|
210
|
+
// tick does not flip a slim machine back), else the fleet default `IMAGE_HANDLING_MODE`.
|
|
211
|
+
const mode = options.mode ?? (await resolveImageHandlingMode(options.stateDir)).mode;
|
|
177
212
|
const session = await openImageDescriptions({
|
|
178
213
|
stateDir: options.stateDir,
|
|
179
214
|
dashboardUrl: options.dashboardUrl,
|
|
180
215
|
deviceToken: options.deviceToken,
|
|
181
216
|
fetchImpl: options.fetchImpl,
|
|
217
|
+
mode,
|
|
182
218
|
});
|
|
183
219
|
if (options.budget)
|
|
184
220
|
options.budget.images = session;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { isMissingFileFailure } from "./health-detail.js";
|
|
12
|
-
import { readJsonFile,
|
|
12
|
+
import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
13
13
|
export const IMAGE_DESCRIPTION_STATE_FILENAME = "image-descriptions.json";
|
|
14
14
|
/** Oldest keys leave first past this many (about 5 MB on disk). */
|
|
15
15
|
export const IMAGE_DESCRIPTION_STATE_MAX_KEYS = 150_000;
|
|
@@ -35,10 +35,54 @@ export async function readDescribedImageKeys(stateDir) {
|
|
|
35
35
|
return [];
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
/** Oldest failures leave first past this many. */
|
|
39
|
+
export const IMAGE_FAILED_STATE_MAX_KEYS = 5_000;
|
|
40
|
+
const HOUR_MS = 3_600_000;
|
|
41
|
+
/** A refusal that repeats for the same bytes (a 4xx): a week, then a month. */
|
|
42
|
+
const PERMANENT_BACKOFF_MS = [7 * 24 * HOUR_MS, 30 * 24 * HOUR_MS];
|
|
43
|
+
/** A failure that may pass (the door or the model was down): 1 h, 6 h, 24 h, then 72 h. */
|
|
44
|
+
const TRANSIENT_BACKOFF_MS = [HOUR_MS, 6 * HOUR_MS, 24 * HOUR_MS, 72 * HOUR_MS];
|
|
45
|
+
/** How long to leave an image alone after its Nth failed attempt. */
|
|
46
|
+
export function imageRetryDelayMs(attempts, permanent) {
|
|
47
|
+
const steps = permanent ? PERMANENT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
|
|
48
|
+
return steps[Math.min(Math.max(attempts, 1), steps.length) - 1];
|
|
49
|
+
}
|
|
50
|
+
/** The failures on record, key to record. A missing or unreadable file is an empty map, said out loud when unreadable. */
|
|
51
|
+
export async function readFailedImages(stateDir) {
|
|
52
|
+
try {
|
|
53
|
+
const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
|
|
54
|
+
if (!raw?.failed || typeof raw.failed !== "object" || Array.isArray(raw.failed))
|
|
55
|
+
return {};
|
|
56
|
+
const kept = {};
|
|
57
|
+
for (const [key, value] of Object.entries(raw.failed)) {
|
|
58
|
+
const record = value;
|
|
59
|
+
if (key.length !== KEY_CHARS || !record)
|
|
60
|
+
continue;
|
|
61
|
+
if (typeof record.reason !== "string" || typeof record.attempts !== "number" || typeof record.retry_after !== "number")
|
|
62
|
+
continue;
|
|
63
|
+
kept[key] = { reason: record.reason, attempts: record.attempts, retry_after: record.retry_after };
|
|
64
|
+
}
|
|
65
|
+
return kept;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (!isMissingFileFailure(error)) {
|
|
69
|
+
console.error("[image-descriptions] failed-image list unreadable, starting from an empty list", JSON.stringify({ reason: "image_failed_state_unreadable" }));
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function writeImageDescriptionState(stateDir, state) {
|
|
75
|
+
const keys = state.described;
|
|
39
76
|
const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
|
|
40
|
-
|
|
77
|
+
const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
|
|
78
|
+
const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
|
|
79
|
+
await writeJsonFileAtomic(imageDescriptionStatePath(stateDir), {
|
|
41
80
|
schema_version: "cockpit-image-descriptions.v1",
|
|
42
81
|
described: kept,
|
|
82
|
+
failed,
|
|
43
83
|
});
|
|
84
|
+
}
|
|
85
|
+
/** Writes the described list and keeps whatever failures are on record. */
|
|
86
|
+
export async function writeDescribedImageKeys(stateDir, keys) {
|
|
87
|
+
await writeImageDescriptionState(stateDir, { described: keys, failed: await readFailedImages(stateDir) });
|
|
44
88
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This machine's image-handling mode, as Tower last told it (BLI-17273).
|
|
3
|
+
*
|
|
4
|
+
* The fleet default is `IMAGE_HANDLING_MODE` (keep). Tower can set a different mode for ONE machine
|
|
5
|
+
* (`collector_image_mode:<device_id>` in `system_settings`); the answer rides the reply to the
|
|
6
|
+
* sync-start receipt (`image_handling_mode`). The tick records what it heard here, in one small file in
|
|
7
|
+
* the collector state directory, and every later reader (the uploader, `cockpit sources check`)
|
|
8
|
+
* reads the file. The mode for a tick is therefore:
|
|
9
|
+
*
|
|
10
|
+
* the reply's value, when this tick got one (it is written to the file first, so the file IS it)
|
|
11
|
+
* else the last value on file (an offline tick must not flip a slim machine back
|
|
12
|
+
* to keep: each flip re-uploads every live-window
|
|
13
|
+
* transcript as a new object)
|
|
14
|
+
* else `IMAGE_HANDLING_MODE`
|
|
15
|
+
*
|
|
16
|
+
* The file also remembers, sticky, that this machine EVER ran slim (`drop_seen_at`). A machine put
|
|
17
|
+
* back on keep still holds slim server copies of the files it uploaded while slim, and
|
|
18
|
+
* `cockpit sources check` must go on treating those as "keep your local file".
|
|
19
|
+
*
|
|
20
|
+
* Holds the mode and two timestamps. Never an id, a path or a byte.
|
|
21
|
+
*/
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
|
|
24
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
25
|
+
import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
26
|
+
export const IMAGE_MODE_STATE_FILENAME = "image-handling-mode.json";
|
|
27
|
+
const TAG = "[image-mode]";
|
|
28
|
+
export function isImageHandlingMode(value) {
|
|
29
|
+
return value === "describe_and_keep" || value === "describe_and_drop";
|
|
30
|
+
}
|
|
31
|
+
export function imageModeStatePath(stateDir) {
|
|
32
|
+
return path.join(stateDir, IMAGE_MODE_STATE_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Stands in for `drop_seen_at` when the file was there but could not be read, so whether this machine
|
|
36
|
+
* ever ran slim is unknown. It counts as "ran slim": the wrong guess costs a kept local file, the
|
|
37
|
+
* other wrong guess lets `cockpit sources check` call a slim server copy safe to delete.
|
|
38
|
+
*/
|
|
39
|
+
export const DROP_SEEN_UNKNOWN = "unknown_state_unreadable";
|
|
40
|
+
async function readImageModeFile(stateDir) {
|
|
41
|
+
let raw;
|
|
42
|
+
try {
|
|
43
|
+
raw = await readJsonFile(imageModeStatePath(stateDir));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isMissingFileFailure(error))
|
|
47
|
+
return { state: null, damaged: false, recoveredDropSeenAt: null };
|
|
48
|
+
console.error(TAG + " cache unreadable, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_unreadable", ...describeError(error) }));
|
|
49
|
+
return { state: null, damaged: true, recoveredDropSeenAt: null };
|
|
50
|
+
}
|
|
51
|
+
const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
52
|
+
if (!record) {
|
|
53
|
+
console.error(TAG + " cache is not an object, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_shape_invalid" }));
|
|
54
|
+
return { state: null, damaged: true, recoveredDropSeenAt: null };
|
|
55
|
+
}
|
|
56
|
+
const dropSeenAt = typeof record.drop_seen_at === "string" ? record.drop_seen_at : null;
|
|
57
|
+
if (!isImageHandlingMode(record.mode)) {
|
|
58
|
+
console.error(TAG + " cached mode not recognised, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_value_invalid" }));
|
|
59
|
+
return { state: null, damaged: true, recoveredDropSeenAt: dropSeenAt };
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
state: {
|
|
63
|
+
mode: record.mode,
|
|
64
|
+
observed_at: typeof record.observed_at === "string" ? record.observed_at : "",
|
|
65
|
+
drop_seen_at: dropSeenAt,
|
|
66
|
+
},
|
|
67
|
+
damaged: false,
|
|
68
|
+
recoveredDropSeenAt: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The file's content, or null when there is none or it cannot be trusted (said out loud when unreadable). */
|
|
72
|
+
export async function readImageModeState(stateDir) {
|
|
73
|
+
return (await readImageModeFile(stateDir)).state;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* What a reader of the file should use right now: the cached mode, else (damaged file) slim when a
|
|
77
|
+
* `drop_seen_at` could be recovered from it, else the fleet default. `slimAware` is true when this
|
|
78
|
+
* machine runs slim OR has ever run slim. `read` is a read the caller already did this tick, so a
|
|
79
|
+
* damaged file is read and logged once.
|
|
80
|
+
*/
|
|
81
|
+
export async function resolveImageHandlingMode(stateDir, read) {
|
|
82
|
+
const { state, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
|
|
83
|
+
// A damaged file that still named when this machine first ran slim: it ran slim, keep it slim
|
|
84
|
+
// (flipping to keep would re-upload every live-window transcript as a new whole object).
|
|
85
|
+
const mode = state?.mode ?? (damaged && recoveredDropSeenAt !== null ? "describe_and_drop" : IMAGE_HANDLING_MODE);
|
|
86
|
+
return {
|
|
87
|
+
mode,
|
|
88
|
+
source: state ? "cache" : damaged ? "damaged_cache" : "default",
|
|
89
|
+
// A file that is there but unreadable is "ever slim, unknown": fail closed (BLI-17273).
|
|
90
|
+
slimAware: mode === "describe_and_drop" || state?.drop_seen_at != null || damaged,
|
|
91
|
+
state,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Put what the reply said on file. An unknown value is ignored with a log (the old value stays);
|
|
96
|
+
* a write failure is logged and the previous file stays in force. Returns true only when the file
|
|
97
|
+
* now holds the observed mode.
|
|
98
|
+
*/
|
|
99
|
+
export async function recordObservedImageMode(stateDir, observed, now = () => new Date(), read) {
|
|
100
|
+
if (observed === undefined || observed === null)
|
|
101
|
+
return false;
|
|
102
|
+
if (!isImageHandlingMode(observed)) {
|
|
103
|
+
console.error(TAG + " reply carried a mode this collector does not know, ignored", JSON.stringify({ reason: "image_mode_reply_value_unknown", value_type: typeof observed }));
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const { state: previous, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
|
|
107
|
+
const stamp = now().toISOString();
|
|
108
|
+
const next = {
|
|
109
|
+
mode: observed,
|
|
110
|
+
observed_at: stamp,
|
|
111
|
+
// Never rebuild "never ran slim" over a file we could not read: keep what parsed, else the unknown marker.
|
|
112
|
+
drop_seen_at: previous?.drop_seen_at ?? (damaged ? (recoveredDropSeenAt ?? DROP_SEEN_UNKNOWN) : observed === "describe_and_drop" ? stamp : null),
|
|
113
|
+
};
|
|
114
|
+
if (damaged) {
|
|
115
|
+
console.error(TAG + " rewriting a damaged mode file; ever-slim history kept", JSON.stringify({ reason: "image_mode_cache_damaged_rewritten", drop_seen_recovered: recoveredDropSeenAt !== null }));
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await writeJsonFileAtomic(imageModeStatePath(stateDir), { schema_version: "cockpit-image-handling-mode.v1", ...next });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
console.error(TAG + " could not cache the mode Tower sent; the previous value stays in force", JSON.stringify({ reason: "image_mode_cache_write_failed", mode: observed, ...describeError(error) }));
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (previous?.mode !== observed) {
|
|
125
|
+
console.error(TAG + " mode for this machine changed", JSON.stringify({ reason: "image_mode_changed", from: previous?.mode ?? (damaged ? "damaged" : "none"), to: observed }));
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The tick's decision, logged once: where the mode came from and what it is. Called right after the
|
|
131
|
+
* sync-start receipt. `observed` is the reply's value (undefined when no reply arrived).
|
|
132
|
+
*/
|
|
133
|
+
export async function settleTickImageMode(stateDir, observed, now) {
|
|
134
|
+
// One read per tick: a damaged file is logged once, and the 'changed' line can say from:'damaged'.
|
|
135
|
+
const read = await readImageModeFile(stateDir);
|
|
136
|
+
const wrote = await recordObservedImageMode(stateDir, observed, now, read);
|
|
137
|
+
// After a write the file is whole again, so read it fresh; otherwise the tick's own read stands.
|
|
138
|
+
const resolved = wrote ? await resolveImageHandlingMode(stateDir) : await resolveImageHandlingMode(stateDir, read);
|
|
139
|
+
const source = wrote ? "reply" : resolved.source;
|
|
140
|
+
console.error(TAG + " mode for this tick", JSON.stringify({ reason: `image_mode_from_${source}`, source, mode: resolved.mode }));
|
|
141
|
+
return { mode: resolved.mode, source };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `settleTickImageMode` for the sync tick, which it must never fail: a throw is logged and the
|
|
145
|
+
* uploader falls back to the file or the fleet default.
|
|
146
|
+
*/
|
|
147
|
+
export async function settleTickImageModeBestEffort(stateDir, observed) {
|
|
148
|
+
try {
|
|
149
|
+
await settleTickImageMode(stateDir, observed);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
console.error(TAG + " could not settle the mode for this tick; the uploader reads the file or the fleet default", JSON.stringify({ reason: "image_mode_settle_failed", error_name: error instanceof Error ? error.name : typeof error }));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -47,6 +47,26 @@ export async function writeJsonFile(filePath, value) {
|
|
|
47
47
|
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Same file, same owner-only mode, but written to a sibling temp file and renamed over the target, so
|
|
52
|
+
* a reader (or a crash) never sees a half-written file (BLI-17273: a torn image-mode file made a slim
|
|
53
|
+
* machine forget it had ever run slim). The temp file is removed when the write or the rename fails.
|
|
54
|
+
*/
|
|
55
|
+
export async function writeJsonFileAtomic(filePath, value) {
|
|
56
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
57
|
+
const temp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
await fs.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
if (process.platform !== "win32") {
|
|
61
|
+
await fs.chmod(temp, 0o600).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
await fs.rename(temp, filePath);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
await fs.rm(temp, { force: true }).catch(() => undefined);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
50
70
|
/**
|
|
51
71
|
* Separate from `isMissingFileFailure`: the two callers here hold a raw `fs`
|
|
52
72
|
* rejection rather than a wrapped failure, and "the file was never there" is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.210",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
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.65",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.120",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.74"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|