@bli-cockpit/cli 0.2.267 → 0.2.269
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/pinned-transcript.js +143 -0
- package/dist/adapters/raw-evidence-budget-admission.js +19 -6
- package/dist/adapters/raw-evidence-manifest.js +6 -3
- package/dist/adapters/raw-evidence-sanitize-held.js +17 -9
- package/dist/adapters/raw-evidence-snapshot-supersede.js +55 -5
- package/dist/adapters/raw-evidence-stage-file.js +43 -0
- package/dist/adapters/raw-evidence-transcript-body.js +8 -1
- package/dist/adapters/raw-evidence-transcript-file.js +56 -73
- package/dist/adapters/raw-evidence-transcript-stage.js +84 -0
- package/dist/adapters/raw-evidence-transcript-stream.js +3 -3
- package/dist/adapters/staged-output.js +128 -0
- package/dist/adapters/transcript-text-stream.js +9 -91
- package/dist/commands/public-root.js +1 -1
- package/dist/image-byte-scan.js +185 -0
- package/dist/image-census-stream.js +33 -23
- package/dist/image-description-send.js +80 -0
- package/dist/image-description-session.js +47 -104
- package/dist/slim/claude-rules.js +19 -1
- package/dist/slim/codex-rules.js +19 -1
- package/dist/slim/slim-stream.js +84 -41
- package/dist/slim/slim-support.js +22 -0
- package/dist/slim/slim-transcript.js +1 -1
- package/dist/staging-gate.js +36 -16
- package/package.json +4 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transcript opened ONCE, its length pinned, read as byte lines by both passes and by the describe
|
|
3
|
+
* step's payload reads (BLI-23317, stage D).
|
|
4
|
+
*
|
|
5
|
+
* The census records where each image sits in THIS file's bytes, and the swap pass and the describe
|
|
6
|
+
* request read those very offsets later, so every read must come from the same bytes. Therefore:
|
|
7
|
+
*
|
|
8
|
+
* - the file is opened once and the length is the descriptor's size at that moment; nothing past it
|
|
9
|
+
* is ever read, so a live session appending meanwhile cannot move an offset;
|
|
10
|
+
* - every read goes through that descriptor (a rename over the path leaves this snapshot intact);
|
|
11
|
+
* - at the end `verifyUnchanged` asks the descriptor again. A file that got SHORTER, or that kept its
|
|
12
|
+
* size and changed its mtime (rewritten in place), is `file_changed_during_pass`: the caller sets the
|
|
13
|
+
* streamed result aside and the next tick builds it again. A file that only GREW is an append, and
|
|
14
|
+
* the bytes below the pinned length are the bytes the passes saw, so it stands (and is logged).
|
|
15
|
+
*
|
|
16
|
+
* Lines are split at 0x0a on bytes, which is the same cut as splitting the decoded text at "\n" for
|
|
17
|
+
* valid UTF-8 (the byte never occurs inside a multi-byte character). Each line is a view into the read
|
|
18
|
+
* buffer where it fits in one chunk, and a fresh copy where it spans chunks: a view is valid until the
|
|
19
|
+
* generator is asked for the next line, so a consumer must not keep one.
|
|
20
|
+
*
|
|
21
|
+
* Failure vocabulary matches `transcript-text-stream.ts`: a read that fails is a `TranscriptReadError`
|
|
22
|
+
* (never a short file), a file over the cap is `TranscriptTooLargeError`.
|
|
23
|
+
*/
|
|
24
|
+
import fsp from "node:fs/promises";
|
|
25
|
+
import { describeError } from "../health-detail.js";
|
|
26
|
+
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
|
|
27
|
+
const TAG = "[pinned-transcript]";
|
|
28
|
+
const NEWLINE = 0x0a;
|
|
29
|
+
/** Bytes per read step into one reused buffer. */
|
|
30
|
+
export const PINNED_CHUNK_BYTES = 256 * 1024;
|
|
31
|
+
export async function openPinnedTranscript(filePath, options = {}) {
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await fsp.open(filePath, "r");
|
|
35
|
+
const first = await handle.stat();
|
|
36
|
+
if (options.maxFileBytes && first.size > options.maxFileBytes) {
|
|
37
|
+
await handle.close().catch(() => undefined);
|
|
38
|
+
handle = undefined;
|
|
39
|
+
console.error(TAG + " transcript over the file cap, nothing read", JSON.stringify({ reason: "file_too_large", byte_size: first.size, max_file_bytes: options.maxFileBytes }));
|
|
40
|
+
throw new TranscriptTooLargeError(options.maxFileBytes);
|
|
41
|
+
}
|
|
42
|
+
return pinned(handle, first.size, first.mtimeMs);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof TranscriptTooLargeError)
|
|
46
|
+
throw error;
|
|
47
|
+
await handle?.close().catch(() => undefined);
|
|
48
|
+
console.error(TAG + " transcript could not be opened", JSON.stringify({ reason: "file_read_failed", stage: "open", ...describeError(error) }));
|
|
49
|
+
throw new TranscriptReadError("open", error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function pinned(handle, length, mtimeMs) {
|
|
53
|
+
let closed = false;
|
|
54
|
+
const readFailed = (stage, bytesRead, error) => {
|
|
55
|
+
console.error(TAG + " transcript could not be read to its end", JSON.stringify({ reason: "file_read_failed", stage, bytes_read: bytesRead, ...describeError(error) }));
|
|
56
|
+
throw new TranscriptReadError(stage, error);
|
|
57
|
+
};
|
|
58
|
+
async function* lines() {
|
|
59
|
+
if (length === 0) {
|
|
60
|
+
yield { start: 0, bytes: Buffer.alloc(0) };
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const chunk = Buffer.allocUnsafe(PINNED_CHUNK_BYTES);
|
|
64
|
+
let held = [];
|
|
65
|
+
let heldBytes = 0;
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
for (let position = 0; position < length;) {
|
|
68
|
+
let bytesRead;
|
|
69
|
+
try {
|
|
70
|
+
({ bytesRead } = await handle.read(chunk, 0, Math.min(PINNED_CHUNK_BYTES, length - position), position));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return readFailed("stream", position, error);
|
|
74
|
+
}
|
|
75
|
+
if (bytesRead === 0)
|
|
76
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
77
|
+
position += bytesRead;
|
|
78
|
+
const view = chunk.subarray(0, bytesRead);
|
|
79
|
+
let from = 0;
|
|
80
|
+
for (let newline = view.indexOf(NEWLINE, from); newline !== -1; newline = view.indexOf(NEWLINE, from)) {
|
|
81
|
+
const piece = view.subarray(from, newline);
|
|
82
|
+
const bytes = heldBytes === 0 ? piece : Buffer.concat([...held, piece], heldBytes + piece.length);
|
|
83
|
+
held = [];
|
|
84
|
+
heldBytes = 0;
|
|
85
|
+
yield { start: lineStart, bytes };
|
|
86
|
+
lineStart += bytes.length + 1;
|
|
87
|
+
from = newline + 1;
|
|
88
|
+
}
|
|
89
|
+
if (from < bytesRead) {
|
|
90
|
+
held.push(Buffer.from(view.subarray(from)));
|
|
91
|
+
heldBytes += bytesRead - from;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
yield { start: lineStart, bytes: heldBytes === 0 ? Buffer.alloc(0) : Buffer.concat(held, heldBytes) };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
length,
|
|
98
|
+
lines,
|
|
99
|
+
async read(offset, size) {
|
|
100
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
101
|
+
let done = 0;
|
|
102
|
+
while (done < size) {
|
|
103
|
+
let bytesRead;
|
|
104
|
+
try {
|
|
105
|
+
({ bytesRead } = await handle.read(buffer, done, size - done, offset + done));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return readFailed("range", offset + done, error);
|
|
109
|
+
}
|
|
110
|
+
if (bytesRead === 0)
|
|
111
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
112
|
+
done += bytesRead;
|
|
113
|
+
}
|
|
114
|
+
return buffer;
|
|
115
|
+
},
|
|
116
|
+
async verifyUnchanged() {
|
|
117
|
+
let now;
|
|
118
|
+
try {
|
|
119
|
+
now = await handle.stat();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return readFailed("verify", length, error);
|
|
123
|
+
}
|
|
124
|
+
if (now.size < length || (now.size === length && now.mtimeMs !== mtimeMs)) {
|
|
125
|
+
console.error(TAG + " transcript changed while it was being prepared, set aside for the next tick", JSON.stringify({
|
|
126
|
+
reason: "file_changed_during_pass", pinned_bytes: length, bytes_now: now.size, mtime_changed: now.mtimeMs !== mtimeMs,
|
|
127
|
+
}));
|
|
128
|
+
throw new TranscriptNotStreamable("file_changed_during_pass");
|
|
129
|
+
}
|
|
130
|
+
if (now.size > length) {
|
|
131
|
+
console.error(TAG + " transcript grew while it was being prepared; the pinned bytes stand", JSON.stringify({ reason: "source_grew_during_prepare", pinned_bytes: length, bytes_now: now.size }));
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async close() {
|
|
135
|
+
if (closed)
|
|
136
|
+
return;
|
|
137
|
+
closed = true;
|
|
138
|
+
await handle.close().catch((error) => {
|
|
139
|
+
console.error(TAG + " file handle did not close", JSON.stringify({ reason: "file_close_failed", ...describeError(error) }));
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { terminalDeliveryReason } from "../raw-evidence-staging.js";
|
|
2
|
-
import { admitToStaging } from "../staging-gate.js";
|
|
2
|
+
import { admitToStaging, roomToStageUpTo } from "../staging-gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* The one door a file passes before its bytes may be copied: the per-sync
|
|
5
5
|
* budget below, then the disk (BLI-12832). A refusal from the disk hands the
|
|
@@ -34,6 +34,23 @@ export function admitEvidence(collection, byteLength, file) {
|
|
|
34
34
|
refundBudget(collection.budget, charged);
|
|
35
35
|
return `staging_deferred:${diskReason}`;
|
|
36
36
|
}
|
|
37
|
+
/** The budget's refusal for a file of this size, with nothing charged or counted: the rule `admitToBudget` applies. */
|
|
38
|
+
function budgetDeferralReason(budget, byteLength) {
|
|
39
|
+
const single = budget.singleFileByteAllowance;
|
|
40
|
+
const firstLargeFile = single && budget.remainingBytes === single.normalBytes &&
|
|
41
|
+
byteLength <= single.maxFileBytes;
|
|
42
|
+
return budget.remainingObjects <= 0 ? "deferred_object_budget" :
|
|
43
|
+
byteLength > 0 && byteLength > budget.remainingBytes && !firstLargeFile ? "deferred_byte_budget" : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* May the streamed transcript pass write its output to staging while it makes it (BLI-23317 stage D)? The
|
|
47
|
+
* real admission needs the finished size and hash, so this asks ahead, read-only, with the source file's
|
|
48
|
+
* size as the bound: the per-sync budget, then the disk gate. A refusal returns its label and the pass
|
|
49
|
+
* writes nothing; the ordinary admission then refuses the file exactly as before. No refusal returns null.
|
|
50
|
+
*/
|
|
51
|
+
export function roomToStreamToStaging(collection, byteLength) {
|
|
52
|
+
return budgetDeferralReason(collection.budget, byteLength) ?? roomToStageUpTo(collection.budget.staging, byteLength);
|
|
53
|
+
}
|
|
37
54
|
/** Hands back one admission that a later gate refused, so nothing was staged and nothing counts as sent. */
|
|
38
55
|
function refundBudget(budget, byteLength) {
|
|
39
56
|
budget.remainingObjects += 1;
|
|
@@ -47,11 +64,7 @@ function refundBudget(budget, byteLength) {
|
|
|
47
64
|
* budget bounds buffered bytes.
|
|
48
65
|
*/
|
|
49
66
|
export function admitToBudget(budget, byteLength) {
|
|
50
|
-
const
|
|
51
|
-
const firstLargeFile = single && budget.remainingBytes === single.normalBytes &&
|
|
52
|
-
byteLength <= single.maxFileBytes;
|
|
53
|
-
const reason = budget.remainingObjects <= 0 ? "deferred_object_budget" :
|
|
54
|
-
byteLength > 0 && byteLength > budget.remainingBytes && !firstLargeFile ? "deferred_byte_budget" : null;
|
|
67
|
+
const reason = budgetDeferralReason(budget, byteLength);
|
|
55
68
|
if (reason) {
|
|
56
69
|
[budget.deferredObjects, budget.deferredBytes] = [(budget.deferredObjects ?? 0) + 1, (budget.deferredBytes ?? 0) + byteLength];
|
|
57
70
|
return reason;
|
|
@@ -4,7 +4,10 @@ import { remoteObjectKey, sha256 } from "./raw-evidence-keys.js";
|
|
|
4
4
|
export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
|
|
5
5
|
export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
|
|
6
6
|
export function evidenceEntry(options) {
|
|
7
|
-
const
|
|
7
|
+
const content = Buffer.isBuffer(options.bytes)
|
|
8
|
+
? { sha256: sha256(options.bytes), byteLength: options.bytes.byteLength }
|
|
9
|
+
: options.bytes;
|
|
10
|
+
const digest = content.sha256;
|
|
8
11
|
const objectKey = remoteObjectKey({
|
|
9
12
|
context: options.context,
|
|
10
13
|
packId: options.packId,
|
|
@@ -17,7 +20,7 @@ export function evidenceEntry(options) {
|
|
|
17
20
|
relative_path: options.relativePath,
|
|
18
21
|
object_key: objectKey,
|
|
19
22
|
content_hash_sha256: digest,
|
|
20
|
-
byte_size:
|
|
23
|
+
byte_size: content.byteLength,
|
|
21
24
|
media_type: options.mediaType,
|
|
22
25
|
redacted_summary: options.redactedSummary,
|
|
23
26
|
...(options.redaction ? { redaction: options.redaction } : {}),
|
|
@@ -34,7 +37,7 @@ export function evidenceEntry(options) {
|
|
|
34
37
|
storage_bucket: RAW_EVIDENCE_BUCKET,
|
|
35
38
|
object_key: objectKey,
|
|
36
39
|
content_hash_sha256: digest,
|
|
37
|
-
byte_size:
|
|
40
|
+
byte_size: content.byteLength,
|
|
38
41
|
},
|
|
39
42
|
}
|
|
40
43
|
: {}),
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { WHOLE_FILE_MASK_LINE, fallbackRedactionMetadata, logRedactionCrash, maskSecretBearingLines, } from "./raw-evidence-sanitize.js";
|
|
4
|
+
/** What `scannedCleanRedactionMetadata` gives for these bytes, from their hash and size (the same record, key for key). */
|
|
5
|
+
function scannedCleanFromFacts(facts) {
|
|
6
|
+
return {
|
|
7
|
+
...scannedCleanRedactionMetadata(Buffer.alloc(0)),
|
|
8
|
+
original_content_hash_sha256: facts.sha256,
|
|
9
|
+
sanitized_content_hash_sha256: facts.sha256,
|
|
10
|
+
original_byte_size: facts.byteLength,
|
|
11
|
+
sanitized_byte_size: facts.byteLength,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
4
14
|
/** Code units per encode window: 4 Mi units encode to at most 12 MiB, and the window is garbage right after. */
|
|
5
15
|
const WINDOW_UNITS = 4 * 1024 * 1024;
|
|
6
16
|
/**
|
|
@@ -28,16 +38,14 @@ function isHighSurrogate(unit) {
|
|
|
28
38
|
function isLowSurrogate(unit) {
|
|
29
39
|
return unit >= 0xdc00 && unit <= 0xdfff;
|
|
30
40
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Same decision table as `sanitizeTextEvidenceForUpload`. The holder's text is cleared before the
|
|
33
|
-
* bytes of the answer are built, so the answer is the only large thing left alive when this returns.
|
|
34
|
-
*/
|
|
35
41
|
export function sanitizeHeldTextForUpload(held, options) {
|
|
36
42
|
// No local in this frame points at the text: only `held` does, so clearing it frees the transcript.
|
|
37
43
|
let facts;
|
|
44
|
+
const { staged } = held;
|
|
38
45
|
const scan = {
|
|
39
46
|
textLength: held.text.length,
|
|
40
|
-
|
|
47
|
+
// Staged bytes were hashed as they were written, so the original's hash and size cost nothing here.
|
|
48
|
+
facts: () => (facts ??= staged ? { sha256: staged.sha256, byteLength: staged.byteLength } : held.exactBytes ? { sha256: sha256Of(held.exactBytes), byteLength: held.exactBytes.byteLength } : factsOfText(held.text)),
|
|
41
49
|
exactBytes: held.exactBytes,
|
|
42
50
|
redactedFields: options.redactedFields,
|
|
43
51
|
secretLikeFileName: options.secretLikeFileName === true,
|
|
@@ -55,6 +63,10 @@ export function sanitizeHeldTextForUpload(held, options) {
|
|
|
55
63
|
}
|
|
56
64
|
if (found.attempt.redacted)
|
|
57
65
|
return keepDeterministicRedaction(held, scan, found.attempt);
|
|
66
|
+
if (staged) {
|
|
67
|
+
held.text = null;
|
|
68
|
+
return { status: "clean", staged, redaction: scannedCleanFromFacts(staged) };
|
|
69
|
+
}
|
|
58
70
|
const bytes = held.exactBytes ?? Buffer.from(held.text, "utf8");
|
|
59
71
|
held.text = null;
|
|
60
72
|
return { status: "clean", bytes, redaction: scannedCleanRedactionMetadata(bytes) };
|
|
@@ -164,10 +176,6 @@ function stubForCrashedRedaction(scan, facts) {
|
|
|
164
176
|
completenessLabel: "redaction_crashed_stubbed",
|
|
165
177
|
};
|
|
166
178
|
}
|
|
167
|
-
/**
|
|
168
|
-
* Mask secret-like values in a transcript and record that it happened (see `maskSecretsInTranscript`
|
|
169
|
-
* in raw-evidence-sanitize.ts, whose answer this one matches), from text the caller holds once.
|
|
170
|
-
*/
|
|
171
179
|
export function maskSecretsInHeldTranscript(collection, held, options) {
|
|
172
180
|
const sanitized = sanitizeHeldTextForUpload(held, {
|
|
173
181
|
redactedFields: [`${options.kind}.body`],
|
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* says so: `snapshot_rewritten`, both sizes, and the byte range the first
|
|
21
21
|
* difference falls in. Never content.
|
|
22
22
|
*/
|
|
23
|
+
import { createReadStream } from "node:fs";
|
|
23
24
|
import { REDACTION_STUB_LABEL } from "@bli-cockpit/telemetry-core";
|
|
24
|
-
import { firstDifferenceRange, isPrefixSuccessor, prefixCheckpoints, } from "@bli-cockpit/telemetry-core/snapshot-supersede";
|
|
25
|
+
import { firstDifferenceRange, isPrefixSuccessor, prefixCheckpoints, prefixDigestsFromStream, } from "@bli-cockpit/telemetry-core/snapshot-supersede";
|
|
25
26
|
/**
|
|
26
27
|
* The newest committed snapshot per source key, from the cursor's objects map,
|
|
27
28
|
* restricted to objects this work context owns (the server refuses a claim
|
|
@@ -57,30 +58,79 @@ export function describeSnapshot(bytes) {
|
|
|
57
58
|
* decide and says nothing.
|
|
58
59
|
*/
|
|
59
60
|
export function supersedeClaimFor(options) {
|
|
61
|
+
return decideClaim({ contentHash: options.contentHash, kind: options.kind, byteSize: options.bytes.byteLength, prior: options.prior }, (prior) => isPrefixSuccessor(options.bytes, prior), (prior) => firstDifferenceRange(options.bytes, prior));
|
|
62
|
+
}
|
|
63
|
+
/** The decision and its log, shared by the in-memory and the on-disk forms so they cannot drift. */
|
|
64
|
+
function decideClaim(options, isPrefix, differenceRange) {
|
|
60
65
|
const { prior } = options;
|
|
61
66
|
if (!prior || prior.content_hash_sha256 === options.contentHash)
|
|
62
67
|
return null;
|
|
63
|
-
if (
|
|
68
|
+
if (isPrefix(prior)) {
|
|
64
69
|
console.error("[evidence-upload] snapshot supersedes the previous one", JSON.stringify({
|
|
65
70
|
reason: "snapshot_supersede_claimed",
|
|
66
71
|
rule: "prefix",
|
|
67
72
|
kind: options.kind,
|
|
68
73
|
previous_byte_size: prior.byte_size,
|
|
69
|
-
byte_size: options.
|
|
74
|
+
byte_size: options.byteSize,
|
|
70
75
|
}));
|
|
71
76
|
return { pointer_id: prior.raw_evidence_pointer_id, rule: "prefix" };
|
|
72
77
|
}
|
|
73
|
-
const range =
|
|
78
|
+
const range = differenceRange(prior);
|
|
74
79
|
console.error("[evidence-upload] snapshot rewritten, keeping both", JSON.stringify({
|
|
75
80
|
reason: "snapshot_rewritten",
|
|
76
81
|
kind: options.kind,
|
|
77
82
|
previous_byte_size: prior.byte_size,
|
|
78
|
-
byte_size: options.
|
|
83
|
+
byte_size: options.byteSize,
|
|
79
84
|
first_differing_offset_at_least: range.at_least,
|
|
80
85
|
first_differing_offset_below: range.below,
|
|
81
86
|
}));
|
|
82
87
|
return null;
|
|
83
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* `describeSnapshot` and `supersedeClaimFor` for bytes that live in a file (BLI-23317 stage D): ONE
|
|
91
|
+
* streamed pass over the file yields every prefix digest either needs (`prefixDigestsFromStream`, one
|
|
92
|
+
* chunk and a hash state in memory), then the same decision runs on them. `claim` false skips the
|
|
93
|
+
* claim (a stub-replacement claim wins over a prefix claim and never reads the prior).
|
|
94
|
+
*/
|
|
95
|
+
export async function describeStagedSnapshot(options) {
|
|
96
|
+
const { byteLength, prior } = options;
|
|
97
|
+
const checkpointOffsets = [];
|
|
98
|
+
for (let at = 64; at < byteLength; at *= 2)
|
|
99
|
+
checkpointOffsets.push(at);
|
|
100
|
+
const wantClaim = options.claim && prior !== undefined && prior.content_hash_sha256 !== options.contentHash;
|
|
101
|
+
const priorOffsets = wantClaim
|
|
102
|
+
? [prior.byte_size, ...Object.keys(prior.checkpoints ?? {}).map(Number)].filter((at) => at > 0 && at <= Math.min(prior.byte_size, byteLength))
|
|
103
|
+
: [];
|
|
104
|
+
const result = await prefixDigestsFromStream(createReadStream(options.filePath, { highWaterMark: 1024 * 1024 }), [...checkpointOffsets, ...priorOffsets]);
|
|
105
|
+
if (result.total_bytes !== byteLength) {
|
|
106
|
+
// The staged file is not the bytes that were hashed: a claim made on it would be a lie.
|
|
107
|
+
console.error("[evidence-upload] staged file size differs from what was written; no claim", JSON.stringify({
|
|
108
|
+
reason: "staged_size_mismatch", expected_bytes: byteLength, found_bytes: result.total_bytes, kind: options.kind,
|
|
109
|
+
}));
|
|
110
|
+
throw new Error("staged_size_mismatch");
|
|
111
|
+
}
|
|
112
|
+
const checkpoints = {};
|
|
113
|
+
for (const at of checkpointOffsets)
|
|
114
|
+
checkpoints[String(at)] = result.digests.get(at).slice(0, 16);
|
|
115
|
+
const supersedes = wantClaim
|
|
116
|
+
? decideClaim({ contentHash: options.contentHash, kind: options.kind, byteSize: byteLength, prior }, (known) => known.byte_size > 0 && known.byte_size < byteLength && result.digests.get(known.byte_size) === known.content_hash_sha256, (known) => {
|
|
117
|
+
const limit = Math.min(known.byte_size, byteLength);
|
|
118
|
+
let atLeast = 0;
|
|
119
|
+
let below = limit;
|
|
120
|
+
const offsets = Object.keys(known.checkpoints ?? {}).map(Number).filter((at) => at <= limit).sort((a, b) => a - b);
|
|
121
|
+
for (const at of offsets) {
|
|
122
|
+
if (result.digests.get(at)?.slice(0, 16) === known.checkpoints[String(at)])
|
|
123
|
+
atLeast = at;
|
|
124
|
+
else {
|
|
125
|
+
below = at;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { at_least: atLeast, below };
|
|
130
|
+
})
|
|
131
|
+
: null;
|
|
132
|
+
return { snapshot: { checkpoints }, supersedes };
|
|
133
|
+
}
|
|
84
134
|
/**
|
|
85
135
|
* BLI-11102: the claim a re-offered transcript makes when the server named its
|
|
86
136
|
* stored copy a redaction-crash stub. Only a main transcript that redacted
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a transcript whose bytes are ALREADY a file (BLI-23317 stage D): the `.partial` the streamed
|
|
3
|
+
* pass wrote and hashed. The twin of `stageEvidenceBytes` for bytes that were never held in memory.
|
|
4
|
+
*
|
|
5
|
+
* Same contract: content an earlier sync already staged is adopted (the partial is dropped), a new
|
|
6
|
+
* object lands under its final name only by rename, so the manifest never points at a truncated file,
|
|
7
|
+
* and a failure leaves no partial behind.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { describeError } from "../health-detail.js";
|
|
12
|
+
import { resolveStagedObject } from "../raw-evidence-staging.js";
|
|
13
|
+
import { chmodPrivate } from "./raw-evidence-pack-store.js";
|
|
14
|
+
import { discardStagedOutput } from "./staged-output.js";
|
|
15
|
+
export async function stageEvidenceFile(collection, options) {
|
|
16
|
+
const { staged } = options;
|
|
17
|
+
const existing = await resolveStagedObject(collection.rawEvidenceRoot, collection.staging, staged.sha256);
|
|
18
|
+
if (existing) {
|
|
19
|
+
collection.stagedReusedCount += 1;
|
|
20
|
+
console.error("[raw-evidence] staged copy reused", JSON.stringify({
|
|
21
|
+
reason: "staged_reused",
|
|
22
|
+
kind: options.kind,
|
|
23
|
+
content_hash_prefix: staged.sha256.slice(0, 16),
|
|
24
|
+
byte_size: existing.entry.byte_size,
|
|
25
|
+
pack_id: existing.entry.pack_id,
|
|
26
|
+
}));
|
|
27
|
+
await discardStagedOutput(staged, "staged_copy_reused");
|
|
28
|
+
return { local_path: existing.local_path, staged_in_pack: false };
|
|
29
|
+
}
|
|
30
|
+
const destination = path.join(collection.filesDir, options.fileName);
|
|
31
|
+
try {
|
|
32
|
+
await chmodPrivate(staged.path, 0o600);
|
|
33
|
+
await fs.rename(staged.path, destination);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
await fs.rm(staged.path, { force: true }).catch(() => undefined);
|
|
37
|
+
console.error("[raw-evidence] staging write failed, nothing was published", JSON.stringify({ reason: "staging_write_failed", kind: options.kind, byte_size: staged.byteLength, ...describeError(error) }));
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
collection.stagedNewCount += 1;
|
|
41
|
+
console.error("[raw-evidence] staged partial published", JSON.stringify({ reason: "staged_published", kind: options.kind, byte_size: staged.byteLength }));
|
|
42
|
+
return { local_path: destination, staged_in_pack: true };
|
|
43
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
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 { roomToStreamToStaging } from "./raw-evidence-budget-admission.js";
|
|
15
16
|
import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
|
|
16
17
|
/** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
|
|
17
18
|
export function agentOfKind(kind) {
|
|
@@ -41,7 +42,13 @@ export async function prepareTranscriptBody(collection, file) {
|
|
|
41
42
|
const session = collection.budget.images;
|
|
42
43
|
const agent = agentOfKind(file.kind);
|
|
43
44
|
if (file.source.stream) {
|
|
44
|
-
|
|
45
|
+
// BLI-23317 stage D: the slimmed bytes are written to this pack's `files` directory as they are made,
|
|
46
|
+
// but only when the budget and the disk gate would admit a file of the source's size: never a byte ahead of the gate.
|
|
47
|
+
const refusal = roomToStreamToStaging(collection, file.source.stream.inputBytes ?? 0);
|
|
48
|
+
if (refusal) {
|
|
49
|
+
console.error("[raw-evidence] streamed output is not written ahead of the staging gate", JSON.stringify({ reason: "stream_to_staging_declined", gate: refusal, kind: file.kind, input_bytes: file.source.stream.inputBytes ?? null }));
|
|
50
|
+
}
|
|
51
|
+
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind, stageDir: refusal ? undefined : collection.filesDir });
|
|
45
52
|
if (streamed) {
|
|
46
53
|
file.source.readBytes = streamed.readBytes;
|
|
47
54
|
return { held: streamed.held, volatile: streamed.volatile, pendingImages: streamed.pendingImages };
|
|
@@ -20,16 +20,17 @@ import fs from "node:fs/promises";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { describeError } from "../health-detail.js";
|
|
22
22
|
import { DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, } from "../raw-evidence-staging.js";
|
|
23
|
-
import { isSecretLikePath, sha256,
|
|
23
|
+
import { isSecretLikePath, sha256, } from "./raw-evidence-keys.js";
|
|
24
24
|
import { maskSecretsInHeldTranscript } from "./raw-evidence-sanitize-held.js";
|
|
25
|
+
import { discardStagedOutput } from "./staged-output.js";
|
|
26
|
+
import { byteLengthOf, dropStagedBody, stageOneTranscript } from "./raw-evidence-transcript-stage.js";
|
|
25
27
|
import { agentOfKind, prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
|
|
26
28
|
import { TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
|
|
27
29
|
import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
|
|
28
|
-
import { evidenceEntry } from "./raw-evidence-manifest.js";
|
|
29
30
|
import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
|
|
30
31
|
import { logVolatileReuse, trustVolatileEntry } from "./raw-evidence-volatile-reuse.js";
|
|
31
|
-
import {
|
|
32
|
-
import { admitEvidence,
|
|
32
|
+
import { stubReplacementClaim } from "./raw-evidence-snapshot-supersede.js";
|
|
33
|
+
import { admitEvidence, } from "./raw-evidence-collection-state.js";
|
|
33
34
|
/**
|
|
34
35
|
* Reads, secret-guards, content-addresses, budget-checks, and copies one
|
|
35
36
|
* attributed transcript into the pack.
|
|
@@ -80,31 +81,41 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
80
81
|
throw error;
|
|
81
82
|
}
|
|
82
83
|
const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = prepared;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
const byteSize = byteLengthOf(sanitized);
|
|
85
|
+
try {
|
|
86
|
+
await rememberPreparedTranscript(collection, options, names, {
|
|
87
|
+
stamp, recipe, readBytes: read.source.readBytes ?? read.byteLength, contentHash, byteSize, volatile, pendingImages, redactedEntry,
|
|
88
|
+
});
|
|
89
|
+
if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
|
|
90
|
+
await dropStagedBody(sanitized, "content_already_durable");
|
|
91
|
+
collection.reused.push({
|
|
92
|
+
kind: options.kind,
|
|
93
|
+
label: names.evidenceLabel,
|
|
94
|
+
content_hash_sha256: contentHash,
|
|
95
|
+
codex_session_id: options.sessionId,
|
|
96
|
+
});
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const deferReason = admitEvidence(collection, byteSize, { contentHash, kind: options.kind });
|
|
100
|
+
if (deferReason) {
|
|
101
|
+
await dropStagedBody(sanitized, deferReason);
|
|
102
|
+
markBudgetCapApplied(collection, deferReason);
|
|
103
|
+
return skip(deferReason);
|
|
104
|
+
}
|
|
105
|
+
await stageOneTranscript(collection, {
|
|
106
|
+
...options,
|
|
107
|
+
names,
|
|
108
|
+
sanitized,
|
|
109
|
+
contentHash,
|
|
110
|
+
stubClaim,
|
|
92
111
|
});
|
|
93
112
|
return true;
|
|
94
113
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
catch (error) {
|
|
115
|
+
// A streamed partial is deleted whatever stopped the file (a no-op once it was published or discarded).
|
|
116
|
+
await dropStagedBody(sanitized, "collect_failed");
|
|
117
|
+
throw error;
|
|
99
118
|
}
|
|
100
|
-
await stageOneTranscript(collection, {
|
|
101
|
-
...options,
|
|
102
|
-
names,
|
|
103
|
-
sanitized,
|
|
104
|
-
contentHash,
|
|
105
|
-
stubClaim,
|
|
106
|
-
});
|
|
107
|
-
return true;
|
|
108
119
|
}
|
|
109
120
|
function recordEvidenceSkip(collection, kind, label, reason) {
|
|
110
121
|
collection.skipped.push({ kind, label, reason });
|
|
@@ -117,21 +128,33 @@ async function prepareTranscriptForUpload(collection, options, names, source) {
|
|
|
117
128
|
// bytes, the decoded text and the masked text are never all alive together.
|
|
118
129
|
const body = await prepareTranscriptBody(collection, { source, kind: options.kind, sessionId: options.sessionId });
|
|
119
130
|
const redactedBefore = collection.redacted.length;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
let sanitized;
|
|
132
|
+
try {
|
|
133
|
+
sanitized = maskSecretsInHeldTranscript(collection, body.held, {
|
|
134
|
+
kind: options.kind,
|
|
135
|
+
evidenceLabel: names.evidenceLabel,
|
|
136
|
+
secretLikeFileName: names.secretLikeFileName,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (body.held.staged)
|
|
141
|
+
await discardStagedOutput(body.held.staged, "mask_threw");
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
// BLI-23317 stage D: only a clean file IS its staged bytes. Anything the mask rewrote has new bytes, built
|
|
145
|
+
// from the sanitizer's text, so the streamed partial is of no use.
|
|
146
|
+
if (body.held.staged && !("staged" in sanitized))
|
|
147
|
+
await discardStagedOutput(body.held.staged, "redaction_rewrote_the_bytes");
|
|
125
148
|
// The ledger entry this mask pushed (if any), kept so a later skip can replay it.
|
|
126
149
|
const redactedEntry = collection.redacted.length > redactedBefore ? collection.redacted[collection.redacted.length - 1] : null;
|
|
127
|
-
const contentHash = sha256(sanitized.bytes);
|
|
150
|
+
const contentHash = "staged" in sanitized ? sanitized.staged.sha256 : sha256(sanitized.bytes);
|
|
128
151
|
// BLI-11102: a session the server named a redaction stub is re-offered even
|
|
129
152
|
// when these bytes are already durable, because the stub is not a delivery.
|
|
130
153
|
const stubClaim = stubReplacementClaim({
|
|
131
154
|
kind: options.kind,
|
|
132
155
|
stubPointerId: collection.stubReoffers.get(options.sessionId),
|
|
133
156
|
completenessLabel: sanitized.completenessLabel,
|
|
134
|
-
byteSize: sanitized
|
|
157
|
+
byteSize: byteLengthOf(sanitized),
|
|
135
158
|
alreadyDurable: collection.skipContentHashes.has(contentHash),
|
|
136
159
|
});
|
|
137
160
|
return { sanitized, contentHash, stubClaim, volatile: body.volatile, pendingImages: body.pendingImages, redactedEntry };
|
|
@@ -239,46 +262,6 @@ function logDeliveryBackoffHold(kind, sourceKey) {
|
|
|
239
262
|
source_key: sourceKey,
|
|
240
263
|
}));
|
|
241
264
|
}
|
|
242
|
-
/** Put an accepted transcript's bytes on disk and add its manifest entry. */
|
|
243
|
-
async function stageOneTranscript(collection, file) {
|
|
244
|
-
collection.index.value += 1;
|
|
245
|
-
const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(file.filePath)}-${file.names.packedFileName}`);
|
|
246
|
-
const staged = await stageEvidenceBytes(collection, {
|
|
247
|
-
contentHash: file.contentHash,
|
|
248
|
-
bytes: file.sanitized.bytes,
|
|
249
|
-
fileName: path.basename(relativePath),
|
|
250
|
-
kind: file.kind,
|
|
251
|
-
sourceKey: file.names.sourceKey,
|
|
252
|
-
});
|
|
253
|
-
collection.entries.push(evidenceEntry({
|
|
254
|
-
context: collection.context,
|
|
255
|
-
kind: file.kind,
|
|
256
|
-
packId: collection.packId,
|
|
257
|
-
localPath: staged.local_path,
|
|
258
|
-
relativePath,
|
|
259
|
-
mediaType: file.mediaType,
|
|
260
|
-
redactedSummary: file.sanitized.status === "redacted"
|
|
261
|
-
? `${file.redactedSummary} Secret-like values were deterministically redacted before upload.`
|
|
262
|
-
: file.redactedSummary,
|
|
263
|
-
// Both branches carry a record now (BLI-3277), so "was anything
|
|
264
|
-
// replaced?" is the status, never the presence of `redaction`.
|
|
265
|
-
redaction: file.sanitized.redaction,
|
|
266
|
-
bytes: file.sanitized.bytes,
|
|
267
|
-
codexSessionId: file.sessionId,
|
|
268
|
-
contentAddress: file.contentAddress(file.contentHash.slice(0, 16)),
|
|
269
|
-
stagedInPack: staged.staged_in_pack,
|
|
270
|
-
sourceKey: file.names.sourceKey,
|
|
271
|
-
// BLI-11088: remember this snapshot's shape, and claim the last one when
|
|
272
|
-
// these bytes rebuild it.
|
|
273
|
-
snapshot: describeSnapshot(file.sanitized.bytes),
|
|
274
|
-
supersedes: file.stubClaim ?? supersedeClaimFor({
|
|
275
|
-
bytes: file.sanitized.bytes,
|
|
276
|
-
contentHash: file.contentHash,
|
|
277
|
-
kind: file.kind,
|
|
278
|
-
prior: collection.priorSnapshots.get(file.names.sourceKey),
|
|
279
|
-
}),
|
|
280
|
-
}));
|
|
281
|
-
}
|
|
282
265
|
/** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
|
|
283
266
|
function streamMinBytesFor(collection, kind) {
|
|
284
267
|
const session = collection.budget.images;
|
|
@@ -317,7 +300,7 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes, streamMinBytes)
|
|
|
317
300
|
// falls through to the read below, which reports the failure itself.
|
|
318
301
|
const size = await fs.stat(filePath).then((stat) => stat.size, () => null);
|
|
319
302
|
if (size !== null && size >= streamMinBytes) {
|
|
320
|
-
return { status: "ok", source: { bytes: null, stream: { filePath, maxFileBytes } }, byteLength: size };
|
|
303
|
+
return { status: "ok", source: { bytes: null, stream: { filePath, maxFileBytes, inputBytes: size } }, byteLength: size };
|
|
321
304
|
}
|
|
322
305
|
}
|
|
323
306
|
let bytes;
|