@bli-cockpit/cli 0.2.266 → 0.2.268

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.
@@ -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 single = budget.singleFileByteAllowance;
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 digest = sha256(options.bytes);
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: options.bytes.byteLength,
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: options.bytes.byteLength,
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
- facts: () => (facts ??= held.exactBytes ? { sha256: sha256Of(held.exactBytes), byteLength: held.exactBytes.byteLength } : factsOfText(held.text)),
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 (isPrefixSuccessor(options.bytes, prior)) {
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.bytes.byteLength,
74
+ byte_size: options.byteSize,
70
75
  }));
71
76
  return { pointer_id: prior.raw_evidence_pointer_id, rule: "prefix" };
72
77
  }
73
- const range = firstDifferenceRange(options.bytes, prior);
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.bytes.byteLength,
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
- const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind });
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, shortHash, } from "./raw-evidence-keys.js";
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 { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
32
- import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
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
- await rememberPreparedTranscript(collection, options, names, {
84
- stamp, recipe, readBytes: read.source.readBytes ?? read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
85
- });
86
- if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
87
- collection.reused.push({
88
- kind: options.kind,
89
- label: names.evidenceLabel,
90
- content_hash_sha256: contentHash,
91
- codex_session_id: options.sessionId,
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
- const deferReason = admitEvidence(collection, sanitized.bytes.byteLength, { contentHash, kind: options.kind });
96
- if (deferReason) {
97
- markBudgetCapApplied(collection, deferReason);
98
- return skip(deferReason);
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
- const sanitized = maskSecretsInHeldTranscript(collection, body.held, {
121
- kind: options.kind,
122
- evidenceLabel: names.evidenceLabel,
123
- secretLikeFileName: names.secretLikeFileName,
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.bytes.byteLength,
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;