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. Failures are sorted
616    /// by session ID independently of filesystem enumeration order.
617    pub fn list_all(&self) -> Result<SessionListing, PersistenceError> {
618        let mut listing = SessionListing::default();
619        let read_dir = match fs::read_dir(&self.root) {
620            Ok(read_dir) => read_dir,
621            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
622                return Ok(listing);
623            }
624            Err(error) => return Err(error.into()),
625        };
626        for candidate in read_dir {
627            let Ok(candidate) = candidate else {
628                continue;
629            };
630            let path = candidate.path();
631            if !path.is_dir() {
632                continue;
633            }
634            let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
635                continue;
636            };
637            if name.starts_with('.') || validate_session_id(name).is_err() {
638                continue;
639            }
640            let meta_path = path.join(META_FILE);
641            match read_archive_meta(&meta_path) {
642                Ok(file) => listing.entries.push(file.entry),
643                Err(error) => {
644                    let missing_meta = is_not_found(&error) && !path.join(EVENTS_FILE).exists();
645                    if !missing_meta {
646                        listing.failures.push(SessionFailure {
647                            id: name.to_owned(),
648                            detail: error.to_string(),
649                        });
650                    }
651                }
652            }
653        }
654        listing.entries.sort_by(|a, b| {
655            b.updated_at
656                .cmp(&a.updated_at)
657                .then_with(|| b.created_at.cmp(&a.created_at))
658                .then_with(|| a.id.cmp(&b.id))
659        });
660        listing.failures.sort_by(|a, b| a.id.cmp(&b.id));
661        Ok(listing)
662    }
663
664    /// Load one archived session: entry, provider metadata, and ordered
665    /// events. Missing sessions and unreadable metadata are reported with
666    /// distinct errors; damaged journals still return every valid event.
667    pub fn load(&self, id: &str) -> Result<ArchivedSession, PersistenceError> {
668        validate_session_id(id)?;
669        let file = read_archive_meta(&self.meta_path(id)).map_err(|error| {
670            if is_not_found(&error) {
671                PersistenceError::Io(std::io::Error::new(
672                    std::io::ErrorKind::NotFound,
673                    format!("archived session {id} was not found"),
674                ))
675            } else {
676                error
677            }
678        })?;
679        if file.entry.id != id {
680            return Err(malformed(format!(
681                "archived entry id {:?} does not match session id {id:?}",
682                file.entry.id
683            )));
684        }
685        let (events, warnings) = read_events(&self.events_path(id))?;
686        Ok(ArchivedSession {
687            entry: file.entry,
688            metadata: file.metadata,
689            events,
690            warnings,
691        })
692    }
693
694    /// Append one journal record synchronously and force it to stable
695    /// storage. Runtime streaming should prefer [`Self::buffered`].
696    pub fn append(&self, id: &str, event: &ArchiveEvent) -> Result<(), PersistenceError> {
697        validate_session_id(id)?;
698        let line = encode_event_line(event)?;
699        let path = self.events_path(id);
700        if !self.meta_path(id).exists() {
701            return Err(PersistenceError::Io(std::io::Error::new(
702                std::io::ErrorKind::NotFound,
703                format!("archived session {id} was not found"),
704            )));
705        }
706        if let Some(parent) = path.parent()
707            && !parent.as_os_str().is_empty()
708        {
709            fs::create_dir_all(parent)?;
710        }
711        let mut file = open_journal(&path)?;
712        file.write_all(line.as_bytes())?;
713        file.sync_data()?;
714        Ok(())
715    }
716
717    pub fn append_human(
718        &self,
719        id: &str,
720        text: impl Into<String>,
721        direct: bool,
722    ) -> Result<(), PersistenceError> {
723        self.append(id, &ArchiveEvent::human(text, direct))
724    }
725
726    pub fn append_agent(&self, id: &str, event: &AgentEvent) -> Result<(), PersistenceError> {
727        self.append(id, &ArchiveEvent::agent(event.clone()))
728    }
729
730    /// Edit provider metadata in place, preserving unknown keys.
731    pub fn update_metadata<F>(&self, id: &str, apply: F) -> Result<(), PersistenceError>
732    where
733        F: FnOnce(&mut SessionMetadata),
734    {
735        validate_session_id(id)?;
736        edit_archive_metadata(&self.meta_path(id), apply)
737    }
738
739    /// Edit the archive entry (title, state, roster, activity timestamp).
740    /// The session id is pinned to the directory; titles stay bounded.
741    pub fn update_entry<F>(&self, id: &str, apply: F) -> Result<(), PersistenceError>
742    where
743        F: FnOnce(&mut ArchiveEntry),
744    {
745        validate_session_id(id)?;
746        edit_archive_entry(&self.meta_path(id), id, apply)
747    }
748
749    pub fn set_title(&self, id: &str, title: impl AsRef<str>) -> Result<(), PersistenceError> {
750        let title = title.as_ref().to_owned();
751        self.update_entry(id, |entry| entry.title = title)
752    }
753
754    pub fn set_state(&self, id: &str, state: ArchiveState) -> Result<(), PersistenceError> {
755        self.update_entry(id, |entry| entry.state = state)
756    }
757
758    /// Mark the session as most recently active.
759    pub fn touch(&self, id: &str) -> Result<(), PersistenceError> {
760        self.update_entry(id, |entry| entry.updated_at = now_unix_nanos())
761    }
762
763    /// Start a background writer that keeps journal and metadata filesystem
764    /// work off the caller thread. Events and metadata edits become durable
765    /// at [`BufferedSessionArchive::flush`] boundaries and on drop.
766    pub fn buffered(&self, id: &str) -> std::io::Result<BufferedSessionArchive> {
767        self.buffered_with_errors(id, |_| {})
768    }
769
770    /// Like [`Self::buffered`], reporting background write failures through
771    /// `on_error` so the terminal can surface them in the status ribbon.
772    pub fn buffered_with_errors<F>(
773        &self,
774        id: &str,
775        on_error: F,
776    ) -> std::io::Result<BufferedSessionArchive>
777    where
778        F: Fn(String) + Send + 'static,
779    {
780        validate_session_id(id).map_err(|error| {
781            std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string())
782        })?;
783        let entry_path = self.meta_path(id);
784        if !entry_path.exists() {
785            return Err(std::io::Error::new(
786                std::io::ErrorKind::NotFound,
787                format!("archived session {id} was not found"),
788            ));
789        }
790        let worker = ArchiveWorker {
791            id: id.to_owned(),
792            entry_path,
793            events_path: self.events_path(id),
794            writer: None,
795            retry: VecDeque::new(),
796            pending: VecDeque::new(),
797            events_since_boundary: false,
798            first_error: None,
799            on_error: Box::new(on_error),
800        };
801        BufferedSessionArchive::spawn(id.to_owned(), worker)
802    }
803}
804
805type MetadataEdit = Box<dyn FnOnce(&mut SessionMetadata) + Send>;
806type EntryEdit = Box<dyn FnOnce(&mut ArchiveEntry) + Send>;
807
808enum PendingOperation {
809    ReplaceMetadata(SessionMetadata),
810    EditMetadata(Option<MetadataEdit>),
811    EditEntry(Option<EntryEdit>),
812    TouchEntry,
813    WriteSnapshot(ArchiveMetaFile),
814}
815
816enum ArchiveCommand {
817    AppendEvent {
818        line: String,
819    },
820    Enqueue(PendingOperation),
821    Checkpoint,
822    Flush {
823        reply: Sender<Result<(), PersistenceError>>,
824    },
825    Shutdown {
826        reply: Sender<()>,
827    },
828}
829
830struct ArchiveWorker {
831    id: String,
832    entry_path: PathBuf,
833    events_path: PathBuf,
834    writer: Option<std::io::BufWriter<File>>,
835    retry: VecDeque<String>,
836    pending: VecDeque<PendingOperation>,
837    events_since_boundary: bool,
838    first_error: Option<String>,
839    on_error: Box<dyn Fn(String) + Send>,
840}
841
842impl ArchiveWorker {
843    fn note_error(&mut self, error: &PersistenceError) {
844        if self.first_error.is_none() {
845            self.first_error = Some(error.to_string());
846            (self.on_error)(error.to_string());
847        }
848    }
849
850    fn ensure_open(&mut self) -> Result<&mut std::io::BufWriter<File>, PersistenceError> {
851        if self.writer.is_none() {
852            if let Some(parent) = self.events_path.parent()
853                && !parent.as_os_str().is_empty()
854            {
855                fs::create_dir_all(parent)?;
856            }
857            let file = open_journal(&self.events_path)?;
858            self.writer = Some(std::io::BufWriter::new(file));
859        }
860        Ok(self.writer.as_mut().expect("writer initialized"))
861    }
862
863    fn drain_retry(&mut self) -> Result<(), PersistenceError> {
864        while !self.retry.is_empty() {
865            let line = self.retry.front().cloned().expect("retry non-empty");
866            let writer = self.ensure_open()?;
867            writer
868                .write_all(line.as_bytes())
869                .map_err(PersistenceError::Io)?;
870            self.retry.pop_front();
871        }
872        Ok(())
873    }
874
875    fn sync_journal(&mut self) -> Result<(), PersistenceError> {
876        self.drain_retry()?;
877        if let Some(writer) = self.writer.as_mut() {
878            writer.flush()?;
879            writer.get_ref().sync_data()?;
880        }
881        Ok(())
882    }
883
884    fn apply_operation(
885        &mut self,
886        operation: &mut PendingOperation,
887    ) -> Result<(), PersistenceError> {
888        match operation {
889            PendingOperation::WriteSnapshot(file) => write_archive_meta(&self.entry_path, file),
890            PendingOperation::ReplaceMetadata(metadata) => {
891                replace_archive_metadata(&self.entry_path, metadata)
892            }
893            PendingOperation::EditMetadata(edit) => {
894                let mut file = read_archive_meta(&self.entry_path)?;
895                if let Some(edit) = edit.take() {
896                    edit(&mut file.metadata);
897                }
898                *operation = PendingOperation::WriteSnapshot(file);
899                self.apply_operation(operation)
900            }
901            PendingOperation::EditEntry(edit) => {
902                let mut file = read_archive_meta(&self.entry_path)?;
903                if let Some(edit) = edit.take() {
904                    edit(&mut file.entry);
905                }
906                file.entry.id = self.id.clone();
907                file.entry.title = bound_title(&file.entry.title);
908                *operation = PendingOperation::WriteSnapshot(file);
909                self.apply_operation(operation)
910            }
911            PendingOperation::TouchEntry => {
912                let mut file = read_archive_meta(&self.entry_path)?;
913                file.entry.updated_at = now_unix_nanos();
914                write_archive_meta(&self.entry_path, &file)
915            }
916        }
917    }
918
919    fn complete_boundary(&mut self) -> Result<(), PersistenceError> {
920        let mut result = self.sync_journal();
921        if result.is_ok() && self.events_since_boundary {
922            self.pending.push_back(PendingOperation::TouchEntry);
923            self.events_since_boundary = false;
924        }
925        while result.is_ok() {
926            let Some(mut operation) = self.pending.pop_front() else {
927                break;
928            };
929            if let Err(error) = self.apply_operation(&mut operation) {
930                self.note_error(&error);
931                self.pending.push_front(operation);
932                result = Err(error);
933            }
934        }
935        if result.is_ok() {
936            self.first_error = None;
937        }
938        result
939    }
940
941    fn run(mut self, receiver: Receiver<ArchiveCommand>) -> Result<(), PersistenceError> {
942        loop {
943            let command = match receiver.recv_timeout(std::time::Duration::from_secs(1)) {
944                Ok(command) => command,
945                Err(mpsc::RecvTimeoutError::Timeout) => {
946                    if self.pending.is_empty()
947                        && self.retry.is_empty()
948                        && !self.events_since_boundary
949                    {
950                        continue;
951                    }
952                    if let Err(error) = self.complete_boundary() {
953                        self.note_error(&error);
954                    }
955                    continue;
956                }
957                Err(mpsc::RecvTimeoutError::Disconnected) => break,
958            };
959            match command {
960                ArchiveCommand::Checkpoint => {
961                    if let Err(error) = self.complete_boundary() {
962                        self.note_error(&error);
963                    }
964                }
965                ArchiveCommand::AppendEvent { line } => {
966                    self.retry.push_back(line);
967                    self.events_since_boundary = true;
968                    if let Err(error) = self.drain_retry() {
969                        self.note_error(&error);
970                    }
971                }
972                ArchiveCommand::Enqueue(operation) => {
973                    self.pending.push_back(operation);
974                }
975                ArchiveCommand::Flush { reply } => {
976                    let result = self.complete_boundary();
977                    let _ = reply.send(result);
978                }
979                ArchiveCommand::Shutdown { reply } => {
980                    let result = self.complete_boundary();
981                    let _ = reply.send(());
982                    return result;
983                }
984            }
985        }
986        self.complete_boundary()
987    }
988}
989
990/// Background archive writer used from the terminal event loop.
991///
992/// The handle only serializes events and queues commands; all filesystem work
993/// happens on its worker thread. Queued journal lines and metadata edits are
994/// retried until [`flush`](Self::flush) or drop makes them durable, and the
995/// entry's `updated_at` advances at each successful boundary that received
996/// events so the session browser can order by last activity.
997pub struct BufferedSessionArchive {
998    id: String,
999    sender: Sender<ArchiveCommand>,
1000    worker: Option<std::thread::JoinHandle<Result<(), PersistenceError>>>,
1001}
1002
1003impl std::fmt::Debug for BufferedSessionArchive {
1004    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1005        formatter
1006            .debug_struct("BufferedSessionArchive")
1007            .field("id", &self.id)
1008            .field("worker_running", &self.worker.is_some())
1009            .finish_non_exhaustive()
1010    }
1011}
1012
1013impl BufferedSessionArchive {
1014    fn spawn(id: String, worker: ArchiveWorker) -> std::io::Result<Self> {
1015        let (sender, receiver) = mpsc::channel();
1016        let thread = std::thread::Builder::new()
1017            .name("codeswarm-session-archive".into())
1018            .spawn(move || worker.run(receiver))?;
1019        Ok(Self {
1020            id,
1021            sender,
1022            worker: Some(thread),
1023        })
1024    }
1025
1026    pub fn id(&self) -> &str {
1027        &self.id
1028    }
1029
1030    fn send(&self, command: ArchiveCommand) -> Result<(), PersistenceError> {
1031        self.sender.send(command).map_err(|_| {
1032            PersistenceError::Io(std::io::Error::new(
1033                std::io::ErrorKind::BrokenPipe,
1034                "session archive background writer stopped",
1035            ))
1036        })
1037    }
1038
1039    /// Serialize and queue one journal record without filesystem I/O in the
1040    /// caller.
1041    pub fn append(&self, event: &ArchiveEvent) -> Result<(), PersistenceError> {
1042        let line = encode_event_line(event)?;
1043        self.send(ArchiveCommand::AppendEvent { line })
1044    }
1045
1046    pub fn append_human(
1047        &self,
1048        text: impl Into<String>,
1049        direct: bool,
1050    ) -> Result<(), PersistenceError> {
1051        self.append(&ArchiveEvent::human(text, direct))
1052    }
1053
1054    pub fn append_agent(&self, event: &AgentEvent) -> Result<(), PersistenceError> {
1055        self.append(&ArchiveEvent::agent(event.clone()))
1056    }
1057
1058    /// Queue a complete provider metadata snapshot, retaining unknown keys.
1059    pub fn replace_metadata(&self, metadata: SessionMetadata) -> Result<(), PersistenceError> {
1060        self.send(ArchiveCommand::Enqueue(PendingOperation::ReplaceMetadata(
1061            metadata,
1062        )))
1063    }
1064
1065    /// Queue an in-place provider metadata edit, preserving unknown keys.
1066    pub fn update_metadata<F>(&self, apply: F) -> Result<(), PersistenceError>
1067    where
1068        F: FnOnce(&mut SessionMetadata) + Send + 'static,
1069    {
1070        self.send(ArchiveCommand::Enqueue(PendingOperation::EditMetadata(
1071            Some(Box::new(apply)),
1072        )))
1073    }
1074
1075    /// Queue an archive entry edit (title, state, roster). The session id is
1076    /// pinned to the directory and titles stay bounded.
1077    pub fn update_entry<F>(&self, apply: F) -> Result<(), PersistenceError>
1078    where
1079        F: FnOnce(&mut ArchiveEntry) + Send + 'static,
1080    {
1081        self.send(ArchiveCommand::Enqueue(PendingOperation::EditEntry(Some(
1082            Box::new(apply),
1083        ))))
1084    }
1085
1086    /// Queue an activity timestamp update.
1087    pub fn touch(&self) -> Result<(), PersistenceError> {
1088        self.send(ArchiveCommand::Enqueue(PendingOperation::TouchEntry))
1089    }
1090
1091    /// Request a durable checkpoint without blocking the UI.
1092    pub fn checkpoint(&self) -> Result<(), PersistenceError> {
1093        self.send(ArchiveCommand::Checkpoint)
1094    }
1095
1096    /// Drain queued records and metadata edits and make them durable.
1097    pub fn flush(&self) -> Result<(), PersistenceError> {
1098        let (reply, result) = mpsc::channel();
1099        self.send(ArchiveCommand::Flush { reply })?;
1100        result.recv().map_err(|_| {
1101            PersistenceError::Io(std::io::Error::new(
1102                std::io::ErrorKind::BrokenPipe,
1103                "session archive background writer stopped",
1104            ))
1105        })?
1106    }
1107}
1108
1109impl Drop for BufferedSessionArchive {
1110    fn drop(&mut self) {
1111        let Some(worker) = self.worker.take() else {
1112            return;
1113        };
1114        let (reply, result) = mpsc::channel();
1115        if self.sender.send(ArchiveCommand::Shutdown { reply }).is_ok() {
1116            let _ = result.recv();
1117        }
1118        let _ = worker.join();
1119    }
1120}
1121
1122#[cfg(unix)]
1123fn set_private(file: &File) -> std::io::Result<()> {
1124    use std::os::unix::fs::PermissionsExt;
1125    file.set_permissions(fs::Permissions::from_mode(0o600))
1126}
1127
1128#[cfg(not(unix))]
1129fn set_private(_file: &File) -> std::io::Result<()> {
1130    Ok(())
1131}
1132
1133#[cfg(unix)]
1134fn set_private_dir(path: &Path) -> std::io::Result<()> {
1135    use std::os::unix::fs::PermissionsExt;
1136    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1137}
1138
1139#[cfg(not(unix))]
1140fn set_private_dir(_path: &Path) -> std::io::Result<()> {
1141    Ok(())
1142}
1143
1144fn atomic_private_write(path: &Path, payload: &[u8]) -> Result<(), PersistenceError> {
1145    let file_name = path
1146        .file_name()
1147        .and_then(|name| name.to_str())
1148        .unwrap_or("meta");
1149    let temporary = path.with_file_name(format!(
1150        ".{file_name}.tmp-{}-{}",
1151        std::process::id(),
1152        TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
1153    ));
1154    let result = (|| {
1155        if let Some(parent) = path.parent()
1156            && !parent.as_os_str().is_empty()
1157        {
1158            fs::create_dir_all(parent)?;
1159        }
1160        let mut file = OpenOptions::new()
1161            .create_new(true)
1162            .write(true)
1163            .open(&temporary)?;
1164        set_private(&file)?;
1165        file.write_all(payload)?;
1166        file.sync_all()?;
1167        fs::rename(&temporary, path)?;
1168        Ok(())
1169    })();
1170    if result.is_err() {
1171        let _ = fs::remove_file(&temporary);
1172    }
1173    result
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178    use std::fs;
1179    use std::sync::mpsc;
1180    use std::time::{Duration, SystemTime, UNIX_EPOCH};
1181
1182    use serde_json::json;
1183
1184    use super::{
1185        ArchiveEvent, ArchiveState, BufferedSessionArchive, CreateSession, EVENTS_FILE,
1186        MAX_TITLE_CHARS, META_FILE, SessionArchive, SessionListing, generate_session_id,
1187        validate_session_id,
1188    };
1189    use crate::AgentEvent;
1190    use crate::persistence::{PersistenceError, SessionMetadata};
1191
1192    fn temp_root(name: &str) -> std::path::PathBuf {
1193        let unique = SystemTime::now()
1194            .duration_since(UNIX_EPOCH)
1195            .expect("clock")
1196            .as_nanos();
1197        let root = std::env::temp_dir().join(format!("codeswarm-session-archive-{unique}-{name}"));
1198        fs::create_dir_all(&root).expect("create root");
1199        root
1200    }
1201
1202    fn project(root: &std::path::Path, name: &str) -> std::path::PathBuf {
1203        let path = root.join(name);
1204        fs::create_dir_all(&path).expect("create project");
1205        path
1206    }
1207
1208    fn metadata(pairs: &[(&str, serde_json::Value)]) -> SessionMetadata {
1209        let mut data = SessionMetadata::empty();
1210        for (key, value) in pairs {
1211            data.insert(*key, value.clone());
1212        }
1213        data
1214    }
1215
1216    fn remove(root: std::path::PathBuf) {
1217        fs::remove_dir_all(root).expect("cleanup");
1218    }
1219
1220    #[test]
1221    fn generated_ids_are_unique_and_path_safe() {
1222        let mut ids = std::collections::BTreeSet::new();
1223        for _ in 0..512 {
1224            let id = generate_session_id();
1225            assert_eq!(id.len(), 32);
1226            assert!(id.bytes().all(|byte| byte.is_ascii_hexdigit()));
1227            validate_session_id(&id).expect("valid generated id");
1228            ids.insert(id);
1229        }
1230        assert_eq!(ids.len(), 512);
1231    }
1232
1233    #[test]
1234    fn invalid_session_ids_are_rejected_everywhere() {
1235        let root = temp_root("invalid-ids");
1236        let archive = SessionArchive::open(&root);
1237        let request = CreateSession::new(root.join("project"));
1238        for id in [
1239            "",
1240            "../escape",
1241            "sub/dir",
1242            "sub\\dir",
1243            ".hidden",
1244            "a b",
1245            "id\n",
1246        ] {
1247            assert!(
1248                super::validate_session_id(id).is_err(),
1249                "id {id:?} must be rejected"
1250            );
1251            assert!(archive.create_exact(id, request.clone()).is_err(), "{id:?}");
1252            assert!(archive.load(id).is_err(), "{id:?}");
1253            assert!(archive.buffered(id).is_err(), "{id:?}");
1254        }
1255        let too_long = "a".repeat(65);
1256        assert!(archive.create_exact(&too_long, request.clone()).is_err());
1257        assert_eq!(
1258            fs::read_dir(&root).expect("root").count(),
1259            0,
1260            "invalid ids must not create directories"
1261        );
1262        remove(root);
1263    }
1264
1265    #[test]
1266    fn create_and_load_round_trip_metadata() {
1267        let root = temp_root("create-load");
1268        let archive = SessionArchive::open(&root);
1269        let cwd = project(&root, "relay-fix");
1270        let entry = archive
1271            .create(
1272                CreateSession::new(&cwd)
1273                    .title("Fix the relay ring")
1274                    .roster(vec!["Codex".into(), "Agy".into()])
1275                    .metadata(metadata(&[("provider_session", json!("abc-123"))])),
1276            )
1277            .expect("create");
1278        assert_eq!(entry.title, "Fix the relay ring");
1279        assert_eq!(entry.roster, vec!["Codex".to_owned(), "Agy".to_owned()]);
1280        assert_eq!(entry.state, ArchiveState::Active);
1281        assert_eq!(entry.cwd, cwd.canonicalize().expect("canonical"));
1282        assert!(entry.created_at > 0);
1283        assert!(entry.updated_at >= entry.created_at);
1284        assert!(entry.created_time().is_some());
1285
1286        let loaded = archive.load(&entry.id).expect("load");
1287        assert_eq!(loaded.entry, entry);
1288        assert!(loaded.events.is_empty());
1289        assert!(loaded.warnings.is_empty());
1290        assert_eq!(
1291            loaded.metadata.get("provider_session"),
1292            Some(&json!("abc-123"))
1293        );
1294        remove(root);
1295    }
1296
1297    #[test]
1298    fn new_sessions_never_overwrite_archived_conversations() {
1299        let root = temp_root("no-overwrite");
1300        let archive = SessionArchive::open(&root);
1301        let request = CreateSession::new(root.join("project")).title("original");
1302        let entry = archive
1303            .create_exact("fixedid", request.clone())
1304            .expect("create");
1305        archive
1306            .append_human("fixedid", "precious history", false)
1307            .expect("append");
1308        let conflict = archive
1309            .create_exact("fixedid", request)
1310            .expect_err("exists");
1311        assert_eq!(
1312            conflict.to_string(),
1313            "persistence I/O error: archived session fixedid already exists"
1314        );
1315        let generated = archive
1316            .create(CreateSession::new(root.join("project")).title("other"))
1317            .expect("create");
1318        assert_ne!(generated.id, entry.id);
1319        let loaded = archive.load("fixedid").expect("load");
1320        assert_eq!(loaded.entry.title, "original");
1321        assert_eq!(
1322            loaded.events,
1323            vec![ArchiveEvent::human("precious history", false)]
1324        );
1325        remove(root);
1326    }
1327
1328    #[test]
1329    fn events_round_trip_in_order() {
1330        let root = temp_root("events");
1331        let archive = SessionArchive::open(&root);
1332        let entry = archive
1333            .create(CreateSession::new(root.join("p")))
1334            .expect("create");
1335        archive
1336            .append_human(&entry.id, "please fix the ring", false)
1337            .expect("append human");
1338        archive
1339            .append_agent(
1340                &entry.id,
1341                &AgentEvent::Text {
1342                    slot: 0,
1343                    text: "on it".into(),
1344                },
1345            )
1346            .expect("append agent");
1347        archive
1348            .append_human(&entry.id, "direct note", true)
1349            .expect("append direct");
1350        let loaded = archive.load(&entry.id).expect("load");
1351        assert_eq!(
1352            loaded.events,
1353            vec![
1354                ArchiveEvent::human("please fix the ring", false),
1355                ArchiveEvent::agent(AgentEvent::Text {
1356                    slot: 0,
1357                    text: "on it".into(),
1358                }),
1359                ArchiveEvent::human("direct note", true),
1360            ]
1361        );
1362        assert!(loaded.warnings.is_empty());
1363        assert!(loaded.entry.updated_at >= loaded.entry.created_at);
1364        remove(root);
1365    }
1366
1367    #[test]
1368    fn titles_are_bounded_safely() {
1369        let root = temp_root("titles");
1370        let archive = SessionArchive::open(&root);
1371        let long = "héllo wörld ".repeat(40);
1372        let entry = archive
1373            .create(CreateSession::new(root.join("p")).title(long.clone()))
1374            .expect("create");
1375        assert_eq!(entry.title.chars().count(), MAX_TITLE_CHARS);
1376        assert!(long.starts_with(&entry.title));
1377        assert!(
1378            entry
1379                .title
1380                .chars()
1381                .last()
1382                .is_some_and(char::is_alphanumeric)
1383        );
1384        remove(root);
1385    }
1386
1387    #[test]
1388    fn listing_matches_canonical_cwd() {
1389        let root = temp_root("canonical");
1390        let archive = SessionArchive::open(&root);
1391        let project_dir = project(&root, "widget");
1392        let first = archive
1393            .create(CreateSession::new(&project_dir).title("first"))
1394            .expect("create");
1395        let second = archive
1396            .create(CreateSession::new(root.join("other")).title("second"))
1397            .expect("create");
1398        let listing = archive.list(&project_dir).expect("list");
1399        assert_eq!(
1400            listing
1401                .entries
1402                .iter()
1403                .map(|entry| entry.id.clone())
1404                .collect::<Vec<_>>(),
1405            vec![first.id.clone()]
1406        );
1407        assert_eq!(archive.list(&root).expect("list").entries.len(), 0);
1408        #[cfg(unix)]
1409        {
1410            let alias = root.join("widget-alias");
1411            std::os::unix::fs::symlink(&project_dir, &alias).expect("symlink");
1412            let through_alias = archive.list(&alias).expect("list via symlink");
1413            assert_eq!(
1414                through_alias
1415                    .entries
1416                    .iter()
1417                    .map(|entry| entry.id.clone())
1418                    .collect::<Vec<_>>(),
1419                vec![first.id]
1420            );
1421        }
1422        let _ = second;
1423        remove(root);
1424    }
1425
1426    #[test]
1427    fn list_orders_by_recent_activity() {
1428        let root = temp_root("ordering");
1429        let archive = SessionArchive::open(&root);
1430        let first = archive
1431            .create(CreateSession::new(root.join("p")))
1432            .expect("create");
1433        let second = archive
1434            .create(CreateSession::new(root.join("p")))
1435            .expect("create");
1436        let third = archive
1437            .create(CreateSession::new(root.join("p")))
1438            .expect("create");
1439        archive
1440            .update_entry(&first.id, |entry| entry.updated_at = 100)
1441            .expect("edit");
1442        archive
1443            .update_entry(&second.id, |entry| entry.updated_at = 300)
1444            .expect("edit");
1445        archive
1446            .update_entry(&third.id, |entry| entry.updated_at = 200)
1447            .expect("edit");
1448        let listing = archive.list_all().expect("list");
1449        assert_eq!(
1450            listing
1451                .entries
1452                .iter()
1453                .map(|entry| entry.id.clone())
1454                .collect::<Vec<_>>(),
1455            vec![second.id, third.id, first.id]
1456        );
1457        remove(root);
1458    }
1459
1460    #[test]
1461    fn corrupt_metadata_does_not_hide_valid_sessions() {
1462        let root = temp_root("corrupt");
1463        let archive = SessionArchive::open(&root);
1464        let healthy = archive
1465            .create(CreateSession::new(root.join("p")).title("healthy"))
1466            .expect("create");
1467        let corrupt = archive
1468            .create_exact("corruptmeta", CreateSession::new(root.join("p")))
1469            .expect("create");
1470        fs::write(root.join("corruptmeta").join(META_FILE), "{not json").expect("write");
1471        let orphan_dir = root.join("orphan_events");
1472        fs::create_dir(&orphan_dir).expect("dir");
1473        fs::write(orphan_dir.join(EVENTS_FILE), "leftover\n").expect("write");
1474        fs::write(orphan_dir.join(META_FILE), "{\"cwd\":").expect("write");
1475
1476        let listing = archive.list_all().expect("list");
1477        assert_eq!(
1478            listing
1479                .entries
1480                .iter()
1481                .map(|entry| entry.id.clone())
1482                .collect::<Vec<_>>(),
1483            vec![healthy.id]
1484        );
1485        assert_eq!(
1486            listing
1487                .failures
1488                .iter()
1489                .map(|failure| failure.id.clone())
1490                .collect::<Vec<_>>(),
1491            vec!["corruptmeta".to_owned(), "orphan_events".to_owned()]
1492        );
1493        assert!(
1494            listing
1495                .failures
1496                .iter()
1497                .all(|failure| !failure.detail.is_empty())
1498        );
1499        assert!(archive.load("corruptmeta").is_err());
1500        assert_eq!(
1501            archive.list(&root.join("p")).expect("list").entries.len(),
1502            1,
1503            "corrupt metadata must not hide this project's healthy session"
1504        );
1505        let _ = corrupt;
1506        remove(root);
1507    }
1508
1509    #[test]
1510    fn archive_failures_are_ordered_independently_of_creation_order() {
1511        for ids in [["z-last", "a-first"], ["a-first", "z-last"]] {
1512            let root = temp_root("failure-order");
1513            let archive = SessionArchive::open(&root);
1514            for id in ids {
1515                fs::create_dir(root.join(id)).unwrap();
1516                fs::write(root.join(id).join(META_FILE), "broken").unwrap();
1517            }
1518            let listing = archive.list_all().unwrap();
1519            assert_eq!(
1520                listing
1521                    .failures
1522                    .iter()
1523                    .map(|failure| failure.id.as_str())
1524                    .collect::<Vec<_>>(),
1525                ["a-first", "z-last"]
1526            );
1527            remove(root);
1528        }
1529    }
1530
1531    #[test]
1532    fn session_without_metadata_files_is_ignored_when_empty() {
1533        let root = temp_root("torn-create");
1534        let archive = SessionArchive::open(&root);
1535        fs::create_dir(root.join("emptysession")).expect("dir");
1536        let listing: SessionListing = archive.list_all().expect("list");
1537        assert!(listing.entries.is_empty());
1538        assert!(listing.failures.is_empty());
1539        remove(root);
1540    }
1541
1542    #[test]
1543    fn torn_final_journal_line_is_tolerated_without_losing_events() {
1544        let root = temp_root("torn-journal");
1545        let archive = SessionArchive::open(&root);
1546        let entry = archive
1547            .create(CreateSession::new(root.join("p")))
1548            .expect("create");
1549        archive
1550            .append_human(&entry.id, "kept message", false)
1551            .expect("append");
1552        let journal = root.join(&entry.id).join(EVENTS_FILE);
1553        let intact = fs::read_to_string(&journal).expect("read journal");
1554        fs::write(&journal, format!("{intact}{{\"type\":\"hum")).expect("torn write");
1555        let loaded = archive.load(&entry.id).expect("load");
1556        assert_eq!(
1557            loaded.events,
1558            vec![ArchiveEvent::human("kept message", false)]
1559        );
1560        assert_eq!(loaded.warnings.len(), 1);
1561        assert!(loaded.warnings[0].contains("incomplete final journal line"));
1562        remove(root);
1563    }
1564
1565    #[test]
1566    fn malformed_middle_journal_lines_do_not_discard_valid_events() {
1567        let root = temp_root("middle-journal");
1568        let archive = SessionArchive::open(&root);
1569        let entry = archive
1570            .create(CreateSession::new(root.join("p")))
1571            .expect("create");
1572        let first = serde_json::to_string(&ArchiveEvent::human("first", false)).expect("json");
1573        let third = serde_json::to_string(&ArchiveEvent::agent(AgentEvent::Text {
1574            slot: 1,
1575            text: "third".into(),
1576        }))
1577        .expect("json");
1578        fs::write(
1579            root.join(&entry.id).join(EVENTS_FILE),
1580            format!("{first}\nnot-json-at-all\n{third}\n"),
1581        )
1582        .expect("write journal");
1583        let loaded = archive.load(&entry.id).expect("load");
1584        assert_eq!(
1585            loaded.events,
1586            vec![
1587                ArchiveEvent::human("first", false),
1588                ArchiveEvent::agent(AgentEvent::Text {
1589                    slot: 1,
1590                    text: "third".into(),
1591                }),
1592            ]
1593        );
1594        assert_eq!(loaded.warnings.len(), 1);
1595        assert!(loaded.warnings[0].contains("line 2"));
1596        remove(root);
1597    }
1598
1599    #[test]
1600    fn unknown_metadata_and_top_level_keys_are_preserved() {
1601        let root = temp_root("unknown-keys");
1602        let archive = SessionArchive::open(&root);
1603        let entry = archive
1604            .create(CreateSession::new(root.join("p")).metadata(metadata(&[
1605                ("agents", json!([{"identity": "openai.com"}])),
1606                ("mystery_provider_key", json!({"keep": true})),
1607            ])))
1608            .expect("create");
1609        let meta_path = root.join(&entry.id).join(META_FILE);
1610        let mut raw = fs::read_to_string(&meta_path).expect("read meta");
1611        raw.insert_str(raw.len() - 1, ",\"future_top_level\":{\"carried\":true}");
1612        fs::write(&meta_path, raw).expect("write meta");
1613        let raw = fs::read_to_string(&meta_path).expect("read meta");
1614        let future = raw.replace("\"schema_version\": 1", "\"schema_version\": 99");
1615        fs::write(&meta_path, &future).expect("write meta");
1616        assert!(archive.load(&entry.id).is_err(), "future schema is refused");
1617        fs::write(&meta_path, raw).expect("restore meta");
1618        archive
1619            .update_metadata(&entry.id, |data| {
1620                data.insert("added_later", json!("value"));
1621            })
1622            .expect("update");
1623        let loaded = archive.load(&entry.id).expect("load");
1624        assert_eq!(
1625            loaded.metadata.get("mystery_provider_key"),
1626            Some(&json!({"keep": true}))
1627        );
1628        assert_eq!(loaded.metadata.get("added_later"), Some(&json!("value")));
1629        assert_eq!(
1630            loaded.metadata.get("agents"),
1631            Some(&json!([{"identity": "openai.com"}]))
1632        );
1633        let on_disk = fs::read_to_string(&meta_path).expect("read meta");
1634        assert!(on_disk.contains("\"future_top_level\""));
1635        remove(root);
1636    }
1637
1638    #[test]
1639    fn entry_states_round_trip_and_unknown_states_fall_back() {
1640        let root = temp_root("states");
1641        let archive = SessionArchive::open(&root);
1642        let entry = archive
1643            .create(CreateSession::new(root.join("p")).state(ArchiveState::Completed))
1644            .expect("create");
1645        archive
1646            .set_state(&entry.id, ArchiveState::Cancelled)
1647            .expect("set");
1648        assert_eq!(
1649            archive.load(&entry.id).expect("load").entry.state,
1650            ArchiveState::Cancelled
1651        );
1652        let meta_path = root.join(&entry.id).join(META_FILE);
1653        let raw = fs::read_to_string(&meta_path).expect("read");
1654        let updated = raw.replace("\"state\": \"cancelled\"", "\"state\": \"fancy_future\"");
1655        fs::write(&meta_path, updated).expect("write");
1656        assert_eq!(
1657            archive.load(&entry.id).expect("load").entry.state,
1658            ArchiveState::Unknown
1659        );
1660        remove(root);
1661    }
1662
1663    #[test]
1664    fn missing_sessions_and_roots_are_clean_errors() {
1665        let root = temp_root("missing");
1666        let archive = SessionArchive::open(root.join("does-not-exist"));
1667        let listing = archive.list_all().expect("empty listing");
1668        assert!(listing.entries.is_empty());
1669        assert!(listing.failures.is_empty());
1670        let error = archive.load("absent").expect_err("missing session");
1671        assert!(matches!(
1672            error,
1673            PersistenceError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound
1674        ));
1675        let archive = SessionArchive::open(&root);
1676        let entry = archive
1677            .create(CreateSession::new(root.join("p")))
1678            .expect("create");
1679        let loaded = archive.load(&entry.id).expect("load without journal");
1680        assert!(loaded.events.is_empty());
1681        assert!(loaded.warnings.is_empty());
1682        let orphan = archive
1683            .append("nevercreated", &ArchiveEvent::human("orphan", false))
1684            .expect_err("append without session");
1685        assert!(matches!(
1686            orphan,
1687            PersistenceError::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound
1688        ));
1689        assert!(!root.join("nevercreated").exists(), "no orphan directory");
1690        remove(root);
1691    }
1692
1693    #[test]
1694    fn appending_after_a_torn_journal_retains_the_new_record() {
1695        let root = temp_root("torn-append");
1696        let cwd = project(&root, "project");
1697        let archive = SessionArchive::open(root.join("archive"));
1698        let entry = archive.create(CreateSession::new(cwd)).unwrap();
1699        let path = archive.events_path(&entry.id);
1700        fs::write(&path, b"{\"incomplete\":").unwrap();
1701        let writer = archive.buffered(&entry.id).unwrap();
1702        writer.append_human("new message", false).unwrap();
1703        writer.flush().unwrap();
1704        drop(writer);
1705        let loaded = archive.load(&entry.id).unwrap();
1706        assert_eq!(
1707            loaded.events,
1708            vec![ArchiveEvent::human("new message", false)]
1709        );
1710        assert_eq!(loaded.warnings.len(), 1);
1711        remove(root);
1712    }
1713
1714    #[test]
1715    fn metadata_edit_is_retained_when_its_first_write_fails() {
1716        let root = temp_root("edit-retry");
1717        let cwd = project(&root, "project");
1718        let archive = SessionArchive::open(root.join("archive"));
1719        let entry = archive.create(CreateSession::new(cwd)).unwrap();
1720        let path = archive.meta_path(&entry.id);
1721        let original = fs::read(&path).unwrap();
1722        let writer = archive.buffered(&entry.id).unwrap();
1723        let moved_path = path.clone();
1724        writer
1725            .update_metadata(move |metadata| {
1726                metadata.insert("retained", true);
1727                fs::remove_file(&moved_path).unwrap();
1728                fs::create_dir(&moved_path).unwrap();
1729            })
1730            .unwrap();
1731        assert!(writer.flush().is_err());
1732        fs::remove_dir(&path).unwrap();
1733        fs::write(&path, original).unwrap();
1734        writer.flush().unwrap();
1735        assert_eq!(
1736            archive.load(&entry.id).unwrap().metadata.get("retained"),
1737            Some(&json!(true))
1738        );
1739        drop(writer);
1740        remove(root);
1741    }
1742
1743    #[test]
1744    fn buffered_writer_is_durable_at_flush_and_drop() {
1745        let root = temp_root("buffered");
1746        let archive = SessionArchive::open(&root);
1747        let entry = archive
1748            .create(CreateSession::new(root.join("p")))
1749            .expect("create");
1750        let writer: BufferedSessionArchive = archive.buffered(&entry.id).expect("writer");
1751        assert_eq!(writer.id(), entry.id);
1752        writer.append_human("queued first", false).expect("queue");
1753        writer.flush().expect("flush");
1754        let loaded = archive.load(&entry.id).expect("load");
1755        assert_eq!(
1756            loaded.events,
1757            vec![ArchiveEvent::human("queued first", false)]
1758        );
1759        let before = loaded.entry.updated_at;
1760
1761        writer
1762            .append_agent(&AgentEvent::Text {
1763                slot: 0,
1764                text: "reply".into(),
1765            })
1766            .expect("queue");
1767        writer.append_human("queued second", true).expect("queue");
1768        drop(writer);
1769        let loaded = archive.load(&entry.id).expect("load after drop");
1770        assert_eq!(
1771            loaded.events,
1772            vec![
1773                ArchiveEvent::human("queued first", false),
1774                ArchiveEvent::agent(AgentEvent::Text {
1775                    slot: 0,
1776                    text: "reply".into(),
1777                }),
1778                ArchiveEvent::human("queued second", true),
1779            ]
1780        );
1781        assert!(loaded.entry.updated_at >= before);
1782        assert!(loaded.warnings.is_empty());
1783        remove(root);
1784    }
1785
1786    #[test]
1787    fn buffered_metadata_operations_apply_in_order() {
1788        let root = temp_root("buffered-meta");
1789        let archive = SessionArchive::open(&root);
1790        let entry = archive
1791            .create(
1792                CreateSession::new(root.join("p"))
1793                    .metadata(metadata(&[("provider_session", json!("before"))])),
1794            )
1795            .expect("create");
1796        let writer = archive.buffered(&entry.id).expect("writer");
1797        writer
1798            .replace_metadata(metadata(&[("provider_session", json!("replaced"))]))
1799            .expect("queue snapshot");
1800        writer
1801            .update_metadata(|data| {
1802                data.insert("roster", json!(["Codex", "Agy"]));
1803                data.insert("mystery", json!({"keep": true}));
1804            })
1805            .expect("queue edit");
1806        writer
1807            .update_entry(|entry| {
1808                entry.title = "renamed".into();
1809                entry.state = ArchiveState::Completed;
1810            })
1811            .expect("queue entry edit");
1812        writer.flush().expect("flush");
1813        let loaded = archive.load(&entry.id).expect("load");
1814        assert_eq!(loaded.entry.title, "renamed");
1815        assert_eq!(loaded.entry.state, ArchiveState::Completed);
1816        assert_eq!(
1817            loaded.metadata.get("provider_session"),
1818            Some(&json!("replaced"))
1819        );
1820        assert_eq!(
1821            loaded.metadata.get("roster"),
1822            Some(&json!(["Codex", "Agy"]))
1823        );
1824        assert_eq!(loaded.metadata.get("mystery"), Some(&json!({"keep": true})));
1825        remove(root);
1826    }
1827
1828    #[test]
1829    fn buffered_writer_recovers_from_transient_failures_and_reports() {
1830        let root = temp_root("buffered-recovery");
1831        let archive = SessionArchive::open(&root);
1832        let entry = archive
1833            .create(CreateSession::new(root.join("p")))
1834            .expect("create");
1835        let journal = root.join(&entry.id).join(EVENTS_FILE);
1836        let (sender, errors) = mpsc::channel();
1837        let writer = archive
1838            .buffered_with_errors(&entry.id, move |error| {
1839                let _ = sender.send(error);
1840            })
1841            .expect("writer");
1842        fs::write(&journal, b"").expect("create journal");
1843        fs::remove_file(&journal).expect("remove journal");
1844        fs::create_dir(&journal).expect("break journal path");
1845        writer.append_human("during failure", false).expect("queue");
1846        assert!(
1847            errors.recv_timeout(Duration::from_secs(2)).is_ok(),
1848            "background failure must be reported"
1849        );
1850        writer.flush().expect_err("flush reports the failure");
1851        fs::remove_dir(&journal).expect("repair journal path");
1852        writer.flush().expect("flush after repair");
1853        let loaded = archive.load(&entry.id).expect("load");
1854        assert_eq!(
1855            loaded.events,
1856            vec![ArchiveEvent::human("during failure", false)]
1857        );
1858        assert!(loaded.warnings.is_empty());
1859        drop(writer);
1860        remove(root);
1861    }
1862
1863    #[test]
1864    fn buffered_writer_for_missing_session_is_rejected() {
1865        let root = temp_root("buffered-missing");
1866        let archive = SessionArchive::open(&root);
1867        let error = archive.buffered("nosuchsession").expect_err("missing");
1868        assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
1869        remove(root);
1870    }
1871
1872    #[cfg(unix)]
1873    #[test]
1874    fn archive_files_and_directories_are_private() {
1875        use std::os::unix::fs::PermissionsExt;
1876        let root = temp_root("private");
1877        let archive = SessionArchive::open(&root);
1878        let entry = archive
1879            .create(CreateSession::new(root.join("p")))
1880            .expect("create");
1881        archive
1882            .append_human(&entry.id, "private", false)
1883            .expect("append");
1884        let dir_mode = fs::metadata(root.join(&entry.id))
1885            .expect("dir")
1886            .permissions()
1887            .mode()
1888            & 0o777;
1889        let meta_mode = fs::metadata(root.join(&entry.id).join(META_FILE))
1890            .expect("meta")
1891            .permissions()
1892            .mode()
1893            & 0o777;
1894        let journal_mode = fs::metadata(root.join(&entry.id).join(EVENTS_FILE))
1895            .expect("journal")
1896            .permissions()
1897            .mode()
1898            & 0o777;
1899        assert_eq!(dir_mode, 0o700);
1900        assert_eq!(meta_mode, 0o600);
1901        assert_eq!(journal_mode, 0o600);
1902        remove(root);
1903    }
1904
1905    #[test]
1906    fn sync_metadata_edits_preserve_unknown_keys() {
1907        let root = temp_root("sync-edit");
1908        let archive = SessionArchive::open(&root);
1909        let entry = archive
1910            .create(
1911                CreateSession::new(root.join("p"))
1912                    .metadata(metadata(&[("agents", json!([{"identity": "claude.ai"}]))])),
1913            )
1914            .expect("create");
1915        archive.set_title(&entry.id, "retitled").expect("title");
1916        archive
1917            .update_metadata(&entry.id, |data| {
1918                data.remove("agents");
1919                data.insert("provider_session", json!("sid-9"));
1920            })
1921            .expect("update");
1922        let loaded = archive.load(&entry.id).expect("load");
1923        assert_eq!(loaded.entry.title, "retitled");
1924        assert_eq!(loaded.metadata.get("agents"), None);
1925        assert_eq!(
1926            loaded.metadata.get("provider_session"),
1927            Some(&json!("sid-9"))
1928        );
1929        remove(root);
1930    }
1931}