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 pub fn commit(&mut self, width: usize) {
290 if let Some(pages) = &mut self.pages {
291 pages.commit(width);
292 }
293 }
294 pub fn id(&self) -> &SessionId {
295 &self.source.reference.session_id
296 }
297 fn reset(&mut self) {
298 self.file = None;
299 self.identity = None;
300 self.length = 0;
301 self.offset = 0;
302 self.start = 0;
303 self.pending.clear();
304 self.chunk.clear();
305 self.guards = Default::default();
306 self.records = 0;
307 self.pages = None;
308 self.transcript = super::transcript::Transcript::default();
309 self.error = None;
310 }
311 fn bytes(&mut self, offset: u64, size: usize) -> Result<Vec<u8>, String> {
312 let file = self.file.as_mut().ok_or("Session file is unavailable.")?;
313 file.seek(SeekFrom::Start(offset))
314 .map_err(|error| error.to_string())?;
315 let mut bytes = vec![0; size];
316 file.read_exact(&mut bytes)
317 .map_err(|error| error.to_string())?;
318 self.bytes_read += size as u64;
319 Ok(bytes)
320 }
321 fn guards(&mut self) -> Result<(Vec<u8>, Vec<u8>), String> {
322 let size = GUARD_BYTES.min(self.offset as usize);
323 Ok((
324 self.bytes(0, size)?,
325 self.bytes(self.offset - size as u64, size)?,
326 ))
327 }
328 fn probe(&mut self) -> Result<(bool, bool), String> {
329 let Some(file) = open(&self.source)? else {
330 let reset = self.identity.is_some();
331 self.reset();
332 return Ok((reset, false));
333 };
334 let metadata: Metadata = file.metadata().map_err(|error| error.to_string())?;
335 let identity = identity(&file)?;
336 let modified = metadata.modified().ok();
337 let mut reset = self.identity.is_some_and(|old| old != identity)
338 || metadata.len() < self.offset
339 || metadata.len() < self.length
340 || self.identity.is_some()
341 && metadata.len() == self.length
342 && self.modified != modified;
343 self.file = Some(file);
344 if !reset && self.offset != 0 && self.guards()? != self.guards {
345 reset = true;
346 }
347 if reset {
348 let file = self.file.take();
349 self.reset();
350 self.file = file;
351 }
352 self.identity = Some(identity);
353 self.modified = modified;
354 self.length = metadata.len();
355 Ok((reset, true))
356 }
357 fn validate(&self, event: &Event, index: usize) -> Result<(), String> {
358 let Kind::Review(header) = &event.kind else {
359 return if index == 0 {
360 Err("Session has no review header.".into())
361 } else {
362 Ok(())
363 };
364 };
365 if index != 0 {
366 return Err("Session contains a second review header.".into());
367 }
368 let Header {
369 session_id,
370 run_id,
371 request_id,
372 producer,
373 state,
374 version,
375 ..
376 } = header.as_ref();
377 let expected = &self.source.reference;
378 let mismatch = session_id
379 .as_ref()
380 .is_some_and(|id| id != &expected.session_id)
381 || run_id.as_ref().is_some_and(|id| id != &expected.run_id)
382 || request_id
383 .as_ref()
384 .is_some_and(|id| id != &expected.request_id)
385 || producer.as_ref().is_some_and(|id| id != &expected.producer)
386 || state.as_ref().is_some_and(|id| id != &expected.state);
387 let incomplete = version.is_some()
388 && (session_id.is_none()
389 || run_id.is_none()
390 || request_id.is_none()
391 || producer.is_none()
392 || state.is_none());
393 if mismatch || incomplete {
394 return Err(
395 "Session header does not match its state, producer, Run, request and session identity."
396 .into(),
397 );
398 }
399 if version.is_some_and(|version| version != super::VERSION) {
400 return Err("Unsupported saved session version.".into());
401 }
402 Ok(())
403 }
404 fn index(&mut self) -> Result<bool, ReadError> {
405 if self.pending.is_empty() && self.offset < self.length {
406 let available = BATCH_BYTES.min(self.length.saturating_sub(self.offset) as usize);
407 let bytes = self.bytes(self.offset, available)?;
408 for (byte, _) in bytes.iter().enumerate().filter(|(_, byte)| **byte == b'\n') {
409 let end = self.offset + byte as u64;
410 self.pending
411 .push_back((self.start, (end - self.start) as usize));
412 self.start = end + 1;
413 }
414 self.chunk_offset = self.offset;
415 self.chunk = bytes;
416 self.offset += available as u64;
417 }
418 let mut budget = 0;
419 let mut count = 0;
420 while count < BATCH_EVENTS && budget < BATCH_BYTES {
421 let Some((offset, size)) = self.pending.pop_front() else {
422 break;
423 };
424 let owned;
428 let bytes = if offset >= self.chunk_offset
429 && offset + size as u64 <= self.chunk_offset + self.chunk.len() as u64
430 {
431 &self.chunk[(offset - self.chunk_offset) as usize
432 ..(offset - self.chunk_offset) as usize + size]
433 } else {
434 owned = self.bytes(offset, size)?;
435 &owned
436 };
437 let event = Event::parse(bytes).map_err(|error| {
438 ReadError::Data(format!(
439 "Invalid saved session event at line {}: {error}",
440 self.records + 1
441 ))
442 })?;
443 self.validate(&event, self.records)
444 .map_err(ReadError::Data)?;
445 let patches = self.transcript.apply(&event);
446 for patch in patches {
447 self.pages
448 .as_mut()
449 .expect("pages initialized")
450 .set(patch.id.0, &patch.text)?;
451 }
452 self.records += 1;
453 self.decoded_events += 1;
454 budget += size;
455 count += 1;
456 }
457 self.guards = self.guards()?;
458 Ok(self.offset < self.length || !self.pending.is_empty())
459 }
460 pub fn step(&mut self, width: usize, height: usize, position: Position) -> Window {
463 self.step_expanded(width, height, position, &std::collections::BTreeSet::new())
464 }
465 pub fn step_expanded(
466 &mut self,
467 width: usize,
468 height: usize,
469 position: Position,
470 expanded: &std::collections::BTreeSet<super::transcript::BlockId>,
471 ) -> Window {
472 let result = (|| {
473 let (reset, exists) = self.probe()?;
474 if !exists {
475 return Ok(Window {
476 reset,
477 status: Some(
478 if self.source.saved {
479 "This Agent session was removed by session GC (or the saved file was removed)."
480 } else {
481 "This Agent session was never recorded as saved, or has not been created yet."
482 }
483 .into(),
484 ),
485 ..Window::default()
486 });
487 }
488 if width == 0 {
489 return Ok(Window {
490 reset,
491 ..Window::default()
492 });
493 }
494 if self.pages.is_none() {
495 self.pages = Some(super::pages::Pages::new(width)?);
496 }
497 let position = if reset {
498 Position::Bottom
499 } else {
500 self.pages
501 .as_mut()
502 .expect("pages initialized")
503 .position(position)?
504 };
505 for patch in self.transcript.expansion(expanded) {
506 self.pages
507 .as_mut()
508 .expect("pages initialized")
509 .set(patch.id.0, &patch.text)?;
510 }
511 self.pages
512 .as_mut()
513 .expect("pages initialized")
514 .width(width)?;
515 let indexing = if self.error.is_none() {
516 self.index()?
517 } else {
518 false
519 };
520 let pages = self.pages.as_mut().expect("pages initialized");
521 let layout = pages.layout()?;
522 let mut window = if layout {
523 Window::default()
524 } else {
525 let (top, total, anchor, rows) = pages.window(position, height)?;
526 let groups = pages
527 .anchors(top, height)?
528 .into_iter()
529 .enumerate()
530 .filter_map(|(row, anchor)| {
531 let id = super::transcript::BlockId(anchor.event);
532 self.transcript.is_group(id).then_some((row, id))
533 })
534 .collect();
535 let thinking = pages
536 .anchors(top, height)?
537 .into_iter()
538 .enumerate()
539 .filter_map(|(row, anchor)| {
540 self.transcript
541 .is_thinking(super::transcript::BlockId(anchor.event))
542 .then_some(row)
543 })
544 .collect();
545 Window {
546 top,
547 total,
548 anchor,
549 rows,
550 groups,
551 thinking,
552 ..Window::default()
553 }
554 };
555 window.position = position;
556 window.reset = reset;
557 window.loading = self.error.is_none() && (indexing || layout);
558 window.status = self.error.clone().or_else(|| {
559 (self.records == 0).then(|| "Waiting for a complete review header…".into())
560 });
561 Ok::<_, ReadError>(window)
562 })();
563 match result {
564 Ok(window) => window,
565 Err(error) => {
566 let reset = matches!(error, ReadError::Io(_));
567 match &error {
568 ReadError::Data(message) => {
569 if self.file.is_some()
570 && let Ok(guards) = self.guards()
571 {
572 self.guards = guards;
573 }
574 self.error = Some(message.clone());
575 }
576 ReadError::Io(_) => self.reset(),
577 }
578 Window {
579 reset,
580 status: Some(format!("Conversation unavailable: {error}")),
581 ..Window::default()
582 }
583 }
584 }
585 }
586}