1use std::collections::HashSet;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use serde_json::Value;
26
27use crate::bindings::hook::murmur::conversation::read::{Message, MessagePage};
28use crate::errors::RuntimeError;
29
30pub(crate) const CONVERSATION_ROOT_DIR: &str = "conversations";
32
33pub(crate) const RECORD_FILE_NAME: &str = "conversation.jsonl";
35
36pub(crate) const RECORD_HEADER_TYPE: &str = "murmur.record";
38
39const RECORD_DIR_MODE: u32 = 0o700;
42
43pub(crate) const RECORD_FILE_MODE: u32 = 0o600;
45
46const CURSOR_PREFIX: &str = "mc_";
49
50const MAX_PAGE_LIMIT: u32 = 100;
53
54const SEGMENT_RULE: &str = "must be a single path segment: no '/', no '.' or '..', not absolute, \
59 and not starting with a dot";
60
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
66pub struct TruncationMarker {
67 pub dropped: u64,
68 pub oldest_surviving_id: String,
70 pub last_dropped_id: String,
74 pub at_ms: u64,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89pub struct RecordHeader {
90 #[serde(rename = "type")]
92 pub kind: String,
93 pub capsule: String,
95 pub created_ms: u64,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub truncated: Option<TruncationMarker>,
101}
102
103pub(crate) fn header_line(header: &RecordHeader) -> Result<String, String> {
105 let mut line = serde_json::to_string(header)
106 .map_err(|err| format!("failed to encode the record header: {err}"))?;
107 line.push('\n');
108 Ok(line)
109}
110
111pub(crate) fn read_header(path: &Path) -> Option<RecordHeader> {
114 let raw = std::fs::read_to_string(path).ok()?;
115 parse_header(raw.lines().next()?)
116}
117
118pub(crate) fn parse_header(line: &str) -> Option<RecordHeader> {
120 let header: RecordHeader = serde_json::from_str(line).ok()?;
121 (header.kind == RECORD_HEADER_TYPE).then_some(header)
122}
123
124pub(crate) fn message_id_timestamp_ms(id: &str) -> Option<u64> {
129 let hex = id.strip_prefix("msg_")?;
130 if hex.len() < 12 {
131 return None;
132 }
133 u64::from_str_radix(&hex[..12], 16).ok()
134}
135
136pub(crate) fn validate_record_segment(field: &str, value: &str) -> Result<(), RuntimeError> {
141 crate::state_store::validate_store_name(value).map_err(|_| {
142 RuntimeError::InvalidConversationRecord {
143 field: field.to_string(),
144 value: value.to_string(),
145 message: SEGMENT_RULE.to_string(),
146 }
147 })
148}
149
150pub(crate) fn resolve_record_name(
154 context: Option<&murmur_artifact::ContextConfig>,
155 capsule_name: &str,
156) -> Option<String> {
157 match context {
158 Some(context) if !context.record => None,
159 Some(context) => Some(
160 context
161 .record_store
162 .clone()
163 .unwrap_or_else(|| capsule_name.to_string()),
164 ),
165 None => Some(capsule_name.to_string()),
166 }
167}
168
169pub(crate) fn record_root(record: &str) -> Result<PathBuf, String> {
172 validate_record_segment("context.record_store", record).map_err(|err| err.to_string())?;
173 Ok(crate::state_store::murmur_home_dir()?
174 .join(CONVERSATION_ROOT_DIR)
175 .join(record))
176}
177
178pub(crate) fn record_file(root: &Path, context_id: &str) -> PathBuf {
184 root.join(context_id).join(RECORD_FILE_NAME)
185}
186
187pub(crate) struct ConversationRecord {
193 dir: PathBuf,
195 workdir: PathBuf,
197 capsule: Option<String>,
200 header_ensured: bool,
203 recorded: HashSet<String>,
207 failure_reported: bool,
210 malformed_reported: bool,
212}
213
214impl ConversationRecord {
215 pub(crate) fn open(
222 root: &Path,
223 context_id: &str,
224 workdir: &Path,
225 capsule: Option<&str>,
226 ) -> Option<Self> {
227 if crate::state_store::validate_store_name(context_id).is_err() {
228 report(
229 workdir,
230 &format!(
231 "[conversation] context id '{context_id}' {SEGMENT_RULE}; this conversation \
232 is not recorded"
233 ),
234 );
235 return None;
236 }
237 Some(Self {
238 dir: root.join(context_id),
239 workdir: workdir.to_path_buf(),
240 capsule: capsule.map(str::to_string),
241 header_ensured: false,
242 recorded: HashSet::new(),
243 failure_reported: false,
244 malformed_reported: false,
245 })
246 }
247
248 pub(crate) fn path(&self) -> PathBuf {
249 self.dir.join(RECORD_FILE_NAME)
250 }
251
252 pub(crate) fn load(&mut self) -> Vec<Value> {
257 let path = self.path();
258 let messages = match read_record(&path, &self.workdir, &mut self.malformed_reported) {
259 Ok(messages) => messages,
260 Err(reason) => {
261 report(
262 &self.workdir,
263 &format!(
264 "[conversation] {} could not be read ({reason}); this task starts with no \
265 prior messages",
266 path.display()
267 ),
268 );
269 Vec::new()
270 }
271 };
272 for message in &messages {
273 if let Some(id) = crate::agent::message_id(message) {
274 self.recorded.insert(id.to_string());
275 }
276 }
277 messages
278 }
279
280 pub(crate) fn append(&mut self, messages: &[Value]) {
291 for message in messages {
292 let id = crate::agent::message_id(message).map(str::to_string);
293 if let Some(id) = id.as_deref() {
294 if self.recorded.contains(id) {
295 continue;
296 }
297 }
298 if let Err(reason) = self.append_line(message) {
299 if !self.failure_reported {
300 self.failure_reported = true;
301 report(
302 &self.workdir,
303 &format!(
304 "[conversation] {} could not be written ({reason}); this task \
305 continues unrecorded",
306 self.path().display()
307 ),
308 );
309 }
310 return;
311 }
312 if let Some(id) = id {
313 self.recorded.insert(id);
314 }
315 }
316 }
317
318 fn append_line(&mut self, message: &Value) -> Result<(), String> {
323 let mut line = serde_json::to_string(message)
324 .map_err(|err| format!("failed to encode the message: {err}"))?;
325 line.push('\n');
326
327 self.ensure_dirs()?;
328 self.ensure_header()?;
329
330 let mut file = open_record_file(&self.path(), false)?;
331 file.write_all(line.as_bytes())
332 .map_err(|err| err.to_string())
333 }
334
335 fn ensure_dirs(&self) -> Result<(), String> {
341 let mut chain = Vec::new();
342 let mut dir = Some(self.dir.as_path());
343 for _ in 0..3 {
344 if let Some(current) = dir {
345 chain.push(current);
346 dir = current.parent();
347 }
348 }
349 if let (Some(owner), Ok(home)) = (dir, crate::state_store::murmur_home_dir()) {
350 if owner == home {
351 crate::murmur_home::ensure_murmur_home()?;
352 }
353 }
354 for path in chain.into_iter().rev() {
355 crate::state_store::ensure_private_dir(path, RECORD_DIR_MODE)
356 .map_err(|reason| format!("{}: {reason}", path.display()))?;
357 }
358 Ok(())
359 }
360
361 fn ensure_header(&mut self) -> Result<(), String> {
379 if self.header_ensured {
380 return Ok(());
381 }
382 let Some(capsule) = self.capsule.clone() else {
383 self.header_ensured = true;
384 return Ok(());
385 };
386 let path = self.path();
387 let existing = match std::fs::read_to_string(&path) {
388 Ok(raw) => raw,
389 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
390 Err(err) => return Err(format!("{}: {err}", path.display())),
391 };
392 if existing.lines().next().and_then(parse_header).is_some() {
393 self.header_ensured = true;
394 return Ok(());
395 }
396
397 let header = RecordHeader {
398 kind: RECORD_HEADER_TYPE.to_string(),
399 capsule,
400 created_ms: crate::retention::now_ms(),
401 truncated: None,
402 };
403 let line = header_line(&header)?;
404 if existing.is_empty() {
408 open_record_file(&path, true)?
409 .write_all(line.as_bytes())
410 .map_err(|err| format!("{}: {err}", path.display()))?;
411 } else {
412 let mut contents = line;
413 contents.push_str(&existing);
414 crate::retention::StagedRewrite::stage_with_mode(
415 &path,
416 contents.as_bytes(),
417 Some(RECORD_FILE_MODE),
418 )?
419 .commit()?;
420 }
421 self.header_ensured = true;
422 Ok(())
423 }
424}
425
426fn open_record_file(path: &Path, truncate: bool) -> Result<std::fs::File, String> {
429 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
430
431 let mut options = std::fs::OpenOptions::new();
432 options.create(true).mode(RECORD_FILE_MODE);
433 if truncate {
434 options.write(true).truncate(true);
435 } else {
436 options.append(true);
437 }
438 let file = options
439 .open(path)
440 .map_err(|err| format!("{}: {err}", path.display()))?;
441 file.set_permissions(std::fs::Permissions::from_mode(RECORD_FILE_MODE))
442 .map_err(|err| format!("{}: {err}", path.display()))?;
443 Ok(file)
444}
445
446#[derive(Default)]
453pub(crate) struct RecordCache {
454 path: Option<PathBuf>,
456 len: u64,
458 messages: Vec<Value>,
460 malformed_reported: bool,
462 #[cfg(test)]
464 reads: u32,
465}
466
467impl RecordCache {
468 fn messages(&mut self, path: &Path, workdir: &Path) -> Result<&[Value], String> {
474 let len = match std::fs::metadata(path) {
475 Ok(metadata) => metadata.len(),
476 Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
477 Err(err) => return Err(err.to_string()),
478 };
479 if self.path.as_deref() == Some(path) && self.len == len {
480 return Ok(&self.messages);
481 }
482
483 self.path = None;
487 #[cfg(test)]
488 {
489 self.reads += 1;
490 }
491 self.messages = read_record(path, workdir, &mut self.malformed_reported)?;
492 self.len = len;
493 self.path = Some(path.to_path_buf());
494 Ok(&self.messages)
495 }
496
497 #[cfg(test)]
498 pub(crate) fn reads(&self) -> u32 {
499 self.reads
500 }
501}
502
503pub(crate) fn page(
511 cache: &mut RecordCache,
512 path: Option<&Path>,
513 workdir: &Path,
514 cursor: Option<&str>,
515 limit: u32,
516) -> Result<MessagePage, String> {
517 let Some(path) = path else {
518 return Ok(empty_page());
519 };
520 let messages = cache
521 .messages(path, workdir)
522 .map_err(|reason| format!("unavailable: {reason}"))?;
523
524 let end = match cursor {
527 None => messages.len(),
528 Some(cursor) => decode_cursor(cursor)
529 .filter(|end| *end <= messages.len())
530 .ok_or_else(|| "invalid-cursor".to_string())?,
531 };
532 let limit = limit.clamp(1, MAX_PAGE_LIMIT) as usize;
533 let start = end.saturating_sub(limit);
534
535 let mut window: Vec<Value> = messages[start..end].to_vec();
536 window.reverse();
537 Ok(MessagePage {
542 messages: crate::agent::to_wit_messages(&window)
543 .into_iter()
544 .map(|message| Message {
545 role: message.role,
546 content: message.content,
547 id: message.id,
548 source_id: message.source_id,
549 })
550 .collect(),
551 next_cursor: (start > 0).then(|| encode_cursor(start)),
552 total: u32::try_from(messages.len()).unwrap_or(u32::MAX),
553 })
554}
555
556fn empty_page() -> MessagePage {
558 MessagePage {
559 messages: Vec::new(),
560 next_cursor: None,
561 total: 0,
562 }
563}
564
565fn read_record(path: &Path, workdir: &Path, reported: &mut bool) -> Result<Vec<Value>, String> {
577 let raw = match std::fs::read_to_string(path) {
578 Ok(raw) => raw,
579 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
580 Err(err) => return Err(err.to_string()),
581 };
582
583 let mut messages = Vec::new();
584 for (index, line) in raw.lines().enumerate() {
585 if line.trim().is_empty() {
586 continue;
587 }
588 match parse_message_line(line) {
589 Some(message) => messages.push(message),
590 None => {
591 if !*reported {
592 *reported = true;
593 report(
594 workdir,
595 &format!(
596 "[conversation] {}: line {} is not a message and was skipped",
597 path.display(),
598 index + 1
599 ),
600 );
601 }
602 }
603 }
604 }
605 Ok(messages)
606}
607
608pub(crate) fn parse_message_line(line: &str) -> Option<Value> {
613 let value: Value = serde_json::from_str(line).ok()?;
614 value
615 .get("role")
616 .and_then(Value::as_str)
617 .is_some()
618 .then_some(value)
619}
620
621fn encode_cursor(position: usize) -> String {
622 format!("{CURSOR_PREFIX}{position}")
623}
624
625fn decode_cursor(cursor: &str) -> Option<usize> {
626 cursor.strip_prefix(CURSOR_PREFIX)?.parse().ok()
627}
628
629fn report(workdir: &Path, message: &str) {
631 eprintln!("[capsule-runtime] {message}");
632 crate::agent::append_bootstrap_log(workdir, message);
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use serde_json::json;
639
640 const CAPSULE: &str = "capsule";
642
643 fn message(id: &str, role: &str, text: &str) -> Value {
644 json!({"role": role, "content": [{"type": "text", "text": text}], "id": id})
645 }
646
647 fn id_at(id: usize) -> String {
648 format!("msg_{:032x}", id)
649 }
650
651 #[test]
652 fn record_file_is_held_owner_only_on_append() {
653 let home = tempfile::tempdir().unwrap();
654 let workdir = tempfile::tempdir().unwrap();
655 let root = home.path().join("conversations/shared");
656 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
657 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
658 crate::murmur_home::wide_file(&path, "", 0o644);
659
660 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
661 record.append(&[message(&id_at(1), "user", "hello")]);
662
663 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
664 assert_eq!(crate::murmur_home::mode_of(&root.join("ctx_1")), 0o700);
665 assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 1);
666 }
667
668 #[test]
669 fn record_file_is_created_owner_only_with_its_header() {
670 let home = tempfile::tempdir().unwrap();
671 let workdir = tempfile::tempdir().unwrap();
672 let root = home.path().join("conversations/shared");
673
674 let mut record =
675 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
676 record.append(&[message(&id_at(1), "user", "hello")]);
677
678 assert_eq!(crate::murmur_home::mode_of(&record.path()), 0o600);
679 }
680
681 #[test]
683 fn record_rewrite_keeps_the_file_owner_only() {
684 let home = tempfile::tempdir().unwrap();
685 let workdir = tempfile::tempdir().unwrap();
686 let root = home.path().join("conversations/shared");
687 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
688 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
689 let old = serde_json::to_string(&message(&id_at(1), "user", "old")).unwrap();
690 crate::murmur_home::wide_file(&path, &format!("{old}\n"), 0o644);
691
692 let mut record =
693 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
694 record.append(&[message(&id_at(2), "user", "new")]);
695
696 assert!(read_header(&path).is_some(), "the record was adopted");
697 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
698 }
699
700 #[test]
701 fn record_root_under_a_wide_murmur_home_holds_the_home_owner_only() {
702 let home = tempfile::tempdir().unwrap();
703 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
704 crate::murmur_home::run_with_home(
705 "conversation::tests::inner_append_under_scratch_home",
706 home.path(),
707 );
708 assert_eq!(
709 crate::murmur_home::mode_of(&home.path().join(".murmur")),
710 0o700
711 );
712 let record = home
713 .path()
714 .join(".murmur/conversations/shared/ctx_1")
715 .join(RECORD_FILE_NAME);
716 assert_eq!(crate::murmur_home::mode_of(&record), 0o600);
717 }
718
719 #[test]
720 #[ignore = "run by record_root_under_a_wide_murmur_home_holds_the_home_owner_only"]
721 fn inner_append_under_scratch_home() {
722 if !crate::murmur_home::in_scratch_home() {
723 return;
724 }
725 let workdir = tempfile::tempdir().unwrap();
726 let root = record_root("shared").unwrap();
727 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
728 record.append(&[message(&id_at(1), "user", "hello")]);
729 assert!(record.path().exists());
730 }
731
732 #[test]
735 fn the_first_append_writes_a_header_naming_the_capsule() {
736 let home = tempfile::tempdir().unwrap();
737 let workdir = tempfile::tempdir().unwrap();
738 let root = home.path().join("conversations/shared");
739
740 let mut record =
741 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
742 record.append(&[message(&id_at(1), "user", "hello")]);
743
744 let raw = std::fs::read_to_string(record.path()).unwrap();
745 let lines: Vec<&str> = raw.lines().collect();
746 assert_eq!(lines.len(), 2, "a header and one message: {lines:?}");
747 let header = parse_header(lines[0]).expect("the first line is a header");
748 assert_eq!(header.kind, RECORD_HEADER_TYPE);
749 assert_eq!(header.capsule, CAPSULE);
750 assert!(header.truncated.is_none(), "nothing has been dropped yet");
751 assert!(header.created_ms > 0);
752 }
753
754 #[test]
757 fn a_header_line_changes_neither_the_loaded_messages_nor_total() {
758 let home = tempfile::tempdir().unwrap();
759 let workdir = tempfile::tempdir().unwrap();
760 let root = home.path().join("conversations/c");
761
762 let mut record =
763 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
764 record.append(&[
765 message(&id_at(1), "user", "one"),
766 message(&id_at(2), "assistant", "two"),
767 ]);
768 let path = record.path();
769
770 let mut reader =
771 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
772 let loaded = reader.load();
773 assert_eq!(loaded.len(), 2, "the header is not a message: {loaded:?}");
774
775 let mut cache = RecordCache::default();
776 let page = page(&mut cache, Some(&path), workdir.path(), None, 100).unwrap();
777 assert_eq!(page.total, 2);
778 assert_eq!(page.messages.len(), 2);
779 }
780
781 #[test]
785 fn a_headerless_record_is_adopted_on_the_next_append() {
786 let home = tempfile::tempdir().unwrap();
787 let workdir = tempfile::tempdir().unwrap();
788 let root = home.path().join("conversations/c");
789 let dir = root.join("ctx_1");
790 std::fs::create_dir_all(&dir).unwrap();
791 let path = dir.join(RECORD_FILE_NAME);
792 let headerless = format!(
793 "{}
794{}
795",
796 serde_json::to_string(&message(&id_at(1), "user", "one")).unwrap(),
797 serde_json::to_string(&message(&id_at(2), "assistant", "two")).unwrap()
798 );
799 std::fs::write(&path, &headerless).unwrap();
800 assert!(read_header(&path).is_none(), "unowned before the append");
801
802 let mut record =
803 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
804 record.append(&[message(&id_at(3), "user", "three")]);
805
806 let raw = std::fs::read_to_string(&path).unwrap();
807 assert_eq!(read_header(&path).unwrap().capsule, CAPSULE);
808 assert!(
809 raw.contains(headerless.trim_end()),
810 "every line it already held survives byte for byte: {raw}"
811 );
812 assert_eq!(raw.lines().count(), 4, "a header and three messages");
813 }
814
815 #[test]
818 fn an_owned_record_keeps_the_header_it_already_has() {
819 let home = tempfile::tempdir().unwrap();
820 let workdir = tempfile::tempdir().unwrap();
821 let root = home.path().join("conversations/c");
822 let dir = root.join("ctx_1");
823 std::fs::create_dir_all(&dir).unwrap();
824 let path = dir.join(RECORD_FILE_NAME);
825 let header = RecordHeader {
826 kind: RECORD_HEADER_TYPE.to_string(),
827 capsule: "someone-else".to_string(),
828 created_ms: 1_756_400_000_000,
829 truncated: Some(TruncationMarker {
830 dropped: 5,
831 oldest_surviving_id: id_at(6),
832 last_dropped_id: id_at(5),
833 at_ms: 1_756_400_000_001,
834 }),
835 };
836 std::fs::write(&path, header_line(&header).unwrap()).unwrap();
837
838 let mut record =
839 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
840 record.append(&[message(&id_at(7), "user", "seven")]);
841
842 assert_eq!(read_header(&path).unwrap(), header);
843 }
844
845 #[test]
848 fn a_reloaded_message_keeps_its_id_and_is_not_written_twice() {
849 let home = tempfile::tempdir().unwrap();
850 let workdir = tempfile::tempdir().unwrap();
851 let root = home.path().join("conversations/capsule");
852
853 let mut first =
854 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
855 first.append(&[message(&id_at(1), "user", "hello")]);
856
857 let mut second =
858 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
859 let loaded = second.load();
860 assert_eq!(loaded.len(), 1);
861 assert_eq!(
862 crate::agent::message_id(&loaded[0]),
863 Some(id_at(1).as_str())
864 );
865
866 second.append(&loaded);
867 second.append(&[message(&id_at(2), "assistant", "hi")]);
868 assert_eq!(
869 second.load().len(),
870 2,
871 "a reloaded message is never re-appended"
872 );
873 }
874
875 #[test]
878 fn the_directory_chain_is_created_at_0700_on_first_write() {
879 use std::os::unix::fs::PermissionsExt;
880
881 let home = tempfile::tempdir().unwrap();
882 let workdir = tempfile::tempdir().unwrap();
883 let conversations = home.path().join("conversations");
884 let root = conversations.join("capsule");
885
886 let mut record =
887 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
888 assert!(!conversations.exists(), "opening a record creates nothing");
889
890 record.append(&[message(&id_at(1), "user", "hello")]);
891 for dir in [&conversations, &root, &root.join("ctx_1")] {
892 let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
893 assert_eq!(
894 mode,
895 0o700,
896 "{} must be 0700, got {mode:04o}",
897 dir.display()
898 );
899 }
900 }
901
902 #[test]
905 fn a_context_id_that_is_not_a_segment_records_nothing() {
906 let home = tempfile::tempdir().unwrap();
907 let workdir = tempfile::tempdir().unwrap();
908 for context_id in ["../escape", "a/b", "", ".hidden"] {
909 assert!(
910 ConversationRecord::open(
911 &home.path().join("conversations/c"),
912 context_id,
913 workdir.path(),
914 Some(CAPSULE)
915 )
916 .is_none(),
917 "'{context_id}' must not open a record"
918 );
919 }
920 assert!(!home.path().join("conversations").exists());
921 }
922
923 #[test]
925 fn paging_walks_the_record_newest_first_without_repeating_a_message() {
926 let home = tempfile::tempdir().unwrap();
927 let workdir = tempfile::tempdir().unwrap();
928 let root = home.path().join("conversations/capsule");
929 let mut record =
930 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
931 let written: Vec<Value> = (1..=5)
932 .map(|n| message(&id_at(n), "user", &format!("m{n}")))
933 .collect();
934 record.append(&written);
935 let path = record.path();
936
937 let mut seen = Vec::new();
938 let mut cursor: Option<String> = None;
939 loop {
940 let page = page(
941 &mut RecordCache::default(),
942 Some(&path),
943 workdir.path(),
944 cursor.as_deref(),
945 2,
946 )
947 .unwrap();
948 assert_eq!(page.total, 5);
949 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
950 match page.next_cursor {
951 Some(next) => cursor = Some(next),
952 None => break,
953 }
954 }
955 assert_eq!(
956 seen,
957 (1..=5).rev().map(id_at).collect::<Vec<_>>(),
958 "the whole record, newest first, exactly once each"
959 );
960 }
961
962 #[test]
965 fn limit_is_clamped_at_both_ends() {
966 let home = tempfile::tempdir().unwrap();
967 let workdir = tempfile::tempdir().unwrap();
968 let root = home.path().join("conversations/capsule");
969 let mut record =
970 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
971 record.append(
972 &(1..=120)
973 .map(|n| message(&id_at(n), "user", "m"))
974 .collect::<Vec<_>>(),
975 );
976 let path = record.path();
977
978 assert_eq!(
979 page(
980 &mut RecordCache::default(),
981 Some(&path),
982 workdir.path(),
983 None,
984 0
985 )
986 .unwrap()
987 .messages
988 .len(),
989 1
990 );
991 assert_eq!(
992 page(
993 &mut RecordCache::default(),
994 Some(&path),
995 workdir.path(),
996 None,
997 u32::MAX
998 )
999 .unwrap()
1000 .messages
1001 .len(),
1002 MAX_PAGE_LIMIT as usize
1003 );
1004 }
1005
1006 #[test]
1009 fn a_foreign_cursor_is_refused() {
1010 let home = tempfile::tempdir().unwrap();
1011 let workdir = tempfile::tempdir().unwrap();
1012 let root = home.path().join("conversations/capsule");
1013 let mut record =
1014 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1015 record.append(&[message(&id_at(1), "user", "m")]);
1016 let path = record.path();
1017
1018 for cursor in ["7", "mc_", "mc_nine", "mc_9", "cur_1"] {
1019 assert_eq!(
1020 page(
1021 &mut RecordCache::default(),
1022 Some(&path),
1023 workdir.path(),
1024 Some(cursor),
1025 10
1026 )
1027 .err(),
1028 Some("invalid-cursor".to_string()),
1029 "cursor '{cursor}'"
1030 );
1031 }
1032 }
1033
1034 #[test]
1037 fn an_absent_record_is_an_empty_page() {
1038 let home = tempfile::tempdir().unwrap();
1039 let workdir = tempfile::tempdir().unwrap();
1040 let missing = home
1041 .path()
1042 .join("conversations/capsule/ctx_1/conversation.jsonl");
1043
1044 for path in [None, Some(missing.as_path())] {
1045 let page = page(&mut RecordCache::default(), path, workdir.path(), None, 10).unwrap();
1046 assert!(page.messages.is_empty());
1047 assert_eq!(page.next_cursor, None);
1048 assert_eq!(page.total, 0);
1049 }
1050 }
1051
1052 #[test]
1055 fn a_read_with_no_record_leaves_the_cache_alone() {
1056 let home = tempfile::tempdir().unwrap();
1057 let workdir = tempfile::tempdir().unwrap();
1058 let root = home.path().join("conversations/capsule");
1059 let mut record =
1060 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1061 record.append(&[message(&id_at(1), "user", "a")]);
1062 let path = record.path();
1063
1064 let mut cache = RecordCache::default();
1065 assert_eq!(
1066 page(&mut cache, Some(&path), workdir.path(), None, 10)
1067 .unwrap()
1068 .total,
1069 1
1070 );
1071 assert!(page(&mut cache, None, workdir.path(), None, 10)
1072 .unwrap()
1073 .messages
1074 .is_empty());
1075 assert_eq!(
1076 page(&mut cache, Some(&path), workdir.path(), None, 10)
1077 .unwrap()
1078 .total,
1079 1
1080 );
1081 assert_eq!(cache.reads(), 1, "the parse survived the empty read");
1082 }
1083
1084 #[test]
1087 fn a_malformed_line_is_skipped_and_reported() {
1088 let home = tempfile::tempdir().unwrap();
1089 let workdir = tempfile::tempdir().unwrap();
1090 let dir = home.path().join("conversations/capsule/ctx_1");
1091 std::fs::create_dir_all(&dir).unwrap();
1092 let path = dir.join(RECORD_FILE_NAME);
1093 let mut raw = String::new();
1094 for n in 1..=3 {
1095 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1096 raw.push('\n');
1097 }
1098 raw.push_str("{\"role\":\"user\"");
1099 std::fs::write(&path, raw).unwrap();
1100
1101 let page = page(
1102 &mut RecordCache::default(),
1103 Some(&path),
1104 workdir.path(),
1105 None,
1106 10,
1107 )
1108 .unwrap();
1109 assert_eq!(page.total, 3);
1110 assert_eq!(page.messages.len(), 3);
1111
1112 let log =
1113 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1114 assert_eq!(
1115 log.lines()
1116 .filter(|line| line.contains("line 4 is not a message"))
1117 .count(),
1118 1,
1119 "reported once, naming the line: {log}"
1120 );
1121 }
1122
1123 #[test]
1129 fn a_failed_write_is_retried_and_leaves_no_hole() {
1130 let home = tempfile::tempdir().unwrap();
1131 let workdir = tempfile::tempdir().unwrap();
1132 let root = home.path().join("conversations/capsule");
1133 let blocker = root.join("ctx_1");
1134 std::fs::create_dir_all(&root).unwrap();
1135 std::fs::write(&blocker, "").unwrap();
1136
1137 let mut record =
1138 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1139 let first = message(&id_at(1), "user", "a");
1140 let second = message(&id_at(2), "assistant", "b");
1141 record.append(std::slice::from_ref(&first));
1142 assert!(
1143 blocker.is_file(),
1144 "the directory chain could not be created"
1145 );
1146
1147 std::fs::remove_file(&blocker).unwrap();
1148 record.append(&[first, second]);
1149
1150 let written: Vec<Value> = std::fs::read_to_string(record.path())
1151 .unwrap()
1152 .lines()
1153 .filter_map(parse_message_line)
1154 .collect();
1155 assert_eq!(
1156 written
1157 .iter()
1158 .map(|m| crate::agent::message_id(m).unwrap())
1159 .collect::<Vec<_>>(),
1160 vec![id_at(1), id_at(2)],
1161 "the message the failure cost is on disk, before the one that followed it"
1162 );
1163 }
1164
1165 #[test]
1167 fn a_paging_loop_reads_the_record_once() {
1168 let home = tempfile::tempdir().unwrap();
1169 let workdir = tempfile::tempdir().unwrap();
1170 let root = home.path().join("conversations/capsule");
1171 let mut record =
1172 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1173 record.append(
1174 &(1..=250)
1175 .map(|n| message(&id_at(n), "user", "m"))
1176 .collect::<Vec<_>>(),
1177 );
1178 let path = record.path();
1179 let mut cache = RecordCache::default();
1180
1181 let mut pages = 0;
1182 let mut seen = Vec::new();
1183 let mut cursor: Option<String> = None;
1184 loop {
1185 let page = page(
1186 &mut cache,
1187 Some(&path),
1188 workdir.path(),
1189 cursor.as_deref(),
1190 25,
1191 )
1192 .unwrap();
1193 pages += 1;
1194 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1195 match page.next_cursor {
1196 Some(next) => cursor = Some(next),
1197 None => break,
1198 }
1199 }
1200 assert_eq!(pages, 10);
1201 assert_eq!(seen, (1..=250).rev().map(id_at).collect::<Vec<_>>());
1202 assert_eq!(cache.reads(), 1, "one read for the whole walk");
1203
1204 record.append(
1207 &(251..=260)
1208 .map(|n| message(&id_at(n), "user", "m"))
1209 .collect::<Vec<_>>(),
1210 );
1211 let mut seen = Vec::new();
1212 let mut cursor: Option<String> = None;
1213 let mut first_total = None;
1214 loop {
1215 let page = page(
1216 &mut cache,
1217 Some(&path),
1218 workdir.path(),
1219 cursor.as_deref(),
1220 25,
1221 )
1222 .unwrap();
1223 first_total.get_or_insert(page.total);
1224 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1225 match page.next_cursor {
1226 Some(next) => cursor = Some(next),
1227 None => break,
1228 }
1229 }
1230 assert_eq!(first_total, Some(260));
1231 assert_eq!(seen, (1..=260).rev().map(id_at).collect::<Vec<_>>());
1232 assert_eq!(cache.reads(), 2, "one further read for the second walk");
1233 }
1234
1235 #[test]
1237 fn a_malformed_line_is_reported_once_for_a_whole_walk() {
1238 let home = tempfile::tempdir().unwrap();
1239 let workdir = tempfile::tempdir().unwrap();
1240 let dir = home.path().join("conversations/capsule/ctx_1");
1241 std::fs::create_dir_all(&dir).unwrap();
1242 let path = dir.join(RECORD_FILE_NAME);
1243 let mut raw = String::new();
1244 for n in 1..=10 {
1245 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1246 raw.push('\n');
1247 }
1248 raw.push_str("{\"role\":\"user\"");
1249 std::fs::write(&path, raw).unwrap();
1250
1251 let mut cache = RecordCache::default();
1252 let mut cursor: Option<String> = None;
1253 let mut pages = 0;
1254 loop {
1255 let page = page(
1256 &mut cache,
1257 Some(&path),
1258 workdir.path(),
1259 cursor.as_deref(),
1260 2,
1261 )
1262 .unwrap();
1263 pages += 1;
1264 assert_eq!(page.total, 10);
1265 match page.next_cursor {
1266 Some(next) => cursor = Some(next),
1267 None => break,
1268 }
1269 }
1270 assert_eq!(pages, 5);
1271
1272 let log =
1273 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1274 assert_eq!(
1275 log.lines()
1276 .filter(|line| line.contains("line 11 is not a message"))
1277 .count(),
1278 1,
1279 "one entry for the whole walk: {log}"
1280 );
1281 }
1282
1283 #[test]
1286 fn record_off_wins_over_a_declared_store() {
1287 let off = murmur_artifact::ContextConfig {
1288 max_tokens: None,
1289 record: false,
1290 record_store: Some("shey".to_string()),
1291 seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
1292 seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
1293 retain: None,
1294 };
1295 assert_eq!(resolve_record_name(Some(&off), "capsule"), None);
1296
1297 let on = murmur_artifact::ContextConfig {
1298 record: true,
1299 ..off.clone()
1300 };
1301 assert_eq!(
1302 resolve_record_name(Some(&on), "capsule"),
1303 Some("shey".to_string())
1304 );
1305 assert_eq!(
1306 resolve_record_name(None, "capsule"),
1307 Some("capsule".to_string()),
1308 "a capsule declaring no context block still records"
1309 );
1310 }
1311
1312 #[test]
1314 fn a_malformed_record_name_is_refused_by_name() {
1315 for value in ["", "../escape", "/abs", "a/b", ".hidden"] {
1316 let err = validate_record_segment("context.record_store", value)
1317 .expect_err("'{value}' must be refused");
1318 let message = err.to_string();
1319 assert!(message.contains(&format!("'{value}'")), "{message}");
1320 assert!(message.contains("single path segment"), "{message}");
1321 assert!(message.contains("context.record_store"), "{message}");
1322 }
1323 validate_record_segment("--context", "ctx_fixed").unwrap();
1324 }
1325}