1use 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
33pub const ARCHIVE_SCHEMA_VERSION: u32 = 1;
35pub const META_FILE: &str = "meta.json";
37pub const EVENTS_FILE: &str = "events.jsonl";
39pub 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
64pub 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
72pub 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
127pub 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#[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 #[serde(other)]
163 Unknown,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168pub struct ArchiveEntry {
169 pub id: String,
170 pub cwd: PathBuf,
172 pub title: String,
173 #[serde(default)]
174 pub preview: String,
175 pub created_at: i64,
177 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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct ArchivedSession {
226 pub entry: ArchiveEntry,
227 pub metadata: SessionMetadata,
229 pub events: Vec<ArchiveEvent>,
231 pub warnings: Vec<String>,
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct SessionFailure {
240 pub id: String,
241 pub detail: String,
242}
243
244#[derive(Clone, Debug, Default, Eq, PartialEq)]
246pub struct SessionListing {
247 pub entries: Vec<ArchiveEntry>,
249 pub failures: Vec<SessionFailure>,
251}
252
253#[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#[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
426fn 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
487fn 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#[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 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 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 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 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 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 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 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 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 pub fn touch(&self, id: &str) -> Result<(), PersistenceError> {
760 self.update_entry(id, |entry| entry.updated_at = now_unix_nanos())
761 }
762
763 pub fn buffered(&self, id: &str) -> std::io::Result<BufferedSessionArchive> {
767 self.buffered_with_errors(id, |_| {})
768 }
769
770 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
990pub 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 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 pub fn replace_metadata(&self, metadata: SessionMetadata) -> Result<(), PersistenceError> {
1060 self.send(ArchiveCommand::Enqueue(PendingOperation::ReplaceMetadata(
1061 metadata,
1062 )))
1063 }
1064
1065 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 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 pub fn touch(&self) -> Result<(), PersistenceError> {
1088 self.send(ArchiveCommand::Enqueue(PendingOperation::TouchEntry))
1089 }
1090
1091 pub fn checkpoint(&self) -> Result<(), PersistenceError> {
1093 self.send(ArchiveCommand::Checkpoint)
1094 }
1095
1096 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}