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 if let Some((gfs, zone)) =
34 global_output_source(&filestore, &global_store, &mstore, &cmd.block_id, &cmd.filename)
35 {
36 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 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 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 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, );
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
147pub(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 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 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 use crate::backend::blockcontroller::shell::{extend_output_idx, read_via_index, read_via_stale_index};
232 if cmd.filename == "output" {
233 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; }
252 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 extended?;
278 match read_via_index(&filestore, &read_block, offset as u64, limit as u64) {
279 Some(read) => read,
280 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 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 let stamps = read.stamps.filter(|s| s.len() == lines.len());
316
317 Some(BlockfileReadRangeResult { lines, total: total_lines, stamps, ..Default::default() })
318 };
319 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 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, 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 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, );
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
458const WHOLE_FILE_WARN_BYTES: i64 = 16 * 1024 * 1024;
461
462const WHOLE_FILE_REFUSE_BYTES: i64 = 64 * 1024 * 1024;
469
470fn 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
478const SLOW_READ_RANGE_MS: u64 = 1_000;
481
482#[derive(Default)]
487struct ReadRangeClock {
488 source_ms: u64,
490 gen_before_ms: u64,
492 index: Option<IndexClock>,
494 gen_after_ms: u64,
496}
497
498#[derive(Default)]
499struct IndexClock {
500 queued_ms: u64,
502 extend_ms: Option<u64>,
504 run_ms: u64,
506 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 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
558pub(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
593pub(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
611fn 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
627fn 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 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 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 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); 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 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 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 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
958fn 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
975pub(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
997async 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;