1use super::{
4 Event, Header, Kind, SessionRef,
5 document::{Anchor, Position},
6};
7use crate::{
8 platform,
9 store::{self, RequestView},
10 types::SessionId,
11};
12use std::{
13 collections::VecDeque,
14 ffi::OsStr,
15 fs::{File, Metadata},
16 io::{Read, Seek, SeekFrom},
17 path::{Path, PathBuf},
18 time::SystemTime,
19};
20
21const BATCH_BYTES: usize = 256 * 1024;
23const BATCH_EVENTS: usize = 128;
24const GUARD_BYTES: usize = 256;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Source {
29 pub state: PathBuf,
30 pub reference: SessionRef,
31 pub saved: bool,
32}
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Resolution {
35 Local(Source),
36 Unavailable(String),
37}
38
39pub async fn resolve(state: &Path, view: &RequestView) -> Result<Resolution, String> {
40 resolve_cached(state, view, None).await
41}
42
43pub async fn resolve_cached(
44 state: &Path,
45 view: &RequestView,
46 identity: Option<&crate::types::StateId>,
47) -> Result<Resolution, String> {
48 let state_id = match identity {
49 Some(identity) => identity.clone(),
50 None => {
51 let identity_state = state.to_path_buf();
52 crate::task::joined(
53 tokio::task::spawn_blocking(move || store::read_state_id(&identity_state)).await,
54 )
55 .map_err(|error| error.to_string())??
56 .ok_or("State has no identity.")?
57 }
58 };
59 let producer = store::Producer::current().name;
60 let mut view = view.clone();
61 if view.request.origin.is_none()
64 && view
65 .request
66 .producer
67 .as_ref()
68 .is_none_or(|source| source.name == producer)
69 && view
70 .execution
71 .as_ref()
72 .and_then(|execution| execution.producer.as_ref())
73 .is_none_or(|source| source.name == producer)
74 && view
75 .execution
76 .as_ref()
77 .is_none_or(|execution| execution.origin.is_none())
78 {
79 let original = view
80 .execution
81 .as_ref()
82 .map(|execution| &execution.provenance)
83 .or(view.request.provenance.as_ref());
84 if let Some(original) = original
85 && original.request_id != view.request.id
86 && view.request.session.is_none()
87 && view
88 .request
89 .producer
90 .as_ref()
91 .and_then(|producer| producer.session.as_ref())
92 .is_none()
93 && view
94 .execution
95 .as_ref()
96 .and_then(|execution| execution.producer.as_ref())
97 .and_then(|producer| producer.session.as_ref())
98 .is_none()
99 {
100 let source = store::read_request(state, &original.request_id).await?;
101 if source.request.run_id != original.run_id {
102 return Err("Original session request has a different Run identity.".into());
103 }
104 view = source;
105 }
106 }
107 let request = &view.request;
108 let reference = request
109 .session
110 .as_ref()
111 .or_else(|| {
112 request
113 .producer
114 .as_ref()
115 .and_then(|producer| producer.session.as_ref())
116 })
117 .or_else(|| {
118 view.execution
119 .as_ref()
120 .and_then(|execution| execution.producer.as_ref())
121 .and_then(|producer| producer.session.as_ref())
122 });
123 if let Some(reference) = reference {
124 if !reference.valid() {
125 return Err("Invalid saved session reference.".into());
126 }
127 if reference.state != state_id || reference.producer != producer {
128 return Ok(Resolution::Unavailable(format!(
129 "This conversation lives in {}'s state {} (session {}). It is remote/unavailable here, not removed by local session GC.",
130 reference.producer, reference.state, reference.session_id
131 )));
132 }
133 return Ok(Resolution::Local(Source {
134 state: state.into(),
135 reference: reference.clone(),
136 saved: true,
137 }));
138 }
139 if request.origin.is_some()
140 || view
141 .execution
142 .as_ref()
143 .is_some_and(|execution| execution.origin.is_some())
144 || request
145 .producer
146 .as_ref()
147 .is_some_and(|source| source.name != producer)
148 || view
149 .execution
150 .as_ref()
151 .and_then(|execution| execution.producer.as_ref())
152 .is_some_and(|source| source.name != producer)
153 {
154 return Ok(Resolution::Unavailable(
155 "This remote conversation has no local session reference; it is unavailable here."
156 .into(),
157 ));
158 }
159 match &request.session_id {
160 Some(id) => Ok(Resolution::Local(Source {
161 state: state.into(),
162 reference: SessionRef {
163 producer,
164 state: state_id,
165 run_id: request.run_id.clone(),
166 request_id: request.id.clone(),
167 session_id: id.clone(),
168 },
169 saved: false,
170 })),
171 None => Ok(Resolution::Unavailable(
172 "No saved Agent conversation was recorded for this request. It may have reused evidence with no session reference, predate session saving, or never have started an Agent review."
173 .into(),
174 )),
175 }
176}
177
178fn identity(file: &File) -> Result<platform::FileIdentity, String> {
179 platform::file_identity(file).map_err(|error| error.to_string())
180}
181fn private_directory(file: &File) -> Result<(), String> {
182 if !file.metadata().map_err(|error| error.to_string())?.is_dir() {
183 return Err("Session store is not a directory.".into());
184 }
185 if !platform::is_owner_only(file).map_err(|error| error.to_string())? {
186 return Err("Session store must be owner-only and owned by this user.".into());
187 }
188 Ok(())
189}
190fn open(source: &Source) -> Result<Option<File>, String> {
191 let root = platform::open_directory(&source.state).map_err(|error| error.to_string())?;
192 let name =
193 platform::EntryName::new(OsStr::new(super::DIRECTORY)).expect("constant session directory");
194 let result = (|| {
195 let directory = platform::open_entry(&root, &name)?;
196 private_directory(&directory).map_err(std::io::Error::other)?;
197 let name = platform::EntryName::new(OsStr::new(&format!(
198 "{}.jsonl",
199 source.reference.session_id
200 )))
201 .expect("validated session id");
202 let file = platform::open_entry(&directory, &name)?;
203 if !platform::is_private_file(&file)? {
204 return Err(std::io::Error::other(
205 "Session must be a private, single-link regular file.",
206 ));
207 }
208 if !platform::is_owner_only(&file)? {
209 return Err(std::io::Error::other("Session belongs to another user."));
210 }
211 Ok(file)
212 })();
213 match result {
214 Ok(file) => Ok(Some(file)),
215 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
216 Err(error) => Err(error.to_string()),
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
221enum ReadError {
222 #[error("{0}")]
223 Data(String),
224 #[error("{0}")]
225 Io(String),
226}
227
228impl From<String> for ReadError {
229 fn from(error: String) -> Self {
230 Self::Io(error)
231 }
232}
233
234pub struct Reader {
236 pub source: Source,
237 file: Option<File>,
238 identity: Option<platform::FileIdentity>,
239 modified: Option<SystemTime>,
240 length: u64,
241 offset: u64,
242 start: u64,
243 pending: VecDeque<(u64, usize)>,
244 chunk: Vec<u8>,
245 chunk_offset: u64,
246 guards: (Vec<u8>, Vec<u8>),
247 records: usize,
248 pages: Option<super::pages::Pages>,
249 transcript: super::transcript::Transcript,
250 error: Option<String>,
251 pub bytes_read: u64,
252 pub decoded_events: usize,
253}
254#[derive(Debug, Clone, Default)]
255pub struct Window {
256 pub rows: Vec<String>,
257 pub top: usize,
258 pub total: usize,
259 pub anchor: Anchor,
260 pub position: Position,
261 pub loading: bool,
262 pub reset: bool,
263 pub status: Option<String>,
264 pub groups: Vec<(usize, super::transcript::BlockId)>,
265 pub thinking: Vec<usize>,
266}
267impl Reader {
268 pub fn new(source: Source) -> Self {
269 Self {
270 source,
271 file: None,
272 identity: None,
273 modified: None,
274 length: 0,
275 offset: 0,
276 start: 0,
277 pending: VecDeque::new(),
278 chunk: Vec::new(),
279 chunk_offset: 0,
280 guards: Default::default(),
281 records: 0,
282 pages: None,
283 transcript: super::transcript::Transcript::default(),
284 error: None,
285 bytes_read: 0,
286 decoded_events: 0,
287 }
288 }
289 #[cfg(test)]
290 pub(crate) fn records_len(&self) -> usize {
291 self.records
292 }
293 pub fn commit(&mut self, width: usize) {
294 if let Some(pages) = &mut self.pages {
295 pages.commit(width);
296 }
297 }
298 pub fn id(&self) -> &SessionId {
299 &self.source.reference.session_id
300 }
301 fn reset(&mut self) {
302 self.file = None;
303 self.identity = None;
304 self.length = 0;
305 self.offset = 0;
306 self.start = 0;
307 self.pending.clear();
308 self.chunk.clear();
309 self.guards = Default::default();
310 self.records = 0;
311 self.pages = None;
312 self.transcript = super::transcript::Transcript::default();
313 self.error = None;
314 }
315 fn bytes(&mut self, offset: u64, size: usize) -> Result<Vec<u8>, String> {
316 let file = self.file.as_mut().ok_or("Session file is unavailable.")?;
317 file.seek(SeekFrom::Start(offset))
318 .map_err(|error| error.to_string())?;
319 let mut bytes = vec![0; size];
320 file.read_exact(&mut bytes)
321 .map_err(|error| error.to_string())?;
322 self.bytes_read += size as u64;
323 Ok(bytes)
324 }
325 fn guards(&mut self) -> Result<(Vec<u8>, Vec<u8>), String> {
326 let size = GUARD_BYTES.min(self.offset as usize);
327 Ok((
328 self.bytes(0, size)?,
329 self.bytes(self.offset - size as u64, size)?,
330 ))
331 }
332 fn probe(&mut self) -> Result<(bool, bool), String> {
333 let Some(file) = open(&self.source)? else {
334 let reset = self.identity.is_some();
335 self.reset();
336 return Ok((reset, false));
337 };
338 let metadata: Metadata = file.metadata().map_err(|error| error.to_string())?;
339 let identity = identity(&file)?;
340 let modified = metadata.modified().ok();
341 let mut reset = self.identity.is_some_and(|old| old != identity)
342 || metadata.len() < self.offset
343 || metadata.len() < self.length
344 || self.identity.is_some()
345 && metadata.len() == self.length
346 && self.modified != modified;
347 self.file = Some(file);
348 if !reset && self.offset != 0 && self.guards()? != self.guards {
349 reset = true;
350 }
351 if reset {
352 let file = self.file.take();
353 self.reset();
354 self.file = file;
355 }
356 self.identity = Some(identity);
357 self.modified = modified;
358 self.length = metadata.len();
359 Ok((reset, true))
360 }
361 fn validate(&self, event: &Event, index: usize) -> Result<(), String> {
362 let Kind::Review(header) = &event.kind else {
363 return if index == 0 {
364 Err("Session has no review header.".into())
365 } else {
366 Ok(())
367 };
368 };
369 if index != 0 {
370 return Err("Session contains a second review header.".into());
371 }
372 let Header {
373 session_id,
374 run_id,
375 request_id,
376 producer,
377 state,
378 version,
379 ..
380 } = header.as_ref();
381 let expected = &self.source.reference;
382 let mismatch = session_id
383 .as_ref()
384 .is_some_and(|id| id != &expected.session_id)
385 || run_id.as_ref().is_some_and(|id| id != &expected.run_id)
386 || request_id
387 .as_ref()
388 .is_some_and(|id| id != &expected.request_id)
389 || producer.as_ref().is_some_and(|id| id != &expected.producer)
390 || state.as_ref().is_some_and(|id| id != &expected.state);
391 let incomplete = version.is_some()
392 && (session_id.is_none()
393 || run_id.is_none()
394 || request_id.is_none()
395 || producer.is_none()
396 || state.is_none());
397 if mismatch || incomplete {
398 return Err(
399 "Session header does not match its state, producer, Run, request and session identity."
400 .into(),
401 );
402 }
403 if version.is_some_and(|version| version != super::VERSION) {
404 return Err("Unsupported saved session version.".into());
405 }
406 Ok(())
407 }
408 fn index(&mut self) -> Result<bool, ReadError> {
409 if self.pending.is_empty() && self.offset < self.length {
410 let available = BATCH_BYTES.min(self.length.saturating_sub(self.offset) as usize);
411 let bytes = self.bytes(self.offset, available)?;
412 for (byte, _) in bytes.iter().enumerate().filter(|(_, byte)| **byte == b'\n') {
413 let end = self.offset + byte as u64;
414 self.pending
415 .push_back((self.start, (end - self.start) as usize));
416 self.start = end + 1;
417 }
418 self.chunk_offset = self.offset;
419 self.chunk = bytes;
420 self.offset += available as u64;
421 }
422 let mut budget = 0;
423 let mut count = 0;
424 while count < BATCH_EVENTS && budget < BATCH_BYTES {
425 let Some((offset, size)) = self.pending.pop_front() else {
426 break;
427 };
428 let owned;
432 let bytes = if offset >= self.chunk_offset
433 && offset + size as u64 <= self.chunk_offset + self.chunk.len() as u64
434 {
435 &self.chunk[(offset - self.chunk_offset) as usize
436 ..(offset - self.chunk_offset) as usize + size]
437 } else {
438 owned = self.bytes(offset, size)?;
439 &owned
440 };
441 let event = Event::parse(bytes).map_err(|error| {
442 ReadError::Data(format!(
443 "Invalid saved session event at line {}: {error}",
444 self.records + 1
445 ))
446 })?;
447 self.validate(&event, self.records)
448 .map_err(ReadError::Data)?;
449 let patches = self.transcript.apply(&event);
450 for patch in patches {
451 self.pages
452 .as_mut()
453 .expect("pages initialized")
454 .set(patch.id.0, &patch.text)?;
455 }
456 self.records += 1;
457 self.decoded_events += 1;
458 budget += size;
459 count += 1;
460 }
461 self.guards = self.guards()?;
462 Ok(self.offset < self.length || !self.pending.is_empty())
463 }
464 pub fn step(&mut self, width: usize, height: usize, position: Position) -> Window {
467 self.step_expanded(width, height, position, &std::collections::BTreeSet::new())
468 }
469 pub fn step_expanded(
470 &mut self,
471 width: usize,
472 height: usize,
473 position: Position,
474 expanded: &std::collections::BTreeSet<super::transcript::BlockId>,
475 ) -> Window {
476 let result = (|| {
477 let (reset, exists) = self.probe()?;
478 if !exists {
479 return Ok(Window {
480 reset,
481 status: Some(
482 if self.source.saved {
483 "This Agent session was removed by session GC (or the saved file was removed)."
484 } else {
485 "This Agent session was never recorded as saved, or has not been created yet."
486 }
487 .into(),
488 ),
489 ..Window::default()
490 });
491 }
492 if width == 0 {
493 return Ok(Window {
494 reset,
495 ..Window::default()
496 });
497 }
498 if self.pages.is_none() {
499 self.pages = Some(super::pages::Pages::new(width)?);
500 }
501 let position = if reset {
502 Position::Bottom
503 } else {
504 self.pages
505 .as_mut()
506 .expect("pages initialized")
507 .position(position)?
508 };
509 for patch in self.transcript.expansion(expanded) {
510 self.pages
511 .as_mut()
512 .expect("pages initialized")
513 .set(patch.id.0, &patch.text)?;
514 }
515 self.pages
516 .as_mut()
517 .expect("pages initialized")
518 .width(width)?;
519 let indexing = if self.error.is_none() {
520 self.index()?
521 } else {
522 false
523 };
524 let pages = self.pages.as_mut().expect("pages initialized");
525 let layout = pages.layout()?;
526 let mut window = if layout {
527 Window::default()
528 } else {
529 let (top, total, anchor, rows) = pages.window(position, height)?;
530 let groups = pages
531 .anchors(top, height)?
532 .into_iter()
533 .enumerate()
534 .filter_map(|(row, anchor)| {
535 let id = super::transcript::BlockId(anchor.event);
536 self.transcript.is_group(id).then_some((row, id))
537 })
538 .collect();
539 let thinking = pages
540 .anchors(top, height)?
541 .into_iter()
542 .enumerate()
543 .filter_map(|(row, anchor)| {
544 self.transcript
545 .is_thinking(super::transcript::BlockId(anchor.event))
546 .then_some(row)
547 })
548 .collect();
549 Window {
550 top,
551 total,
552 anchor,
553 rows,
554 groups,
555 thinking,
556 ..Window::default()
557 }
558 };
559 window.position = position;
560 window.reset = reset;
561 window.loading = self.error.is_none() && (indexing || layout);
562 window.status = self.error.clone().or_else(|| {
563 (self.records == 0).then(|| "Waiting for a complete review header…".into())
564 });
565 Ok::<_, ReadError>(window)
566 })();
567 match result {
568 Ok(window) => window,
569 Err(error) => {
570 let reset = matches!(error, ReadError::Io(_));
571 match &error {
572 ReadError::Data(message) => {
573 if self.file.is_some()
574 && let Ok(guards) = self.guards()
575 {
576 self.guards = guards;
577 }
578 self.error = Some(message.clone());
579 }
580 ReadError::Io(_) => self.reset(),
581 }
582 Window {
583 reset,
584 status: Some(format!("Conversation unavailable: {error}")),
585 ..Window::default()
586 }
587 }
588 }
589 }
590}