Skip to main content

agentmux_srv/backend/blockcontroller/shell/
indexing.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `output.idx` rebuild: a lazily-built, self-validating line-offset cache.
5
6use crate::backend::storage::filestore::FileStore;
7
8/// Magic header size for `output.idx`: the first 8 bytes are the `output` byte-size
9/// the index was built for. The index is valid iff this equals `output`'s current
10/// size; otherwise it is stale and must be rebuilt.
11pub(crate) const OUTPUT_IDX_HEADER_LEN: i64 = 8;
12
13/// Key in `output.idx`'s file metadata naming the `output` generation the
14/// index was built for (Phase 5a-2b; `filestore/counter.rs`). Written in the
15/// same transaction as the index, so the label always describes the bytes.
16const IDX_GEN_META: &str = "for_gen";
17
18const IDX: &str = "output.idx";
19
20/// `output`'s size and valid generation, from the database — never this
21/// process's `stat` cache, which another srv instance's append makes stale.
22/// A counter only behind (an older build appended since) is caught up first,
23/// so its generation holds across those appends (`filestore/counter.rs`).
24pub(crate) fn output_now(fs: &FileStore, zone: &str) -> Option<(u64, Option<String>)> {
25    let state = fs.catch_up_line_counter(zone, "output").ok()??;
26    Some((state.size.max(0) as u64, state.counted.map(|c| c.gen)))
27}
28
29/// `output` and its `output.idx` as they were at ONE moment — one database
30/// snapshot (Codex on #3634): separate reads of the index's size, header and
31/// label could each see a different version of an index another srv instance
32/// is rebuilding, and pass an old count off as fresh.
33pub(crate) struct OutputIndex {
34    pub output_size: u64,
35    /// `output`'s valid generation; `None` when it is not counted.
36    #[cfg(test)]
37    pub output_gen: Option<String>,
38    /// The line count `output.idx` gives, if it describes `output` exactly:
39    /// its covered size is `output_size` and it is labelled with
40    /// `output_gen`. `None`: missing, stale, or another generation's.
41    pub fresh_lines: Option<u64>,
42}
43
44/// See [`OutputIndex`]. `None` when there is no `output`.
45pub(crate) fn output_index(fs: &FileStore, zone: &str) -> Option<OutputIndex> {
46    let snap = fs.derived_snapshot(zone, "output", IDX, Some(OUTPUT_IDX_HEADER_LEN)).ok()??;
47    let output_size = snap.output_size.max(0) as u64;
48    let fresh_lines = snap.derived.as_ref().and_then(|idx| {
49        let header = idx.bytes.as_deref()?;
50        let covered = u64::from_le_bytes(header.get(..8)?.try_into().ok()?);
51        (idx.size >= OUTPUT_IDX_HEADER_LEN
52            && covered == output_size
53            && labelled_for(&idx.meta, snap.output_gen.as_deref()))
54        .then_some(((idx.size - OUTPUT_IDX_HEADER_LEN) / 8) as u64)
55    });
56    Some(OutputIndex {
57        output_size,
58        #[cfg(test)]
59        output_gen: snap.output_gen,
60        fresh_lines,
61    })
62}
63
64/// Whether an index with metadata `meta` may describe `output` of generation
65/// `output_gen`, apart from its covered size.
66///
67/// A size match alone is not proof: `output` replaced or restored with
68/// content that happens to reach the old covered size would have its lines
69/// read at another file's offsets. When `output` has a valid generation the
70/// index must be labelled with it; any mismatch — unlabelled (older build),
71/// or built for another generation — means rebuild, which is always safe.
72/// An uncounted `output` (legacy or written by an older build) keeps the
73/// covered-size rule alone, as before.
74fn labelled_for(meta: &crate::backend::storage::filestore::FileMeta, output_gen: Option<&str>) -> bool {
75    match output_gen {
76        None => true,
77        Some(gen) => meta.get(IDX_GEN_META).and_then(|v| v.as_str()) == Some(gen),
78    }
79}
80
81/// Lines `[offset, offset + limit)` of `output`, read through `output.idx`.
82pub(crate) struct IndexedRead {
83    /// Total line count of `output` in the snapshot.
84    pub total: u64,
85    /// The bytes of the requested lines (from the first line's offset up to
86    /// the next line's, or the end of `output`).
87    pub raw: Vec<u8>,
88    /// Byte offset in `output` of each returned line, in order.
89    pub line_offsets: Vec<u64>,
90    /// Receive-time stamps for the returned lines (unix ms, `0` = unknown),
91    /// from the `output.tsidx` sidecar in the same snapshot, so they are joined
92    /// to the lines they were written for (Codex on #3634). Only a window of
93    /// the sidecar around the lines is read (`stamps.rs`). `None`: no sidecar,
94    /// no records near the lines, or bytes not fully stored.
95    pub stamps: Option<Vec<i64>>,
96}
97
98/// Read lines `[offset, offset + limit)` of `output` through a fresh
99/// `output.idx`, with the freshness check, the index entries and the output
100/// bytes all from ONE database snapshot (Codex on #3634): validating in one
101/// read and fetching entries and bytes in later ones would let another srv
102/// instance replace `output` and rebuild the index in between — the answer
103/// would mix the new file's lines with the old file's total.
104///
105/// `None` when the index doesn't describe `output` in that snapshot (missing,
106/// stale, another generation's) or a read hits bytes not stored yet: the
107/// caller rebuilds the index and retries once, or takes its slow path.
108pub(crate) fn read_via_index(fs: &FileStore, zone: &str, offset: u64, limit: u64) -> Option<IndexedRead> {
109    read_indexed(fs, zone, offset, limit, false)
110}
111
112/// [`read_via_index`] from an index that covers only a prefix of `output`:
113/// lines appended since it was built aren't returned (`total` is the
114/// index's count), nor is the last indexed line, whose end the index can't
115/// give. For the read right after extending the index, when an append
116/// landed in between — before this, that read fell back to loading the
117/// whole transcript (1.3 GB, ~15 s, under the store's lock) whenever an
118/// agent's spawn was writing to the transcript its pane was opening
119/// (docs/reports/REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md). The lines it
120/// leaves out are the newest few, which the pane's live stream delivers.
121/// `None` under the same conditions as [`read_via_index`], and when nothing
122/// requested is within the indexed prefix.
123pub(crate) fn read_via_stale_index(fs: &FileStore, zone: &str, offset: u64, limit: u64) -> Option<IndexedRead> {
124    read_indexed(fs, zone, offset, limit, true)
125}
126
127fn read_indexed(fs: &FileStore, zone: &str, offset: u64, limit: u64, stale_ok: bool) -> Option<IndexedRead> {
128    const H: i64 = OUTPUT_IDX_HEADER_LEN;
129    fs.read_snapshot(|snap| {
130        let Some(out) = snap.file(zone, "output")? else { return Ok(None) };
131        let Some(idx) = snap.file(zone, IDX)? else { return Ok(None) };
132        let Some(header) = snap.bytes(zone, IDX, 0, H)? else { return Ok(None) };
133        let covered = u64::from_le_bytes(header.as_slice().try_into().unwrap_or([0; 8]));
134        let output_size = out.size.max(0) as u64;
135        // A prefix only counts for the same generation: a replaced `output`
136        // fails the label check whatever its size.
137        let fresh = covered == output_size;
138        let usable_prefix = stale_ok && covered < output_size && out.gen.is_some();
139        if idx.size < H || !(fresh || usable_prefix) || !labelled_for(&idx.meta, out.gen.as_deref()) {
140            return Ok(None);
141        }
142        let entries_total = ((idx.size - H) / 8) as u64;
143        // From a prefix, the last indexed line may have grown since: serve
144        // only lines whose end is the next entry.
145        let total = if fresh { entries_total } else { entries_total.saturating_sub(1) };
146        if !fresh && (limit == 0 || offset >= total) {
147            return Ok(None);
148        }
149        let mut read = IndexedRead {
150            total,
151            raw: Vec::new(),
152            line_offsets: Vec::new(),
153            stamps: None,
154        };
155        if limit == 0 || offset >= total {
156            return Ok(Some(read));
157        }
158        let count = limit.min(total - offset);
159        // Entries for the returned lines, plus the next line's (the end of
160        // the last returned line) when there is one.
161        let want = count + u64::from(offset + count < entries_total);
162        let Some(entries) = snap.bytes(zone, IDX, H + (offset * 8) as i64, (want * 8) as i64)? else { return Ok(None) };
163        let at = |k: u64| u64::from_le_bytes(entries[(k * 8) as usize..(k * 8 + 8) as usize].try_into().unwrap());
164        let start = at(0);
165        let end = if want > count { at(count) } else { output_size };
166        if start > end || end > output_size {
167            return Ok(None);
168        }
169        let Some(raw) = snap.bytes(zone, "output", start as i64, (end - start) as i64)? else { return Ok(None) };
170        read.raw = raw;
171        read.line_offsets = (0..count).map(at).collect();
172        const TS: &str = crate::backend::agent_session::TSIDX_FILE;
173        read.stamps = match snap.file(zone, TS)? {
174            Some(ts) if ts.size > 0 => {
175                let bytes = |off: i64, len: i64| snap.bytes(zone, TS, off, len);
176                super::stamps::stamps_for(&bytes, ts.size, &read.line_offsets)?
177            }
178            _ => None,
179        };
180        Ok(Some(read))
181    })
182    .ok()
183    .flatten()
184}
185
186/// Rebuild `output.idx` from `output` in a single streaming scan and atomically
187/// replace it. The index is the byte offset of every **non-blank** line, matching
188/// the reader's line addressing (`String::lines().filter(!trim().is_empty())`).
189///
190/// Layout: `[covered_size: u64-LE][offset_0: u64-LE][offset_1]...`. `covered_size`
191/// records the `output` size this index reflects so the read path can detect
192/// staleness in O(1) and rebuild only when `output` actually grew.
193///
194/// The scan streams `output` in 1 MiB windows so memory stays O(one line + offsets)
195/// rather than loading the whole (potentially multi-GB) file. Line splitting is
196/// done on raw bytes; since UTF-8 continuation bytes never collide with `\n`, lines
197/// are never split mid-codepoint, so per-line `from_utf8_lossy` matches the reader.
198///
199/// `output_size` and `output_gen` must be read together (`output_index`, or
200/// `output_now`): the index is published only if `output` is still
201/// `output_gen`, so a size taken from one file and a generation from its
202/// replacement would publish a partial index under the new generation (Codex
203/// on #3634).
204///
205/// Returns the number of indexed (non-blank) lines on success, or `None` if
206/// `output` is unreadable, was replaced during the scan, or the index write
207/// fails (caller falls back to slow path).
208#[cfg(test)]
209pub(crate) fn rebuild_output_idx(
210    fs: &FileStore,
211    block_id: &str,
212    output_size: u64,
213    output_gen: Option<String>,
214) -> Option<u64> {
215    build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen, None)
216}
217
218/// Bring an existing `output.idx` up to `output`'s current size by scanning
219/// ONLY the appended bytes, instead of rescanning from byte zero.
220///
221/// Falls back to a full rebuild whenever the existing index can't be trusted
222/// as a base: missing, header unreadable, no entries yet, built for another
223/// generation, or `output` shrank below what the index already covers
224/// (rotation/truncation).
225///
226/// Why this exists: the index is a pure cache with no incremental mutation, so
227/// a stale one used to mean a full O(file-size) rescan. On a live agent
228/// `output` grows continuously, so "stale" was the steady state — a 30-second
229/// line-count poll drove 37 full rebuilds in 19 minutes on a 759 MB
230/// transcript, mean 3168 ms, ~10% duty cycle
231/// (docs/reports/REPORT_AGENT_PANE_LOAD_RENDER_ARCHITECTURE_2026_08_27.md §5).
232///
233/// Returning the stale count instead was tried and is WRONG: `line_count`
234/// feeds `useHistoryPagination`'s tail window (`offset = total - PAGE_SIZE`),
235/// so an undercount silently drops the most recent history on reopen — codex
236/// P1 on PR #2838. The count has to stay exact; only the cost of keeping it
237/// exact is negotiable.
238pub(crate) fn extend_output_idx(fs: &FileStore, block_id: &str) -> Option<u64> {
239    // A counter merely behind (an older build appended) is caught up first:
240    // otherwise the snapshot below sees no generation, the index is published
241    // unlabelled, and the next reader — once the counter has caught up —
242    // rejects it and rebuilds the whole index.
243    let _ = fs.catch_up_line_counter(block_id, "output");
244    // An append that lost a race (the index changed under it, e.g. another
245    // srv instance extended it first) gets one more go from a fresh snapshot,
246    // then a full rebuild — never a failure, which would send a history read
247    // to the whole-file fallback.
248    for _ in 0..2 {
249        match try_extend(fs, block_id) {
250            Extend::Done(lines) => return lines,
251            Extend::Raced => continue,
252        }
253    }
254    let (output_size, output_gen) = output_now(fs, block_id)?;
255    build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen, None)
256}
257
258enum Extend {
259    Done(Option<u64>),
260    /// The index changed between the snapshot and the append.
261    Raced,
262}
263
264fn try_extend(fs: &FileStore, block_id: &str) -> Extend {
265    const H: i64 = OUTPUT_IDX_HEADER_LEN;
266    // Only what extending needs, in ONE snapshot: `output`'s size and
267    // generation, and the index's size, label, header and last entry. Reading
268    // the whole index here (15 MB for a 1.9 M-line transcript) and rewriting
269    // it was most of the cost of every pane open.
270    let base = fs.read_snapshot(|snap| {
271        let Some(out) = snap.file(block_id, "output")? else { return Ok(None) };
272        let idx = snap.file(block_id, IDX)?;
273        let ends = match &idx {
274            Some(i) if i.size >= H + 8 && (i.size - H) % 8 == 0 => {
275                Some((snap.bytes(block_id, IDX, 0, H)?, snap.bytes(block_id, IDX, i.size - 8, 8)?))
276            }
277            _ => None,
278        };
279        Ok(Some((out, idx, ends)))
280    });
281    let Ok(Some((out, idx, ends))) = base else { return Extend::Done(None) };
282    let output_size = out.size.max(0) as u64;
283    let output_gen = out.gen;
284    let full = || Extend::Done(build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen.clone(), None));
285
286    let Some(idx) = idx else { return full() };
287    // Extending reuses every entry but the last, so it needs proof that the
288    // index describes a prefix of THIS `output`: a valid generation, and the
289    // index labelled with it. An uncounted `output` has no such proof — an
290    // older build may have rewritten it (its `rev` moved, the epoch with it)
291    // to something longer than the index covers (Codex on #3663) — and an
292    // index built for another generation is no base at all. Both rebuild.
293    if output_gen.is_none() || !labelled_for(&idx.meta, output_gen.as_deref()) {
294        return full();
295    }
296    // No entries, a torn size, or bytes not stored: nothing to anchor on.
297    let Some((Some(header), Some(last))) = ends else { return full() };
298    let entry_count = ((idx.size - H) / 8) as u64;
299    let covered = u64::from_le_bytes(header.as_slice().try_into().unwrap_or([0; 8]));
300
301    if covered == output_size {
302        return Extend::Done(Some(entry_count)); // already current
303    }
304    // Shrank (rotated/truncated): a base we can't trust.
305    if covered > output_size {
306        return full();
307    }
308
309    // Re-scan from the START of the last indexed line, not from `covered`. The
310    // previous build may have indexed a trailing line that had no newline yet;
311    // bytes appended since continue THAT line rather than starting a new one,
312    // so its entry has to be re-derived (it may also have been blank then and
313    // non-blank now). Everything before it is settled and stays as it is.
314    let seed_entries = entry_count - 1;
315    let scan_start = u64::from_le_bytes(last.as_slice().try_into().unwrap_or([0; 8]));
316    if scan_start > output_size {
317        return full();
318    }
319    match build_output_idx_from(fs, block_id, output_size, scan_start, Vec::new(), seed_entries, output_gen.clone(), Some(idx.size)) {
320        Some(lines) => Extend::Done(Some(lines)),
321        None => Extend::Raced,
322    }
323}
324
325/// Shared scanner behind both entry points.
326///
327/// `scan_start` MUST be the byte offset of a line start (0, or an offset read
328/// back out of the index). `seed` is the raw offset bytes for the lines before
329/// it and `seed_count` how many — they are copied through untouched, so the
330/// blank/CRLF rules only ever get applied by the one loop below and the two
331/// paths can't drift apart.
332#[allow(clippy::too_many_arguments)]
333fn build_output_idx_from(
334    fs: &FileStore,
335    block_id: &str,
336    output_size: u64,
337    scan_start: u64,
338    seed: Vec<u8>,
339    seed_count: u64,
340    for_gen: Option<String>,
341    // `Some(index size)`: append to the existing index instead of replacing
342    // it — keep its first `seed_count` entries where they are, write the new
343    // ones after them and the new covered size in its header, only if it is
344    // still that size (`FileStore::patch_derived_if`). `seed` is then empty.
345    append_to: Option<i64>,
346) -> Option<u64> {
347    const WIN: i64 = 1 << 20; // 1 MiB read window
348
349    // Start/duration logging (docs/status/STATUS_CROSS_CHANNEL_AGENT_OPEN_FULL_APP_FREEZE_2026_08_22.md
350    // §7.1): the prior completion-only log recorded a rebuild happened but
351    // not how long it ran for or when it started, so a live incident
352    // couldn't be checked for whether a rebuild's execution window actually
353    // overlapped some other stalled RPC — only that both happened "around
354    // the same time." Kept as a lasting diagnostic, not a one-off debug
355    // print — a slow rebuild is exactly the kind of thing worth being able
356    // to correlate after the fact, the same way `mem_attribution` already is.
357    let started = std::time::Instant::now();
358    // `for_gen`: the generation of `output` this index is built for, taken by
359    // the caller BEFORE anything it builds on (seed entries, scanned bytes)
360    // was read. The write below only lands if it is still current.
361    tracing::info!(
362        block_id = %block_id,
363        covered = output_size,
364        scan_start,
365        scanned_bytes = output_size.saturating_sub(scan_start),
366        incremental = scan_start > 0,
367        "output.idx rebuild starting"
368    );
369
370    // Offsets buffer starts with the covered-size header, then any reused
371    // entries for the region before `scan_start`.
372    let mut buf: Vec<u8> = Vec::new();
373    if append_to.is_none() {
374        buf.extend_from_slice(&output_size.to_le_bytes());
375        buf.extend_from_slice(&seed);
376    }
377
378    let mut line_count: u64 = seed_count;
379    let mut cursor: u64 = scan_start; // byte offset where the current line begins
380    let mut line_buf: Vec<u8> = Vec::new(); // bytes of the current line, excluding '\n'
381    let mut read_pos: i64 = scan_start as i64;
382
383    let flush_line = |line_buf: &mut Vec<u8>,
384                      cursor: &mut u64,
385                      buf: &mut Vec<u8>,
386                      line_count: &mut u64,
387                      had_newline: bool| {
388        // The reader strips a trailing '\r' (CRLF) and treats trim-empty as
389        // blank. Shared with the FileStore line counter, which must agree.
390        let is_blank = crate::backend::storage::filestore::is_blank_line(line_buf);
391        if !is_blank {
392            buf.extend_from_slice(&cursor.to_le_bytes());
393            *line_count += 1;
394        }
395        // Advance cursor past this line's bytes (+1 for the consumed '\n').
396        *cursor += line_buf.len() as u64 + if had_newline { 1 } else { 0 };
397        line_buf.clear();
398    };
399
400    while read_pos < output_size as i64 {
401        // From the database, bounded by `output_size`: `read_at` clamps to
402        // this process's cached size, which another instance's append can
403        // leave behind — the index would then claim lines it never scanned.
404        let len = WIN.min(output_size as i64 - read_pos);
405        let chunk = match fs.read_bytes_db(block_id, "output", read_pos, len) {
406            Ok(v) => v,
407            Err(e) => {
408                // codex P2 on #2724: every exit path must log a terminal
409                // event (with duration) — an unmatched "starting" event with
410                // no completion reads as "still running," easy to mistake
411                // for a rebuild that was active throughout an incident when
412                // it actually failed fast and returned immediately.
413                tracing::warn!(
414                    block_id = %block_id,
415                    error = %e,
416                    duration_ms = started.elapsed().as_millis() as u64,
417                    "output.idx rebuild failed: read_at error"
418                );
419                return None;
420            }
421        };
422        if chunk.is_empty() {
423            break;
424        }
425        for &b in &chunk {
426            if b == b'\n' {
427                flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, true);
428            } else {
429                line_buf.push(b);
430            }
431        }
432        read_pos += chunk.len() as i64;
433    }
434    // Trailing line with no final '\n'.
435    if !line_buf.is_empty() {
436        flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, false);
437    }
438
439    // Index and its generation label in one transaction (created if missing),
440    // and only if `output` is still that generation (Codex on #3634): a
441    // replace during the scan must neither publish an index of the old bytes
442    // nor hand this caller offsets it would then apply to the new file.
443    let mut label = std::collections::HashMap::new();
444    label.insert(
445        IDX_GEN_META.to_string(),
446        for_gen.clone().map_or(serde_json::Value::Null, serde_json::Value::String),
447    );
448    let written = match (append_to, for_gen.as_deref()) {
449        (None, _) => fs.put_file_with_meta_if(block_id, IDX, &buf, label, "output", for_gen.as_deref()),
450        (Some(idx_size), Some(gen)) => fs.patch_derived_if(
451            block_id,
452            IDX,
453            &output_size.to_le_bytes(),
454            OUTPUT_IDX_HEADER_LEN + seed_count as i64 * 8,
455            &buf,
456            "output",
457            gen,
458            idx_size,
459        ),
460        // Appending needs a generation to guard on (`try_extend` checks).
461        (Some(_), None) => Ok(false),
462    };
463    match written {
464        Ok(false) => {
465            tracing::info!(
466                block_id = %block_id,
467                duration_ms = started.elapsed().as_millis() as u64,
468                "output.idx rebuild discarded: output was replaced, or the index changed, during the scan"
469            );
470            None
471        }
472        Ok(true) => {
473            tracing::info!(
474                block_id = %block_id,
475                lines = line_count,
476                covered = output_size,
477                duration_ms = started.elapsed().as_millis() as u64,
478                "output.idx rebuilt"
479            );
480            Some(line_count)
481        }
482        Err(e) => {
483            tracing::warn!(
484                block_id = %block_id,
485                error = %e,
486                duration_ms = started.elapsed().as_millis() as u64,
487                "output.idx rebuild write failed"
488            );
489            None
490        }
491    }
492}
493
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::backend::storage::filestore::{FileMeta, FileOpts};
499
500    #[test]
501    fn a_rebuild_for_a_generation_that_is_no_longer_current_publishes_nothing() {
502        // Codex P2 on #3634: `output` replaced after the builder captured its
503        // generation. The index of the old bytes must not be written, and the
504        // caller must not get a count (it would apply those offsets to the new
505        // file).
506        let fs = FileStore::open_in_memory().unwrap();
507        let zone = "blk-idx-guard";
508        fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
509        fs.append_lines(zone, "output", b"a\nb\nc\n").unwrap();
510        let old = output_now(&fs, zone).and_then(|(_, g)| g);
511        fs.write_file(zone, "output", b"x\n").unwrap();
512
513        assert_eq!(build_output_idx_from(&fs, zone, 6, 0, Vec::new(), 0, old, None), None);
514        assert!(fs.line_state(zone, "output.idx").unwrap().is_none(), "no index may be published");
515
516        // With the current generation it builds and publishes.
517        assert_eq!(rebuild_output_idx(&fs, zone, 2, output_now(&fs, zone).and_then(|(_, g)| g)), Some(1));
518        assert!(fs.line_state(zone, "output.idx").unwrap().is_some());
519    }
520
521    #[test]
522    fn a_read_through_the_index_comes_from_one_snapshot_or_not_at_all() {
523        let fs = FileStore::open_in_memory().unwrap();
524        let zone = "blk-idx-read";
525        fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
526        fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n").unwrap();
527        // No index yet: nothing to read through.
528        assert!(read_via_index(&fs, zone, 0, 10).is_none());
529        let view = output_index(&fs, zone).unwrap();
530        assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(3));
531
532        let r = read_via_index(&fs, zone, 1, 10).unwrap();
533        assert_eq!((r.total, r.line_offsets.clone()), (3, vec![8, 16]));
534        assert_eq!(r.raw, b"{\"b\":2}\n{\"c\":3}\n");
535        assert_eq!(r.stamps, None, "no stamp sidecar yet");
536        // Stamps come from the sidecar in the same snapshot as the lines: per
537        // line, the newest record at or before its offset.
538        let stamps: &[u8] = b"{\"off\":0,\"ms\":7}\n{\"off\":16,\"ms\":9}\n";
539        fs.make_file(zone, crate::backend::agent_session::TSIDX_FILE, FileMeta::default(), FileOpts::default()).unwrap();
540        fs.append_data(zone, crate::backend::agent_session::TSIDX_FILE, stamps).unwrap();
541        assert_eq!(read_via_index(&fs, zone, 1, 10).unwrap().stamps, Some(vec![7, 9]));
542        let r = read_via_index(&fs, zone, 0, 1).unwrap();
543        assert_eq!((r.line_offsets.clone(), r.raw), (vec![0], b"{\"a\":1}\n".to_vec()));
544        assert!(read_via_index(&fs, zone, 5, 10).unwrap().line_offsets.is_empty());
545
546        // Replaced by content of the same size and a different layout: the
547        // index no longer describes it, so nothing is read through it.
548        fs.write_file(zone, "output", b"{\"x\":11111}\n{\"y\":22222}\n").unwrap();
549        assert_eq!(fs.line_state(zone, "output").unwrap().unwrap().size, 24);
550        assert!(read_via_index(&fs, zone, 0, 10).is_none());
551    }
552
553    /// An agent's spawn appending to the transcript between the index being
554    /// extended and the read: the strict read gives up (the index no longer
555    /// covers `output`), and the stale read serves the indexed lines whose end
556    /// is known — never the last indexed line, which the append may continue.
557    #[test]
558    fn a_read_outrun_by_an_append_serves_the_indexed_prefix() {
559        let fs = FileStore::open_in_memory().unwrap();
560        let zone = "blk-idx-stale";
561        fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
562        fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n").unwrap();
563        let view = output_index(&fs, zone).unwrap();
564        assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(3));
565        fs.append_lines(zone, "output", b"{\"d\":4}\n").unwrap();
566
567        assert!(read_via_index(&fs, zone, 0, 10).is_none(), "the index no longer covers output");
568        let r = read_via_stale_index(&fs, zone, 0, 10).unwrap();
569        assert_eq!(r.total, 2, "lines 0 and 1; line 2's end isn't in the index");
570        assert_eq!(r.line_offsets, vec![0, 8]);
571        assert_eq!(r.raw, b"{\"a\":1}\n{\"b\":2}\n");
572        let r = read_via_stale_index(&fs, zone, 1, 10).unwrap();
573        assert_eq!((r.line_offsets.clone(), r.raw), (vec![8], b"{\"b\":2}\n".to_vec()));
574        assert!(read_via_stale_index(&fs, zone, 2, 10).is_none(), "nothing requested is in the usable prefix");
575        assert!(read_via_stale_index(&fs, zone, 0, 0).is_none());
576    }
577
578    #[test]
579    fn a_stale_read_refuses_a_replaced_output() {
580        let fs = FileStore::open_in_memory().unwrap();
581        let zone = "blk-idx-stale-replaced";
582        fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
583        fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n").unwrap();
584        let view = output_index(&fs, zone).unwrap();
585        assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(2));
586        // Replaced by longer content: a prefix of the old file is no prefix of this one.
587        fs.replace_file(zone, "output", b"{\"x\":11111}\n{\"y\":22222}\n{\"z\":3}\n", &[]).unwrap();
588        assert!(read_via_stale_index(&fs, zone, 0, 10).is_none());
589    }
590
591    /// Extending in place (header patched, new entries appended) must leave
592    /// exactly the bytes a full rebuild of the same `output` writes — through
593    /// blank lines, a line still open when the index was last built, and
594    /// enough entries to cross a 64 KB part.
595    #[test]
596    fn extending_in_place_matches_a_full_rebuild() {
597        let fs = FileStore::open_in_memory().unwrap();
598        let (zone, twin) = ("blk-idx-extend", "blk-idx-extend-twin");
599        for z in [zone, twin] {
600            fs.make_file(z, "output", FileMeta::default(), FileOpts::default()).unwrap();
601        }
602        let many: String = (0..9000).map(|i| format!("{{\"n\":{i}}}\n")).collect();
603        let chunks: Vec<&[u8]> = vec![b"{\"a\":1}\n\n{\"b\":", b"2}\n{\"c\":3}\n  \n", many.as_bytes(), b"{\"tail\":"];
604        for chunk in chunks {
605            for z in [zone, twin] {
606                fs.append_data(z, "output", chunk).unwrap();
607            }
608            assert!(output_now(&fs, zone).unwrap().1.is_some(), "output must stay counted, or extending only ever rebuilds");
609            let extended = extend_output_idx(&fs, zone).unwrap();
610            let view = output_index(&fs, twin).unwrap();
611            let rebuilt = rebuild_output_idx(&fs, twin, view.output_size, view.output_gen).unwrap();
612            assert_eq!(extended, rebuilt);
613            assert_eq!(
614                fs.read_file(zone, IDX).unwrap(),
615                fs.read_file(twin, IDX).unwrap(),
616                "extended index differs from a rebuild after {} lines",
617                rebuilt
618            );
619        }
620        assert_eq!(output_index(&fs, zone).unwrap().fresh_lines, Some(9004));
621    }
622
623    #[test]
624    fn a_fresh_index_reads_the_same_through_either_door() {
625        let fs = FileStore::open_in_memory().unwrap();
626        let zone = "blk-idx-fresh-both";
627        fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
628        fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n").unwrap();
629        let view = output_index(&fs, zone).unwrap();
630        rebuild_output_idx(&fs, zone, view.output_size, view.output_gen).unwrap();
631        let strict = read_via_index(&fs, zone, 0, 10).unwrap();
632        let stale = read_via_stale_index(&fs, zone, 0, 10).unwrap();
633        assert_eq!((strict.total, strict.raw), (stale.total, stale.raw));
634    }
635}