Skip to main content

artifactize_core/agent/session/
live.rs

1//! Incremental, state-scoped session reading. Call bounded `step` jobs off the UI thread.
2//! Only byte offsets and row counts survive cache eviction; older history is paged in on demand.
3use 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
21/// Bound each indexing job, so even a continuously growing session yields to terminal input.
22const BATCH_BYTES: usize = 256 * 1024;
23const BATCH_EVENTS: usize = 128;
24/// Small content guards detect truncate/regrow and in-place changes around already read data.
25const 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    // A local follower may have no session_id until completion. Resolve only its original
62    // local producer, never a mirrored provenance id that happens to collide in this state.
63    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
234/// A selected reader owns its ephemeral text/row files. Dropping or resetting it removes them.
235pub 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            // One complete typed Event and its pretty text require temporary memory proportional
425            // to that event. No arbitrary producer-incompatible record cap, permanent clone or
426            // per-refresh decode: both are dropped after the ephemeral text file is written.
427            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    /// Chunk scan and disk layout are bounded; the one-time typed decode of a completed
461    /// record necessarily allocates its Event and formatted text, then releases both.
462    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}