@bli-cockpit/cli 0.2.264 → 0.2.266
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-transcript-body.js +20 -9
- package/dist/adapters/raw-evidence-transcript-file.js +35 -5
- package/dist/adapters/raw-evidence-transcript-stream.js +63 -0
- package/dist/adapters/transcript-text-stream.js +124 -0
- package/dist/autostart-darwin-plist.js +11 -5
- package/dist/autostart-self-heal.js +11 -2
- package/dist/autostart-windows-scripts.js +14 -3
- package/dist/autostart.js +14 -1
- package/dist/cli.js +2 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/image-census-stream.js +37 -0
- package/dist/image-description-session.js +101 -22
- package/dist/optimize-for-size-entry.js +3 -0
- package/dist/optimize-for-size.js +25 -0
- package/dist/slim/slim-stream.js +77 -0
- package/dist/slim/slim-transcript.js +1 -1
- package/package.json +1 -1
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
import { isUtf8 } from "node:buffer";
|
|
13
13
|
import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
|
|
14
14
|
import { describeError } from "../health-detail.js";
|
|
15
|
+
import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
|
|
16
|
+
/** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
|
|
17
|
+
export function agentOfKind(kind) {
|
|
18
|
+
return kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
|
|
19
|
+
}
|
|
15
20
|
/** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
|
|
16
21
|
export function holdDecodedText(source) {
|
|
17
22
|
const bytes = source.bytes;
|
|
@@ -27,12 +32,23 @@ export function holdDecodedText(source) {
|
|
|
27
32
|
* BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
|
|
28
33
|
* base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
|
|
29
34
|
* The text is unchanged (the very same decoding) unless this sync has an image session AND the switch
|
|
30
|
-
* is `describe_and_drop`; in `describe_and_keep` it always comes back untouched.
|
|
31
|
-
*
|
|
35
|
+
* is `describe_and_drop`; in `describe_and_keep` it always comes back untouched. An image step
|
|
36
|
+
* that breaks hands back the original text and says so. The one throw: a streamed source (BLI-23317)
|
|
37
|
+
* whose file cannot be read to the end raises `TranscriptReadError` or `TranscriptTooLargeError`, which
|
|
38
|
+
* the caller turns into the `file_read_failed` and `file_too_large` skips.
|
|
32
39
|
*/
|
|
33
40
|
export async function prepareTranscriptBody(collection, file) {
|
|
34
41
|
const session = collection.budget.images;
|
|
35
|
-
const agent =
|
|
42
|
+
const agent = agentOfKind(file.kind);
|
|
43
|
+
if (file.source.stream) {
|
|
44
|
+
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind });
|
|
45
|
+
if (streamed) {
|
|
46
|
+
file.source.readBytes = streamed.readBytes;
|
|
47
|
+
return { held: streamed.held, volatile: streamed.volatile, pendingImages: streamed.pendingImages };
|
|
48
|
+
}
|
|
49
|
+
file.source.bytes = await readTranscriptWhole(file.source.stream);
|
|
50
|
+
file.source.readBytes = file.source.bytes.byteLength;
|
|
51
|
+
}
|
|
36
52
|
const untouched = (volatile, pendingImages) => ({ held: holdDecodedText(file.source), volatile, pendingImages });
|
|
37
53
|
if (!session || !agent)
|
|
38
54
|
return untouched(false, null);
|
|
@@ -46,14 +62,9 @@ export async function prepareTranscriptBody(collection, file) {
|
|
|
46
62
|
// BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
|
|
47
63
|
// this very file: an image still without a stored description (keep mode never replaces, so
|
|
48
64
|
// any image counts) or a slim step that failed and should be retried.
|
|
49
|
-
const imagesPending = session.mode === "describe_and_drop"
|
|
50
|
-
? prepared.imageCount > prepared.replacedCount
|
|
51
|
-
: prepared.imageCount > 0;
|
|
52
|
-
const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
|
|
53
|
-
const volatile = imagesPending || slimFailed;
|
|
54
65
|
// BLI-22794: volatile ONLY because of images without a stored description (the slim step held
|
|
55
66
|
// up): the bytes change only when one of these gains one, so the memo may keep the entry.
|
|
56
|
-
const
|
|
67
|
+
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
57
68
|
if (prepared.changed) {
|
|
58
69
|
// The old path encoded the new text and read it back; a text with no lone surrogate (slim output
|
|
59
70
|
// re-escapes them) is the same either way, so the new text is the exact decoding of the new bytes.
|
|
@@ -22,7 +22,8 @@ import { describeError } from "../health-detail.js";
|
|
|
22
22
|
import { DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, } from "../raw-evidence-staging.js";
|
|
23
23
|
import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
|
|
24
24
|
import { maskSecretsInHeldTranscript } from "./raw-evidence-sanitize-held.js";
|
|
25
|
-
import { prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
|
|
25
|
+
import { agentOfKind, prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
|
|
26
|
+
import { TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
|
|
26
27
|
import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
|
|
27
28
|
import { evidenceEntry } from "./raw-evidence-manifest.js";
|
|
28
29
|
import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
|
|
@@ -57,16 +58,30 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
57
58
|
const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
|
|
58
59
|
if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
|
|
59
60
|
return true;
|
|
60
|
-
const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
|
|
61
|
+
const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
|
|
61
62
|
if (read.status === "read_failed")
|
|
62
63
|
return skip("file_read_failed");
|
|
63
64
|
if (read.status === "too_large") {
|
|
64
65
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
65
66
|
return skip("file_too_large");
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
let prepared;
|
|
69
|
+
try {
|
|
70
|
+
prepared = await prepareTranscriptForUpload(collection, options, names, read.source);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
|
|
74
|
+
if (error instanceof TranscriptReadError)
|
|
75
|
+
return skip("file_read_failed");
|
|
76
|
+
if (error instanceof TranscriptTooLargeError) {
|
|
77
|
+
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
78
|
+
return skip("file_too_large");
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = prepared;
|
|
68
83
|
await rememberPreparedTranscript(collection, options, names, {
|
|
69
|
-
stamp, recipe, readBytes: read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
|
|
84
|
+
stamp, recipe, readBytes: read.source.readBytes ?? read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
|
|
70
85
|
});
|
|
71
86
|
if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
|
|
72
87
|
collection.reused.push({
|
|
@@ -264,12 +279,19 @@ async function stageOneTranscript(collection, file) {
|
|
|
264
279
|
}),
|
|
265
280
|
}));
|
|
266
281
|
}
|
|
282
|
+
/** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
|
|
283
|
+
function streamMinBytesFor(collection, kind) {
|
|
284
|
+
const session = collection.budget.images;
|
|
285
|
+
if (!session?.prepareTranscriptFile || !agentOfKind(kind))
|
|
286
|
+
return null;
|
|
287
|
+
return session.streamMinBytes ?? null;
|
|
288
|
+
}
|
|
267
289
|
/**
|
|
268
290
|
* Size is checked twice on purpose: once by `stat` so an oversized transcript
|
|
269
291
|
* is never buffered at all, and once on the bytes actually read, because a live
|
|
270
292
|
* session can grow between the two.
|
|
271
293
|
*/
|
|
272
|
-
async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
|
|
294
|
+
async function readEvidenceFileWithinCap(filePath, maxFileBytes, streamMinBytes) {
|
|
273
295
|
if (maxFileBytes) {
|
|
274
296
|
let stat;
|
|
275
297
|
try {
|
|
@@ -290,6 +312,14 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
|
|
|
290
312
|
if (stat.size > maxFileBytes)
|
|
291
313
|
return { status: "too_large" };
|
|
292
314
|
}
|
|
315
|
+
if (streamMinBytes !== null) {
|
|
316
|
+
// BLI-23317: a big transcript is streamed by the prepare step, not buffered here. A stat that fails
|
|
317
|
+
// falls through to the read below, which reports the failure itself.
|
|
318
|
+
const size = await fs.stat(filePath).then((stat) => stat.size, () => null);
|
|
319
|
+
if (size !== null && size >= streamMinBytes) {
|
|
320
|
+
return { status: "ok", source: { bytes: null, stream: { filePath, maxFileBytes } }, byteLength: size };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
293
323
|
let bytes;
|
|
294
324
|
try {
|
|
295
325
|
bytes = await fs.readFile(filePath);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript that is never read whole (BLI-23317): its path goes to the image session's two
|
|
3
|
+
* streaming passes, and what comes back is the one slimmed string the sanitizer takes.
|
|
4
|
+
*
|
|
5
|
+
* Three endings, each named:
|
|
6
|
+
* - a body: the text is the slim form of the file (valid UTF-8 was checked across every chunk, so
|
|
7
|
+
* `exactBytes` is null: the text encodes back to the bytes a clean file would upload);
|
|
8
|
+
* - null: the streamed path cannot give the whole-file answer (invalid UTF-8, a file that changed
|
|
9
|
+
* between the passes, an image step that broke), and the caller reads the file whole, which is
|
|
10
|
+
* what ran before this existed and still keeps the original bytes for the clean branch;
|
|
11
|
+
* - a throw of `TranscriptReadError` or `TranscriptTooLargeError`: the same outcomes a whole-file
|
|
12
|
+
* read gives (`file_read_failed`, `file_too_large`), never a shorter file.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import { describeError } from "../health-detail.js";
|
|
16
|
+
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
|
|
17
|
+
const TAG = "[raw-evidence]";
|
|
18
|
+
/**
|
|
19
|
+
* BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from this very
|
|
20
|
+
* file: an image still without a stored description (keep mode never replaces, so any image counts) or
|
|
21
|
+
* a slim step that failed and should be retried. BLI-22794: volatile ONLY because of images without a
|
|
22
|
+
* stored description means the bytes change only when one of them gains one, so the memo may keep the entry.
|
|
23
|
+
*/
|
|
24
|
+
export function volatilityOf(mode, prepared) {
|
|
25
|
+
const imagesPending = mode === "describe_and_drop"
|
|
26
|
+
? prepared.imageCount > prepared.replacedCount
|
|
27
|
+
: prepared.imageCount > 0;
|
|
28
|
+
const slimFailed = mode === "describe_and_drop" && prepared.slim === null;
|
|
29
|
+
const volatile = imagesPending || slimFailed;
|
|
30
|
+
return { volatile, pendingImages: volatile && !slimFailed ? prepared.pendingImages : null };
|
|
31
|
+
}
|
|
32
|
+
export async function streamTranscriptBody(session, agent, file) {
|
|
33
|
+
if (!session?.prepareTranscriptFile || !agent) {
|
|
34
|
+
console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes });
|
|
39
|
+
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
40
|
+
console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile }));
|
|
41
|
+
return { held: { text: prepared.text, exactBytes: null }, volatile, pendingImages, readBytes: prepared.readBytes };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
|
|
45
|
+
throw error;
|
|
46
|
+
console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
|
|
51
|
+
export async function readTranscriptWhole(file) {
|
|
52
|
+
let bytes;
|
|
53
|
+
try {
|
|
54
|
+
bytes = await fs.readFile(file.filePath);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(TAG + " evidence file could not be read", JSON.stringify({ reason: "file_read_failed", stage: "read", ...describeError(error) }));
|
|
58
|
+
throw new TranscriptReadError("read", error);
|
|
59
|
+
}
|
|
60
|
+
if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
|
|
61
|
+
throw new TranscriptTooLargeError(file.maxFileBytes);
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transcript's lines as text, straight from disk, for the pass that never holds the whole file (BLI-23317).
|
|
3
|
+
*
|
|
4
|
+
* Unlike `transcript-line-stream.ts` (the image reader's, which logs a failed read and ends the lines
|
|
5
|
+
* early), this one tells the two endings apart. A read that fails is a `TranscriptReadError`, never a
|
|
6
|
+
* shorter file: the caller maps it to the same `file_read_failed` skip a whole-file read failure gets.
|
|
7
|
+
* Text that is not valid UTF-8 is a `TranscriptNotStreamable`: the streamed path replaces nothing, so
|
|
8
|
+
* the caller falls back to the whole-file path, which keeps the original bytes for the clean branch.
|
|
9
|
+
*
|
|
10
|
+
* One `TextDecoder` (fatal, BOM kept, `stream: true`) lives across all chunks, so a character split
|
|
11
|
+
* between two chunks is valid input, and `decode()` at EOF rejects a file that ends mid-character.
|
|
12
|
+
* For valid UTF-8 it yields exactly the string `Buffer.toString("utf8")` makes, cut at "\n" like
|
|
13
|
+
* `split("\n")`: the last line is whatever follows the final newline, empty or not.
|
|
14
|
+
*
|
|
15
|
+
* A line is held as pieces until its newline arrives, so the peak is the longest line (a single very
|
|
16
|
+
* long line is still concatenated whole; that bound is not claimed to be small).
|
|
17
|
+
*/
|
|
18
|
+
import fsp from "node:fs/promises";
|
|
19
|
+
import { TextDecoder } from "node:util";
|
|
20
|
+
import { describeError } from "../health-detail.js";
|
|
21
|
+
/** Bytes read per step, into one reused buffer. */
|
|
22
|
+
export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024;
|
|
23
|
+
const CHUNK_BYTES = TRANSCRIPT_CHUNK_BYTES;
|
|
24
|
+
/** The file could not be read to its end. Not EOF: whoever catches it must not treat the lines so far as the file. */
|
|
25
|
+
export class TranscriptReadError extends Error {
|
|
26
|
+
stage;
|
|
27
|
+
underlying;
|
|
28
|
+
constructor(stage, underlying) {
|
|
29
|
+
super(`transcript read failed at ${stage}`);
|
|
30
|
+
this.name = "TranscriptReadError";
|
|
31
|
+
this.stage = stage;
|
|
32
|
+
this.underlying = underlying;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The file is bigger than this pass's cap. */
|
|
36
|
+
export class TranscriptTooLargeError extends Error {
|
|
37
|
+
maxFileBytes;
|
|
38
|
+
constructor(maxFileBytes) {
|
|
39
|
+
super("transcript over the file cap");
|
|
40
|
+
this.name = "TranscriptTooLargeError";
|
|
41
|
+
this.maxFileBytes = maxFileBytes;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The streamed path cannot give the whole-file answer for this file; read it whole instead. `reason` is a label. */
|
|
45
|
+
export class TranscriptNotStreamable extends Error {
|
|
46
|
+
reason;
|
|
47
|
+
constructor(reason) {
|
|
48
|
+
super(`transcript not streamable: ${reason}`);
|
|
49
|
+
this.name = "TranscriptNotStreamable";
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The lines of `filePath`. `byteLimit` reads exactly that many bytes (the second pass reads the first
|
|
55
|
+
* pass's count, so a file that grew between the passes is not half-seen) and fails with
|
|
56
|
+
* `file_changed_during_pass` when fewer arrive. `stopAfter` is a ceiling only (the first pass, bounded
|
|
57
|
+
* by the file cap): fewer is fine.
|
|
58
|
+
*/
|
|
59
|
+
export function openTranscriptLines(filePath, options = {}) {
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
const { byteLimit } = options;
|
|
62
|
+
const readTo = byteLimit ?? options.stopAfter;
|
|
63
|
+
async function* lines() {
|
|
64
|
+
if (readTo === 0) {
|
|
65
|
+
yield "";
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
69
|
+
let handle;
|
|
70
|
+
let held = [];
|
|
71
|
+
try {
|
|
72
|
+
handle = await fsp.open(filePath, "r");
|
|
73
|
+
// One buffer for the whole pass: the decoder copies out of it, so nothing waits on the collector to free a chunk.
|
|
74
|
+
const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
|
|
75
|
+
for (let position = 0;;) {
|
|
76
|
+
const want = readTo === undefined ? CHUNK_BYTES : Math.min(CHUNK_BYTES, readTo - position);
|
|
77
|
+
if (want <= 0)
|
|
78
|
+
break;
|
|
79
|
+
const { bytesRead } = await handle.read(buffer, 0, want, position);
|
|
80
|
+
if (bytesRead === 0)
|
|
81
|
+
break;
|
|
82
|
+
position += bytesRead;
|
|
83
|
+
bytes += bytesRead;
|
|
84
|
+
const text = decodeOrThrow(decoder, buffer.subarray(0, bytesRead));
|
|
85
|
+
let start = 0;
|
|
86
|
+
for (let newline = text.indexOf("\n", start); newline !== -1; newline = text.indexOf("\n", start)) {
|
|
87
|
+
held.push(text.slice(start, newline));
|
|
88
|
+
const line = held.length === 1 ? held[0] : held.join("");
|
|
89
|
+
held = [];
|
|
90
|
+
start = newline + 1;
|
|
91
|
+
yield line;
|
|
92
|
+
}
|
|
93
|
+
if (start < text.length)
|
|
94
|
+
held.push(text.slice(start));
|
|
95
|
+
}
|
|
96
|
+
const tail = decodeOrThrow(decoder, undefined);
|
|
97
|
+
if (tail)
|
|
98
|
+
held.push(tail);
|
|
99
|
+
if (byteLimit !== undefined && bytes < byteLimit)
|
|
100
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
101
|
+
yield held.join("");
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof TranscriptNotStreamable)
|
|
105
|
+
throw error;
|
|
106
|
+
console.error("[transcript-text-stream] transcript could not be read to its end", JSON.stringify({ reason: "file_read_failed", stage: "stream", bytes_read: bytes, ...describeError(error) }));
|
|
107
|
+
throw new TranscriptReadError("stream", error);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await handle?.close().catch((error) => {
|
|
111
|
+
console.error("[transcript-text-stream] file handle did not close", JSON.stringify({ reason: "file_close_failed", ...describeError(error) }));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { lines: lines(), bytesRead: () => bytes };
|
|
116
|
+
}
|
|
117
|
+
function decodeOrThrow(decoder, chunk) {
|
|
118
|
+
try {
|
|
119
|
+
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new TranscriptNotStreamable("invalid_utf8");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -126,7 +126,8 @@ export async function readRegisteredDarwinRuntimePaths(homeDir) {
|
|
|
126
126
|
const command = decodeXmlEntities(plist);
|
|
127
127
|
return {
|
|
128
128
|
node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
|
|
129
|
-
cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
|
|
129
|
+
cli_entry_point: /"\$node_bin"(?: --optimize-for-size)? '([^']*)' sync /u.exec(command)?.[1] ?? null,
|
|
130
|
+
optimize_for_size: /"\$node_bin" '[^']*' sync /u.test(command) ? false : /"\$node_bin" --optimize-for-size '[^']*' sync /u.test(command) ? true : null,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
133
|
/**
|
|
@@ -222,12 +223,15 @@ function watchPathsBlock(watchPaths) {
|
|
|
222
223
|
" </array>",
|
|
223
224
|
];
|
|
224
225
|
}
|
|
226
|
+
/** The V8 flag the scheduled sync runs node with (BLI-23317). */
|
|
227
|
+
export const NODE_OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
|
|
225
228
|
function renderDarwinSyncCommand(options) {
|
|
229
|
+
const nodeFlag = options.optimizeForSize === false ? "" : ` ${NODE_OPTIMIZE_FOR_SIZE_FLAG}`;
|
|
226
230
|
const dashboardArg = isDefaultDashboardUrl(options.dashboardUrl)
|
|
227
231
|
? ""
|
|
228
232
|
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
229
233
|
const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
230
|
-
const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
|
|
234
|
+
const commands = options.workDirs.map((root) => `"$node_bin"${nodeFlag} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
|
|
231
235
|
return [
|
|
232
236
|
"exit_code=0",
|
|
233
237
|
...darwinNodeResolutionPreamble(options.nodeExecutable),
|
|
@@ -278,14 +282,16 @@ function darwinAgentRegistrationProblems(plist, expected) {
|
|
|
278
282
|
if (!plist.includes(` <key>ThrottleInterval</key>\n <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`)) {
|
|
279
283
|
problems.push(`minimum gap between ticks is not ${DARWIN_MIN_SECONDS_BETWEEN_TICKS} seconds`);
|
|
280
284
|
}
|
|
281
|
-
|
|
285
|
+
// BLI-23317: the form with --optimize-for-size is what is written; the form without it is still accepted.
|
|
286
|
+
const matchesCurrentRuntime = [true, false].some((optimizeForSize) => plist.includes(programArgumentsBlock(renderDarwinSyncCommand({
|
|
282
287
|
discoveryArgs: expected.discoveryArgs,
|
|
283
288
|
workDirs: expected.workDirs,
|
|
284
289
|
dashboardUrl: expected.dashboardUrl,
|
|
285
290
|
nodeExecutable: expected.nodeExecutable,
|
|
286
291
|
cliEntryPoint: expected.cliEntryPoint,
|
|
287
|
-
|
|
288
|
-
|
|
292
|
+
optimizeForSize,
|
|
293
|
+
})).join("\n")));
|
|
294
|
+
if (!matchesCurrentRuntime) {
|
|
289
295
|
problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
|
|
290
296
|
}
|
|
291
297
|
return problems;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
|
|
4
|
+
import { autostartStatus, installAutostartAgent, registeredLauncherUpgradeProblems, registeredRuntimePathProblems, } from "./autostart.js";
|
|
5
5
|
import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
|
|
6
6
|
import { isAutostartOptedOut } from "./autostart-opt-out.js";
|
|
7
7
|
export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
|
|
@@ -52,6 +52,14 @@ async function readAutostartProblem(paths, options, platform) {
|
|
|
52
52
|
platform,
|
|
53
53
|
})
|
|
54
54
|
: [];
|
|
55
|
+
// BLI-23317: an install that still runs node without --optimize-for-size works, so it is not "broken";
|
|
56
|
+
// it is rewritten once here, on the normal cadence, and logged as its own reason.
|
|
57
|
+
const upgradeProblems = status.status === "loaded"
|
|
58
|
+
? await registeredLauncherUpgradeProblems({ homeDir: options.homeDir, exec: options.exec, platform })
|
|
59
|
+
: [];
|
|
60
|
+
if (upgradeProblems.length > 0) {
|
|
61
|
+
console.error("[autostart-self-heal] registration runs node without --optimize-for-size; rewriting it", JSON.stringify({ reason: "registration_predates_optimize_for_size", platform }));
|
|
62
|
+
}
|
|
55
63
|
const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
|
|
56
64
|
// BLI-21564: the reminder job is repaired with the sync agent. A missing or broken reminder job is a
|
|
57
65
|
// problem of its own (an existing install that predates it gets it created here), and the opt-out
|
|
@@ -59,7 +67,7 @@ async function readAutostartProblem(paths, options, platform) {
|
|
|
59
67
|
const remindProblem = status.status === "loaded" && status.remind && status.remind.status !== "loaded" && status.remind.status !== "unsupported"
|
|
60
68
|
? [`reminder job ${status.remind.status === "absent" ? "absent" : status.remind.message ?? "not loaded"}`]
|
|
61
69
|
: [];
|
|
62
|
-
if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && remindProblem.length === 0) {
|
|
70
|
+
if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && upgradeProblems.length === 0 && remindProblem.length === 0) {
|
|
63
71
|
// Healthy is the steady state and stays silent. A marker left by an
|
|
64
72
|
// earlier attempt is spent: that repair worked, so the next break starts
|
|
65
73
|
// fresh instead of waiting out a retry window.
|
|
@@ -70,6 +78,7 @@ async function readAutostartProblem(paths, options, platform) {
|
|
|
70
78
|
...(absent ? ["registration absent"] : []),
|
|
71
79
|
...(status.message ? [status.message] : []),
|
|
72
80
|
...runtimeProblems,
|
|
81
|
+
...upgradeProblems,
|
|
73
82
|
...remindProblem,
|
|
74
83
|
]
|
|
75
84
|
.join("; ")
|
|
@@ -110,6 +110,9 @@ export async function readRegisteredWindowsRuntimePaths(homeDir) {
|
|
|
110
110
|
return {
|
|
111
111
|
node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
112
112
|
cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
113
|
+
optimize_for_size: /& \$nodeExecutable --optimize-for-size \$cliEntryPoint sync /u.test(script)
|
|
114
|
+
? true
|
|
115
|
+
: /& \$nodeExecutable \$cliEntryPoint sync /u.test(script) ? false : null,
|
|
113
116
|
};
|
|
114
117
|
}
|
|
115
118
|
/**
|
|
@@ -125,15 +128,22 @@ export async function syncScriptProblem(options) {
|
|
|
125
128
|
if (!options.repoRoots || options.repoRoots.length === 0)
|
|
126
129
|
return null;
|
|
127
130
|
const settings = resolveAutostartSettings(options, "win32");
|
|
128
|
-
const
|
|
131
|
+
const onDisk = await readFile(scriptPath, "utf8").catch(() => null);
|
|
132
|
+
const expectedFor = async (optimizeForSize) => `${UTF8_BOM}${renderWindowsSyncScript({
|
|
129
133
|
discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
|
|
130
134
|
workDirs: settings.work_dirs,
|
|
131
135
|
dashboardUrl: settings.dashboard_url,
|
|
132
136
|
nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
|
|
133
137
|
cliEntryPoint: settings.cli_entry_point,
|
|
134
138
|
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
139
|
+
optimizeForSize,
|
|
135
140
|
})}`;
|
|
136
|
-
|
|
141
|
+
// BLI-23317: the script with --optimize-for-size is what is written; the older one without it is still accepted.
|
|
142
|
+
const difference = renderedFileDifference(onDisk, await expectedFor(true)) === null
|
|
143
|
+
? null
|
|
144
|
+
: renderedFileDifference(onDisk, await expectedFor(false)) === null
|
|
145
|
+
? null
|
|
146
|
+
: renderedFileDifference(onDisk, await expectedFor(true));
|
|
137
147
|
return difference
|
|
138
148
|
? `sync script does not match the current roots or Tower runtime (${describeDifference(difference)})`
|
|
139
149
|
: null;
|
|
@@ -271,13 +281,14 @@ export function renderWindowsLauncherScript(options) {
|
|
|
271
281
|
].join("\r\n");
|
|
272
282
|
}
|
|
273
283
|
function renderWindowsSyncScript(options) {
|
|
284
|
+
const nodeFlag = options.optimizeForSize === false ? "" : " --optimize-for-size";
|
|
274
285
|
const dashboardArgs = isDefaultDashboardUrl(options.dashboardUrl)
|
|
275
286
|
? ""
|
|
276
287
|
: ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
|
|
277
288
|
const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
278
289
|
const commands = options.workDirs.flatMap((root) => [
|
|
279
290
|
"try {",
|
|
280
|
-
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
|
|
291
|
+
` & $nodeExecutable${nodeFlag} $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
|
|
281
292
|
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
282
293
|
"} catch {",
|
|
283
294
|
" Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
|
package/dist/autostart.js
CHANGED
|
@@ -75,7 +75,20 @@ export async function readRegisteredRuntimePaths(options) {
|
|
|
75
75
|
if (platform === "win32") {
|
|
76
76
|
return readRegisteredWindowsRuntimePaths(options.homeDir);
|
|
77
77
|
}
|
|
78
|
-
return { node_executable: null, cli_entry_point: null };
|
|
78
|
+
return { node_executable: null, cli_entry_point: null, optimize_for_size: null };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* BLI-23317: a registration written before `--optimize-for-size` still RUNS, so it is not a broken
|
|
82
|
+
* registration and `registeredRuntimePathProblems` stays silent about it. The scheduled tick's
|
|
83
|
+
* self-heal asks this separately so the existing fleet is rewritten to the new form on its normal
|
|
84
|
+
* cadence instead of waiting for a hand-run `cockpit doctor`.
|
|
85
|
+
*/
|
|
86
|
+
export async function registeredLauncherUpgradeProblems(options) {
|
|
87
|
+
const platform = options.platform ?? process.platform;
|
|
88
|
+
if (platform !== (options.hostPlatform ?? process.platform))
|
|
89
|
+
return [];
|
|
90
|
+
const registered = await readRegisteredRuntimePaths(options);
|
|
91
|
+
return registered.optimize_for_size === false ? ["registration predates --optimize-for-size"] : [];
|
|
79
92
|
}
|
|
80
93
|
/**
|
|
81
94
|
* Named problems with the registered runtime, or an empty list. Path-free
|
package/dist/cli.js
CHANGED
|
@@ -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.266");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pass one of the streamed transcript: find every embedded image line by line and keep only what the
|
|
3
|
+
* describe step needs, never the text (BLI-23317).
|
|
4
|
+
*
|
|
5
|
+
* `findEmbeddedImages` over the whole text and over each line give the same images, because an image's
|
|
6
|
+
* characters never include "\n" and the finder's quote and data-URL anchors sit inside one line
|
|
7
|
+
* (`slimPass` already throws when one does not). So the census is the whole-text finder run per line,
|
|
8
|
+
* and what outlives a line is small: one record per distinct image, a tally per image of how often it
|
|
9
|
+
* occurs and how many characters swapping each occurrence saves, and the base64 of only those images
|
|
10
|
+
* the describe step may send (a copy, so no line or chunk stays alive behind it).
|
|
11
|
+
*/
|
|
12
|
+
import { findEmbeddedImages, formatImageMarker, } from "@bli-cockpit/telemetry-core";
|
|
13
|
+
/** Could this line hold an embedded image? A superset of what the finder matches: its anchors are `base64` and the four image magics. */
|
|
14
|
+
export function lineMayHoldImage(line) {
|
|
15
|
+
return line.includes("base64") || line.includes("iVBOR") || line.includes("/9j/") || line.includes("R0lGO") || line.includes("UklGR");
|
|
16
|
+
}
|
|
17
|
+
export async function censusOfLines(lines, keepBase64) {
|
|
18
|
+
const census = { total: 0, unique: new Map(), tally: new Map(), base64: new Map() };
|
|
19
|
+
for await (const line of lines) {
|
|
20
|
+
if (!lineMayHoldImage(line))
|
|
21
|
+
continue;
|
|
22
|
+
for (const image of findEmbeddedImages(line)) {
|
|
23
|
+
census.total += 1;
|
|
24
|
+
const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
|
|
25
|
+
const tally = census.tally.get(image.sha256) ?? { count: 0, savedChars: 0 };
|
|
26
|
+
tally.count += 1;
|
|
27
|
+
tally.savedChars += image.replaceEnd - image.replaceStart - marker.length;
|
|
28
|
+
census.tally.set(image.sha256, tally);
|
|
29
|
+
if (census.unique.has(image.sha256))
|
|
30
|
+
continue;
|
|
31
|
+
census.unique.set(image.sha256, image);
|
|
32
|
+
if (keepBase64(image))
|
|
33
|
+
census.base64.set(image.sha256, Buffer.from(line.slice(image.base64Start, image.base64End), "latin1"));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return census;
|
|
37
|
+
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, formatImageMarker, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
|
|
9
9
|
import { postImageToDescribeDoor } from "./image-describe-client.js";
|
|
10
10
|
import { resolveImageHandlingMode } from "./image-mode-state.js";
|
|
11
|
-
import {
|
|
11
|
+
import { censusOfLines } from "./image-census-stream.js";
|
|
12
|
+
import { openTranscriptLines, TranscriptNotStreamable, TranscriptTooLargeError } from "./adapters/transcript-text-stream.js";
|
|
13
|
+
import { slimLineStream } from "./slim/slim-stream.js";
|
|
14
|
+
import { logSlimmed, slimOrKeep, slimPassOrNull, slimTranscript } from "./slim/slim-transcript.js";
|
|
12
15
|
import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
|
|
13
16
|
/** New images sent to the door in one sync (all worktrees together); the count never refills within a tick. */
|
|
14
17
|
export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
|
|
@@ -34,16 +37,19 @@ export async function openImageDescriptions(options) {
|
|
|
34
37
|
const maxRunMs = options.maxRunMs ?? IMAGE_DESCRIBE_MAX_RUN_MS;
|
|
35
38
|
const sentThisRun = 0;
|
|
36
39
|
const run = { options, mode, fetchImpl, described, orderedKeys, failedImages, stateDirty, settledThisRun, now, describingMs: 0, maxPerRun, maxRunMs, sentThisRun };
|
|
40
|
+
const streamMinBytes = options.streamMinBytes ?? STREAM_MIN_BYTES;
|
|
37
41
|
return {
|
|
38
42
|
mode,
|
|
43
|
+
streamMinBytes,
|
|
39
44
|
isDescribed: (sha256) => described.has(imageStateKey(sha256)),
|
|
40
45
|
hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
|
|
41
46
|
async prepareTranscript(input) {
|
|
42
|
-
const {
|
|
47
|
+
const { replace, finds, ...afterImages } = await describeAndReplace(run, input, findsOfText(input.text));
|
|
43
48
|
if (mode !== "describe_and_drop")
|
|
44
49
|
return { ...afterImages, text: input.text, changed: false, slim: null };
|
|
45
50
|
// BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
|
|
46
51
|
// intermediate copy). A pass that cannot run falls back to the two-step order, which is the same bytes.
|
|
52
|
+
const swap = { images: finds.images, replace };
|
|
47
53
|
const pass = slimPassOrNull(input.text, input.source, { sessionId: input.sessionId }, swap);
|
|
48
54
|
if (pass)
|
|
49
55
|
return { ...afterImages, text: pass.text, changed: pass.changed, slim: pass.stats };
|
|
@@ -51,8 +57,79 @@ export async function openImageDescriptions(options) {
|
|
|
51
57
|
const slim = slimOrKeep(replaced.text, input.source, { sessionId: input.sessionId });
|
|
52
58
|
return { ...afterImages, text: slim.text, changed: slim.text !== input.text, slim: slim.stats };
|
|
53
59
|
},
|
|
60
|
+
prepareTranscriptFile: mode === "describe_and_drop" ? (input) => prepareFileInTwoPasses(run, input) : undefined,
|
|
54
61
|
};
|
|
55
62
|
}
|
|
63
|
+
/** Files smaller than this are cheaper to read whole; the streamed path pays a second read. */
|
|
64
|
+
const STREAM_MIN_BYTES = 8 * 1024 * 1024;
|
|
65
|
+
function findsOfText(text) {
|
|
66
|
+
const images = findEmbeddedImages(text);
|
|
67
|
+
const unique = new Map();
|
|
68
|
+
for (const image of images)
|
|
69
|
+
if (!unique.has(image.sha256))
|
|
70
|
+
unique.set(image.sha256, image);
|
|
71
|
+
return {
|
|
72
|
+
total: images.length,
|
|
73
|
+
unique,
|
|
74
|
+
images,
|
|
75
|
+
base64For: (image) => base64Of(text, image),
|
|
76
|
+
swapped: (replace) => countImageSwaps({ images, replace }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function findsOfCensus(census) {
|
|
80
|
+
return {
|
|
81
|
+
total: census.total,
|
|
82
|
+
unique: census.unique,
|
|
83
|
+
base64For: (image) => {
|
|
84
|
+
const kept = census.base64.get(image.sha256);
|
|
85
|
+
if (!kept)
|
|
86
|
+
throw new Error("image_base64_not_kept");
|
|
87
|
+
return kept.toString("latin1");
|
|
88
|
+
},
|
|
89
|
+
swapped: (replace) => {
|
|
90
|
+
let replacedCount = 0;
|
|
91
|
+
let charsSaved = 0;
|
|
92
|
+
for (const [sha256, image] of census.unique) {
|
|
93
|
+
if (!replace(image))
|
|
94
|
+
continue;
|
|
95
|
+
const tally = census.tally.get(sha256);
|
|
96
|
+
replacedCount += tally.count;
|
|
97
|
+
charsSaved += tally.savedChars;
|
|
98
|
+
}
|
|
99
|
+
return { replacedCount, charsSaved };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function prepareFileInTwoPasses(run, input) {
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
// Pass one: discover. Nothing of the text is kept; only the images and, for those that may still be sent, their base64.
|
|
106
|
+
const room = Math.max(0, run.maxPerRun - run.sentThisRun);
|
|
107
|
+
let wanted = 0;
|
|
108
|
+
const first = openTranscriptLines(input.filePath, { stopAfter: input.maxFileBytes ? input.maxFileBytes + 1 : undefined });
|
|
109
|
+
const census = await censusOfLines(first.lines, (image) => {
|
|
110
|
+
// The same filters `selectImagesToDescribe` applies, so what is kept is what may be sent.
|
|
111
|
+
const key = imageStateKey(image.sha256);
|
|
112
|
+
const failure = run.failedImages[key];
|
|
113
|
+
if (wanted >= room || run.described.has(key) || run.settledThisRun.has(image.sha256) || image.byteSize > IMAGE_DESCRIBE_MAX_BYTES)
|
|
114
|
+
return false;
|
|
115
|
+
if (failure && failure.retry_after > run.now())
|
|
116
|
+
return false;
|
|
117
|
+
wanted += 1;
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
const readBytes = first.bytesRead();
|
|
121
|
+
if (input.maxFileBytes && readBytes > input.maxFileBytes)
|
|
122
|
+
throw new TranscriptTooLargeError(input.maxFileBytes);
|
|
123
|
+
// Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
|
|
124
|
+
const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census));
|
|
125
|
+
// Pass two: swap and slim over exactly the bytes pass one saw.
|
|
126
|
+
const second = openTranscriptLines(input.filePath, { byteLimit: readBytes });
|
|
127
|
+
const slim = await slimLineStream(second.lines, input.source, replace, readBytes);
|
|
128
|
+
if (slim.replacedCount !== afterImages.replacedCount)
|
|
129
|
+
throw new TranscriptNotStreamable("images_changed_between_passes");
|
|
130
|
+
logSlimmed(input.source, input.sessionId, slim.stats, startedAt);
|
|
131
|
+
return { ...afterImages, text: slim.text, changed: slim.changed, slim: slim.stats, readBytes };
|
|
132
|
+
}
|
|
56
133
|
function describeRoomLeft(run, sha256s) {
|
|
57
134
|
if (run.sentThisRun >= run.maxPerRun)
|
|
58
135
|
return false;
|
|
@@ -74,7 +151,7 @@ function markDescribed(run, sha256) {
|
|
|
74
151
|
described.add(key);
|
|
75
152
|
orderedKeys.push(key);
|
|
76
153
|
}
|
|
77
|
-
async function sendOne(run, image,
|
|
154
|
+
async function sendOne(run, image, base64, input) {
|
|
78
155
|
const { options, fetchImpl, settledThisRun, failedImages } = run;
|
|
79
156
|
run.sentThisRun += 1;
|
|
80
157
|
const outcome = await postImageToDescribeDoor({ fetchImpl, dashboardUrl: options.dashboardUrl, deviceToken: options.deviceToken, sleep: options.sleep }, {
|
|
@@ -82,7 +159,7 @@ async function sendOne(run, image, text, input) {
|
|
|
82
159
|
sha256: image.sha256,
|
|
83
160
|
media_type: image.mediaType,
|
|
84
161
|
byte_size: image.byteSize,
|
|
85
|
-
content_base64:
|
|
162
|
+
content_base64: base64,
|
|
86
163
|
source: input.source,
|
|
87
164
|
session_id: input.sessionId.slice(0, 200),
|
|
88
165
|
});
|
|
@@ -113,19 +190,15 @@ function rememberFailure(run, key, outcome) {
|
|
|
113
190
|
run.stateDirty = true;
|
|
114
191
|
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) }));
|
|
115
192
|
}
|
|
116
|
-
async function describeAndReplace(run, input) {
|
|
193
|
+
async function describeAndReplace(run, input, finds) {
|
|
117
194
|
const { mode, described, options, orderedKeys, failedImages } = run;
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
return {
|
|
122
|
-
const unique = new Map();
|
|
123
|
-
for (const image of images)
|
|
124
|
-
if (!unique.has(image.sha256))
|
|
125
|
-
unique.set(image.sha256, image);
|
|
195
|
+
const replace = (image) => described.has(imageStateKey(image.sha256));
|
|
196
|
+
const { unique } = finds;
|
|
197
|
+
if (finds.total === 0)
|
|
198
|
+
return { replace, finds, pendingImages: [], imageCount: 0, uniqueCount: 0, replacedCount: 0, charsSaved: 0, deferredOnly: false };
|
|
126
199
|
const counts = { settled_earlier: 0, already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
|
|
127
200
|
const toSend = selectImagesToDescribe(run, unique, counts);
|
|
128
|
-
await sendPendingImages(run, toSend, counts, input);
|
|
201
|
+
await sendPendingImages(run, toSend, counts, input, finds.base64For);
|
|
129
202
|
const failedReasons = countImageOutcomes(run, toSend, counts);
|
|
130
203
|
if (counts.described_now + counts.known > 0 || run.stateDirty) {
|
|
131
204
|
await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
|
|
@@ -133,24 +206,30 @@ async function describeAndReplace(run, input) {
|
|
|
133
206
|
}
|
|
134
207
|
const deferredOnly = counts.over_budget + counts.backing_off > 0
|
|
135
208
|
&& counts.described_now + counts.known + counts.failed + counts.too_large + counts.settled_earlier === 0;
|
|
136
|
-
const replaced = mode === "describe_and_drop" ?
|
|
137
|
-
|
|
209
|
+
const replaced = mode === "describe_and_drop" ? finds.swapped(replace) : { replacedCount: 0, charsSaved: 0 };
|
|
210
|
+
// BLI-23317: an image over the describe limit is never sent, so it can never gain a description and
|
|
211
|
+
// never changes the output. Listing it made the describer look like it still had room, and the
|
|
212
|
+
// transcript was rebuilt on every tick (rebuilt_describer_has_room). Counted in `too_large` above.
|
|
213
|
+
const pendingImages = [...unique.values()]
|
|
214
|
+
.filter((image) => !described.has(imageStateKey(image.sha256)) && image.byteSize <= IMAGE_DESCRIBE_MAX_BYTES)
|
|
215
|
+
.map((image) => image.sha256);
|
|
138
216
|
console.error(TAG + " transcript prepared", JSON.stringify({
|
|
139
217
|
mode,
|
|
140
218
|
source: input.source,
|
|
141
|
-
images:
|
|
219
|
+
images: finds.total,
|
|
142
220
|
unique: unique.size,
|
|
143
221
|
...counts,
|
|
144
222
|
deferred_only: deferredOnly,
|
|
145
223
|
failed_reasons: failedReasons,
|
|
146
224
|
replaced: replaced.replacedCount,
|
|
147
225
|
chars_saved: replaced.charsSaved,
|
|
148
|
-
kept_without_description: mode === "describe_and_drop" ?
|
|
226
|
+
kept_without_description: mode === "describe_and_drop" ? finds.total - replaced.replacedCount : 0,
|
|
149
227
|
}));
|
|
150
228
|
return {
|
|
151
|
-
|
|
229
|
+
replace,
|
|
230
|
+
finds,
|
|
152
231
|
pendingImages,
|
|
153
|
-
imageCount:
|
|
232
|
+
imageCount: finds.total,
|
|
154
233
|
uniqueCount: unique.size,
|
|
155
234
|
replacedCount: replaced.replacedCount,
|
|
156
235
|
charsSaved: replaced.charsSaved,
|
|
@@ -197,7 +276,7 @@ function selectImagesToDescribe(run, unique, counts) {
|
|
|
197
276
|
}
|
|
198
277
|
return toSend;
|
|
199
278
|
}
|
|
200
|
-
async function sendPendingImages(run, toSend, counts, input) {
|
|
279
|
+
async function sendPendingImages(run, toSend, counts, input, base64For) {
|
|
201
280
|
const { now, maxPerRun, maxRunMs } = run;
|
|
202
281
|
const callStartedAt = now();
|
|
203
282
|
let next = 0;
|
|
@@ -209,7 +288,7 @@ async function sendPendingImages(run, toSend, counts, input) {
|
|
|
209
288
|
counts.over_budget += 1;
|
|
210
289
|
continue;
|
|
211
290
|
}
|
|
212
|
-
await sendOne(run, image,
|
|
291
|
+
await sendOne(run, image, base64For(image), input);
|
|
213
292
|
}
|
|
214
293
|
};
|
|
215
294
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, toSend.length) }, worker));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import v8 from "node:v8";
|
|
2
|
+
/**
|
|
3
|
+
* BLI-23317: run V8 with `--optimize-for-size`. Measured 15-30% lower peak memory with no slowdown on
|
|
4
|
+
* Node 20-26 (one 200 MB transcript 848 -> 721 MB; CLI floor 113 -> 94 MB). `NODE_OPTIONS` rejects the
|
|
5
|
+
* flag, so the scheduled launchers pass it on the node command line and a hand-run `cockpit` applies it
|
|
6
|
+
* here, as the first statement of the entry (`optimize-for-size-entry.ts`, imported before anything heavy).
|
|
7
|
+
*
|
|
8
|
+
* Only a refusal is logged: this runs at the start of every command a person types, and a stderr line
|
|
9
|
+
* on each success would be noise in their terminal. A refusal is logged and ignored, never fatal: the flag is a memory saving, not a requirement.
|
|
10
|
+
*/
|
|
11
|
+
export const OPTIMIZE_FOR_SIZE_FLAG = "--optimize-for-size";
|
|
12
|
+
export function applyOptimizeForSize(options = {}) {
|
|
13
|
+
const execArgv = options.execArgv ?? process.execArgv;
|
|
14
|
+
if (execArgv.includes(OPTIMIZE_FOR_SIZE_FLAG)) {
|
|
15
|
+
return "already_set_by_launcher";
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
(options.setFlags ?? v8.setFlagsFromString)(OPTIMIZE_FOR_SIZE_FLAG);
|
|
19
|
+
return "applied";
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error("[optimize-for-size] could not apply the flag; continuing without it", JSON.stringify({ reason: "set_flags_refused", error: error instanceof Error ? error.name : "unknown" }));
|
|
23
|
+
return "refused";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pass two of the streamed transcript: image swap and slimming over lines as they arrive (BLI-23317).
|
|
3
|
+
*
|
|
4
|
+
* The same per-line work as `slimPass` (swap the chosen images for markers inside the line, then the
|
|
5
|
+
* slim rules), with the lines coming from a stream instead of a decoded string, so the original text
|
|
6
|
+
* and the slimmed text are never both whole in memory. Output is byte-identical to `slimPass` on the
|
|
7
|
+
* joined input; `raw-evidence-transcript-stream.test.ts` and the frozen goldens hold it to that.
|
|
8
|
+
*
|
|
9
|
+
* The result is ONE string, because the sanitizer after it takes one. Slimmed lines are written as
|
|
10
|
+
* UTF-8 into one byte buffer sized to the file (off the JS heap, one byte per ASCII character, no
|
|
11
|
+
* per-line garbage waiting on the collector) and decoded once at the end, so the last step holds the
|
|
12
|
+
* bytes beside the string they make, not a second copy of the string. Text that came from valid
|
|
13
|
+
* UTF-8 survives encode and decode unchanged; the slim rules re-escape lone surrogates, so none is
|
|
14
|
+
* ever written.
|
|
15
|
+
*/
|
|
16
|
+
import { findEmbeddedImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
|
|
17
|
+
import { lineMayHoldImage } from "../image-census-stream.js";
|
|
18
|
+
import { createSlimmer } from "./slim-transcript.js";
|
|
19
|
+
const NEWLINE = 0x0a;
|
|
20
|
+
/** UTF-8 bytes of the lines so far, in a buffer that starts at the input's size and grows only if the output outgrows it. */
|
|
21
|
+
class LineBytes {
|
|
22
|
+
buffer;
|
|
23
|
+
length = 0;
|
|
24
|
+
constructor(capacity) {
|
|
25
|
+
this.buffer = Buffer.allocUnsafe(Math.max(capacity, 1024));
|
|
26
|
+
}
|
|
27
|
+
reserve(extra) {
|
|
28
|
+
const needed = this.length + extra;
|
|
29
|
+
if (needed <= this.buffer.length)
|
|
30
|
+
return;
|
|
31
|
+
const grown = Buffer.allocUnsafe(Math.max(needed, Math.ceil(this.buffer.length * 1.25)));
|
|
32
|
+
this.buffer.copy(grown, 0, 0, this.length);
|
|
33
|
+
this.buffer = grown;
|
|
34
|
+
}
|
|
35
|
+
newline() {
|
|
36
|
+
this.reserve(1);
|
|
37
|
+
this.buffer[this.length++] = NEWLINE;
|
|
38
|
+
}
|
|
39
|
+
line(text) {
|
|
40
|
+
const size = Buffer.byteLength(text);
|
|
41
|
+
this.reserve(size);
|
|
42
|
+
this.length += this.buffer.write(text, this.length, size, "utf8");
|
|
43
|
+
}
|
|
44
|
+
text() {
|
|
45
|
+
return this.buffer.toString("utf8", 0, this.length);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function slimLineStream(lines, kind, replace, inputBytes) {
|
|
49
|
+
const slimmer = createSlimmer(kind);
|
|
50
|
+
const output = new LineBytes(inputBytes);
|
|
51
|
+
let replacedCount = 0;
|
|
52
|
+
let charsSaved = 0;
|
|
53
|
+
let changed = false;
|
|
54
|
+
let first = true;
|
|
55
|
+
for await (const original of lines) {
|
|
56
|
+
let line = original;
|
|
57
|
+
if (lineMayHoldImage(line)) {
|
|
58
|
+
const found = findEmbeddedImages(line);
|
|
59
|
+
if (found.length > 0) {
|
|
60
|
+
const replaced = replaceImagesWithMarkers(line, found, replace);
|
|
61
|
+
if (replaced.replacedCount > 0) {
|
|
62
|
+
line = replaced.text;
|
|
63
|
+
replacedCount += replaced.replacedCount;
|
|
64
|
+
charsSaved += replaced.charsSaved;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const out = slimmer.slimLine(line);
|
|
69
|
+
if (line !== original || out !== original)
|
|
70
|
+
changed = true;
|
|
71
|
+
if (!first)
|
|
72
|
+
output.newline();
|
|
73
|
+
first = false;
|
|
74
|
+
output.line(out);
|
|
75
|
+
}
|
|
76
|
+
return { text: output.text(), stats: slimmer.stats(), replacedCount, charsSaved, changed };
|
|
77
|
+
}
|
|
@@ -170,7 +170,7 @@ export function slimTranscript(text, kind) {
|
|
|
170
170
|
return { text: slim.text, stats: slim.stats };
|
|
171
171
|
}
|
|
172
172
|
const TAG = "[slim-transcript]";
|
|
173
|
-
function logSlimmed(kind, sessionId, stats, startedAt) {
|
|
173
|
+
export function logSlimmed(kind, sessionId, stats, startedAt) {
|
|
174
174
|
console.error(TAG + (stats.linesModified === 0 ? " nothing to strip" : " transcript slimmed"), JSON.stringify({
|
|
175
175
|
source: kind,
|
|
176
176
|
session_id: sessionId.slice(0, 80),
|