Skip to main content

agentmux_srv/server/app_api/
blockfile.rs

1use super::*;
2
3pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
4    register_blockfile_line_count(engine, state);
5    register_blockfile_read_range(engine, state);
6    register_blockfile_read_state(engine, state);
7    register_blockfile_write_state(engine, state);
8}
9
10fn register_blockfile_line_count(engine: &Arc<WshRpcEngine>, state: &AppState) {
11    let broker = state.broker.clone();
12    let mstore = state.mstore.clone();
13    let filestore = state.filestore.clone();
14    let global_store = state.global_transcript_store.clone();
15
16    engine.register_typed(
17        COMMAND_BLOCKFILE_LINE_COUNT,
18        move |cmd: CommandBlockfileLineCountData, _ctx| {
19            let broker = broker.clone();
20            let mstore = mstore.clone();
21            let filestore = filestore.clone();
22            let global_store = global_store.clone();
23            async move {
24
25                tracing::info!(block_id = %cmd.block_id, filename = %cmd.filename, "blockfile:line_count");
26
27                // Cross-channel fallback (checked first): when this channel has
28                // no local `output` for the block, the local `session:line_count`
29                // meta is absent/stale, so a fresh cross-channel open would
30                // report 0 lines and the pane would render empty. Count from the
31                // agent's GLOBAL transcript zone instead. See
32                // `docs/analysis/ANALYSIS_CROSS_CHANNEL_CONVERSATION_HISTORY_2026_06_14.md`.
33                if let Some((gfs, zone)) =
34                    global_output_source(&filestore, &global_store, &mstore, &cmd.block_id, &cmd.filename)
35                {
36                    // Blocking pool (#2841). A counted zone answers from its
37                    // counter (O(1), Phase 5a-3); a zone without an epoch gets
38                    // one first (`init_line_counter`, one read of the file,
39                    // once). Only if it can't be counted does this extend or
40                    // rebuild `output.idx`, as before.
41                    let count = match tokio::task::spawn_blocking(move || {
42                        let stream = format!("g:{zone}");
43                        counted_line_count(&gfs, &zone, stream).or_else(|| {
44                            global_zone_line_count(&gfs, &zone)
45                                .map(|count| BlockfileLineCountResult { count, ..Default::default() })
46                        })
47                    })
48                    .await
49                    {
50                        Ok(v) => v,
51                        Err(e) => {
52                            tracing::warn!(
53                                block_id = %cmd.block_id,
54                                error = %e,
55                                "blockfile:line_count: global zone count task failed; falling back"
56                            );
57                            None
58                        }
59                    };
60                    if let Some(result) = count {
61                        return Ok(result);
62                    }
63                }
64
65                // The block's own `output`, from its counter when it is a
66                // counted transcript (Phase 5a-3): exact, O(1), and the same
67                // count `read_range` serves lines from.
68                if cmd.filename == crate::backend::agent_session::OUTPUT_FILE {
69                    let (fs, block_id) = (filestore.clone(), cmd.block_id.clone());
70                    let counted = tokio::task::spawn_blocking(move || {
71                        let stream = format!("b:{block_id}");
72                        counted_line_count(&fs, &block_id, stream)
73                    })
74                    .await
75                    .ok()
76                    .flatten();
77                    if let Some(result) = counted {
78                        return Ok(result);
79                    }
80                }
81
82                // Fast path: read session:line_count meta (O(1), maintained
83                // by SessionStatsAccumulator). For "output" filename this is
84                // the authoritative total — matches the unbounded counter
85                // that SessionStats increments on every line. FileStore's
86                // persisted line count will trail meta by up to the debounce
87                // interval (1s), and reading the full file just to count
88                // lines is O(file size) which defeats the point of a fast
89                // line_count endpoint.
90                if cmd.filename == "output" {
91                    if let Ok(Some(block)) = mstore.get::<Block>(&cmd.block_id) {
92                        if let Some(count) = block.meta.get("session:line_count").and_then(|v| v.as_u64()) {
93                            return Ok(BlockfileLineCountResult { count, ..Default::default() });
94                        }
95                    }
96                }
97
98                // Fallback: count from MPS event ring buffer (capped at MAX_PERSIST = 4096).
99                let scope = format!("block:{}", cmd.block_id);
100                let events = broker.read_event_history(
101                    crate::backend::mps::EVENT_BLOCK_FILE,
102                    &scope,
103                    usize::MAX, // broker clamps to MAX_PERSIST internally
104                );
105
106                let mut count: u64 = 0;
107                for event in events {
108                    if let Some(ref event_data) = event.data {
109                        let ev_filename = event_data.get("filename")
110                            .and_then(|v| v.as_str()).unwrap_or("");
111                        if ev_filename != cmd.filename {
112                            continue;
113                        }
114                        if let Some(data64) = event_data.get("data64").and_then(|v| v.as_str()) {
115                            if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data64) {
116                                let text = String::from_utf8_lossy(&bytes);
117                                for line in text.lines() {
118                                    if !line.trim().is_empty() {
119                                        count += 1;
120                                    }
121                                }
122                            }
123                        }
124                    }
125                }
126
127                Ok(BlockfileLineCountResult { count, ..Default::default() })
128            }
129        },
130    );
131}
132
133fn register_blockfile_read_range(engine: &Arc<WshRpcEngine>, state: &AppState) {
134    let broker = state.broker.clone();
135    let filestore = state.filestore.clone();
136    let global_store = state.global_transcript_store.clone();
137    let mstore = state.mstore.clone();
138
139    engine.register_typed(
140        COMMAND_BLOCKFILE_READ_RANGE,
141        move |cmd: CommandBlockfileReadRangeData, _ctx| {
142            read_range(broker.clone(), filestore.clone(), global_store.clone(), mstore.clone(), cmd)
143        },
144    );
145}
146
147/// `blockfile:read_range`, also called in-process: the viewer feed reads its
148/// snapshot through it (`server::http_viewer`), so the feed gets the same
149/// bounded read the pane does and never a whole-file one.
150pub(crate) async fn read_range(
151    broker: Arc<crate::backend::mps::Broker>,
152    filestore: Arc<crate::backend::storage::filestore::FileStore>,
153    global_store: Option<Arc<crate::backend::storage::filestore::FileStore>>,
154    mstore: Arc<crate::backend::storage::store::Store>,
155    cmd: CommandBlockfileReadRangeData,
156) -> Result<BlockfileReadRangeResult, String> {
157    tracing::info!(block_id = %cmd.block_id, filename = %cmd.filename, offset = cmd.offset, limit = cmd.limit, "blockfile:read_range");
158    let started = std::time::Instant::now();
159    let mut clock = ReadRangeClock::default();
160
161    let limit = cmd.limit.min(10_000) as usize;
162    let offset = cmd.offset as usize;
163    let end = offset.saturating_add(limit);
164
165    // Cross-channel fallback: when this channel has no local `output`
166    // for the block, read the agent's GLOBAL transcript zone
167    // (`agent:<defId>:current`) instead. `read_block` is the zone for
168    // every FileStore call below — the local block_id normally, the
169    // agent zone when the agent ran in another build/channel.
170    let source = global_output_source(&filestore, &global_store, &mstore, &cmd.block_id, &cmd.filename);
171    let stream = match &source {
172        Some((_, zone)) => format!("g:{zone}"),
173        None => format!("b:{}", cmd.block_id),
174    };
175    let (filestore, read_block) = source.unwrap_or_else(|| (filestore.clone(), cmd.block_id.clone()));
176    clock.source_ms = ms_since(started);
177
178    // Generation (Phase 5a-3): read before and after the lines. A
179    // replace always mints a new one, so the same value on both
180    // sides proves every line came from that generation — only then
181    // does the response name it. `expect_gen` turns any other
182    // outcome into `gen_mismatch` instead of lines of another file.
183    let transcript = cmd.filename == crate::backend::agent_session::OUTPUT_FILE;
184    let lap = std::time::Instant::now();
185    let gen_before = if transcript { db_generation(&filestore, &read_block).await } else { None };
186    clock.gen_before_ms = ms_since(lap);
187    let mismatch = |gen: Option<String>| BlockfileReadRangeResult {
188        stream: Some(stream.clone()),
189        gen,
190        gen_mismatch: Some(true),
191        ..Default::default()
192    };
193    if let Some(expected) = &cmd.expect_gen {
194        if gen_before.as_deref() != Some(expected.as_str()) {
195            return Ok(mismatch(gen_before));
196        }
197    }
198    let finish = |mut result: BlockfileReadRangeResult, gen_after: Option<String>| {
199        if let Some(turns) = cmd.tail_turns.filter(|t| *t > 0) {
200            trim_to_last_turns(&mut result, offset as u64, turns as usize);
201        }
202        if let Some(bytes) = cmd.tail_bytes.filter(|b| *b > 0) {
203            trim_to_tail_bytes(&mut result, offset as u64, bytes as usize);
204        }
205        if gen_before.is_some() && gen_after == gen_before {
206            result.stream = Some(stream.clone());
207            result.gen = gen_before.clone();
208            result
209        } else if cmd.expect_gen.is_some() {
210            mismatch(gen_after)
211        } else {
212            result
213        }
214    };
215
216    // Fast path: output.idx — a lazily-built, self-validating byte-offset
217    // index of every non-blank line in `output`. It lets us seek directly
218    // to the requested line range instead of loading the whole file.
219    //
220    // The index is a pure cache of `output`: its 8-byte header records
221    // the `output` size it was built for. If that equals `output`'s
222    // current size (and it is labelled with `output`'s generation) the
223    // index is fresh; otherwise THIS path extends it (`extend_output_idx`,
224    // #2838): it scans just the bytes appended since, re-deriving the
225    // last indexed line so a straddling partial line isn't
226    // double-counted, and rebuilds from byte 0 only when the index
227    // can't be a base (missing, shrunk, another generation's).
228    //
229    // Gated to non-circular files: circular `output` (terminal ring buffers)
230    // drops early bytes, so absolute byte offsets wouldn't map cleanly.
231    use crate::backend::blockcontroller::shell::{extend_output_idx, read_via_index, read_via_stale_index};
232    if cmd.filename == "output" {
233        // Runs on the blocking pool (#2841). A full rebuild is a
234        // streaming scan of `output`, which reaches hundreds of MB
235        // on a long-lived agent, and every index/output read below
236        // is blocking file I/O as well — none of it may occupy a
237        // Tokio runtime worker. The closure body is unchanged; only
238        // where it runs is.
239        let idx_clock = std::sync::Arc::new(std::sync::Mutex::new(IndexClock::default()));
240        let idx_result: Option<BlockfileReadRangeResult> = {
241            let filestore = filestore.clone();
242            let read_block = read_block.clone();
243            let idx_clock = idx_clock.clone();
244            let spawned = std::time::Instant::now();
245            let compute = move || -> Option<BlockfileReadRangeResult> {
246            idx_clock.lock().unwrap().queued_ms = ms_since(spawned);
247            let run = std::time::Instant::now();
248            let out_stat = filestore.stat(&read_block, "output").ok()??;
249            if out_stat.opts.circular {
250                return None; // circular files: fall back to slow path
251            }
252            // Everything the answer rests on — the index judged fresh,
253            // its entries, the output bytes they point at — comes from
254            // ONE database snapshot (Codex on #3634), never the stale
255            // per-process `stat` cache. A missing or stale index is
256            // rebuilt once, for the output as a snapshot saw it, and read
257            // again in a new snapshot; if that still doesn't match
258            // (replaced again meanwhile), the slow path below answers.
259            // A missing or stale index is brought up to date by
260            // extending it from its last line — scanning only the
261            // bytes appended since, as `line_count` did before it
262            // answered from the counter (5a-3b). A full rebuild on
263            // every read of a grown file cost seconds on a large
264            // agent zone. `extend_output_idx` still rebuilds when
265            // the index can't be a base (another generation's,
266            // shrunk, missing).
267            let read = match read_via_index(&filestore, &read_block, offset as u64, limit as u64) {
268                Some(read) => read,
269                None => {
270                    let extend = std::time::Instant::now();
271                    let extended = extend_output_idx(&filestore, &read_block);
272                    idx_clock.lock().unwrap().extend_ms = Some(ms_since(extend));
273                    // A failed extension leaves an index that
274                    // may be far behind: its prefix isn't a
275                    // stand-in for the read, so take the
276                    // whole-file path below (#4384).
277                    extended?;
278                    match read_via_index(&filestore, &read_block, offset as u64, limit as u64) {
279                        Some(read) => read,
280                        // The extension succeeded, so only an
281                        // append that landed since (an agent's
282                        // spawn writing to the transcript its pane
283                        // is opening) can make this miss: serve
284                        // the indexed prefix, a few lines short at
285                        // most, rather than read the whole file
286                        // below (docs/reports/REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md).
287                        None => {
288                            let read = read_via_stale_index(&filestore, &read_block, offset as u64, limit as u64)?;
289                            idx_clock.lock().unwrap().stale = true;
290                            read
291                        }
292                    }
293                }
294            };
295            idx_clock.lock().unwrap().run_ms = ms_since(run);
296            let total_lines = read.total;
297
298            // Empty result cases — answered from the index, no output read.
299            if read.line_offsets.is_empty() {
300                return Some(BlockfileReadRangeResult { lines: vec![], total: total_lines, ..Default::default() });
301            }
302            let raw = read.raw;
303            let text = String::from_utf8_lossy(&raw);
304            let lines: Vec<String> = text
305                .lines()
306                .filter(|l| !l.trim().is_empty())
307                .map(|l| l.to_string())
308                .collect();
309
310            // Receive-time stamps for the returned lines, from the
311            // output.tsidx sidecar in the same snapshot as the lines
312            // (`read_via_index`; only a window of the sidecar is
313            // read). Best-effort: no stamps is never a failed read.
314            // Offsets and lines must pair up one to one.
315            let stamps = read.stamps.filter(|s| s.len() == lines.len());
316
317            Some(BlockfileReadRangeResult { lines, total: total_lines, stamps, ..Default::default() })
318            };
319            // A panic in the scan must degrade to the slow path
320            // below, not fail the read — but it is logged rather
321            // than silently swallowed.
322            match tokio::task::spawn_blocking(compute).await {
323                Ok(v) => v,
324                Err(e) => {
325                    tracing::warn!(
326                        block_id = %cmd.block_id,
327                        error = %e,
328                        "blockfile:read_range: output.idx task failed; falling back"
329                    );
330                    None
331                }
332            }
333        };
334        clock.index = Some(std::mem::take(&mut *idx_clock.lock().unwrap()));
335        if let Some(result) = idx_result {
336            let lap = std::time::Instant::now();
337            let gen_after = if transcript { db_generation(&filestore, &read_block).await } else { None };
338            clock.gen_after_ms = ms_since(lap);
339            let result = finish(result, gen_after);
340            clock.log_done(&cmd.block_id, offset, limit, "index", started, &result);
341            return Ok(result);
342        }
343    }
344
345    // Phase 1.3: Prefer FileStore (persistent, no size cap) over the
346    // MPS broker ring buffer (MAX_PERSIST = 4096 events).
347    //
348    // If FileStore has the file and it is non-empty, read from disk.
349    // Otherwise fall back to ring buffer for backward compatibility.
350    let filestore_lines = match filestore.stat(&read_block, &cmd.filename) {
351        Ok(Some(ref wf)) if wf.size > 0 => {
352            if !whole_file_read_allowed(&cmd.filename, wf.opts.circular, wf.size) {
353                tracing::warn!(
354                    block_id = %cmd.block_id,
355                    size = wf.size,
356                    total_ms = ms_since(started),
357                    "blockfile:read_range: output.idx can't serve this read and the transcript is too large to read whole; refusing"
358                );
359                return Err(format!(
360                    "blockfile:read_range: the transcript index for {} is unavailable right now; retry",
361                    cmd.block_id
362                ));
363            }
364            if wf.size >= WHOLE_FILE_WARN_BYTES {
365                tracing::warn!(
366                    block_id = %cmd.block_id,
367                    filename = %cmd.filename,
368                    size = wf.size,
369                    "blockfile:read_range: reading the whole file (no usable output.idx)"
370                );
371            }
372            match filestore.read_file(&read_block, &cmd.filename) {
373                Ok(Some(bytes)) => {
374                    let text = String::from_utf8_lossy(&bytes);
375                    let lines: Vec<String> = text.lines()
376                        .filter(|l| !l.trim().is_empty())
377                        .map(|l| l.to_string())
378                        .collect();
379                    Some(lines)
380                }
381                Ok(None) => None,
382                Err(e) => {
383                    tracing::warn!(
384                        block_id = %cmd.block_id,
385                        filename = %cmd.filename,
386                        error = %e,
387                        "blockfile:read_range: filestore read failed, falling back to ring buffer"
388                    );
389                    None
390                }
391            }
392        }
393        Ok(_) => None, // file absent or empty → fall back
394        Err(e) => {
395            tracing::warn!(
396                block_id = %cmd.block_id,
397                error = %e,
398                "blockfile:read_range: filestore stat failed, falling back to ring buffer"
399            );
400            None
401        }
402    };
403
404    let all_lines = if let Some(lines) = filestore_lines {
405        lines
406    } else {
407        // Fallback: reconstruct from MPS event ring buffer.
408        // The ring buffer holds at most MAX_PERSIST = 4096 events;
409        // older events are evicted. Offset 0 = oldest retained line.
410        let scope = format!("block:{}", cmd.block_id);
411        let events = broker.read_event_history(
412            crate::backend::mps::EVENT_BLOCK_FILE,
413            &scope,
414            usize::MAX, // broker clamps to MAX_PERSIST internally
415        );
416
417        let mut lines: Vec<String> = Vec::new();
418        for event in events {
419            let Some(ref event_data) = event.data else { continue };
420            let ev_filename = event_data.get("filename")
421                .and_then(|v| v.as_str()).unwrap_or("");
422            if ev_filename != cmd.filename {
423                continue;
424            }
425            let Some(data64) = event_data.get("data64").and_then(|v| v.as_str()) else { continue };
426            let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data64) else { continue };
427            let text = String::from_utf8_lossy(&bytes);
428            for line in text.lines() {
429                if !line.trim().is_empty() {
430                    lines.push(line.to_string());
431                }
432            }
433        }
434        lines
435    };
436
437    let total = all_lines.len() as u64;
438    let clamped_offset = offset.min(all_lines.len());
439    let clamped_end = end.min(all_lines.len());
440    let lines: Vec<String> = if clamped_offset >= clamped_end {
441        Vec::new()
442    } else {
443        all_lines[clamped_offset..clamped_end].to_vec()
444    };
445
446    let lap = std::time::Instant::now();
447    let gen_after = if transcript { db_generation(&filestore, &read_block).await } else { None };
448    clock.gen_after_ms = ms_since(lap);
449    let result = finish(BlockfileReadRangeResult { lines, total, ..Default::default() }, gen_after);
450    clock.log_done(&cmd.block_id, offset, limit, "whole_file", started, &result);
451    Ok(result)
452}
453
454fn ms_since(t: std::time::Instant) -> u64 {
455    t.elapsed().as_millis() as u64
456}
457
458/// The whole-file fallback of `blockfile:read_range` loads the file under the
459/// store's lock; a file this large is logged when it does.
460const WHOLE_FILE_WARN_BYTES: i64 = 16 * 1024 * 1024;
461
462/// A non-circular `output` this large is never read whole: loading it holds the
463/// store's connection mutex for the whole read (about 15 s for a 1.3 GB
464/// transcript), stalling every agent's appends and every pane's reads in this
465/// process (docs/reports/REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md). When the
466/// index can't serve such a read, `read_range` answers with an error instead;
467/// the pane falls back to its short tail read, which extends the index again.
468const WHOLE_FILE_REFUSE_BYTES: i64 = 64 * 1024 * 1024;
469
470/// Whether `read_range` may load this file whole (its fallback when
471/// `output.idx` can't serve a read). Everything but a large, non-circular
472/// transcript may; terminal ring buffers (circular) stay bounded by their
473/// `maxsize`.
474fn whole_file_read_allowed(filename: &str, circular: bool, size: i64) -> bool {
475    filename != crate::backend::agent_session::OUTPUT_FILE || circular || size < WHOLE_FILE_REFUSE_BYTES
476}
477
478/// A `blockfile:read_range` call this slow is logged as a warning, with where
479/// its time went.
480const SLOW_READ_RANGE_MS: u64 = 1_000;
481
482/// Where a `blockfile:read_range` call spent its time. Two pane opens waited
483/// about 17 s on this call while the log recorded only when it started
484/// (docs/reports/REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md); with
485/// `filestore: connection held a long time`, this names the step that waited.
486#[derive(Default)]
487struct ReadRangeClock {
488    /// Choosing the store (the block's, or the agent's zone).
489    source_ms: u64,
490    /// `output`'s generation, read before the lines.
491    gen_before_ms: u64,
492    /// The `output.idx` read on the blocking pool, when it ran.
493    index: Option<IndexClock>,
494    /// `output`'s generation, read after the lines.
495    gen_after_ms: u64,
496}
497
498#[derive(Default)]
499struct IndexClock {
500    /// Waiting for a blocking-pool thread.
501    queued_ms: u64,
502    /// Bringing `output.idx` up to date, when the first read found it stale.
503    extend_ms: Option<u64>,
504    /// The whole read on that thread, `extend_ms` included.
505    run_ms: u64,
506    /// Served from an index an append had outrun (`read_via_stale_index`).
507    stale: bool,
508}
509
510impl ReadRangeClock {
511    fn log_done(
512        &self,
513        block_id: &str,
514        offset: usize,
515        limit: usize,
516        path: &str,
517        started: std::time::Instant,
518        result: &BlockfileReadRangeResult,
519    ) {
520        let total_ms = ms_since(started);
521        let bytes: usize = result.lines.iter().map(String::len).sum();
522        let (queued_ms, extend_ms, run_ms, stale) =
523            self.index.as_ref().map_or((0, None, 0, false), |i| (i.queued_ms, i.extend_ms, i.run_ms, i.stale));
524        macro_rules! done {
525            ($level:ident, $msg:literal) => {
526                tracing::$level!(
527                    block_id = %block_id,
528                    offset,
529                    limit,
530                    path,
531                    lines = result.lines.len(),
532                    bytes,
533                    total_ms,
534                    source_ms = self.source_ms,
535                    gen_before_ms = self.gen_before_ms,
536                    queued_ms,
537                    extend_ms = ?extend_ms,
538                    run_ms,
539                    stale,
540                    gen_after_ms = self.gen_after_ms,
541                    $msg
542                )
543            };
544        }
545        if total_ms >= SLOW_READ_RANGE_MS {
546            done!(warn, "blockfile:read_range slow");
547        } else if stale {
548            // Rare by construction (an append outran the index between its
549            // extension and the read), and the only trace that the stale-prefix
550            // path runs in the field, so kept above debug.
551            done!(info, "blockfile:read_range served an index prefix an append had outrun");
552        } else {
553            done!(debug, "blockfile:read_range done");
554        }
555    }
556}
557
558/// Whether `line` starts a turn in a Claude stream-json transcript: the same
559/// rule as the frontend's `ClaudeTranslator.handleUserMessage` producing a
560/// `user_message` — a `user` record whose content is a non-empty string, or
561/// an array with an image and some text and no `tool_result` (a text-only
562/// array is how the CLI records "[Request interrupted by user]" and other
563/// meta lines, never rendered as a turn).
564pub(crate) fn is_claude_turn_start(line: &str) -> bool {
565    use serde_json::Value;
566    if !line.contains("\"user\"") {
567        return false;
568    }
569    let Ok(v) = serde_json::from_str::<Value>(line) else {
570        return false;
571    };
572    if v.get("type").and_then(Value::as_str) != Some("user") {
573        return false;
574    }
575    match v.get("message").and_then(|m| m.get("content")) {
576        Some(Value::String(s)) => !s.is_empty(),
577        Some(Value::Array(blocks)) => {
578            let ty = |b: &Value| b.get("type").and_then(Value::as_str).map(str::to_owned);
579            let has = |t: &str| blocks.iter().any(|b| ty(b).as_deref() == Some(t));
580            !has("tool_result")
581                && has("image")
582                && blocks.iter().any(|b| {
583                    ty(b).as_deref() == Some("text")
584                        && b.get("text")
585                            .and_then(Value::as_str)
586                            .is_some_and(|t| !t.is_empty())
587                })
588        }
589        _ => false,
590    }
591}
592
593/// Index in `lines` where the last `turns` turns begin; `None` when there
594/// are fewer. Scans from the end and stops at the `turns`-th start.
595pub(crate) fn last_turns_start(lines: &[String], turns: usize) -> Option<usize> {
596    if turns == 0 {
597        return None;
598    }
599    let mut seen = 0;
600    for (i, line) in lines.iter().enumerate().rev() {
601        if is_claude_turn_start(line) {
602            seen += 1;
603            if seen == turns {
604                return Some(i);
605            }
606        }
607    }
608    None
609}
610
611/// `tail_turns`: drop everything before the last `turns` turns (stamps
612/// with it) and record where `lines` now starts.
613fn trim_to_last_turns(result: &mut BlockfileReadRangeResult, first_line: u64, turns: usize) {
614    if result.gen_mismatch.is_some() {
615        return;
616    }
617    let cut = last_turns_start(&result.lines, turns).unwrap_or(0);
618    if cut > 0 {
619        result.lines.drain(..cut);
620        if let Some(stamps) = result.stamps.as_mut() {
621            stamps.drain(..cut.min(stamps.len()));
622        }
623    }
624    result.offset = Some(first_line + cut as u64);
625}
626
627/// `tail_bytes`: keep the newest lines that fit in `max_bytes` (the newest
628/// line always stays, however large; a line costs its length plus its
629/// newline), then move the start up to the first turn start among them, if
630/// there is one, so the pane doesn't open on the tail of a turn. Lines before
631/// `first_line` were already trimmed (`tail_turns`): `result.offset` wins.
632fn trim_to_tail_bytes(result: &mut BlockfileReadRangeResult, first_line: u64, max_bytes: usize) {
633    if result.gen_mismatch.is_some() {
634        return;
635    }
636    let first_line = result.offset.unwrap_or(first_line);
637    let mut cut = result.lines.len();
638    let mut kept = 0usize;
639    for (i, line) in result.lines.iter().enumerate().rev() {
640        let cost = line.len() + 1;
641        if cut < result.lines.len() && kept + cost > max_bytes {
642            break;
643        }
644        kept += cost;
645        cut = i;
646    }
647    if cut > 0 {
648        if let Some(turn) = result.lines[cut..].iter().position(|l| is_claude_turn_start(l)) {
649            cut += turn;
650        }
651        result.lines.drain(..cut);
652        if let Some(stamps) = result.stamps.as_mut() {
653            stamps.drain(..cut.min(stamps.len()));
654        }
655    }
656    result.offset = Some(first_line + cut as u64);
657}
658
659#[cfg(test)]
660mod whole_file_tests {
661    use super::*;
662
663    #[test]
664    fn a_large_transcript_is_never_read_whole() {
665        assert!(!whole_file_read_allowed("output", false, WHOLE_FILE_REFUSE_BYTES));
666        assert!(!whole_file_read_allowed("output", false, 1_310_788_351));
667        assert!(whole_file_read_allowed("output", false, WHOLE_FILE_REFUSE_BYTES - 1));
668    }
669
670    #[test]
671    fn ring_buffers_and_other_files_keep_the_whole_file_path() {
672        assert!(whole_file_read_allowed("output", true, 10 * WHOLE_FILE_REFUSE_BYTES));
673        assert!(whole_file_read_allowed("term", false, 10 * WHOLE_FILE_REFUSE_BYTES));
674    }
675}
676
677#[cfg(test)]
678mod tail_turns_tests {
679    use super::*;
680
681    fn user(text: &str) -> String {
682        serde_json::json!({"type": "user", "message": {"role": "user", "content": text}})
683            .to_string()
684    }
685    fn tool_result() -> String {
686        serde_json::json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}}).to_string()
687    }
688    fn delta(text: &str) -> String {
689        serde_json::json!({"type": "stream_event", "event": {"type": "content_block_delta", "delta": {"type": "text_delta", "text": text}}}).to_string()
690    }
691
692    #[test]
693    fn turn_starts_match_what_the_pane_renders_as_a_user_message() {
694        assert!(is_claude_turn_start(&user("deploy now")));
695        assert!(!is_claude_turn_start(&user("")));
696        assert!(!is_claude_turn_start(&tool_result()));
697        assert!(!is_claude_turn_start(&delta("the user said")));
698        let interrupted = serde_json::json!({"type": "user", "message": {"content": [{"type": "text", "text": "[Request interrupted by user]"}]}}).to_string();
699        assert!(!is_claude_turn_start(&interrupted));
700        let with_image = serde_json::json!({"type": "user", "message": {"content": [
701            {"type": "image", "source": {"type": "base64", "data": "AAAA"}},
702            {"type": "text", "text": "what is this?"}
703        ]}})
704        .to_string();
705        assert!(is_claude_turn_start(&with_image));
706        let image_only =
707            serde_json::json!({"type": "user", "message": {"content": [{"type": "image"}]}})
708                .to_string();
709        assert!(!is_claude_turn_start(&image_only));
710        assert!(!is_claude_turn_start("not json \"user\""));
711    }
712
713    fn transcript() -> Vec<String> {
714        // 3 turns: [0..3) [3..6) [6..9)
715        vec![
716            user("one"),
717            delta("a"),
718            tool_result(),
719            user("two"),
720            delta("b"),
721            delta("c"),
722            user("three"),
723            delta("d"),
724            delta("e"),
725        ]
726    }
727
728    #[test]
729    fn finds_where_the_last_turns_begin() {
730        let lines = transcript();
731        assert_eq!(last_turns_start(&lines, 1), Some(6));
732        assert_eq!(last_turns_start(&lines, 2), Some(3));
733        assert_eq!(last_turns_start(&lines, 3), Some(0));
734        assert_eq!(last_turns_start(&lines, 4), None);
735        assert_eq!(last_turns_start(&lines, 0), None);
736    }
737
738    #[test]
739    fn trims_lines_and_stamps_and_reports_the_new_start() {
740        let mut r = BlockfileReadRangeResult {
741            lines: transcript(),
742            total: 109,
743            stamps: Some((0..9).collect()),
744            ..Default::default()
745        };
746        trim_to_last_turns(&mut r, 100, 2);
747        assert_eq!(r.offset, Some(103));
748        assert_eq!(r.lines, transcript()[3..].to_vec());
749        assert_eq!(r.stamps, Some(vec![3, 4, 5, 6, 7, 8]));
750        assert_eq!(r.total, 109, "total is the file's, untouched");
751    }
752
753    #[test]
754    fn fewer_turns_than_asked_returns_the_whole_range() {
755        let mut r = BlockfileReadRangeResult {
756            lines: transcript(),
757            total: 9,
758            ..Default::default()
759        };
760        trim_to_last_turns(&mut r, 0, 7);
761        assert_eq!(r.lines.len(), 9);
762        assert_eq!(r.offset, Some(0));
763    }
764
765    #[test]
766    fn a_generation_mismatch_is_left_alone() {
767        let mut r = BlockfileReadRangeResult {
768            gen_mismatch: Some(true),
769            ..Default::default()
770        };
771        trim_to_last_turns(&mut r, 5, 2);
772        assert_eq!(r.offset, None);
773    }
774
775    fn lines_of(n: usize, len: usize) -> Vec<String> {
776        (0..n).map(|i| format!("{i:0len$}")).collect()
777    }
778
779    #[test]
780    fn keeps_the_newest_lines_that_fit_in_the_byte_budget() {
781        // Ten 9-byte lines cost 10 bytes each with their newline.
782        let mut r = BlockfileReadRangeResult {
783            lines: lines_of(10, 9),
784            total: 110,
785            stamps: Some((0..10).collect()),
786            ..Default::default()
787        };
788        trim_to_tail_bytes(&mut r, 100, 35);
789        assert_eq!(r.lines, lines_of(10, 9)[7..].to_vec());
790        assert_eq!(r.stamps, Some(vec![7, 8, 9]));
791        assert_eq!(r.offset, Some(107));
792        assert_eq!(r.total, 110, "total is the file's, untouched");
793    }
794
795    #[test]
796    fn a_range_within_the_budget_comes_back_whole() {
797        let mut r = BlockfileReadRangeResult { lines: lines_of(4, 9), total: 4, ..Default::default() };
798        trim_to_tail_bytes(&mut r, 0, 1_000);
799        assert_eq!(r.lines.len(), 4);
800        assert_eq!(r.offset, Some(0));
801    }
802
803    #[test]
804    fn the_newest_line_stays_even_over_the_budget() {
805        let mut r = BlockfileReadRangeResult {
806            lines: vec!["old".to_string(), "x".repeat(500)],
807            total: 2,
808            ..Default::default()
809        };
810        trim_to_tail_bytes(&mut r, 0, 10);
811        assert_eq!(r.lines, vec!["x".repeat(500)]);
812        assert_eq!(r.offset, Some(1));
813    }
814
815    #[test]
816    fn a_byte_cut_inside_a_turn_moves_up_to_the_next_turn_start() {
817        // The budget reaches back into turn two's deltas; the pane opens at
818        // turn three instead of on turn two's tail.
819        let lines = transcript();
820        let budget: usize = lines[5..].iter().map(|l| l.len() + 1).sum();
821        let mut r = BlockfileReadRangeResult { lines: lines.clone(), total: 9, ..Default::default() };
822        trim_to_tail_bytes(&mut r, 0, budget);
823        assert_eq!(r.lines, lines[6..].to_vec());
824        assert_eq!(r.offset, Some(6));
825    }
826
827    #[test]
828    fn with_no_turn_start_in_what_fits_the_byte_cut_stands() {
829        let lines = transcript();
830        let budget: usize = lines[7..].iter().map(|l| l.len() + 1).sum();
831        let mut r = BlockfileReadRangeResult { lines: lines.clone(), total: 9, ..Default::default() };
832        trim_to_tail_bytes(&mut r, 0, budget);
833        assert_eq!(r.lines, lines[7..].to_vec());
834        assert_eq!(r.offset, Some(7));
835    }
836
837    #[test]
838    fn the_byte_budget_applies_after_the_turn_trim() {
839        let lines = transcript();
840        let mut r = BlockfileReadRangeResult { lines: lines.clone(), total: 109, ..Default::default() };
841        trim_to_last_turns(&mut r, 100, 2); // lines [3..9) from line 103
842        let budget: usize = lines[6..].iter().map(|l| l.len() + 1).sum();
843        trim_to_tail_bytes(&mut r, 100, budget);
844        assert_eq!(r.lines, lines[6..].to_vec());
845        assert_eq!(r.offset, Some(106));
846    }
847
848    #[test]
849    fn a_generation_mismatch_is_left_alone_by_the_byte_trim() {
850        let mut r = BlockfileReadRangeResult { gen_mismatch: Some(true), ..Default::default() };
851        trim_to_tail_bytes(&mut r, 5, 10);
852        assert_eq!(r.offset, None);
853    }
854
855    #[test]
856    fn the_request_field_is_optional_on_the_wire() {
857        let old: CommandBlockfileReadRangeData = serde_json::from_value(serde_json::json!({
858            "block_id": "b", "filename": "output", "offset": 0, "limit": 10
859        }))
860        .unwrap();
861        assert_eq!(old.tail_turns, None);
862        assert_eq!(old.tail_bytes, None);
863        let new: CommandBlockfileReadRangeData = serde_json::from_value(serde_json::json!({
864            "block_id": "b", "filename": "output", "offset": 0, "limit": 10, "tail_turns": 7, "tail_bytes": 1000000
865        }))
866        .unwrap();
867        assert_eq!(new.tail_turns, Some(7));
868        assert_eq!(new.tail_bytes, Some(1_000_000));
869        let wire = serde_json::to_value(BlockfileReadRangeResult::default()).unwrap();
870        assert!(
871            wire.get("offset").is_none(),
872            "absent unless tail_turns was asked"
873        );
874    }
875}
876
877fn register_blockfile_read_state(engine: &Arc<WshRpcEngine>, state: &AppState) {
878    let filestore = state.filestore.clone();
879    engine.register_typed(
880        COMMAND_BLOCKFILE_READ_STATE,
881        move |cmd: CommandBlockfileReadStateData, _ctx| {
882            let filestore = filestore.clone();
883            async move {
884                if cmd.filename.contains('/') || cmd.filename.contains('\\') || cmd.filename.contains("..") {
885                    return Err("blockfile:read_state: filename must not contain path separators".to_string());
886                }
887                tracing::debug!(block_id = %cmd.block_id, filename = %cmd.filename, "blockfile:read_state");
888
889                let content = match filestore.read_file(&cmd.block_id, &cmd.filename) {
890                    Ok(Some(bytes)) => Some(String::from_utf8_lossy(&bytes).into_owned()),
891                    Ok(None) => None,
892                    Err(e) => {
893                        // NotFound is the common case (no snapshot yet). Suppress.
894                        if matches!(e, crate::backend::storage::StoreError::NotFound) {
895                            None
896                        } else {
897                            tracing::warn!(block_id = %cmd.block_id, error = %e, "blockfile:read_state: read failed");
898                            None
899                        }
900                    }
901                };
902
903                Ok(BlockfileReadStateResult { content })
904            }
905        },
906    );
907}
908
909fn register_blockfile_write_state(engine: &Arc<WshRpcEngine>, state: &AppState) {
910    let filestore = state.filestore.clone();
911    engine.register_typed(
912        COMMAND_BLOCKFILE_WRITE_STATE,
913        move |cmd: CommandBlockfileWriteStateData, _ctx| {
914            let filestore = filestore.clone();
915            async move {
916                if cmd.filename.contains('/') || cmd.filename.contains('\\') || cmd.filename.contains("..") {
917                    return Err("blockfile:write_state: filename must not contain path separators".to_string());
918                }
919                // The transcript and its sidecars are written only by the
920                // transcript paths, which keep its generation and sidecars
921                // consistent (5a-2b). This RPC is for pane state files; letting
922                // it replace `output` would leave `output.idx` / `output.tsidx`
923                // describing other bytes.
924                if matches!(cmd.filename.as_str(), "output" | "output.idx" | "output.tsidx") {
925                    return Err(format!(
926                        "blockfile:write_state: {} is a transcript file and can't be written through this RPC",
927                        cmd.filename
928                    ));
929                }
930                let bytes = cmd.content.as_bytes();
931                let bytes_written = bytes.len() as u64;
932                tracing::debug!(block_id = %cmd.block_id, filename = %cmd.filename, bytes = bytes_written, "blockfile:write_state");
933
934                // FileStore.write_file is atomic at the DB level (single
935                // tx replaces all data parts) — no torn write surfaces.
936                // Need make_file first if the sidecar doesn't yet exist.
937                use crate::backend::storage::filestore::{FileMeta, FileOpts};
938                use crate::backend::storage::StoreError;
939                match filestore.write_file(&cmd.block_id, &cmd.filename, bytes) {
940                    Ok(()) => {}
941                    Err(StoreError::NotFound) => {
942                        filestore
943                            .make_file(&cmd.block_id, &cmd.filename, FileMeta::default(), FileOpts::default())
944                            .map_err(|e| format!("blockfile:write_state: make_file: {e}"))?;
945                        filestore
946                            .write_file(&cmd.block_id, &cmd.filename, bytes)
947                            .map_err(|e| format!("blockfile:write_state: write_file: {e}"))?;
948                    }
949                    Err(e) => return Err(format!("blockfile:write_state: {e}")),
950                }
951
952                Ok(BlockfileWriteStateResult { bytes_written })
953            }
954        },
955    );
956}
957
958/// A transcript stream's line count from its counter, with the stream and
959/// generation it counts (Phase 5a-3). A file with no epoch yet gets one
960/// first — `init_line_counter`, one read of the file, once per epoch; call on
961/// the blocking pool. `None` when it can't be counted (no such file, or
962/// mid-write by a build without transactions), and the caller falls back.
963fn counted_line_count(
964    fs: &crate::backend::storage::filestore::FileStore,
965    zone: &str,
966    stream: String,
967) -> Option<BlockfileLineCountResult> {
968    use crate::backend::agent_session::OUTPUT_FILE;
969    let state = fs.line_state(zone, OUTPUT_FILE).ok()??;
970    let state = if state.counted.is_some() { state } else { fs.init_line_counter(zone, OUTPUT_FILE).ok()?? };
971    let counted = state.counted?;
972    Some(BlockfileLineCountResult { count: counted.lines, stream: Some(stream), gen: Some(counted.gen) })
973}
974
975/// Where `block_id`'s transcript stands, from the same source `read_range`
976/// reads it from (the block's own `output`, or the agent's global zone): its
977/// stream, generation and line count. `None` while it doesn't exist or can't
978/// be counted. For the viewer feed, which reads the tail through `read_range`.
979pub(crate) async fn transcript_position(
980    filestore: &Arc<crate::backend::storage::filestore::FileStore>,
981    global_store: &Option<Arc<crate::backend::storage::filestore::FileStore>>,
982    mstore: &Arc<crate::backend::storage::store::Store>,
983    block_id: &str,
984) -> Option<(String, String, u64)> {
985    use crate::backend::agent_session::OUTPUT_FILE;
986    let (fs, zone, stream) = match global_output_source(filestore, global_store, mstore, block_id, OUTPUT_FILE) {
987        Some((gfs, zone)) => (gfs, zone.clone(), format!("g:{zone}")),
988        None => (filestore.clone(), block_id.to_string(), format!("b:{block_id}")),
989    };
990    let counted = tokio::task::spawn_blocking(move || counted_line_count(&fs, &zone, stream))
991        .await
992        .ok()
993        .flatten()?;
994    Some((counted.stream?, counted.gen?, counted.count))
995}
996
997/// The valid generation of a transcript's `output`, read from the database on
998/// the blocking pool (the global store can be held by another srv instance).
999async fn db_generation(fs: &Arc<crate::backend::storage::filestore::FileStore>, zone: &str) -> Option<String> {
1000    let (fs, zone) = (fs.clone(), zone.to_string());
1001    tokio::task::spawn_blocking(move || {
1002        crate::backend::blockcontroller::shell::output_now(&fs, &zone).and_then(|(_, gen)| gen)
1003    })
1004    .await
1005    .ok()
1006    .flatten()
1007}
1008
1009#[cfg(test)]
1010#[path = "blockfile_transcript_tests.rs"]
1011mod transcript_rpc_tests;