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 let hex = id.strip_prefix("msg_")?;
134 if hex.len() < 12 {
135 return None;
136 }
137 u64::from_str_radix(&hex[..12], 16).ok()
138}
139
140pub(crate) fn validate_record_segment(field: &str, value: &str) -> Result<(), RuntimeError> {
145 crate::state_store::validate_store_name(value).map_err(|_| {
146 RuntimeError::InvalidConversationRecord {
147 field: field.to_string(),
148 value: value.to_string(),
149 message: SEGMENT_RULE.to_string(),
150 }
151 })
152}
153
154pub(crate) fn resolve_record_name(
158 context: Option<&murmur_artifact::ContextConfig>,
159 capsule_name: &str,
160) -> Option<String> {
161 match context {
162 Some(context) if !context.record => None,
163 Some(context) => Some(
164 context
165 .record_store
166 .clone()
167 .unwrap_or_else(|| capsule_name.to_string()),
168 ),
169 None => Some(capsule_name.to_string()),
170 }
171}
172
173pub(crate) fn record_root(record: &str) -> Result<PathBuf, String> {
176 validate_record_segment("context.record_store", record).map_err(|err| err.to_string())?;
177 Ok(crate::state_store::murmur_home_dir()?
178 .join(CONVERSATION_ROOT_DIR)
179 .join(record))
180}
181
182pub(crate) fn record_file(root: &Path, context_id: &str) -> PathBuf {
188 root.join(context_id).join(RECORD_FILE_NAME)
189}
190
191pub(crate) struct ConversationRecord {
197 dir: PathBuf,
199 workdir: PathBuf,
201 capsule: Option<String>,
204 header_ensured: bool,
207 recorded: HashSet<String>,
211 failure_reported: bool,
214 malformed_reported: bool,
216}
217
218impl ConversationRecord {
219 pub(crate) fn open(
226 root: &Path,
227 context_id: &str,
228 workdir: &Path,
229 capsule: Option<&str>,
230 ) -> Option<Self> {
231 if crate::state_store::validate_store_name(context_id).is_err() {
232 report(
233 workdir,
234 &format!(
235 "[conversation] context id '{context_id}' {SEGMENT_RULE}; this conversation \
236 is not recorded"
237 ),
238 );
239 return None;
240 }
241 Some(Self {
242 dir: root.join(context_id),
243 workdir: workdir.to_path_buf(),
244 capsule: capsule.map(str::to_string),
245 header_ensured: false,
246 recorded: HashSet::new(),
247 failure_reported: false,
248 malformed_reported: false,
249 })
250 }
251
252 pub(crate) fn path(&self) -> PathBuf {
253 self.dir.join(RECORD_FILE_NAME)
254 }
255
256 pub(crate) fn load(&mut self) -> Vec<Value> {
261 let path = self.path();
262 let messages = match read_record(&path, &self.workdir, &mut self.malformed_reported) {
263 Ok(messages) => messages,
264 Err(reason) => {
265 report(
266 &self.workdir,
267 &format!(
268 "[conversation] {} could not be read ({reason}); this task starts with no \
269 prior messages",
270 path.display()
271 ),
272 );
273 Vec::new()
274 }
275 };
276 for message in &messages {
277 if let Some(id) = crate::agent::message_id(message) {
278 self.recorded.insert(id.to_string());
279 }
280 }
281 messages
282 }
283
284 pub(crate) fn append(&mut self, messages: &[Value]) {
295 for message in messages {
296 let id = crate::agent::message_id(message).map(str::to_string);
297 if let Some(id) = id.as_deref() {
298 if self.recorded.contains(id) {
299 continue;
300 }
301 }
302 if let Err(reason) = self.append_line(message) {
303 if !self.failure_reported {
304 self.failure_reported = true;
305 report(
306 &self.workdir,
307 &format!(
308 "[conversation] {} could not be written ({reason}); this task \
309 continues unrecorded",
310 self.path().display()
311 ),
312 );
313 }
314 return;
315 }
316 if let Some(id) = id {
317 self.recorded.insert(id);
318 }
319 }
320 }
321
322 fn append_line(&mut self, message: &Value) -> Result<(), String> {
327 let mut line = serde_json::to_string(message)
328 .map_err(|err| format!("failed to encode the message: {err}"))?;
329 line.push('\n');
330
331 self.ensure_dirs()?;
332 self.ensure_header()?;
333
334 let mut file = open_record_file(&self.path(), false)?;
335 file.write_all(line.as_bytes())
336 .map_err(|err| err.to_string())
337 }
338
339 fn ensure_dirs(&self) -> Result<(), String> {
340 ensure_context_dirs(&self.dir)
341 }
342
343 fn ensure_header(&mut self) -> Result<(), String> {
361 if self.header_ensured {
362 return Ok(());
363 }
364 let Some(capsule) = self.capsule.clone() else {
365 self.header_ensured = true;
366 return Ok(());
367 };
368 let path = self.path();
369 let existing = match std::fs::read_to_string(&path) {
370 Ok(raw) => raw,
371 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
372 Err(err) => return Err(format!("{}: {err}", path.display())),
373 };
374 if existing.lines().next().and_then(parse_header).is_some() {
375 self.header_ensured = true;
376 return Ok(());
377 }
378
379 let header = RecordHeader {
380 kind: RECORD_HEADER_TYPE.to_string(),
381 capsule,
382 created_ms: crate::retention::now_ms(),
383 truncated: None,
384 };
385 let line = header_line(&header)?;
386 if existing.is_empty() {
390 open_record_file(&path, true)?
391 .write_all(line.as_bytes())
392 .map_err(|err| format!("{}: {err}", path.display()))?;
393 } else {
394 let mut contents = line;
395 contents.push_str(&existing);
396 crate::retention::StagedRewrite::stage_with_mode(
397 &path,
398 contents.as_bytes(),
399 Some(RECORD_FILE_MODE),
400 )?
401 .commit()?;
402 }
403 self.header_ensured = true;
404 Ok(())
405 }
406}
407
408fn open_record_file(path: &Path, truncate: bool) -> Result<std::fs::File, String> {
411 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
412
413 let mut options = std::fs::OpenOptions::new();
414 options.create(true).mode(RECORD_FILE_MODE);
415 if truncate {
416 options.write(true).truncate(true);
417 } else {
418 options.append(true);
419 }
420 let file = options
421 .open(path)
422 .map_err(|err| format!("{}: {err}", path.display()))?;
423 file.set_permissions(std::fs::Permissions::from_mode(RECORD_FILE_MODE))
424 .map_err(|err| format!("{}: {err}", path.display()))?;
425 Ok(file)
426}
427
428#[derive(Default)]
435pub(crate) struct RecordCache {
436 path: Option<PathBuf>,
438 len: u64,
440 messages: Vec<Value>,
442 malformed_reported: bool,
444 #[cfg(test)]
446 reads: u32,
447}
448
449impl RecordCache {
450 fn messages(&mut self, path: &Path, workdir: &Path) -> Result<&[Value], String> {
456 let len = match std::fs::metadata(path) {
457 Ok(metadata) => metadata.len(),
458 Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
459 Err(err) => return Err(err.to_string()),
460 };
461 if self.path.as_deref() == Some(path) && self.len == len {
462 return Ok(&self.messages);
463 }
464
465 self.path = None;
469 #[cfg(test)]
470 {
471 self.reads += 1;
472 }
473 self.messages = read_record(path, workdir, &mut self.malformed_reported)?;
474 self.len = len;
475 self.path = Some(path.to_path_buf());
476 Ok(&self.messages)
477 }
478
479 #[cfg(test)]
480 pub(crate) fn reads(&self) -> u32 {
481 self.reads
482 }
483}
484
485pub(crate) fn page(
493 cache: &mut RecordCache,
494 path: Option<&Path>,
495 workdir: &Path,
496 cursor: Option<&str>,
497 limit: u32,
498) -> Result<MessagePage, String> {
499 let Some(path) = path else {
500 return Ok(empty_page());
501 };
502 let messages = cache
503 .messages(path, workdir)
504 .map_err(|reason| format!("unavailable: {reason}"))?;
505
506 let end = match cursor {
509 None => messages.len(),
510 Some(cursor) => decode_cursor(cursor)
511 .filter(|end| *end <= messages.len())
512 .ok_or_else(|| "invalid-cursor".to_string())?,
513 };
514 let limit = limit.clamp(1, MAX_PAGE_LIMIT) as usize;
515 let start = end.saturating_sub(limit);
516
517 let mut window: Vec<Value> = messages[start..end].to_vec();
518 window.reverse();
519 Ok(MessagePage {
524 messages: crate::agent::to_wit_messages(&window)
525 .into_iter()
526 .map(|message| Message {
527 role: message.role,
528 content: message.content,
529 id: message.id,
530 source_id: message.source_id,
531 inserted_by: message.inserted_by.map(|mark| {
532 crate::agent::ContextInsertion::from_lowered(mark).to_imported_wit()
533 }),
534 })
535 .collect(),
536 next_cursor: (start > 0).then(|| encode_cursor(start)),
537 total: u32::try_from(messages.len()).unwrap_or(u32::MAX),
538 })
539}
540
541fn empty_page() -> MessagePage {
543 MessagePage {
544 messages: Vec::new(),
545 next_cursor: None,
546 total: 0,
547 }
548}
549
550fn read_record(path: &Path, workdir: &Path, reported: &mut bool) -> Result<Vec<Value>, String> {
562 let raw = match std::fs::read_to_string(path) {
563 Ok(raw) => raw,
564 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
565 Err(err) => return Err(err.to_string()),
566 };
567
568 let mut messages = Vec::new();
569 for (index, line) in raw.lines().enumerate() {
570 if line.trim().is_empty() {
571 continue;
572 }
573 match parse_message_line(line) {
574 Some(message) => messages.push(message),
575 None => {
576 if !*reported {
577 *reported = true;
578 report(
579 workdir,
580 &format!(
581 "[conversation] {}: line {} is not a message and was skipped",
582 path.display(),
583 index + 1
584 ),
585 );
586 }
587 }
588 }
589 }
590 Ok(messages)
591}
592
593pub(crate) fn parse_message_line(line: &str) -> Option<Value> {
598 let value: Value = serde_json::from_str(line).ok()?;
599 value
600 .get("role")
601 .and_then(Value::as_str)
602 .is_some()
603 .then_some(value)
604}
605
606fn encode_cursor(position: usize) -> String {
607 format!("{CURSOR_PREFIX}{position}")
608}
609
610fn decode_cursor(cursor: &str) -> Option<usize> {
611 cursor.strip_prefix(CURSOR_PREFIX)?.parse().ok()
612}
613
614pub(crate) fn ensure_context_dirs(dir: &Path) -> Result<(), String> {
624 let mut chain = Vec::new();
625 let mut ancestor = Some(dir);
626 for _ in 0..3 {
627 if let Some(current) = ancestor {
628 chain.push(current);
629 ancestor = current.parent();
630 }
631 }
632 if let (Some(owner), Ok(home)) = (ancestor, crate::state_store::murmur_home_dir()) {
633 if owner == home {
634 crate::murmur_home::ensure_murmur_home()?;
635 }
636 }
637 for path in chain.into_iter().rev() {
638 crate::state_store::ensure_private_dir(path, RECORD_DIR_MODE)
639 .map_err(|reason| format!("{}: {reason}", path.display()))?;
640 }
641 Ok(())
642}
643
644pub(crate) fn report(workdir: &Path, message: &str) {
646 crate::runtime_err!("[capsule-runtime] {message}");
647 crate::agent::append_bootstrap_log(workdir, message);
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use serde_json::json;
654
655 const CAPSULE: &str = "capsule";
657
658 fn message(id: &str, role: &str, text: &str) -> Value {
659 json!({"role": role, "content": [{"type": "text", "text": text}], "id": id})
660 }
661
662 fn id_at(id: usize) -> String {
663 format!("msg_{:032x}", id)
664 }
665
666 #[test]
667 fn record_file_is_held_owner_only_on_append() {
668 let home = tempfile::tempdir().unwrap();
669 let workdir = tempfile::tempdir().unwrap();
670 let root = home.path().join("conversations/shared");
671 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
672 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
673 crate::murmur_home::wide_file(&path, "", 0o644);
674
675 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
676 record.append(&[message(&id_at(1), "user", "hello")]);
677
678 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
679 assert_eq!(crate::murmur_home::mode_of(&root.join("ctx_1")), 0o700);
680 assert_eq!(std::fs::read_to_string(&path).unwrap().lines().count(), 1);
681 }
682
683 #[test]
684 fn record_file_is_created_owner_only_with_its_header() {
685 let home = tempfile::tempdir().unwrap();
686 let workdir = tempfile::tempdir().unwrap();
687 let root = home.path().join("conversations/shared");
688
689 let mut record =
690 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
691 record.append(&[message(&id_at(1), "user", "hello")]);
692
693 assert_eq!(crate::murmur_home::mode_of(&record.path()), 0o600);
694 }
695
696 #[test]
698 fn record_rewrite_keeps_the_file_owner_only() {
699 let home = tempfile::tempdir().unwrap();
700 let workdir = tempfile::tempdir().unwrap();
701 let root = home.path().join("conversations/shared");
702 crate::murmur_home::wide_dir(&root.join("ctx_1"), 0o755);
703 let path = root.join("ctx_1").join(RECORD_FILE_NAME);
704 let old = serde_json::to_string(&message(&id_at(1), "user", "old")).unwrap();
705 crate::murmur_home::wide_file(&path, &format!("{old}\n"), 0o644);
706
707 let mut record =
708 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
709 record.append(&[message(&id_at(2), "user", "new")]);
710
711 assert!(read_header(&path).is_some(), "the record was adopted");
712 assert_eq!(crate::murmur_home::mode_of(&path), 0o600);
713 }
714
715 #[test]
716 fn record_root_under_a_wide_murmur_home_holds_the_home_owner_only() {
717 let home = tempfile::tempdir().unwrap();
718 crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
719 crate::murmur_home::run_with_home(
720 "conversation::tests::inner_append_under_scratch_home",
721 home.path(),
722 );
723 assert_eq!(
724 crate::murmur_home::mode_of(&home.path().join(".murmur")),
725 0o700
726 );
727 let record = home
728 .path()
729 .join(".murmur/conversations/shared/ctx_1")
730 .join(RECORD_FILE_NAME);
731 assert_eq!(crate::murmur_home::mode_of(&record), 0o600);
732 }
733
734 #[test]
735 #[ignore = "run by record_root_under_a_wide_murmur_home_holds_the_home_owner_only"]
736 fn inner_append_under_scratch_home() {
737 if !crate::murmur_home::in_scratch_home() {
738 return;
739 }
740 let workdir = tempfile::tempdir().unwrap();
741 let root = record_root("shared").unwrap();
742 let mut record = ConversationRecord::open(&root, "ctx_1", workdir.path(), None).unwrap();
743 record.append(&[message(&id_at(1), "user", "hello")]);
744 assert!(record.path().exists());
745 }
746
747 #[test]
750 fn the_first_append_writes_a_header_naming_the_capsule() {
751 let home = tempfile::tempdir().unwrap();
752 let workdir = tempfile::tempdir().unwrap();
753 let root = home.path().join("conversations/shared");
754
755 let mut record =
756 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
757 record.append(&[message(&id_at(1), "user", "hello")]);
758
759 let raw = std::fs::read_to_string(record.path()).unwrap();
760 let lines: Vec<&str> = raw.lines().collect();
761 assert_eq!(lines.len(), 2, "a header and one message: {lines:?}");
762 let header = parse_header(lines[0]).expect("the first line is a header");
763 assert_eq!(header.kind, RECORD_HEADER_TYPE);
764 assert_eq!(header.capsule, CAPSULE);
765 assert!(header.truncated.is_none(), "nothing has been dropped yet");
766 assert!(header.created_ms > 0);
767 }
768
769 #[test]
772 fn a_header_line_changes_neither_the_loaded_messages_nor_total() {
773 let home = tempfile::tempdir().unwrap();
774 let workdir = tempfile::tempdir().unwrap();
775 let root = home.path().join("conversations/c");
776
777 let mut record =
778 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
779 record.append(&[
780 message(&id_at(1), "user", "one"),
781 message(&id_at(2), "assistant", "two"),
782 ]);
783 let path = record.path();
784
785 let mut reader =
786 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
787 let loaded = reader.load();
788 assert_eq!(loaded.len(), 2, "the header is not a message: {loaded:?}");
789
790 let mut cache = RecordCache::default();
791 let page = page(&mut cache, Some(&path), workdir.path(), None, 100).unwrap();
792 assert_eq!(page.total, 2);
793 assert_eq!(page.messages.len(), 2);
794 }
795
796 #[test]
800 fn a_headerless_record_is_adopted_on_the_next_append() {
801 let home = tempfile::tempdir().unwrap();
802 let workdir = tempfile::tempdir().unwrap();
803 let root = home.path().join("conversations/c");
804 let dir = root.join("ctx_1");
805 std::fs::create_dir_all(&dir).unwrap();
806 let path = dir.join(RECORD_FILE_NAME);
807 let headerless = format!(
808 "{}
809{}
810",
811 serde_json::to_string(&message(&id_at(1), "user", "one")).unwrap(),
812 serde_json::to_string(&message(&id_at(2), "assistant", "two")).unwrap()
813 );
814 std::fs::write(&path, &headerless).unwrap();
815 assert!(read_header(&path).is_none(), "unowned before the append");
816
817 let mut record =
818 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
819 record.append(&[message(&id_at(3), "user", "three")]);
820
821 let raw = std::fs::read_to_string(&path).unwrap();
822 assert_eq!(read_header(&path).unwrap().capsule, CAPSULE);
823 assert!(
824 raw.contains(headerless.trim_end()),
825 "every line it already held survives byte for byte: {raw}"
826 );
827 assert_eq!(raw.lines().count(), 4, "a header and three messages");
828 }
829
830 #[test]
833 fn an_owned_record_keeps_the_header_it_already_has() {
834 let home = tempfile::tempdir().unwrap();
835 let workdir = tempfile::tempdir().unwrap();
836 let root = home.path().join("conversations/c");
837 let dir = root.join("ctx_1");
838 std::fs::create_dir_all(&dir).unwrap();
839 let path = dir.join(RECORD_FILE_NAME);
840 let header = RecordHeader {
841 kind: RECORD_HEADER_TYPE.to_string(),
842 capsule: "someone-else".to_string(),
843 created_ms: 1_756_400_000_000,
844 truncated: Some(TruncationMarker {
845 dropped: 5,
846 oldest_surviving_id: id_at(6),
847 last_dropped_id: id_at(5),
848 at_ms: 1_756_400_000_001,
849 }),
850 };
851 std::fs::write(&path, header_line(&header).unwrap()).unwrap();
852
853 let mut record =
854 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
855 record.append(&[message(&id_at(7), "user", "seven")]);
856
857 assert_eq!(read_header(&path).unwrap(), header);
858 }
859
860 #[test]
863 fn a_reloaded_message_keeps_its_id_and_is_not_written_twice() {
864 let home = tempfile::tempdir().unwrap();
865 let workdir = tempfile::tempdir().unwrap();
866 let root = home.path().join("conversations/capsule");
867
868 let mut first =
869 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
870 first.append(&[message(&id_at(1), "user", "hello")]);
871
872 let mut second =
873 ConversationRecord::open(&root, "ctx_fixed", workdir.path(), Some(CAPSULE)).unwrap();
874 let loaded = second.load();
875 assert_eq!(loaded.len(), 1);
876 assert_eq!(
877 crate::agent::message_id(&loaded[0]),
878 Some(id_at(1).as_str())
879 );
880
881 second.append(&loaded);
882 second.append(&[message(&id_at(2), "assistant", "hi")]);
883 assert_eq!(
884 second.load().len(),
885 2,
886 "a reloaded message is never re-appended"
887 );
888 }
889
890 #[test]
893 fn the_directory_chain_is_created_at_0700_on_first_write() {
894 use std::os::unix::fs::PermissionsExt;
895
896 let home = tempfile::tempdir().unwrap();
897 let workdir = tempfile::tempdir().unwrap();
898 let conversations = home.path().join("conversations");
899 let root = conversations.join("capsule");
900
901 let mut record =
902 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
903 assert!(!conversations.exists(), "opening a record creates nothing");
904
905 record.append(&[message(&id_at(1), "user", "hello")]);
906 for dir in [&conversations, &root, &root.join("ctx_1")] {
907 let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
908 assert_eq!(
909 mode,
910 0o700,
911 "{} must be 0700, got {mode:04o}",
912 dir.display()
913 );
914 }
915 }
916
917 #[test]
920 fn a_context_id_that_is_not_a_segment_records_nothing() {
921 let home = tempfile::tempdir().unwrap();
922 let workdir = tempfile::tempdir().unwrap();
923 for context_id in ["../escape", "a/b", "", ".hidden"] {
924 assert!(
925 ConversationRecord::open(
926 &home.path().join("conversations/c"),
927 context_id,
928 workdir.path(),
929 Some(CAPSULE)
930 )
931 .is_none(),
932 "'{context_id}' must not open a record"
933 );
934 }
935 assert!(!home.path().join("conversations").exists());
936 }
937
938 #[test]
940 fn paging_walks_the_record_newest_first_without_repeating_a_message() {
941 let home = tempfile::tempdir().unwrap();
942 let workdir = tempfile::tempdir().unwrap();
943 let root = home.path().join("conversations/capsule");
944 let mut record =
945 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
946 let written: Vec<Value> = (1..=5)
947 .map(|n| message(&id_at(n), "user", &format!("m{n}")))
948 .collect();
949 record.append(&written);
950 let path = record.path();
951
952 let mut seen = Vec::new();
953 let mut cursor: Option<String> = None;
954 loop {
955 let page = page(
956 &mut RecordCache::default(),
957 Some(&path),
958 workdir.path(),
959 cursor.as_deref(),
960 2,
961 )
962 .unwrap();
963 assert_eq!(page.total, 5);
964 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
965 match page.next_cursor {
966 Some(next) => cursor = Some(next),
967 None => break,
968 }
969 }
970 assert_eq!(
971 seen,
972 (1..=5).rev().map(id_at).collect::<Vec<_>>(),
973 "the whole record, newest first, exactly once each"
974 );
975 }
976
977 #[test]
980 fn limit_is_clamped_at_both_ends() {
981 let home = tempfile::tempdir().unwrap();
982 let workdir = tempfile::tempdir().unwrap();
983 let root = home.path().join("conversations/capsule");
984 let mut record =
985 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
986 record.append(
987 &(1..=120)
988 .map(|n| message(&id_at(n), "user", "m"))
989 .collect::<Vec<_>>(),
990 );
991 let path = record.path();
992
993 assert_eq!(
994 page(
995 &mut RecordCache::default(),
996 Some(&path),
997 workdir.path(),
998 None,
999 0
1000 )
1001 .unwrap()
1002 .messages
1003 .len(),
1004 1
1005 );
1006 assert_eq!(
1007 page(
1008 &mut RecordCache::default(),
1009 Some(&path),
1010 workdir.path(),
1011 None,
1012 u32::MAX
1013 )
1014 .unwrap()
1015 .messages
1016 .len(),
1017 MAX_PAGE_LIMIT as usize
1018 );
1019 }
1020
1021 #[test]
1024 fn a_foreign_cursor_is_refused() {
1025 let home = tempfile::tempdir().unwrap();
1026 let workdir = tempfile::tempdir().unwrap();
1027 let root = home.path().join("conversations/capsule");
1028 let mut record =
1029 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1030 record.append(&[message(&id_at(1), "user", "m")]);
1031 let path = record.path();
1032
1033 for cursor in ["7", "mc_", "mc_nine", "mc_9", "cur_1"] {
1034 assert_eq!(
1035 page(
1036 &mut RecordCache::default(),
1037 Some(&path),
1038 workdir.path(),
1039 Some(cursor),
1040 10
1041 )
1042 .err(),
1043 Some("invalid-cursor".to_string()),
1044 "cursor '{cursor}'"
1045 );
1046 }
1047 }
1048
1049 #[test]
1052 fn an_absent_record_is_an_empty_page() {
1053 let home = tempfile::tempdir().unwrap();
1054 let workdir = tempfile::tempdir().unwrap();
1055 let missing = home
1056 .path()
1057 .join("conversations/capsule/ctx_1/conversation.jsonl");
1058
1059 for path in [None, Some(missing.as_path())] {
1060 let page = page(&mut RecordCache::default(), path, workdir.path(), None, 10).unwrap();
1061 assert!(page.messages.is_empty());
1062 assert_eq!(page.next_cursor, None);
1063 assert_eq!(page.total, 0);
1064 }
1065 }
1066
1067 #[test]
1070 fn a_read_with_no_record_leaves_the_cache_alone() {
1071 let home = tempfile::tempdir().unwrap();
1072 let workdir = tempfile::tempdir().unwrap();
1073 let root = home.path().join("conversations/capsule");
1074 let mut record =
1075 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1076 record.append(&[message(&id_at(1), "user", "a")]);
1077 let path = record.path();
1078
1079 let mut cache = RecordCache::default();
1080 assert_eq!(
1081 page(&mut cache, Some(&path), workdir.path(), None, 10)
1082 .unwrap()
1083 .total,
1084 1
1085 );
1086 assert!(page(&mut cache, None, workdir.path(), None, 10)
1087 .unwrap()
1088 .messages
1089 .is_empty());
1090 assert_eq!(
1091 page(&mut cache, Some(&path), workdir.path(), None, 10)
1092 .unwrap()
1093 .total,
1094 1
1095 );
1096 assert_eq!(cache.reads(), 1, "the parse survived the empty read");
1097 }
1098
1099 #[test]
1102 fn a_malformed_line_is_skipped_and_reported() {
1103 let home = tempfile::tempdir().unwrap();
1104 let workdir = tempfile::tempdir().unwrap();
1105 let dir = home.path().join("conversations/capsule/ctx_1");
1106 std::fs::create_dir_all(&dir).unwrap();
1107 let path = dir.join(RECORD_FILE_NAME);
1108 let mut raw = String::new();
1109 for n in 1..=3 {
1110 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1111 raw.push('\n');
1112 }
1113 raw.push_str("{\"role\":\"user\"");
1114 std::fs::write(&path, raw).unwrap();
1115
1116 let page = page(
1117 &mut RecordCache::default(),
1118 Some(&path),
1119 workdir.path(),
1120 None,
1121 10,
1122 )
1123 .unwrap();
1124 assert_eq!(page.total, 3);
1125 assert_eq!(page.messages.len(), 3);
1126
1127 let log =
1128 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1129 assert_eq!(
1130 log.lines()
1131 .filter(|line| line.contains("line 4 is not a message"))
1132 .count(),
1133 1,
1134 "reported once, naming the line: {log}"
1135 );
1136 }
1137
1138 #[test]
1144 fn a_failed_write_is_retried_and_leaves_no_hole() {
1145 let home = tempfile::tempdir().unwrap();
1146 let workdir = tempfile::tempdir().unwrap();
1147 let root = home.path().join("conversations/capsule");
1148 let blocker = root.join("ctx_1");
1149 std::fs::create_dir_all(&root).unwrap();
1150 std::fs::write(&blocker, "").unwrap();
1151
1152 let mut record =
1153 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1154 let first = message(&id_at(1), "user", "a");
1155 let second = message(&id_at(2), "assistant", "b");
1156 record.append(std::slice::from_ref(&first));
1157 assert!(
1158 blocker.is_file(),
1159 "the directory chain could not be created"
1160 );
1161
1162 std::fs::remove_file(&blocker).unwrap();
1163 record.append(&[first, second]);
1164
1165 let written: Vec<Value> = std::fs::read_to_string(record.path())
1166 .unwrap()
1167 .lines()
1168 .filter_map(parse_message_line)
1169 .collect();
1170 assert_eq!(
1171 written
1172 .iter()
1173 .map(|m| crate::agent::message_id(m).unwrap())
1174 .collect::<Vec<_>>(),
1175 vec![id_at(1), id_at(2)],
1176 "the message the failure cost is on disk, before the one that followed it"
1177 );
1178 }
1179
1180 #[test]
1182 fn a_paging_loop_reads_the_record_once() {
1183 let home = tempfile::tempdir().unwrap();
1184 let workdir = tempfile::tempdir().unwrap();
1185 let root = home.path().join("conversations/capsule");
1186 let mut record =
1187 ConversationRecord::open(&root, "ctx_1", workdir.path(), Some(CAPSULE)).unwrap();
1188 record.append(
1189 &(1..=250)
1190 .map(|n| message(&id_at(n), "user", "m"))
1191 .collect::<Vec<_>>(),
1192 );
1193 let path = record.path();
1194 let mut cache = RecordCache::default();
1195
1196 let mut pages = 0;
1197 let mut seen = Vec::new();
1198 let mut cursor: Option<String> = None;
1199 loop {
1200 let page = page(
1201 &mut cache,
1202 Some(&path),
1203 workdir.path(),
1204 cursor.as_deref(),
1205 25,
1206 )
1207 .unwrap();
1208 pages += 1;
1209 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1210 match page.next_cursor {
1211 Some(next) => cursor = Some(next),
1212 None => break,
1213 }
1214 }
1215 assert_eq!(pages, 10);
1216 assert_eq!(seen, (1..=250).rev().map(id_at).collect::<Vec<_>>());
1217 assert_eq!(cache.reads(), 1, "one read for the whole walk");
1218
1219 record.append(
1222 &(251..=260)
1223 .map(|n| message(&id_at(n), "user", "m"))
1224 .collect::<Vec<_>>(),
1225 );
1226 let mut seen = Vec::new();
1227 let mut cursor: Option<String> = None;
1228 let mut first_total = None;
1229 loop {
1230 let page = page(
1231 &mut cache,
1232 Some(&path),
1233 workdir.path(),
1234 cursor.as_deref(),
1235 25,
1236 )
1237 .unwrap();
1238 first_total.get_or_insert(page.total);
1239 seen.extend(page.messages.iter().map(|m| m.id.clone().unwrap()));
1240 match page.next_cursor {
1241 Some(next) => cursor = Some(next),
1242 None => break,
1243 }
1244 }
1245 assert_eq!(first_total, Some(260));
1246 assert_eq!(seen, (1..=260).rev().map(id_at).collect::<Vec<_>>());
1247 assert_eq!(cache.reads(), 2, "one further read for the second walk");
1248 }
1249
1250 #[test]
1252 fn a_malformed_line_is_reported_once_for_a_whole_walk() {
1253 let home = tempfile::tempdir().unwrap();
1254 let workdir = tempfile::tempdir().unwrap();
1255 let dir = home.path().join("conversations/capsule/ctx_1");
1256 std::fs::create_dir_all(&dir).unwrap();
1257 let path = dir.join(RECORD_FILE_NAME);
1258 let mut raw = String::new();
1259 for n in 1..=10 {
1260 raw.push_str(&serde_json::to_string(&message(&id_at(n), "user", "m")).unwrap());
1261 raw.push('\n');
1262 }
1263 raw.push_str("{\"role\":\"user\"");
1264 std::fs::write(&path, raw).unwrap();
1265
1266 let mut cache = RecordCache::default();
1267 let mut cursor: Option<String> = None;
1268 let mut pages = 0;
1269 loop {
1270 let page = page(
1271 &mut cache,
1272 Some(&path),
1273 workdir.path(),
1274 cursor.as_deref(),
1275 2,
1276 )
1277 .unwrap();
1278 pages += 1;
1279 assert_eq!(page.total, 10);
1280 match page.next_cursor {
1281 Some(next) => cursor = Some(next),
1282 None => break,
1283 }
1284 }
1285 assert_eq!(pages, 5);
1286
1287 let log =
1288 std::fs::read_to_string(workdir.path().join("logs/bootstrap.log")).unwrap_or_default();
1289 assert_eq!(
1290 log.lines()
1291 .filter(|line| line.contains("line 11 is not a message"))
1292 .count(),
1293 1,
1294 "one entry for the whole walk: {log}"
1295 );
1296 }
1297
1298 #[test]
1301 fn record_off_wins_over_a_declared_store() {
1302 let off = murmur_artifact::ContextConfig {
1303 max_tokens: None,
1304 record: false,
1305 record_store: Some("shey".to_string()),
1306 seed_budget: murmur_artifact::DEFAULT_SEED_BUDGET,
1307 seed_overflow_margin: murmur_artifact::DEFAULT_SEED_OVERFLOW_MARGIN,
1308 retain: None,
1309 };
1310 assert_eq!(resolve_record_name(Some(&off), "capsule"), None);
1311
1312 let on = murmur_artifact::ContextConfig {
1313 record: true,
1314 ..off.clone()
1315 };
1316 assert_eq!(
1317 resolve_record_name(Some(&on), "capsule"),
1318 Some("shey".to_string())
1319 );
1320 assert_eq!(
1321 resolve_record_name(None, "capsule"),
1322 Some("capsule".to_string()),
1323 "a capsule declaring no context block still records"
1324 );
1325 }
1326
1327 #[test]
1329 fn a_malformed_record_name_is_refused_by_name() {
1330 for value in ["", "../escape", "/abs", "a/b", ".hidden"] {
1331 let err = validate_record_segment("context.record_store", value)
1332 .expect_err("'{value}' must be refused");
1333 let message = err.to_string();
1334 assert!(message.contains(&format!("'{value}'")), "{message}");
1335 assert!(message.contains("single path segment"), "{message}");
1336 assert!(message.contains("context.record_store"), "{message}");
1337 }
1338 validate_record_segment("--context", "ctx_fixed").unwrap();
1339 }
1340}