Skip to main content

codeswarm_adapters/
session_archive.rs

1//! Local, multi-session transcript archive.
2//!
3//! Sessions live under an explicit archive root that callers must provide —
4//! this module never reads configuration or mutates the environment:
5//!
6//! ```text
7//! <root>/<session id>/meta.json     entry + provider metadata (private, atomic)
8//! <root>/<session id>/events.jsonl  append-only journal of human/agent events
9//! ```
10//!
11//! Session ids are generated locally (no global state) and validated before
12//! every use, so an id can never traverse out of the archive root. Journal
13//! reads tolerate an incomplete final line and report — but never silently
14//! discard — malformed records, and per-session metadata corruption is
15//! reported per session instead of hiding other sessions. Streaming writes go
16//! through [`BufferedSessionArchive`], which performs all filesystem work on a
17//! background thread and only requires durability at explicit boundaries.
18
19use std::collections::VecDeque;
20use std::fs::{self, File, OpenOptions};
21use std::io::{Read, Seek, SeekFrom, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::mpsc::{self, Receiver, Sender};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28use serde_json::{Map, Value};
29
30use crate::AgentEvent;
31use crate::persistence::{CURRENT_SCHEMA_VERSION, PersistenceError, SessionMetadata};
32
33/// Current on-disk schema of the archive metadata envelope.
34pub const ARCHIVE_SCHEMA_VERSION: u32 = 1;
35/// Per-session metadata file name inside the archive root.
36pub const META_FILE: &str = "meta.json";
37/// Per-session append-only journal file name inside the archive root.
38pub const EVENTS_FILE: &str = "events.jsonl";
39/// Titles are bounded so a runaway agent response cannot bloat listings.
40pub const MAX_TITLE_CHARS: usize = 200;
41
42const MAX_ID_CHARS: usize = 64;
43const KIND: &str = "session archive";
44
45fn malformed(detail: String) -> PersistenceError {
46    PersistenceError::Malformed { kind: KIND, detail }
47}
48
49fn is_not_found(error: &PersistenceError) -> bool {
50    matches!(
51        error,
52        PersistenceError::Io(io) if io.kind() == std::io::ErrorKind::NotFound
53    )
54}
55
56fn now_unix_nanos() -> i64 {
57    SystemTime::now()
58        .duration_since(UNIX_EPOCH)
59        .ok()
60        .and_then(|duration| i64::try_from(duration.as_nanos()).ok())
61        .unwrap_or(i64::MAX)
62}
63
64/// Timestamps are stored as Unix nanoseconds; this converts to a display value.
65pub fn unix_nanos_time(nanos: i64) -> Option<time::OffsetDateTime> {
66    time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(nanos)).ok()
67}
68
69static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
70static ENTROPY_COUNTER: AtomicU64 = AtomicU64::new(0);
71
72/// Generate a fresh, filesystem-safe session id.
73///
74/// The value comes from the operating system entropy pool when available and
75/// never reads, writes, or mutates global environment state, so repeated
76/// generations in one process remain unique.
77pub fn generate_session_id() -> String {
78    let mut bytes = [0u8; 16];
79    fill_random_bytes(&mut bytes);
80    let mut id = String::with_capacity(bytes.len() * 2);
81    for byte in bytes {
82        id.push(char::from_digit(u32::from(byte >> 4), 16).expect("nibble"));
83        id.push(char::from_digit(u32::from(byte & 0x0f), 16).expect("nibble"));
84    }
85    id
86}
87
88fn fill_random_bytes(buffer: &mut [u8]) {
89    #[cfg(unix)]
90    {
91        use std::io::Read;
92        if let Ok(mut random) = File::open("/dev/urandom")
93            && random.read_exact(buffer).is_ok()
94        {
95            return;
96        }
97    }
98    fill_entropy_bytes(buffer);
99}
100
101fn fill_entropy_bytes(buffer: &mut [u8]) {
102    use std::collections::hash_map::RandomState;
103    use std::hash::{BuildHasher, Hasher};
104
105    let mut seed = SystemTime::now()
106        .duration_since(UNIX_EPOCH)
107        .map(|duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX))
108        .unwrap_or(0);
109    seed ^= u64::from(std::process::id()) << 32;
110    let hash_state = RandomState::new();
111    for (index, chunk) in buffer.chunks_mut(8).enumerate() {
112        let mut z = seed
113            .wrapping_add(ENTROPY_COUNTER.fetch_add(1, Ordering::Relaxed))
114            .wrapping_add(u64::try_from(index).unwrap_or(u64::MAX));
115        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
116        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
117        z ^= z >> 31;
118        let mut hasher = hash_state.build_hasher();
119        hasher.write_u64(z);
120        z ^= hasher.finish();
121        for (target, source) in chunk.iter_mut().zip(z.to_le_bytes()) {
122            *target = source;
123        }
124    }
125}
126
127/// Reject ids that could escape the archive root or confuse the journal.
128///
129/// Valid ids are 1-64 ASCII letters, digits, `-` or `_`; `.` and separators
130/// are excluded entirely, so `..`, `/`, and `\` can never appear.
131pub fn validate_session_id(id: &str) -> Result<(), PersistenceError> {
132    if id.is_empty() {
133        return Err(malformed("session id must not be empty".into()));
134    }
135    if id.chars().count() > MAX_ID_CHARS {
136        return Err(malformed(format!(
137            "session id exceeds {MAX_ID_CHARS} characters"
138        )));
139    }
140    if !id
141        .bytes()
142        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
143    {
144        return Err(malformed(
145            "session id may only contain ASCII letters, digits, '-' and '_'".into(),
146        ));
147    }
148    Ok(())
149}
150
151/// Lifecycle description shown by the session browser.
152#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum ArchiveState {
155    Idle,
156    Failed,
157    #[default]
158    Active,
159    Completed,
160    Cancelled,
161    /// Unknown future states load as `Unknown` instead of failing the session.
162    #[serde(other)]
163    Unknown,
164}
165
166/// Immutable identity and presentation data for one archived session.
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168pub struct ArchiveEntry {
169    pub id: String,
170    /// Canonical project directory recorded at creation time.
171    pub cwd: PathBuf,
172    pub title: String,
173    #[serde(default)]
174    pub preview: String,
175    /// Unix nanoseconds since the epoch.
176    pub created_at: i64,
177    /// Unix nanoseconds since the epoch; the archive advances this at
178    /// durability boundaries so browsers can order by last activity.
179    pub updated_at: i64,
180    #[serde(default)]
181    pub roster: Vec<String>,
182    #[serde(default)]
183    pub state: ArchiveState,
184}
185
186impl ArchiveEntry {
187    pub fn created_time(&self) -> Option<time::OffsetDateTime> {
188        unix_nanos_time(self.created_at)
189    }
190
191    pub fn updated_time(&self) -> Option<time::OffsetDateTime> {
192        unix_nanos_time(self.updated_at)
193    }
194}
195
196/// One archived conversation record: a public human message or a normalized
197/// agent event, in journal order.
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(tag = "type", content = "data", rename_all = "snake_case")]
200pub enum ArchiveEvent {
201    Human {
202        text: String,
203        /// `true` when the message was a direct/private turn rather than a
204        /// roster-wide prompt.
205        direct: bool,
206    },
207    Agent(AgentEvent),
208}
209
210impl ArchiveEvent {
211    pub fn human(text: impl Into<String>, direct: bool) -> Self {
212        Self::Human {
213            text: text.into(),
214            direct,
215        }
216    }
217
218    pub fn agent(event: AgentEvent) -> Self {
219        Self::Agent(event)
220    }
221}
222
223/// A loaded archived session.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct ArchivedSession {
226    pub entry: ArchiveEntry,
227    /// Provider session metadata; unknown keys are preserved verbatim.
228    pub metadata: SessionMetadata,
229    /// Ordered journal contents.
230    pub events: Vec<ArchiveEvent>,
231    /// Human-readable reports of tolerated journal damage (torn final lines,
232    /// skipped malformed records). Empty for healthy sessions.
233    pub warnings: Vec<String>,
234}
235
236/// A session whose metadata could not be read. Reporting is per session so a
237/// single corrupt file never hides other valid sessions.
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct SessionFailure {
240    pub id: String,
241    pub detail: String,
242}
243
244/// Result of listing the archive.
245#[derive(Clone, Debug, Default, Eq, PartialEq)]
246pub struct SessionListing {
247    /// Valid entries ordered by most recent activity.
248    pub entries: Vec<ArchiveEntry>,
249    /// Unreadable sessions with the reason each was skipped.
250    pub failures: Vec<SessionFailure>,
251}
252
253/// Parameters for creating a new archived session.
254#[derive(Clone, Debug)]
255pub struct CreateSession {
256    pub cwd: PathBuf,
257    pub title: String,
258    pub roster: Vec<String>,
259    pub state: ArchiveState,
260    pub metadata: SessionMetadata,
261}
262
263impl CreateSession {
264    pub fn new(cwd: impl Into<PathBuf>) -> Self {
265        Self {
266            cwd: cwd.into(),
267            title: String::new(),
268            roster: Vec::new(),
269            state: ArchiveState::Active,
270            metadata: SessionMetadata::empty(),
271        }
272    }
273
274    pub fn title(mut self, title: impl Into<String>) -> Self {
275        self.title = title.into();
276        self
277    }
278
279    pub fn roster(mut self, roster: Vec<String>) -> Self {
280        self.roster = roster;
281        self
282    }
283
284    pub fn state(mut self, state: ArchiveState) -> Self {
285        self.state = state;
286        self
287    }
288
289    pub fn metadata(mut self, metadata: SessionMetadata) -> Self {
290        self.metadata = metadata;
291        self
292    }
293}
294
295fn bound_title(title: &str) -> String {
296    title.chars().take(MAX_TITLE_CHARS).collect()
297}
298
299fn canonical_cwd(cwd: &Path) -> PathBuf {
300    cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf())
301}
302
303/// On-disk metadata envelope. Unknown top-level keys are retained verbatim so
304/// a future CodeSwarm version can round-trip them.
305#[derive(Clone, Debug, Eq, PartialEq)]
306struct ArchiveMetaFile {
307    entry: ArchiveEntry,
308    metadata: SessionMetadata,
309    extra: Map<String, Value>,
310}
311
312fn read_archive_meta(path: &Path) -> Result<ArchiveMetaFile, PersistenceError> {
313    let raw = fs::read_to_string(path)?;
314    let value: Value = serde_json::from_str(&raw)
315        .map_err(|error| malformed(format!("{}: {error}", path.display())))?;
316    let object = value
317        .as_object()
318        .ok_or_else(|| malformed("archive metadata must be a JSON object".into()))?;
319    let version = match object.get("schema_version") {
320        Some(Value::Number(number)) => number.as_u64().ok_or_else(|| {
321            malformed("archive schema_version must be an unsigned integer".into())
322        })?,
323        Some(_) => {
324            return Err(malformed(
325                "archive schema_version must be an unsigned integer".into(),
326            ));
327        }
328        None => u64::from(ARCHIVE_SCHEMA_VERSION),
329    };
330    if version > u64::from(ARCHIVE_SCHEMA_VERSION) {
331        return Err(PersistenceError::UnsupportedVersion {
332            kind: KIND,
333            version: u32::try_from(version).unwrap_or(u32::MAX),
334        });
335    }
336    let entry = object
337        .get("entry")
338        .ok_or_else(|| malformed("archive metadata is missing the session entry".into()))
339        .and_then(|entry| {
340            serde_json::from_value(entry.clone())
341                .map_err(|error| malformed(format!("archive entry: {error}")))
342        })?;
343    let metadata = match object.get("metadata") {
344        Some(Value::Object(object)) => SessionMetadata::new(object.clone()),
345        Some(_) => {
346            return Err(malformed(
347                "archive metadata envelope must be an object".into(),
348            ));
349        }
350        None => SessionMetadata::empty(),
351    };
352    let extra = object
353        .iter()
354        .filter(|(key, _)| !matches!(key.as_str(), "schema_version" | "entry" | "metadata"))
355        .map(|(key, value)| (key.clone(), value.clone()))
356        .collect();
357    Ok(ArchiveMetaFile {
358        entry,
359        metadata,
360        extra,
361    })
362}
363
364fn write_archive_meta(path: &Path, file: &ArchiveMetaFile) -> Result<(), PersistenceError> {
365    let mut object = file.extra.clone();
366    object.insert("schema_version".into(), Value::from(ARCHIVE_SCHEMA_VERSION));
367    let entry = serde_json::to_value(&file.entry)
368        .map_err(|error| malformed(format!("archive entry: {error}")))?;
369    object.insert("entry".into(), entry);
370    object.insert(
371        "metadata".into(),
372        Value::Object(file.metadata.as_object().clone()),
373    );
374    let payload = serde_json::to_string_pretty(&Value::Object(object))
375        .map_err(|error| malformed(format!("archive metadata: {error}")))?;
376    atomic_private_write(path, payload.as_bytes())
377}
378
379fn edit_archive_metadata<F>(path: &Path, apply: F) -> Result<(), PersistenceError>
380where
381    F: FnOnce(&mut SessionMetadata),
382{
383    let mut file = read_archive_meta(path)?;
384    apply(&mut file.metadata);
385    write_archive_meta(path, &file)
386}
387
388fn edit_archive_entry<F>(path: &Path, expected_id: &str, apply: F) -> Result<(), PersistenceError>
389where
390    F: FnOnce(&mut ArchiveEntry),
391{
392    let mut file = read_archive_meta(path)?;
393    if file.entry.id != expected_id {
394        return Err(malformed(format!(
395            "archived entry id {:?} does not match session id {expected_id:?}",
396            file.entry.id
397        )));
398    }
399    apply(&mut file.entry);
400    file.entry.id = expected_id.to_owned();
401    file.entry.title = bound_title(&file.entry.title);
402    write_archive_meta(path, &file)
403}
404
405fn replace_archive_metadata(
406    path: &Path,
407    metadata: &SessionMetadata,
408) -> Result<(), PersistenceError> {
409    edit_archive_metadata(path, |current| {
410        *current = metadata.clone();
411    })
412}
413
414fn encode_event_line(event: &ArchiveEvent) -> Result<String, PersistenceError> {
415    let event = serde_json::to_value(event)
416        .map_err(|error| malformed(format!("archive event: {error}")))?;
417    let mut envelope = Map::new();
418    envelope.insert("schema_version".into(), Value::from(CURRENT_SCHEMA_VERSION));
419    envelope.insert("event".into(), event);
420    let mut line = serde_json::to_string(&Value::Object(envelope))
421        .map_err(|error| malformed(format!("archive event: {error}")))?;
422    line.push('\n');
423    Ok(line)
424}
425
426/// Read a journal tolerantly: an incomplete final line is a tolerated torn
427/// write, malformed records are skipped and reported, and every valid earlier
428/// record is always returned.
429fn open_journal(path: &Path) -> std::io::Result<File> {
430    let mut options = OpenOptions::new();
431    options.create(true).read(true).append(true);
432    #[cfg(unix)]
433    {
434        use std::os::unix::fs::OpenOptionsExt;
435        options.mode(0o600);
436    }
437    let mut file = options.open(path)?;
438    set_private(&file)?;
439    if file.metadata()?.len() > 0 {
440        file.seek(SeekFrom::End(-1))?;
441        let mut tail = [0];
442        file.read_exact(&mut tail)?;
443        if tail[0] != b'\n' {
444            file.write_all(b"\n")?;
445        }
446    }
447    Ok(file)
448}
449
450fn read_events(path: &Path) -> Result<(Vec<ArchiveEvent>, Vec<String>), PersistenceError> {
451    let content = match fs::read(path) {
452        Ok(content) => content,
453        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
454            return Ok((Vec::new(), Vec::new()));
455        }
456        Err(error) => return Err(error.into()),
457    };
458    let ends_with_newline = content.last() == Some(&b'\n');
459    let mut lines = content.split(|byte| *byte == b'\n').collect::<Vec<_>>();
460    if ends_with_newline {
461        lines.pop();
462    }
463    let last = lines.len().saturating_sub(1);
464    let mut events = Vec::new();
465    let mut warnings = Vec::new();
466    for (index, line) in lines.iter().enumerate() {
467        if line.iter().all(|byte| byte.is_ascii_whitespace()) {
468            continue;
469        }
470        match decode_event_line(line) {
471            Ok(event) => events.push(event),
472            Err(detail) => {
473                if !ends_with_newline && index == last {
474                    warnings.push(format!("tolerated incomplete final journal line: {detail}"));
475                } else {
476                    warnings.push(format!(
477                        "skipped malformed journal line {}: {detail}",
478                        index + 1
479                    ));
480                }
481            }
482        }
483    }
484    Ok((events, warnings))
485}
486
487/// Journal lines accept both the current schema-version envelope and bare
488/// events, mirroring the house persistence format.
489fn decode_event_line(line: &[u8]) -> Result<ArchiveEvent, String> {
490    let text = std::str::from_utf8(line).map_err(|error| format!("invalid UTF-8: {error}"))?;
491    let value: Value =
492        serde_json::from_str(text).map_err(|error| format!("invalid JSON: {error}"))?;
493    let object = value
494        .as_object()
495        .ok_or("journal line must be a JSON object")?;
496    let has_envelope = object.contains_key("event") || object.contains_key("schema_version");
497    let event_value = if has_envelope {
498        let version = object
499            .get("schema_version")
500            .and_then(Value::as_u64)
501            .unwrap_or(u64::from(CURRENT_SCHEMA_VERSION));
502        if version > u64::from(CURRENT_SCHEMA_VERSION) {
503            return Err(format!("unsupported journal schema version {version}"));
504        }
505        object.get("event").ok_or("envelope is missing an event")?
506    } else {
507        &value
508    };
509    serde_json::from_value(event_value.clone()).map_err(|error| error.to_string())
510}
511
512/// Local archive of every CodeSwarm session for one machine.
513///
514/// Constructed from an explicit root; this type performs no environment
515/// lookups, so the root CLI stays in control of storage locations.
516#[derive(Clone, Debug)]
517pub struct SessionArchive {
518    root: PathBuf,
519}
520
521impl SessionArchive {
522    pub fn open(root: impl Into<PathBuf>) -> Self {
523        Self { root: root.into() }
524    }
525
526    pub fn root(&self) -> &Path {
527        &self.root
528    }
529
530    fn session_dir(&self, id: &str) -> PathBuf {
531        self.root.join(id)
532    }
533
534    fn meta_path(&self, id: &str) -> PathBuf {
535        self.session_dir(id).join(META_FILE)
536    }
537
538    fn events_path(&self, id: &str) -> PathBuf {
539        self.session_dir(id).join(EVENTS_FILE)
540    }
541
542    /// Create a new session with a fresh, immutable id. An existing archived
543    /// conversation is never overwritten: the id is allocated exclusively.
544    pub fn create(&self, request: CreateSession) -> Result<ArchiveEntry, PersistenceError> {
545        for _ in 0..8 {
546            let id = generate_session_id();
547            match self.create_exact(&id, request.clone()) {
548                Ok(entry) => return Ok(entry),
549                Err(PersistenceError::Io(error))
550                    if error.kind() == std::io::ErrorKind::AlreadyExists => {}
551                Err(error) => return Err(error),
552            }
553        }
554        Err(malformed("unable to allocate a unique session id".into()))
555    }
556
557    /// Create a session with an explicit id. Fails when the id is invalid or
558    /// already archived, so archived conversations can never be overwritten.
559    pub fn create_exact(
560        &self,
561        id: &str,
562        request: CreateSession,
563    ) -> Result<ArchiveEntry, PersistenceError> {
564        validate_session_id(id)?;
565        fs::create_dir_all(&self.root)?;
566        let dir = self.session_dir(id);
567        fs::create_dir(&dir).map_err(|error| {
568            if error.kind() == std::io::ErrorKind::AlreadyExists {
569                std::io::Error::new(
570                    std::io::ErrorKind::AlreadyExists,
571                    format!("archived session {id} already exists"),
572                )
573            } else {
574                error
575            }
576        })?;
577        set_private_dir(&dir)?;
578        let now = now_unix_nanos();
579        let entry = ArchiveEntry {
580            id: id.to_owned(),
581            cwd: canonical_cwd(&request.cwd),
582            title: bound_title(&request.title),
583            preview: String::new(),
584            created_at: now,
585            updated_at: now,
586            roster: request.roster,
587            state: request.state,
588        };
589        let file = ArchiveMetaFile {
590            entry: entry.clone(),
591            metadata: request.metadata,
592            extra: Map::new(),
593        };
594        match write_archive_meta(&self.meta_path(id), &file) {
595            Ok(()) => Ok(entry),
596            Err(error) => {
597                let _ = fs::remove_dir_all(&dir);
598                Err(error)
599            }
600        }
601    }
602
603    /// List every archived session whose canonical project directory matches
604    /// `cwd`. Failures are always reported: corrupt metadata cannot be
605    /// attributed to a project, so it is surfaced for every listing.
606    pub fn list(&self, cwd: &Path) -> Result<SessionListing, PersistenceError> {
607        let mut listing = self.list_all()?;
608        let wanted = canonical_cwd(cwd);
609        listing
610            .entries
611            .retain(|entry| canonical_cwd(&entry.cwd) == wanted);
612        Ok(listing)
613    }
614
615    /// List every archived session, newest activity first.
616    pub fn list_all(&self) -> Result<SessionListing, PersistenceError> {
617        let mut listing = SessionListing::default();
618        let read_dir = match fs::read_dir(&self.root) {
619            Ok(read_dir) => read_dir,
620            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
621                return Ok(listing);
622            }
623            Err(error) => return Err(error.into()),
624        };
625        for candidate in read_dir {
626            let Ok(candidate) = candidate else {
627                continue;
628            };
629            let path = candidate.path();
630            if !path.is_dir() {
631                continue;
632            }
633            let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
634                continue;
635            };
636            if name.starts_with('.') || validate_session_id(name).is_err() {
637                continue;
638            }
639            let meta_path = path.join(META_FILE);
640            match read_archive_meta(&meta_path) {
641                Ok(file) => listing.entries.push(file.entry),
642                Err(error) => {
643                    let missing_meta = is_not_found(&error) && !path.join(EVENTS_FILE).exists();
644                    if !missing_meta {
645                        listing.failures.push(SessionFailure {
646                            id: name.to_owned(),
647                            detail: error.to_string(),
648                        });
649                    }
650                }
651            }
652        }
653        listing.entries.sort_by(|a, b| {
654            b.updated_at
655                .cmp(&a.updated_at)
656                .then_with(|| b.created_at.cmp(&a.created_at))
657                .then_with(|| a.id.cmp(&b.id))
658        });
659        Ok(listing)
660    }
661
662    /// Load one archived session: entry, provider metadata, and ordered
663    /// events. Missing sessions and unreadable metadata are reported with
664    /// distinct errors; damaged journals still return every valid event.
665    pub fn load(&self, id: &str) -> Result<ArchivedSession, PersistenceError> {
666        validate_session_id(id)?;
667        let file = read_archive_meta(&self.meta_path(id)).map_err(|error| {
668            if is_not_found(&error) {
669                PersistenceError::Io(std::io::Error::new(
670                    std::io::ErrorKind::NotFound,
671                    format!("archived session {id} was not found"),
672                ))
673            } else {
674                error
675            }
676        })?;
677        if file.entry.id != id {
678            return Err(malformed(format!(
679                "archived entry id {:?} does not match session id {id:?}",
680                file.entry.id
681            )));
682        }
683        let (events, warnings) = read_events(&self.events_path(id))?;
684        Ok(ArchivedSession {
685            entry: file.entry,
686            metadata: file.metadata,
687            events,
688            warnings,
689        })
690    }
691
692    /// Append one journal record synchronously and force it to stable
693    /// storage. Runtime streaming should prefer [`Self::buffered`].
694    pub fn append(&self, id: &str, event: &ArchiveEvent) -> Result<(), PersistenceError> {
695        validate_session_id(id)?;
696        let line = encode_event_line(event)?;
697        let path = self.events_path(id);
698        if !self.meta_path(id).exists() {
699            return Err(PersistenceError::Io(std::io::Error::new(
700                std::io::ErrorKind::NotFound,
701                format!("archived session {id} was not found"),
702            )));
703        }
704        if let Some(parent) = path.parent()
705            && !parent.as_os_str().is_empty()
706        {
707            fs::create_dir_all(parent)?;
708        }
709        let mut file = open_journal(&path)?;
710        file.write_all(line.as_bytes())?;
711        file.sync_data()?;
712        Ok(())
713    }
714
715    pub fn append_human(
716        &self,
717        id: &str,
718        text: impl Into<String>,
719        direct: bool,
720    ) -> Result<(), PersistenceError> {
721        self.append(id, &ArchiveEvent::human(text, direct))
722    }
723
724    pub fn append_agent(&self, id: &str, event: &AgentEvent) -> Result<(), PersistenceError> {
725        self.append(id, &ArchiveEvent::agent(event.clone()))
726    }
727
728    /// Edit provider metadata in place, preserving unknown keys.
729    pub fn update_metadata<F>(&self, id: &str, apply: F) -> Result<(), PersistenceError>
730    where
731        F: FnOnce(&mut SessionMetadata),
732    {
733        validate_session_id(id)?;
734        edit_archive_metadata(&self.meta_path(id), apply)
735    }
736
737    /// Edit the archive entry (title, state, roster, activity timestamp).
738    /// The session id is pinned to the directory; titles stay bounded.
739    pub fn update_entry<F>(&self, id: &str, apply: F) -> Result<(), PersistenceError>
740    where
741        F: FnOnce(&mut ArchiveEntry),
742    {
743        validate_session_id(id)?;
744        edit_archive_entry(&self.meta_path(id), id, apply)
745    }
746
747    pub fn set_title(&self, id: &str, title: impl AsRef<str>) -> Result<(), PersistenceError> {
748        let title = title.as_ref().to_owned();
749        self.update_entry(id, |entry| entry.title = title)
750    }
751
752    pub fn set_state(&self, id: &str, state: ArchiveState) -> Result<(), PersistenceError> {
753        self.update_entry(id, |entry| entry.state = state)
754    }
755
756    /// Mark the session as most recently active.
757    pub fn touch(&self, id: &str) -> Result<(), PersistenceError> {
758        self.update_entry(id, |entry| entry.updated_at = now_unix_nanos())
759    }
760
761    /// Start a background writer that keeps journal and metadata filesystem
762    /// work off the caller thread. Events and metadata edits become durable
763    /// at [`BufferedSessionArchive::flush`] boundaries and on drop.
764    pub fn buffered(&self, id: &str) -> std::io::Result<BufferedSessionArchive> {
765        self.buffered_with_errors(id, |_| {})
766    }
767
768    /// Like [`Self::buffered`], reporting background write failures through
769    /// `on_error` so the terminal can surface them in the status ribbon.
770    pub fn buffered_with_errors<F>(
771        &self,
772        id: &str,
773        on_error: F,
774    ) -> std::io::Result<BufferedSessionArchive>
775    where
776        F: Fn(String) + Send + 'static,
777    {
778        validate_session_id(id).map_err(|error| {
779            std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string())
780        })?;
781        let entry_path = self.meta_path(id);
782        if !entry_path.exists() {
783            return Err(std::io::Error::new(
784                std::io::ErrorKind::NotFound,
785                format!("archived session {id} was not found"),
786            ));
787        }
788        let worker = ArchiveWorker {
789            id: id.to_owned(),
790            entry_path,
791            events_path: self.events_path(id),
792            writer: None,
793            retry: VecDeque::new(),
794            pending: VecDeque::new(),
795            events_since_boundary: false,
796            first_error: None,
797            on_error: Box::new(on_error),
798        };
799        BufferedSessionArchive::spawn(id.to_owned(), worker)
800    }
801}
802
803type MetadataEdit = Box<dyn FnOnce(&mut SessionMetadata) + Send>;
804type EntryEdit = Box<dyn FnOnce(&mut ArchiveEntry) + Send>;
805
806enum PendingOperation {
807    ReplaceMetadata(SessionMetadata),
808    EditMetadata(Option<MetadataEdit>),
809    EditEntry(Option<EntryEdit>),
810    TouchEntry,
811    WriteSnapshot(ArchiveMetaFile),
812}
813
814enum ArchiveCommand {
815    AppendEvent {
816        line: String,
817    },
818    Enqueue(PendingOperation),
819    Checkpoint,
820    Flush {
821        reply: Sender<Result<(), PersistenceError>>,
822    },
823    Shutdown {
824        reply: Sender<()>,
825    },
826}
827
828struct ArchiveWorker {
829    id: String,
830    entry_path: PathBuf,
831    events_path: PathBuf,
832    writer: Option<std::io::BufWriter<File>>,
833    retry: VecDeque<String>,
834    pending: VecDeque<PendingOperation>,
835    events_since_boundary: bool,
836    first_error: Option<String>,
837    on_error: Box<dyn Fn(String) + Send>,
838}
839
840impl ArchiveWorker {
841    fn note_error(&mut self, error: &PersistenceError) {
842        if self.first_error.is_none() {
843            self.first_error = Some(error.to_string());
844            (self.on_error)(error.to_string());
845        }
846    }
847
848    fn ensure_open(&mut self) -> Result<&mut std::io::BufWriter<File>, PersistenceError> {
849        if self.writer.is_none() {
850            if let Some(parent) = self.events_path.parent()
851                && !parent.as_os_str().is_empty()
852            {
853                fs::create_dir_all(parent)?;
854            }
855            let file = open_journal(&self.events_path)?;
856            self.writer = Some(std::io::BufWriter::new(file));
857        }
858        Ok(self.writer.as_mut().expect("writer initialized"))
859    }
860
861    fn drain_retry(&mut self) -> Result<(), PersistenceError> {
862        while !self.retry.is_empty() {
863            let line = self.retry.front().cloned().expect("retry non-empty");
864            let writer = self.ensure_open()?;
865            writer
866                .write_all(line.as_bytes())
867                .map_err(PersistenceError::Io)?;
868            self.retry.pop_front();
869        }
870        Ok(())
871    }
872
873    fn sync_journal(&mut self) -> Result<(), PersistenceError> {
874        self.drain_retry()?;
875        if let Some(writer) = self.writer.as_mut() {
876            writer.flush()?;
877            writer.get_ref().sync_data()?;
878        }
879        Ok(())
880    }
881
882    fn apply_operation(
883        &mut self,
884        operation: &mut PendingOperation,
885    ) -> Result<(), PersistenceError> {
886        match operation {
887            PendingOperation::WriteSnapshot(file) => write_archive_meta(&self.entry_path, file),
888            PendingOperation::ReplaceMetadata(metadata) => {
889                replace_archive_metadata(&self.entry_path, metadata)
890            }
891            PendingOperation::EditMetadata(edit) => {
892                let mut file = read_archive_meta(&self.entry_path)?;
893                if let Some(edit) = edit.take() {
894                    edit(&mut file.metadata);
895                }
896                *operation = PendingOperation::WriteSnapshot(file);
897                self.apply_operation(operation)
898            }
899            PendingOperation::EditEntry(edit) => {
900                let mut file = read_archive_meta(&self.entry_path)?;
901                if let Some(edit) = edit.take() {
902                    edit(&mut file.entry);
903                }
904                file.entry.id = self.id.clone();
905                file.entry.title = bound_title(&file.entry.title);
906                *operation = PendingOperation::WriteSnapshot(file);
907                self.apply_operation(operation)
908            }
909            PendingOperation::TouchEntry => {
910                let mut file = read_archive_meta(&self.entry_path)?;
911                file.entry.updated_at = now_unix_nanos();
912                write_archive_meta(&self.entry_path, &file)
913            }
914        }
915    }
916
917    fn complete_boundary(&mut self) -> Result<(), PersistenceError> {
918        let mut result = self.sync_journal();
919        if result.is_ok() && self.events_since_boundary {
920            self.pending.push_back(PendingOperation::TouchEntry);
921            self.events_since_boundary = false;
922        }
923        while result.is_ok() {
924            let Some(mut operation) = self.pending.pop_front() else {
925                break;
926            };
927            if let Err(error) = self.apply_operation(&mut operation) {
928                self.note_error(&error);
929                self.pending.push_front(operation);
930                result = Err(error);
931            }
932        }
933        if result.is_ok() {
934            self.first_error = None;
935        }
936        result
937    }
938
939    fn run(mut self, receiver: Receiver<ArchiveCommand>) -> Result<(), PersistenceError> {
940        loop {
941            let command = match receiver.recv_timeout(std::time::Duration::from_secs(1)) {
942                Ok(command) => command,
943                Err(mpsc::RecvTimeoutError::Timeout) => {
944                    if self.pending.is_empty()
945                        && self.retry.is_empty()
946                        && !self.events_since_boundary
947                    {
948                        continue;
949                    }
950                    if let Err(error) = self.complete_boundary() {
951                        self.note_error(&error);
952                    }
953                    continue;
954                }
955                Err(mpsc::RecvTimeoutError::Disconnected) => break,
956            };
957            match command {
958                ArchiveCommand::Checkpoint => {
959                    if let Err(error) = self.complete_boundary() {
960                        self.note_error(&error);
961                    }
962                }
963                ArchiveCommand::AppendEvent { line } => {
964                    self.retry.push_back(line);
965                    self.events_since_boundary = true;
966                    if let Err(error) = self.drain_retry() {
967                        self.note_error(&error);
968                    }
969                }
970                ArchiveCommand::Enqueue(operation) => {
971                    self.pending.push_back(operation);
972                }
973                ArchiveCommand::Flush { reply } => {
974                    let result = self.complete_boundary();
975                    let _ = reply.send(result);
976                }
977                ArchiveCommand::Shutdown { reply } => {
978                    let result = self.complete_boundary();
979                    let _ = reply.send(());
980                    return result;
981                }
982            }
983        }
984        self.complete_boundary()
985    }
986}
987
988/// Background archive writer used from the terminal event loop.
989///
990/// The handle only serializes events and queues commands; all filesystem work
991/// happens on its worker thread. Queued journal lines and metadata edits are
992/// retried until [`flush`](Self::flush) or drop makes them durable, and the
993/// entry's `updated_at` advances at each successful boundary that received
994/// events so the session browser can order by last activity.
995pub struct BufferedSessionArchive {
996    id: String,
997    sender: Sender<ArchiveCommand>,
998    worker: Option<std::thread::JoinHandle<Result<(), PersistenceError>>>,
999}
1000
1001impl std::fmt::Debug for BufferedSessionArchive {
1002    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1003        formatter
1004            .debug_struct("BufferedSessionArchive")
1005            .field("id", &self.id)
1006            .field("worker_running", &self.worker.is_some())
1007            .finish_non_exhaustive()
1008    }
1009}
1010
1011impl BufferedSessionArchive {
1012    fn spawn(id: String, worker: ArchiveWorker) -> std::io::Result<Self> {
1013        let (sender, receiver) = mpsc::channel();
1014        let thread = std::thread::Builder::new()
1015            .name("codeswarm-session-archive".into())
1016            .spawn(move || worker.run(receiver))?;
1017        Ok(Self {
1018            id,
1019            sender,
1020            worker: Some(thread),
1021        })
1022    }
1023
1024    pub fn id(&self) -> &str {
1025        &self.id
1026    }
1027
1028    fn send(&self, command: ArchiveCommand) -> Result<(), PersistenceError> {
1029        self.sender.send(command).map_err(|_| {
1030            PersistenceError::Io(std::io::Error::new(
1031                std::io::ErrorKind::BrokenPipe,
1032                "session archive background writer stopped",
1033            ))
1034        })
1035    }
1036
1037    /// Serialize and queue one journal record without filesystem I/O in the
1038    /// caller.
1039    pub fn append(&self, event: &ArchiveEvent) -> Result<(), PersistenceError> {
1040        let line = encode_event_line(event)?;
1041        self.send(ArchiveCommand::AppendEvent { line })
1042    }
1043
1044    pub fn append_human(
1045        &self,
1046        text: impl Into<String>,
1047        direct: bool,
1048    ) -> Result<(), PersistenceError> {
1049        self.append(&ArchiveEvent::human(text, direct))
1050    }
1051
1052    pub fn append_agent(&self, event: &AgentEvent) -> Result<(), PersistenceError> {
1053        self.append(&ArchiveEvent::agent(event.clone()))
1054    }
1055
1056    /// Queue a complete provider metadata snapshot, retaining unknown keys.
1057    pub fn replace_metadata(&self, metadata: SessionMetadata) -> Result<(), PersistenceError> {
1058        self.send(ArchiveCommand::Enqueue(PendingOperation::ReplaceMetadata(
1059            metadata,
1060        )))
1061    }
1062
1063    /// Queue an in-place provider metadata edit, preserving unknown keys.
1064    pub fn update_metadata<F>(&self, apply: F) -> Result<(), PersistenceError>
1065    where
1066        F: FnOnce(&mut SessionMetadata) + Send + 'static,
1067    {
1068        self.send(ArchiveCommand::Enqueue(PendingOperation::EditMetadata(
1069            Some(Box::new(apply)),
1070        )))
1071    }
1072
1073    /// Queue an archive entry edit (title, state, roster). The session id is
1074    /// pinned to the directory and titles stay bounded.
1075    pub fn update_entry<F>(&self, apply: F) -> Result<(), PersistenceError>
1076    where
1077        F: FnOnce(&mut ArchiveEntry) + Send + 'static,
1078    {
1079        self.send(ArchiveCommand::Enqueue(PendingOperation::EditEntry(Some(
1080            Box::new(apply),
1081        ))))
1082    }
1083
1084    /// Queue an activity timestamp update.
1085    pub fn touch(&self) -> Result<(), PersistenceError> {
1086        self.send(ArchiveCommand::Enqueue(PendingOperation::TouchEntry))
1087    }
1088
1089    /// Request a durable checkpoint without blocking the UI.
1090    pub fn checkpoint(&self) -> Result<(), PersistenceError> {
1091        self.send(ArchiveCommand::Checkpoint)
1092    }
1093
1094    /// Drain queued records and metadata edits and make them durable.
1095    pub fn flush(&self) -> Result<(), PersistenceError> {
1096        let (reply, result) = mpsc::channel();
1097        self.send(ArchiveCommand::Flush { reply })?;
1098        result.recv().map_err(|_| {
1099            PersistenceError::Io(std::io::Error::new(
1100                std::io::ErrorKind::BrokenPipe,
1101                "session archive background writer stopped",
1102            ))
1103        })?
1104    }
1105}
1106
1107impl Drop for BufferedSessionArchive {
1108    fn drop(&mut self) {
1109        let Some(worker) = self.worker.take() else {
1110            return;
1111        };
1112        let (reply, result) = mpsc::channel();
1113        if self.sender.send(ArchiveCommand::Shutdown { reply }).is_ok() {
1114            let _ = result.recv();
1115        }
1116        let _ = worker.join();
1117    }
1118}
1119
1120#[cfg(unix)]
1121fn set_private(file: &File) -> std::io::Result<()> {
1122    use std::os::unix::fs::PermissionsExt;
1123    file.set_permissions(fs::Permissions::from_mode(0o600))
1124}
1125
1126#[cfg(not(unix))]
1127fn set_private(_file: &File) -> std::io::Result<()> {
1128    Ok(())
1129}
1130
1131#[cfg(unix)]
1132fn set_private_dir(path: &Path) -> std::io::Result<()> {
1133    use std::os::unix::fs::PermissionsExt;
1134    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1135}
1136
1137#[cfg(not(unix))]
1138fn set_private_dir(_path: &Path) -> std::io::Result<()> {
1139    Ok(())
1140}
1141
1142fn atomic_private_write(path: &Path, payload: &[u8]) -> Result<(), PersistenceError> {
1143    let file_name = path
1144        .file_name()
1145        .and_then(|name| name.to_str())
1146        .unwrap_or("meta");
1147    let temporary = path.with_file_name(format!(
1148        ".{file_name}.tmp-{}-{}",
1149        std::process::id(),
1150        TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1151    ));
1152    let result = (|| {
1153        if let Some(parent) = path.parent()
1154            && !parent.as_os_str().is_empty()
1155        {
1156            fs::create_dir_all(parent)?;
1157        }
1158        let mut file = OpenOptions::new()
1159            .create_new(true)
1160            .write(true)
1161            .open(&temporary)?;
1162        set_private(&file)?;
1163        file.write_all(payload)?;
1164        file.sync_all()?;
1165        fs::rename(&temporary, path)?;
1166        Ok(())
1167    })();
1168    if result.is_err() {
1169        let _ = fs::remove_file(&temporary);
1170    }
1171    result
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use std::fs;
1177    use std::sync::mpsc;
1178    use std::time::{Duration, SystemTime, UNIX_EPOCH};
1179
1180    use serde_json::json;
1181
1182    use super::{
1183        ArchiveEvent, ArchiveState, BufferedSessionArchive, CreateSession, EVENTS_FILE,
1184        MAX_TITLE_CHARS, META_FILE, SessionArchive, SessionListing, generate_session_id,
1185        validate_session_id,
1186    };
1187    use crate::AgentEvent;
1188    use crate::persistence::{PersistenceError, SessionMetadata};
1189
1190    fn temp_root(name: &str) -> std::path::PathBuf {
1191        let unique = SystemTime::now()
1192            .duration_since(UNIX_EPOCH)
1193            .expect("clock")
1194            .as_nanos();
1195        let root = std::env::temp_dir().join(format!("codeswarm-session-archive-{unique}-{name}"));
1196        fs::create_dir_all(&root).expect("create root");
1197        root
1198    }
1199
1200    fn project(root: &std::path::Path, name: &str) -> std::path::PathBuf {
1201        let path = root.join(name);
1202        fs::create_dir_all(&path).expect("create project");
1203        path
1204    }
1205
1206    fn metadata(pairs: &[(&str, serde_json::Value)]) -> SessionMetadata {
1207        let mut data = SessionMetadata::empty();
1208        for (key, value) in pairs {
1209            data.insert(*key, value.clone());
1210        }
1211        data
1212    }
1213
1214    fn remove(root: std::path::PathBuf) {
1215        fs::remove_dir_all(root).expect("cleanup");
1216    }
1217
1218    #[test]
1219    fn generated_ids_are_unique_and_path_safe() {
1220        let mut ids = std::collections::BTreeSet::new();
1221        for _ in 0..512 {
1222            let id = generate_session_id();
1223            assert_eq!(id.len(), 32);
1224            assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit()));
1225            validate_session_id(&id).expect("valid generated id");
1226            ids.insert(id);
1227        }
1228        assert_eq!(ids.len(), 512);
1229    }
1230
1231    #[test]
1232    fn invalid_session_ids_are_rejected_everywhere() {
1233        let root = temp_root("invalid-ids");
1234        let archive = SessionArchive::open(&root);
1235        let request = CreateSession::new(root.join("project"));
1236        for id in [
1237            "",
1238            "../escape",
1239            "sub/dir",
1240            "sub\\dir",
1241            ".hidden",
1242            "a b",
1243            "id\n",
1244        ] {
1245            assert!(
1246                super::validate_session_id(id).is_err(),
1247                "id {id:?} must be rejected"
1248            );
1249            assert!(archive.create_exact(id, request.clone()).is_err(), "{id:?}");
1250            assert!(archive.load(id).is_err(), "{id:?}");
1251            assert!(archive.buffered(id).is_err(), "{id:?}");
1252        }
1253        let too_long = "a".repeat(65);
1254        assert!(archive.create_exact(&too_long, request.clone()).is_err());
1255        assert_eq!(
1256            fs::read_dir(&root).expect("root").count(),
1257            0,
1258            "invalid ids must not create directories"
1259        );
1260        remove(root);
1261    }
1262
1263    #[test]
1264    fn create_and_load_round_trip_metadata() {
1265        let root = temp_root("create-load");
1266        let archive = SessionArchive::open(&root);
1267        let cwd = project(&root, "relay-fix");
1268        let entry = archive
1269            .create(
1270                CreateSession::new(&cwd)
1271                    .title("Fix the relay ring")
1272                    .roster(vec!["Codex".into(), "Agy".into()])
1273                    .metadata(metadata(&[("provider_session", json!("abc-123"))])),
1274            )
1275            .expect("create");
1276        assert_eq!(entry.title, "Fix the relay ring");
1277        assert_eq!(entry.roster, vec!["Codex".to_owned(), "Agy".to_owned()]);
1278        assert_eq!(entry.state, ArchiveState::Active);
1279        assert_eq!(entry.cwd, cwd.canonicalize().expect("canonical"));
1280        assert!(entry.created_at > 0);
1281        assert!(entry.updated_at >= entry.created_at);
1282        assert!(entry.created_time().is_some());
1283
1284        let loaded = archive.load(&entry.id).expect("load");
1285        assert_eq!(loaded.entry, entry);
1286        assert!(loaded.events.is_empty());
1287        assert!(loaded.warnings.is_empty());
1288        assert_eq!(
1289            loaded.metadata.get("provider_session"),
1290            Some(&json!("abc-123"))
1291        );
1292        remove(root);
1293    }
1294
1295    #[test]
1296    fn new_sessions_never_overwrite_archived_conversations() {
1297        let root = temp_root("no-overwrite");
1298        let archive = SessionArchive::open(&root);
1299        let request = CreateSession::new(root.join("project")).title("original");
1300        let entry = archive
1301            .create_exact("fixedid", request.clone())
1302            .expect("create");
1303        archive
1304            .append_human("fixedid", "precious history", false)
1305            .expect("append");
1306        let conflict = archive
1307            .create_exact("fixedid", request)
1308            .expect_err("exists");
1309        assert_eq!(
1310            conflict.to_string(),
1311            "persistence I/O error: archived session fixedid already exists"
1312        );
1313        let generated = archive
1314            .create(CreateSession::new(root.join("project")).title("other"))
1315            .expect("create");
1316        assert_ne!(generated.id, entry.id);
1317        let loaded = archive.load("fixedid").expect("load");
1318        assert_eq!(loaded.entry.title, "original");
1319        assert_eq!(
1320            loaded.events,
1321            vec![ArchiveEvent::human("precious history", false)]
1322        );
1323        remove(root);
1324    }
1325
1326    #[test]
1327    fn events_round_trip_in_order() {
1328        let root = temp_root("events");
1329        let archive = SessionArchive::open(&root);
1330        let entry = archive
1331            .create(CreateSession::new(root.join("p")))
1332            .expect("create");
1333        archive
1334            .append_human(&entry.id, "please fix the ring", false)
1335            .expect("append human");
1336        archive
1337            .append_agent(
1338                &entry.id,
1339                &AgentEvent::Text {
1340                    slot: 0,
1341                    text: "on it".into(),
1342                },
1343            )
1344            .expect("append agent");
1345        archive
1346            .append_human(&entry.id, "direct note", true)
1347            .expect("append direct");
1348        let loaded = archive.load(&entry.id).expect("load");
1349        assert_eq!(
1350            loaded.events,
1351            vec![
1352                ArchiveEvent::human("please fix the ring", false),
1353                ArchiveEvent::agent(AgentEvent::Text {
1354                    slot: 0,
1355                    text: "on it".into(),
1356                }),
1357                ArchiveEvent::human("direct note", true),
1358            ]
1359        );
1360        assert!(loaded.warnings.is_empty());
1361        assert!(loaded.entry.updated_at >= loaded.entry.created_at);
1362        remove(root);
1363    }
1364
1365    #[test]
1366    fn titles_are_bounded_safely() {
1367        let root = temp_root("titles");
1368        let archive = SessionArchive::open(&root);
1369        let long = "héllo wörld ".repeat(40);
1370        let entry = archive
1371            .create(CreateSession::new(root.join("p")).title(long.clone()))
1372            .expect("create");
1373        assert_eq!(entry.title.chars().count(), MAX_TITLE_CHARS);
1374        assert!(long.starts_with(&entry.title));
1375        assert!(
1376            entry
1377                .title
1378                .chars()
1379                .last()
1380                .is_some_and(char::is_alphanumeric)
1381        );
1382        remove(root);
1383    }
1384
1385    #[test]
1386    fn listing_matches_canonical_cwd() {
1387        let root = temp_root("canonical");
1388        let archive = SessionArchive::open(&root);
1389        let project_dir = project(&root, "widget");
1390        let first = archive
1391            .create(CreateSession::new(&project_dir).title("first"))
1392            .expect("create");
1393        let second = archive
1394            .create(CreateSession::new(root.join("other")).title("second"))
1395            .expect("create");
1396        let listing = archive.list(&project_dir).expect("list");
1397        assert_eq!(
1398            listing
1399                .entries
1400                .iter()
1401                .map(|entry| entry.id.clone())
1402                .collect::<Vec<_>>(),
1403            vec![first.id.clone()]
1404        );
1405        assert_eq!(archive.list(&root).expect("list").entries.len(), 0);
1406        #[cfg(unix)]
1407        {
1408            let alias = root.join("widget-alias");
1409            std::os::unix::fs::symlink(&project_dir, &alias).expect("symlink");
1410            let through_alias = archive.list(&alias).expect("list via symlink");
1411            assert_eq!(
1412                through_alias
1413                    .entries
1414                    .iter()
1415                    .map(|entry| entry.id.clone())
1416                    .collect::<Vec<_>>(),
1417                vec![first.id]
1418            );
1419        }
1420        let _ = second;
1421        remove(root);
1422    }
1423
1424    #[test]
1425    fn list_orders_by_recent_activity() {
1426        let root = temp_root("ordering");
1427        let archive = SessionArchive::open(&root);
1428        let first = archive
1429            .create(CreateSession::new(root.join("p")))
1430            .expect("create");
1431        let second = archive
1432            .create(CreateSession::new(root.join("p")))
1433            .expect("create");
1434        let third = archive
1435            .create(CreateSession::new(root.join("p")))
1436            .expect("create");
1437        archive
1438            .update_entry(&first.id, |entry| entry.updated_at = 100)
1439            .expect("edit");
1440        archive
1441            .update_entry(&second.id, |entry| entry.updated_at = 300)
1442            .expect("edit");
1443        archive
1444            .update_entry(&third.id, |entry| entry.updated_at = 200)
1445            .expect("edit");
1446        let listing = archive.list_all().expect("list");
1447        assert_eq!(
1448            listing
1449                .entries
1450                .iter()
1451                .map(|entry| entry.id.clone())
1452                .collect::<Vec<_>>(),
1453            vec![second.id, third.id, first.id]
1454        );
1455        remove(root);
1456    }
1457
1458    #[test]
1459    fn corrupt_metadata_does_not_hide_valid_sessions() {
1460        let root = temp_root("corrupt");
1461        let archive = SessionArchive::open(&root);
1462        let healthy = archive
1463            .create(CreateSession::new(root.join("p")).title("healthy"))
1464            .expect("create");
1465        let corrupt = archive
1466            .create_exact("corruptmeta", CreateSession::new(root.join("p")))
1467            .expect("create");
1468        fs::write(root.join("corruptmeta").join(META_FILE), "{not json").expect("write");
1469        let orphan_dir = root.join("orphan_events");
1470        fs::create_dir(&orphan_dir).expect("dir");
1471        fs::write(orphan_dir.join(EVENTS_FILE), "leftover\n").expect("write");
1472        fs::write(orphan_dir.join(META_FILE), "{\"cwd\":").expect("write");
1473
1474        let listing = archive.list_all().expect("list");
1475        assert_eq!(
1476            listing
1477                .entries
1478                .iter()
1479                .map(|entry| entry.id.clone())
1480                .collect::<Vec<_>>(),
1481            vec![healthy.id]
1482        );
1483        assert_eq!(
1484            listing
1485                .failures
1486                .iter()
1487                .map(|failure| failure.id.clone())
1488                .collect::<Vec<_>>(),
1489            vec!["corruptmeta".to_owned(), "orphan_events".to_owned()]
1490        );
1491        assert!(
1492            listing
1493                .failures
1494                .iter()
1495                .all(|failure| !failure.detail.is_empty())
1496        );
1497        assert!(archive.load("corruptmeta").is_err());
1498        assert_eq!(
1499            archive.list(&root.join("p")).expect("list").entries.len(),
1500            1,
1501            "corrupt metadata must not hide this project's healthy session"
1502        );
1503        let _ = corrupt;
1504        remove(root);
1505    }
1506
1507    #[test]
1508    fn session_without_metadata_files_is_ignored_when_empty() {
1509        let root = temp_root("torn-create");
1510        let archive = SessionArchive::open(&root);
1511        fs::create_dir(root.join("emptysession")).expect("dir");
1512        let listing: SessionListing = archive.list_all().expect("list");
1513        assert!(listing.entries.is_empty());
1514        assert!(listing.failures.is_empty());
1515        remove(root);
1516    }
1517
1518    #[test]
1519    fn torn_final_journal_line_is_tolerated_without_losing_events() {
1520        let root = temp_root("torn-journal");
1521        let archive = SessionArchive::open(&root);
1522        let entry = archive
1523            .create(CreateSession::new(root.join("p")))
1524            .expect("create");
1525        archive
1526            .append_human(&entry.id, "kept message", false)
1527            .expect("append");
1528        let journal = root.join(&entry.id).join(EVENTS_FILE);
1529        let intact = fs::read_to_string(&journal).expect("read journal");
1530        fs::write(&journal, format!("{intact}{{\"type\":\"hum")).expect("torn write");
1531        let loaded = archive.load(&entry.id).expect("load");
1532        assert_eq!(
1533            loaded.events,
1534            vec![ArchiveEvent::human("kept message", false)]
1535        );
1536        assert_eq!(loaded.warnings.len(), 1);
1537        assert!(loaded.warnings[0].contains("incomplete final journal line"));
1538        remove(root);
1539    }
1540
1541    #[test]
1542    fn malformed_middle_journal_lines_do_not_discard_valid_events() {
1543        let root = temp_root("middle-journal");
1544        let archive = SessionArchive::open(&root);
1545        let entry = archive
1546            .create(CreateSession::new(root.join("p")))
1547            .expect("create");
1548        let first = serde_json::to_string(&ArchiveEvent::human("first", false)).expect("json");
1549        let third = serde_json::to_string(&ArchiveEvent::agent(AgentEvent::Text {
1550            slot: 1,
1551            text: "third".into(),
1552        }))
1553        .expect("json");
1554        fs::write(
1555            root.join(&entry.id).join(EVENTS_FILE),
1556            format!("{first}\nnot-json-at-all\n{third}\n"),
1557        )
1558        .expect("write journal");
1559        let loaded = archive.load(&entry.id).expect("load");
1560        assert_eq!(
1561            loaded.events,
1562            vec![
1563                ArchiveEvent::human("first", false),
1564                ArchiveEvent::agent(AgentEvent::Text {
1565                    slot: 1,
1566                    text: "third".into(),
1567                }),
1568            ]
1569        );
1570        assert_eq!(loaded.warnings.len(), 1);
1571        assert!(loaded.warnings[0].contains("line 2"));
1572        remove(root);
1573    }
1574
1575    #[test]
1576    fn unknown_metadata_and_top_level_keys_are_preserved() {
1577        let root = temp_root("unknown-keys");
1578        let archive = SessionArchive::open(&root);
1579        let entry = archive
1580            .create(CreateSession::new(root.join("p")).metadata(metadata(&[
1581                ("agents", json!([{"identity": "openai.com"}])),
1582                ("mystery_provider_key", json!({"keep": true})),
1583            ])))
1584            .expect("create");
1585        let meta_path = root.join(&entry.id).join(META_FILE);
1586        let mut raw = fs::read_to_string(&meta_path).expect("read meta");
1587        raw.insert_str(raw.len() - 1, ",\"future_top_level\":{\"carried\":true}");
1588        fs::write(&meta_path, raw).expect("write meta");
1589        let raw = fs::read_to_string(&meta_path).expect("read meta");
1590        let future = raw.replace("\"schema_version\": 1", "\"schema_version\": 99");
1591        fs::write(&meta_path, &future).expect("write meta");
1592        assert!(archive.load(&entry.id).is_err(), "future schema is refused");
1593        fs::write(&meta_path, raw).expect("restore meta");
1594        archive
1595            .update_metadata(&entry.id, |data| {
1596                data.insert("added_later", json!("value"));
1597            })
1598            .expect("update");
1599        let loaded = archive.load(&entry.id).expect("load");
1600        assert_eq!(
1601            loaded.metadata.get("mystery_provider_key"),
1602            Some(&json!({"keep": true}))
1603        );
1604        assert_eq!(loaded.metadata.get("added_later"), Some(&json!("value")));
1605        assert_eq!(
1606            loaded.metadata.get("agents"),
1607            Some(&json!([{"identity": "openai.com"}]))
1608        );
1609        let on_disk = fs::read_to_string(&meta_path).expect("read meta");
1610        assert!(on_disk.contains("\"future_top_level\""));
1611        remove(root);
1612    }
1613
1614    #[test]
1615    fn entry_states_round_trip_and_unknown_states_fall_back() {
1616        let root = temp_root("states");
1617        let archive = SessionArchive::open(&root);
1618        let entry = archive
1619            .create(CreateSession::new(root.join("p")).state(ArchiveState::Completed))
1620            .expect("create");
1621        archive
1622            .set_state(&entry.id, ArchiveState::Cancelled)
1623            .expect("set");
1624        assert_eq!(
1625            archive.load(&entry.id).expect("load").entry.state,
1626            ArchiveState::Cancelled
1627        );
1628        let meta_path = root.join(&entry.id).join(META_FILE);
1629        let raw = fs::read_to_string(&meta_path).expect("read");
1630        let updated = raw.replace("\"state\": \"cancelled\"", "\"state\": \"fancy_future\"");
1631        fs::write(&meta_path, updated).expect("write");
1632        assert_eq!(
1633            archive.load(&entry.id).expect("load").entry.state,
1634            ArchiveState::Unknown
1635        );
1636        remove(root);
1637    }
1638
1639    #[test]
1640    fn missing_sessions_and_roots_are_clean_errors() {
1641        let root = temp_root("missing");
1642        let archive = SessionArchive::open(root.join("does-not-exist"));
1643        let listing = archive.list_all().expect("empty listing");
1644        assert!(listing.entries.is_empty());
1645        assert!(listing.failures.is_empty());
1646        let error = archive.load("absent").expect_err("missing session");
1647        assert!(matches!(
1648            error,
1649            PersistenceError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound
1650        ));
1651        let archive = SessionArchive::open(&root);
1652        let entry = archive
1653            .create(CreateSession::new(root.join("p")))
1654            .expect("create");
1655        let loaded = archive.load(&entry.id).expect("load without journal");
1656        assert!(loaded.events.is_empty());
1657        assert!(loaded.warnings.is_empty());
1658        let orphan = archive
1659            .append("nevercreated", &ArchiveEvent::human("orphan", false))
1660            .expect_err("append without session");
1661        assert!(matches!(
1662            orphan,
1663            PersistenceError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound
1664        ));
1665        assert!(!root.join("nevercreated").exists(), "no orphan directory");
1666        remove(root);
1667    }
1668
1669    #[test]
1670    fn appending_after_a_torn_journal_retains_the_new_record() {
1671        let root = temp_root("torn-append");
1672        let cwd = project(&root, "project");
1673        let archive = SessionArchive::open(root.join("archive"));
1674        let entry = archive.create(CreateSession::new(cwd)).unwrap();
1675        let path = archive.events_path(&entry.id);
1676        fs::write(&path, b"{\"incomplete\":").unwrap();
1677        let writer = archive.buffered(&entry.id).unwrap();
1678        writer.append_human("new message", false).unwrap();
1679        writer.flush().unwrap();
1680        drop(writer);
1681        let loaded = archive.load(&entry.id).unwrap();
1682        assert_eq!(
1683            loaded.events,
1684            vec![ArchiveEvent::human("new message", false)]
1685        );
1686        assert_eq!(loaded.warnings.len(), 1);
1687        remove(root);
1688    }
1689
1690    #[test]
1691    fn metadata_edit_is_retained_when_its_first_write_fails() {
1692        let root = temp_root("edit-retry");
1693        let cwd = project(&root, "project");
1694        let archive = SessionArchive::open(root.join("archive"));
1695        let entry = archive.create(CreateSession::new(cwd)).unwrap();
1696        let path = archive.meta_path(&entry.id);
1697        let original = fs::read(&path).unwrap();
1698        let writer = archive.buffered(&entry.id).unwrap();
1699        let moved_path = path.clone();
1700        writer
1701            .update_metadata(move |metadata| {
1702                metadata.insert("retained", true);
1703                fs::remove_file(&moved_path).unwrap();
1704                fs::create_dir(&moved_path).unwrap();
1705            })
1706            .unwrap();
1707        assert!(writer.flush().is_err());
1708        fs::remove_dir(&path).unwrap();
1709        fs::write(&path, original).unwrap();
1710        writer.flush().unwrap();
1711        assert_eq!(
1712            archive.load(&entry.id).unwrap().metadata.get("retained"),
1713            Some(&json!(true))
1714        );
1715        drop(writer);
1716        remove(root);
1717    }
1718
1719    #[test]
1720    fn buffered_writer_is_durable_at_flush_and_drop() {
1721        let root = temp_root("buffered");
1722        let archive = SessionArchive::open(&root);
1723        let entry = archive
1724            .create(CreateSession::new(root.join("p")))
1725            .expect("create");
1726        let writer: BufferedSessionArchive = archive.buffered(&entry.id).expect("writer");
1727        assert_eq!(writer.id(), entry.id);
1728        writer.append_human("queued first", false).expect("queue");
1729        writer.flush().expect("flush");
1730        let loaded = archive.load(&entry.id).expect("load");
1731        assert_eq!(
1732            loaded.events,
1733            vec![ArchiveEvent::human("queued first", false)]
1734        );
1735        let before = loaded.entry.updated_at;
1736
1737        writer
1738            .append_agent(&AgentEvent::Text {
1739                slot: 0,
1740                text: "reply".into(),
1741            })
1742            .expect("queue");
1743        writer.append_human("queued second", true).expect("queue");
1744        drop(writer);
1745        let loaded = archive.load(&entry.id).expect("load after drop");
1746        assert_eq!(
1747            loaded.events,
1748            vec![
1749                ArchiveEvent::human("queued first", false),
1750                ArchiveEvent::agent(AgentEvent::Text {
1751                    slot: 0,
1752                    text: "reply".into(),
1753                }),
1754                ArchiveEvent::human("queued second", true),
1755            ]
1756        );
1757        assert!(loaded.entry.updated_at >= before);
1758        assert!(loaded.warnings.is_empty());
1759        remove(root);
1760    }
1761
1762    #[test]
1763    fn buffered_metadata_operations_apply_in_order() {
1764        let root = temp_root("buffered-meta");
1765        let archive = SessionArchive::open(&root);
1766        let entry = archive
1767            .create(
1768                CreateSession::new(root.join("p"))
1769                    .metadata(metadata(&[("provider_session", json!("before"))])),
1770            )
1771            .expect("create");
1772        let writer = archive.buffered(&entry.id).expect("writer");
1773        writer
1774            .replace_metadata(metadata(&[("provider_session", json!("replaced"))]))
1775            .expect("queue snapshot");
1776        writer
1777            .update_metadata(|data| {
1778                data.insert("roster", json!(["Codex", "Agy"]));
1779                data.insert("mystery", json!({"keep": true}));
1780            })
1781            .expect("queue edit");
1782        writer
1783            .update_entry(|entry| {
1784                entry.title = "renamed".into();
1785                entry.state = ArchiveState::Completed;
1786            })
1787            .expect("queue entry edit");
1788        writer.flush().expect("flush");
1789        let loaded = archive.load(&entry.id).expect("load");
1790        assert_eq!(loaded.entry.title, "renamed");
1791        assert_eq!(loaded.entry.state, ArchiveState::Completed);
1792        assert_eq!(
1793            loaded.metadata.get("provider_session"),
1794            Some(&json!("replaced"))
1795        );
1796        assert_eq!(
1797            loaded.metadata.get("roster"),
1798            Some(&json!(["Codex", "Agy"]))
1799        );
1800        assert_eq!(loaded.metadata.get("mystery"), Some(&json!({"keep": true})));
1801        remove(root);
1802    }
1803
1804    #[test]
1805    fn buffered_writer_recovers_from_transient_failures_and_reports() {
1806        let root = temp_root("buffered-recovery");
1807        let archive = SessionArchive::open(&root);
1808        let entry = archive
1809            .create(CreateSession::new(root.join("p")))
1810            .expect("create");
1811        let journal = root.join(&entry.id).join(EVENTS_FILE);
1812        let (sender, errors) = mpsc::channel();
1813        let writer = archive
1814            .buffered_with_errors(&entry.id, move |error| {
1815                let _ = sender.send(error);
1816            })
1817            .expect("writer");
1818        fs::write(&journal, b"").expect("create journal");
1819        fs::remove_file(&journal).expect("remove journal");
1820        fs::create_dir(&journal).expect("break journal path");
1821        writer.append_human("during failure", false).expect("queue");
1822        assert!(
1823            errors.recv_timeout(Duration::from_secs(2)).is_ok(),
1824            "background failure must be reported"
1825        );
1826        writer.flush().expect_err("flush reports the failure");
1827        fs::remove_dir(&journal).expect("repair journal path");
1828        writer.flush().expect("flush after repair");
1829        let loaded = archive.load(&entry.id).expect("load");
1830        assert_eq!(
1831            loaded.events,
1832            vec![ArchiveEvent::human("during failure", false)]
1833        );
1834        assert!(loaded.warnings.is_empty());
1835        drop(writer);
1836        remove(root);
1837    }
1838
1839    #[test]
1840    fn buffered_writer_for_missing_session_is_rejected() {
1841        let root = temp_root("buffered-missing");
1842        let archive = SessionArchive::open(&root);
1843        let error = archive.buffered("nosuchsession").expect_err("missing");
1844        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
1845        remove(root);
1846    }
1847
1848    #[cfg(unix)]
1849    #[test]
1850    fn archive_files_and_directories_are_private() {
1851        use std::os::unix::fs::PermissionsExt;
1852        let root = temp_root("private");
1853        let archive = SessionArchive::open(&root);
1854        let entry = archive
1855            .create(CreateSession::new(root.join("p")))
1856            .expect("create");
1857        archive
1858            .append_human(&entry.id, "private", false)
1859            .expect("append");
1860        let dir_mode = fs::metadata(root.join(&entry.id))
1861            .expect("dir")
1862            .permissions()
1863            .mode()
1864            & 0o777;
1865        let meta_mode = fs::metadata(root.join(&entry.id).join(META_FILE))
1866            .expect("meta")
1867            .permissions()
1868            .mode()
1869            & 0o777;
1870        let journal_mode = fs::metadata(root.join(&entry.id).join(EVENTS_FILE))
1871            .expect("journal")
1872            .permissions()
1873            .mode()
1874            & 0o777;
1875        assert_eq!(dir_mode, 0o700);
1876        assert_eq!(meta_mode, 0o600);
1877        assert_eq!(journal_mode, 0o600);
1878        remove(root);
1879    }
1880
1881    #[test]
1882    fn sync_metadata_edits_preserve_unknown_keys() {
1883        let root = temp_root("sync-edit");
1884        let archive = SessionArchive::open(&root);
1885        let entry = archive
1886            .create(
1887                CreateSession::new(root.join("p"))
1888                    .metadata(metadata(&[("agents", json!([{"identity": "claude.ai"}]))])),
1889            )
1890            .expect("create");
1891        archive.set_title(&entry.id, "retitled").expect("title");
1892        archive
1893            .update_metadata(&entry.id, |data| {
1894                data.remove("agents");
1895                data.insert("provider_session", json!("sid-9"));
1896            })
1897            .expect("update");
1898        let loaded = archive.load(&entry.id).expect("load");
1899        assert_eq!(loaded.entry.title, "retitled");
1900        assert_eq!(loaded.metadata.get("agents"), None);
1901        assert_eq!(
1902            loaded.metadata.get("provider_session"),
1903            Some(&json!("sid-9"))
1904        );
1905        remove(root);
1906    }
1907}