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