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
39pub(crate) const UNRESOLVED_CONTEXT: &str = "<unresolved>";
42
43const RECORD_DIR_MODE: u32 = 0o700;
46
47pub(crate) const RECORD_FILE_MODE: u32 = 0o600;
49
50const CURSOR_PREFIX: &str = "mc_";
53
54const MAX_PAGE_LIMIT: u32 = 100;
57
58const SEGMENT_RULE: &str = "must be a single path segment: no '/', no '.' or '..', not absolute, \
63 and not starting with a dot";
64
65#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub struct TruncationMarker {
71 pub dropped: u64,
72 pub oldest_surviving_id: String,
74 pub last_dropped_id: String,
78 pub at_ms: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct RecordHeader {
94 #[serde(rename = "type")]
96 pub kind: String,
97 pub capsule: String,
99 pub created_ms: u64,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub truncated: Option<TruncationMarker>,
105}
106
107pub(crate) fn header_line(header: &RecordHeader) -> Result<String, String> {
109 let mut line = serde_json::to_string(header)
110 .map_err(|err| format!("failed to encode the record header: {err}"))?;
111 line.push('\n');
112 Ok(line)
113}
114
115pub(crate) fn read_header(path: &Path) -> Option<RecordHeader> {
118 let raw = std::fs::read_to_string(path).ok()?;
119 parse_header(raw.lines().next()?)
120}
121
122pub(crate) fn parse_header(line: &str) -> Option<RecordHeader> {
124 let header: RecordHeader = serde_json::from_str(line).ok()?;
125 (header.kind == RECORD_HEADER_TYPE).then_some(header)
126}
127
128pub(crate) fn message_id_timestamp_ms(id: &str) -> Option<u64> {
133 crate::retention::uuid_v7_timestamp_ms(id, "msg_")
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> {
336 ensure_context_dirs(&self.dir)
337 }
338
339 fn ensure_header(&mut self) -> Result<(), String> {
357 if self.header_ensured {
358 return Ok(());
359 }
360 let Some(capsule) = self.capsule.clone() else {
361 self.header_ensured = true;
362 return Ok(());
363 };
364 let path = self.path();
365 let existing = match std::fs::read_to_string(&path) {
366 Ok(raw) => raw,
367 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
368 Err(err) => return Err(format!("{}: {err}", path.display())),
369 };
370 if existing.lines().next().and_then(parse_header).is_some() {
371 self.header_ensured = true;
372 return Ok(());
373 }
374
375 let header = RecordHeader {
376 kind: RECORD_HEADER_TYPE.to_string(),
377 capsule,
378 created_ms: crate::retention::now_ms(),
379 truncated: None,
380 };
381 let line = header_line(&header)?;
382 if existing.is_empty() {
386 open_record_file(&path, true)?
387 .write_all(line.as_bytes())
388 .map_err(|err| format!("{}: {err}", path.display()))?;
389 } else {
390 let mut contents = line;
391 contents.push_str(&existing);
392 crate::retention::StagedRewrite::stage_with_mode(
393 &path,
394 contents.as_bytes(),
395 Some(RECORD_FILE_MODE),
396 )?
397 .commit()?;
398 }
399 self.header_ensured = true;
400 Ok(())
401 }
402}
403
404fn open_record_file(path: &Path, truncate: bool) -> Result<std::fs::File, String> {
407 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
408
409 let mut options = std::fs::OpenOptions::new();
410 options.create(true).mode(RECORD_FILE_MODE);
411 if truncate {
412 options.write(true).truncate(true);
413 } else {
414 options.append(true);
415 }
416 let file = options
417 .open(path)
418 .map_err(|err| format!("{}: {err}", path.display()))?;
419 file.set_permissions(std::fs::Permissions::from_mode(RECORD_FILE_MODE))
420 .map_err(|err| format!("{}: {err}", path.display()))?;
421 Ok(file)
422}
423
424#[derive(Default)]
431pub(crate) struct RecordCache {
432 path: Option<PathBuf>,
434 len: u64,
436 messages: Vec<Value>,
438 malformed_reported: bool,
440 #[cfg(test)]
442 reads: u32,
443}
444
445impl RecordCache {
446 fn messages(&mut self, path: &Path, workdir: &Path) -> Result<&[Value], String> {
452 let len = match std::fs::metadata(path) {
453 Ok(metadata) => metadata.len(),
454 Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
455 Err(err) => return Err(err.to_string()),
456 };
457 if self.path.as_deref() == Some(path) && self.len == len {
458 return Ok(&self.messages);
459 }
460
461 self.path = None;
465 #[cfg(test)]
466 {
467 self.reads += 1;
468 }
469 self.messages = read_record(path, workdir, &mut self.malformed_reported)?;
470 self.len = len;
471 self.path = Some(path.to_path_buf());
472 Ok(&self.messages)
473 }
474
475 #[cfg(test)]
476 pub(crate) fn reads(&self) -> u32 {
477 self.reads
478 }
479}
480
481pub(crate) fn page(
489 cache: &mut RecordCache,
490 path: Option<&Path>,
491 workdir: &Path,
492 cursor: Option<&str>,
493 limit: u32,
494) -> Result<MessagePage, String> {
495 let Some(path) = path else {
496 return Ok(empty_page());
497 };
498 let messages = cache
499 .messages(path, workdir)
500 .map_err(|reason| format!("unavailable: {reason}"))?;
501
502 let end = match cursor {
505 None => messages.len(),
506 Some(cursor) => decode_cursor(cursor)
507 .filter(|end| *end <= messages.len())
508 .ok_or_else(|| "invalid-cursor".to_string())?,
509 };
510 let limit = limit.clamp(1, MAX_PAGE_LIMIT) as usize;
511 let start = end.saturating_sub(limit);
512
513 let mut window: Vec<Value> = messages[start..end].to_vec();
514 window.reverse();
515 Ok(MessagePage {
520 messages: crate::agent::to_wit_messages(&window)
521 .into_iter()
522 .map(|message| Message {
523 role: message.role,
524 content: message.content,
525 id: message.id,
526 source_id: message.source_id,
527 inserted_by: message.inserted_by.map(|mark| {
528 crate::agent::ContextInsertion::from_lowered(mark).to_imported_wit()
529 }),
530 })
531 .collect(),
532 next_cursor: (start > 0).then(|| encode_cursor(start)),
533 total: u32::try_from(messages.len()).unwrap_or(u32::MAX),
534 })
535}
536
537fn empty_page() -> MessagePage {
539 MessagePage {
540 messages: Vec::new(),
541 next_cursor: None,
542 total: 0,
543 }
544}
545
546fn read_record(path: &Path, workdir: &Path, reported: &mut bool) -> Result<Vec<Value>, String> {
558 let raw = match std::fs::read_to_string(path) {
559 Ok(raw) => raw,
560 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
561 Err(err) => return Err(err.to_string()),
562 };
563
564 let mut messages = Vec::new();
565 for (index, line) in raw.lines().enumerate() {
566 if line.trim().is_empty() {
567 continue;
568 }
569 match parse_message_line(line) {
570 Some(message) => messages.push(message),
571 None => {
572 if !*reported {
573 *reported = true;
574 report(
575 workdir,
576 &format!(
577 "[conversation] {}: line {} is not a message and was skipped",
578 path.display(),
579 index + 1
580 ),
581 );
582 }
583 }
584 }
585 }
586 Ok(messages)
587}
588
589pub(crate) fn parse_message_line(line: &str) -> Option<Value> {
594 let value: Value = serde_json::from_str(line).ok()?;
595 value
596 .get("role")
597 .and_then(Value::as_str)
598 .is_some()
599 .then_some(value)
600}
601
602fn encode_cursor(position: usize) -> String {
603 format!("{CURSOR_PREFIX}{position}")
604}
605
606fn decode_cursor(cursor: &str) -> Option<usize> {
607 cursor.strip_prefix(CURSOR_PREFIX)?.parse().ok()
608}
609
610pub(crate) fn ensure_context_dirs(dir: &Path) -> Result<(), String> {
620 let mut chain = Vec::new();
621 let mut ancestor = Some(dir);
622 for _ in 0..3 {
623 if let Some(current) = ancestor {
624 chain.push(current);
625 ancestor = current.parent();
626 }
627 }
628 if let (Some(owner), Ok(home)) = (ancestor, crate::state_store::murmur_home_dir()) {
629 if owner == home {
630 crate::murmur_home::ensure_murmur_home()?;
631 }
632 }
633 for path in chain.into_iter().rev() {
634 crate::state_store::ensure_private_dir(path, RECORD_DIR_MODE)
635 .map_err(|reason| format!("{}: {reason}", path.display()))?;
636 }
637 Ok(())
638}
639
640pub(crate) fn report(workdir: &Path, message: &str) {
642 crate::runtime_err!("[capsule-runtime] {message}");
643 crate::agent::append_bootstrap_log(workdir, message);
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use serde_json::json;
650
651 const CAPSULE: &str = "capsule";
653
654 fn message(id: &str, role: &str, text: &str) -> Value {
655 json!({"role": role, "content": [{"type": "text", "text": text}], "id": id})
656 }
657
658 fn id_at(id: usize) -> String {
659 format!("msg_{:032x}", id)
660 }
661
662 #[test]
663 fn record_file_is_held_owner_only_on_append() {
664 let home = tempfile::tempdir().unwrap();
665 let workdir = tempfile::tempdir().unwrap();
666 let root = home.path().join("conversations/shared");
667 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
668 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
669 crate::murmur_home::wide_file(&path, "", 0o644);
670
671 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
672 record.append(&[message(&id_at(1), "user", "hello")]);
673
674 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
675 assert_eq!(crate::murmur_home::mode_of(&root.join("ctx_1")), 0o700);
676 assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 1);
677 }
678
679 #[test]
680 fn record_file_is_created_owner_only_with_its_header() {
681 let home = tempfile::tempdir().unwrap();
682 let workdir = tempfile::tempdir().unwrap();
683 let root = home.path().join("conversations/shared");
684
685 let mut record =
686 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
687 record.append(&[message(&id_at(1), "user", "hello")]);
688
689 assert_eq!(crate::murmur_home::mode_of(&record.path()), 0o600);
690 }
691
692 #[test]
694 fn record_rewrite_keeps_the_file_owner_only() {
695 let home = tempfile::tempdir().unwrap();
696 let workdir = tempfile::tempdir().unwrap();
697 let root = home.path().join("conversations/shared");
698 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
699 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
700 let old = serde_json::to_string(&message(&id_at(1), "user", "old")).unwrap();
701 crate::murmur_home::wide_file(&path, &format!("{old}\n"), 0o644);
702
703 let mut record =
704 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
705 record.append(&[message(&id_at(2), "user", "new")]);
706
707 assert!(read_header(&path).is_some(), "the record was adopted");
708 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
709 }
710
711 #[test]
712 fn record_root_under_a_wide_murmur_home_holds_the_home_owner_only() {
713 let home = tempfile::tempdir().unwrap();
714 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
715 crate::murmur_home::run_with_home(
716 "conversation::tests::inner_append_under_scratch_home",
717 home.path(),
718 );
719 assert_eq!(
720 crate::murmur_home::mode_of(&home.path().join(".murmur")),
721 0o700
722 );
723 let record = home
724 .path()
725 .join(".murmur/conversations/shared/ctx_1")
726 .join(RECORD_FILE_NAME);
727 assert_eq!(crate::murmur_home::mode_of(&record), 0o600);
728 }
729
730 #[test]
731 #[ignore = "run by record_root_under_a_wide_murmur_home_holds_the_home_owner_only"]
732 fn inner_append_under_scratch_home() {
733 if !crate::murmur_home::in_scratch_home() {
734 return;
735 }
736 let workdir = tempfile::tempdir().unwrap();
737 let root = record_root("shared").unwrap();
738 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
739 record.append(&[message(&id_at(1), "user", "hello")]);
740 assert!(record.path().exists());
741 }
742
743 #[test]
746 fn the_first_append_writes_a_header_naming_the_capsule() {
747 let home = tempfile::tempdir().unwrap();
748 let workdir = tempfile::tempdir().unwrap();
749 let root = home.path().join("conversations/shared");
750
751 let mut record =
752 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
753 record.append(&[message(&id_at(1), "user", "hello")]);
754
755 let raw = std::fs::read_to_string(record.path()).unwrap();
756 let lines: Vec<&str> = raw.lines().collect();
757 assert_eq!(lines.len(), 2, "a header and one message: {lines:?}");
758 let header = parse_header(lines[0]).expect("the first line is a header");
759 assert_eq!(header.kind, RECORD_HEADER_TYPE);
760 assert_eq!(header.capsule, CAPSULE);
761 assert!(header.truncated.is_none(), "nothing has been dropped yet");
762 assert!(header.created_ms > 0);
763 }
764
765 #[test]
768 fn a_header_line_changes_neither_the_loaded_messages_nor_total() {
769 let home = tempfile::tempdir().unwrap();
770 let workdir = tempfile::tempdir().unwrap();
771 let root = home.path().join("conversations/c");
772
773 let mut record =
774 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
775 record.append(&[
776 message(&id_at(1), "user", "one"),
777 message(&id_at(2), "assistant", "two"),
778 ]);
779 let path = record.path();
780
781 let mut reader =
782 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
783 let loaded = reader.load();
784 assert_eq!(loaded.len(), 2, "the header is not a message: {loaded:?}");
785
786 let mut cache = RecordCache::default();
787 let page = page(&mut cache, Some(&path), workdir.path(), None, 100).unwrap();
788 assert_eq!(page.total, 2);
789 assert_eq!(page.messages.len(), 2);
790 }
791
792 #[test]
796 fn a_headerless_record_is_adopted_on_the_next_append() {
797 let home = tempfile::tempdir().unwrap();
798 let workdir = tempfile::tempdir().unwrap();
799 let root = home.path().join("conversations/c");
800 let dir = root.join("ctx_1");
801 std::fs::create_dir_all(&dir).unwrap();
802 let path = dir.join(RECORD_FILE_NAME);
803 let headerless = format!(
804 "{}
805{}
806",
807 serde_json::to_string(&message(&id_at(1), "user", "one")).unwrap(),
808 serde_json::to_string(&message(&id_at(2), "assistant", "two")).unwrap()
809 );
810 std::fs::write(&path, &headerless).unwrap();
811 assert!(read_header(&path).is_none(), "unowned before the append");
812
813 let mut record =
814 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
815 record.append(&[message(&id_at(3), "user", "three")]);
816
817 let raw = std::fs::read_to_string(&path).unwrap();
818 assert_eq!(read_header(&path).unwrap().capsule, CAPSULE);
819 assert!(
820 raw.contains(headerless.trim_end()),
821 "every line it already held survives byte for byte: {raw}"
822 );
823 assert_eq!(raw.lines().count(), 4, "a header and three messages");
824 }
825
826 #[test]
829 fn an_owned_record_keeps_the_header_it_already_has() {
830 let home = tempfile::tempdir().unwrap();
831 let workdir = tempfile::tempdir().unwrap();
832 let root = home.path().join("conversations/c");
833 let dir = root.join("ctx_1");
834 std::fs::create_dir_all(&dir).unwrap();
835 let path = dir.join(RECORD_FILE_NAME);
836 let header = RecordHeader {
837 kind: RECORD_HEADER_TYPE.to_string(),
838 capsule: "someone-else".to_string(),
839 created_ms: 1_756_400_000_000,
840 truncated: Some(TruncationMarker {
841 dropped: 5,
842 oldest_surviving_id: id_at(6),
843 last_dropped_id: id_at(5),
844 at_ms: 1_756_400_000_001,
845 }),
846 };
847 std::fs::write(&path, header_line(&header).unwrap()).unwrap();
848
849 let mut record =
850 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
851 record.append(&[message(&id_at(7), "user", "seven")]);
852
853 assert_eq!(read_header(&path).unwrap(), header);
854 }
855
856 #[test]
859 fn a_reloaded_message_keeps_its_id_and_is_not_written_twice() {
860 let home = tempfile::tempdir().unwrap();
861 let workdir = tempfile::tempdir().unwrap();
862 let root = home.path().join("conversations/capsule");
863
864 let mut first =
865 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
866 first.append(&[message(&id_at(1), "user", "hello")]);
867
868 let mut second =
869 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
870 let loaded = second.load();
871 assert_eq!(loaded.len(), 1);
872 assert_eq!(
873 crate::agent::message_id(&loaded[0]),
874 Some(id_at(1).as_str())
875 );
876
877 second.append(&loaded);
878 second.append(&[message(&id_at(2), "assistant", "hi")]);
879 assert_eq!(
880 second.load().len(),
881 2,
882 "a reloaded message is never re-appended"
883 );
884 }
885
886 #[test]
889 fn the_directory_chain_is_created_at_0700_on_first_write() {
890 use std::os::unix::fs::PermissionsExt;
891
892 let home = tempfile::tempdir().unwrap();
893 let workdir = tempfile::tempdir().unwrap();
894 let conversations = home.path().join("conversations");
895 let root = conversations.join("capsule");
896
897 let mut record =
898 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
899 assert!(!conversations.exists(), "opening a record creates nothing");
900
901 record.append(&[message(&id_at(1), "user", "hello")]);
902 for dir in [&conversations, &root, &root.join("ctx_1")] {
903 let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
904 assert_eq!(
905 mode,
906 0o700,
907 "{} must be 0700, got {mode:04o}",
908 dir.display()
909 );
910 }
911 }
912
913 #[test]
916 fn a_context_id_that_is_not_a_segment_records_nothing() {
917 let home = tempfile::tempdir().unwrap();
918 let workdir = tempfile::tempdir().unwrap();
919 for context_id in ["../escape", "a/b", "", ".hidden"] {
920 assert!(
921 ConversationRecord::open(
922 &home.path().join("conversations/c"),
923 context_id,
924 workdir.path(),
925 Some(CAPSULE)
926 )
927 .is_none(),
928 "'{context_id}' must not open a record"
929 );
930 }
931 assert!(!home.path().join("conversations").exists());
932 }
933
934 #[test]
936 fn paging_walks_the_record_newest_first_without_repeating_a_message() {
937 let home = tempfile::tempdir().unwrap();
938 let workdir = tempfile::tempdir().unwrap();
939 let root = home.path().join("conversations/capsule");
940 let mut record =
941 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
942 let written: Vec<Value> = (1..=5)
943 .map(|n| message(&id_at(n), "user", &format!("m{n}")))
944 .collect();
945 record.append(&written);
946 let path = record.path();
947
948 let mut seen = Vec::new();
949 let mut cursor: Option<String> = None;
950 loop {
951 let page = page(
952 &mut RecordCache::default(),
953 Some(&path),
954 workdir.path(),
955 cursor.as_deref(),
956 2,
957 )
958 .unwrap();
959 assert_eq!(page.total, 5);
960 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
961 match page.next_cursor {
962 Some(next) => cursor = Some(next),
963 None => break,
964 }
965 }
966 assert_eq!(
967 seen,
968 (1..=5).rev().map(id_at).collect::<Vec<_>>(),
969 "the whole record, newest first, exactly once each"
970 );
971 }
972
973 #[test]
976 fn limit_is_clamped_at_both_ends() {
977 let home = tempfile::tempdir().unwrap();
978 let workdir = tempfile::tempdir().unwrap();
979 let root = home.path().join("conversations/capsule");
980 let mut record =
981 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
982 record.append(
983 &(1..=120)
984 .map(|n| message(&id_at(n), "user", "m"))
985 .collect::<Vec<_>>(),
986 );
987 let path = record.path();
988
989 assert_eq!(
990 page(
991 &mut RecordCache::default(),
992 Some(&path),
993 workdir.path(),
994 None,
995 0
996 )
997 .unwrap()
998 .messages
999 .len(),
1000 1
1001 );
1002 assert_eq!(
1003 page(
1004 &mut RecordCache::default(),
1005 Some(&path),
1006 workdir.path(),
1007 None,
1008 u32::MAX
1009 )
1010 .unwrap()
1011 .messages
1012 .len(),
1013 MAX_PAGE_LIMIT as usize
1014 );
1015 }
1016
1017 #[test]
1020 fn a_foreign_cursor_is_refused() {
1021 let home = tempfile::tempdir().unwrap();
1022 let workdir = tempfile::tempdir().unwrap();
1023 let root = home.path().join("conversations/capsule");
1024 let mut record =
1025 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1026 record.append(&[message(&id_at(1), "user", "m")]);
1027 let path = record.path();
1028
1029 for cursor in ["7", "mc_", "mc_nine", "mc_9", "cur_1"] {
1030 assert_eq!(
1031 page(
1032 &mut RecordCache::default(),
1033 Some(&path),
1034 workdir.path(),
1035 Some(cursor),
1036 10
1037 )
1038 .err(),
1039 Some("invalid-cursor".to_string()),
1040 "cursor '{cursor}'"
1041 );
1042 }
1043 }
1044
1045 #[test]
1048 fn an_absent_record_is_an_empty_page() {
1049 let home = tempfile::tempdir().unwrap();
1050 let workdir = tempfile::tempdir().unwrap();
1051 let missing = home
1052 .path()
1053 .join("conversations/capsule/ctx_1/conversation.jsonl");
1054
1055 for path in [None, Some(missing.as_path())] {
1056 let page = page(&mut RecordCache::default(), path, workdir.path(), None, 10).unwrap();
1057 assert!(page.messages.is_empty());
1058 assert_eq!(page.next_cursor, None);
1059 assert_eq!(page.total, 0);
1060 }
1061 }
1062
1063 #[test]
1066 fn a_read_with_no_record_leaves_the_cache_alone() {
1067 let home = tempfile::tempdir().unwrap();
1068 let workdir = tempfile::tempdir().unwrap();
1069 let root = home.path().join("conversations/capsule");
1070 let mut record =
1071 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1072 record.append(&[message(&id_at(1), "user", "a")]);
1073 let path = record.path();
1074
1075 let mut cache = RecordCache::default();
1076 assert_eq!(
1077 page(&mut cache, Some(&path), workdir.path(), None, 10)
1078 .unwrap()
1079 .total,
1080 1
1081 );
1082 assert!(page(&mut cache, None, workdir.path(), None, 10)
1083 .unwrap()
1084 .messages
1085 .is_empty());
1086 assert_eq!(
1087 page(&mut cache, Some(&path), workdir.path(), None, 10)
1088 .unwrap()
1089 .total,
1090 1
1091 );
1092 assert_eq!(cache.reads(), 1, "the parse survived the empty read");
1093 }
1094
1095 #[test]
1098 fn a_malformed_line_is_skipped_and_reported() {
1099 let home = tempfile::tempdir().unwrap();
1100 let workdir = tempfile::tempdir().unwrap();
1101 let dir = home.path().join("conversations/capsule/ctx_1");
1102 std::fs::create_dir_all(&dir).unwrap();
1103 let path = dir.join(RECORD_FILE_NAME);
1104 let mut raw = String::new();
1105 for n in 1..=3 {
1106 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1107 raw.push('\n');
1108 }
1109 raw.push_str("{\"role\":\"user\"");
1110 std::fs::write(&path, raw).unwrap();
1111
1112 let page = page(
1113 &mut RecordCache::default(),
1114 Some(&path),
1115 workdir.path(),
1116 None,
1117 10,
1118 )
1119 .unwrap();
1120 assert_eq!(page.total, 3);
1121 assert_eq!(page.messages.len(), 3);
1122
1123 let log =
1124 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1125 assert_eq!(
1126 log.lines()
1127 .filter(|line| line.contains("line 4 is not a message"))
1128 .count(),
1129 1,
1130 "reported once, naming the line: {log}"
1131 );
1132 }
1133
1134 #[test]
1140 fn a_failed_write_is_retried_and_leaves_no_hole() {
1141 let home = tempfile::tempdir().unwrap();
1142 let workdir = tempfile::tempdir().unwrap();
1143 let root = home.path().join("conversations/capsule");
1144 let blocker = root.join("ctx_1");
1145 std::fs::create_dir_all(&root).unwrap();
1146 std::fs::write(&blocker, "").unwrap();
1147
1148 let mut record =
1149 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1150 let first = message(&id_at(1), "user", "a");
1151 let second = message(&id_at(2), "assistant", "b");
1152 record.append(std::slice::from_ref(&first));
1153 assert!(
1154 blocker.is_file(),
1155 "the directory chain could not be created"
1156 );
1157
1158 std::fs::remove_file(&blocker).unwrap();
1159 record.append(&[first, second]);
1160
1161 let written: Vec<Value> = std::fs::read_to_string(record.path())
1162 .unwrap()
1163 .lines()
1164 .filter_map(parse_message_line)
1165 .collect();
1166 assert_eq!(
1167 written
1168 .iter()
1169 .map(|m| crate::agent::message_id(m).unwrap())
1170 .collect::<Vec<_>>(),
1171 vec![id_at(1), id_at(2)],
1172 "the message the failure cost is on disk, before the one that followed it"
1173 );
1174 }
1175
1176 #[test]
1178 fn a_paging_loop_reads_the_record_once() {
1179 let home = tempfile::tempdir().unwrap();
1180 let workdir = tempfile::tempdir().unwrap();
1181 let root = home.path().join("conversations/capsule");
1182 let mut record =
1183 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1184 record.append(
1185 &(1..=250)
1186 .map(|n| message(&id_at(n), "user", "m"))
1187 .collect::<Vec<_>>(),
1188 );
1189 let path = record.path();
1190 let mut cache = RecordCache::default();
1191
1192 let mut pages = 0;
1193 let mut seen = Vec::new();
1194 let mut cursor: Option<String> = None;
1195 loop {
1196 let page = page(
1197 &mut cache,
1198 Some(&path),
1199 workdir.path(),
1200 cursor.as_deref(),
1201 25,
1202 )
1203 .unwrap();
1204 pages += 1;
1205 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1206 match page.next_cursor {
1207 Some(next) => cursor = Some(next),
1208 None => break,
1209 }
1210 }
1211 assert_eq!(pages, 10);
1212 assert_eq!(seen, (1..=250).rev().map(id_at).collect::<Vec<_>>());
1213 assert_eq!(cache.reads(), 1, "one read for the whole walk");
1214
1215 record.append(
1218 &(251..=260)
1219 .map(|n| message(&id_at(n), "user", "m"))
1220 .collect::<Vec<_>>(),
1221 );
1222 let mut seen = Vec::new();
1223 let mut cursor: Option<String> = None;
1224 let mut first_total = None;
1225 loop {
1226 let page = page(
1227 &mut cache,
1228 Some(&path),
1229 workdir.path(),
1230 cursor.as_deref(),
1231 25,
1232 )
1233 .unwrap();
1234 first_total.get_or_insert(page.total);
1235 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1236 match page.next_cursor {
1237 Some(next) => cursor = Some(next),
1238 None => break,
1239 }
1240 }
1241 assert_eq!(first_total, Some(260));
1242 assert_eq!(seen, (1..=260).rev().map(id_at).collect::<Vec<_>>());
1243 assert_eq!(cache.reads(), 2, "one further read for the second walk");
1244 }
1245
1246 #[test]
1248 fn a_malformed_line_is_reported_once_for_a_whole_walk() {
1249 let home = tempfile::tempdir().unwrap();
1250 let workdir = tempfile::tempdir().unwrap();
1251 let dir = home.path().join("conversations/capsule/ctx_1");
1252 std::fs::create_dir_all(&dir).unwrap();
1253 let path = dir.join(RECORD_FILE_NAME);
1254 let mut raw = String::new();
1255 for n in 1..=10 {
1256 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1257 raw.push('\n');
1258 }
1259 raw.push_str("{\"role\":\"user\"");
1260 std::fs::write(&path, raw).unwrap();
1261
1262 let mut cache = RecordCache::default();
1263 let mut cursor: Option<String> = None;
1264 let mut pages = 0;
1265 loop {
1266 let page = page(
1267 &mut cache,
1268 Some(&path),
1269 workdir.path(),
1270 cursor.as_deref(),
1271 2,
1272 )
1273 .unwrap();
1274 pages += 1;
1275 assert_eq!(page.total, 10);
1276 match page.next_cursor {
1277 Some(next) => cursor = Some(next),
1278 None => break,
1279 }
1280 }
1281 assert_eq!(pages, 5);
1282
1283 let log =
1284 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1285 assert_eq!(
1286 log.lines()
1287 .filter(|line| line.contains("line 11 is not a message"))
1288 .count(),
1289 1,
1290 "one entry for the whole walk: {log}"
1291 );
1292 }
1293
1294 #[test]
1297 fn record_off_wins_over_a_declared_store() {
1298 let off = murmur_artifact::ContextConfig {
1299 max_tokens: None,
1300 record: false,
1301 record_store: Some("shey".to_string()),
1302 seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
1303 seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
1304 retain: None,
1305 };
1306 assert_eq!(resolve_record_name(Some(&off), "capsule"), None);
1307
1308 let on = murmur_artifact::ContextConfig {
1309 record: true,
1310 ..off.clone()
1311 };
1312 assert_eq!(
1313 resolve_record_name(Some(&on), "capsule"),
1314 Some("shey".to_string())
1315 );
1316 assert_eq!(
1317 resolve_record_name(None, "capsule"),
1318 Some("capsule".to_string()),
1319 "a capsule declaring no context block still records"
1320 );
1321 }
1322
1323 #[test]
1325 fn a_malformed_record_name_is_refused_by_name() {
1326 for value in ["", "../escape", "/abs", "a/b", ".hidden"] {
1327 let err = validate_record_segment("context.record_store", value)
1328 .expect_err("'{value}' must be refused");
1329 let message = err.to_string();
1330 assert!(message.contains(&format!("'{value}'")), "{message}");
1331 assert!(message.contains("single path segment"), "{message}");
1332 assert!(message.contains("context.record_store"), "{message}");
1333 }
1334 validate_record_segment("--context", "ctx_fixed").unwrap();
1335 }
1336}