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    #[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            // One complete typed Event and its pretty text require temporary memory proportional
429            // to that event. No arbitrary producer-incompatible record cap, permanent clone or
430            // per-refresh decode: both are dropped after the ephemeral text file is written.
431            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    /// Chunk scan and disk layout are bounded; the one-time typed decode of a completed
465    /// record necessarily allocates its Event and formatted text, then releases both.
466    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}