Skip to main content

capsule_runtime/
conversation.rs

1//! The durable conversation record: `~/.murmur/conversations/<record>/<context-id>/conversation.jsonl`,
2//! one JSON object per line, in the order the messages entered the context.
3//!
4//! Three properties decide the layout:
5//!
6//! * **Outside every workdir, and never preopened.** A record outlives the session that wrote it,
7//!   so it cannot live in a session directory; and it is the whole conversation, so no artifact
8//!   gets it through a filesystem grant. `murmur:conversation/read` is the only way in.
9//! * **Keyed by capsule and context, not by workdir.** `<record>` defaults to the capsule name and
10//!   is overridable with `context.record_store`; `<context-id>` is the task's context id, which is
11//!   client-supplied over A2A and settable with `mur run --context`. Two runs sharing a context id
12//!   share one record.
13//! * **Append-only, and never fatal.** The runtime is the only writer. A directory that cannot be
14//!   created, a line that cannot be appended and a line that will not parse are each reported once
15//!   and then survived: a capsule whose record is unwritable must still do its work.
16//!
17//! Resolution and creation are separate, as they are for [`crate::state_store`]: [`record_root`]
18//! resolves and validates without touching the filesystem, and only the first append creates
19//! anything.
20
21use 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
30/// Directory under the murmur home holding every record, one subdirectory per record name.
31pub(crate) const CONVERSATION_ROOT_DIR: &str = "conversations";
32
33/// The record file inside `<record>/<context-id>/`.
34pub(crate) const RECORD_FILE_NAME: &str = "conversation.jsonl";
35
36/// The `type` value on a record's header line, and the only value a header is recognised by.
37pub(crate) const RECORD_HEADER_TYPE: &str = "murmur.record";
38
39/// What a context is called in a diagnostic when the task resolved no context id. Shared so the
40/// refusal to resume and the failure to continue a harness session name the same non-context.
41pub(crate) const UNRESOLVED_CONTEXT: &str = "<unresolved>";
42
43/// Mode applied to the conversation root, each record directory and each context directory:
44/// owner-only, because a record is the whole of one capsule's conversation.
45const RECORD_DIR_MODE: u32 = 0o700;
46
47/// Mode every record file is held at, on each append and each rewrite.
48pub(crate) const RECORD_FILE_MODE: u32 = 0o600;
49
50/// Prefix on every host-minted cursor, so a value the host did not mint is refused rather than
51/// parsed into a position.
52const CURSOR_PREFIX: &str = "mc_";
53
54/// Inclusive ceiling on `read-messages`' `limit`. A page is copied into the guest's memory whole,
55/// so the host caps it rather than trusting a caller's number.
56const MAX_PAGE_LIMIT: u32 = 100;
57
58/// What every refusal of a record or context name says. The rule itself is
59/// [`crate::state_store::validate_store_name`] — the one definition of "a single usable path
60/// segment" — and this is the wording an operator gets, which names all of it at once because the
61/// value they wrote is in front of them.
62const SEGMENT_RULE: &str = "must be a single path segment: no '/', no '.' or '..', not absolute, \
63                            and not starting with a dot";
64
65/// What one record has already dropped from its front.
66///
67/// Cumulative over the record's life: `dropped` is every message truncation has ever taken from
68/// this record, not what the last one took.
69#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub struct TruncationMarker {
71    pub dropped: u64,
72    /// The `msg_` id of the oldest message still in the file.
73    pub oldest_surviving_id: String,
74    /// The `msg_` id of the newest message that went. A reference to any message minted at or
75    /// before this one reads as truncated rather than as unknown — see
76    /// [`crate::retention::locate_message`].
77    pub last_dropped_id: String,
78    /// When the most recent truncation ran, in milliseconds since the epoch.
79    pub at_ms: u64,
80}
81
82/// The first line of a record: a JSON object with a `type` key and no `role`.
83///
84/// Not a message: every reader of a record skips a line that is not a JSON object carrying a
85/// string `role`, so a header is invisible to `murmur:conversation/read`, to the `threaded`
86/// reload, and to the `total` a page reports. Not a sidecar: a sidecar needs a second write that
87/// a crash could desynchronise from the rename, where a header makes the whole truncation
88/// exactly one atomic rename of one file.
89///
90/// [`Self::capsule`] is what makes automatic record pruning store-safe: two capsules pointed at
91/// one `context.record_store` each own only the records their own name is on.
92#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct RecordHeader {
94    /// Always [`RECORD_HEADER_TYPE`]. A first line carrying anything else is not a header.
95    #[serde(rename = "type")]
96    pub kind: String,
97    /// The capsule that owns this record — `name:` from its manifest, never the record store.
98    pub capsule: String,
99    /// When the header was written, in milliseconds since the epoch. On an adopted record, that
100    /// is the adoption, not the conversation's first message.
101    pub created_ms: u64,
102    /// Absent until this record has been truncated.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub truncated: Option<TruncationMarker>,
105}
106
107/// `header` as the one line it occupies, newline included.
108pub(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
115/// The header `path` carries, or `None` for a record whose first line is not one — an unowned
116/// record, and every file that does not exist.
117pub(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
122/// One line as a record header, or `None` when it is not one.
123pub(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
128/// The millisecond timestamp inside a `msg_` id, or `None` when the id is not one the runtime
129/// minted. Message ids are uuid v7 in simple form, so the first 12 hex characters are the mint
130/// time — which is what lets a dropped id be placed against a truncation marker without the
131/// messages themselves.
132pub(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
140/// Whether `value` is usable as one directory segment of a record path.
141///
142/// `field` names where the value came from (`context.record_store`, `--context`), because that is
143/// what an operator has to go and change. Refused at staging, before anything is created.
144pub(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
154/// The record name a capsule's `context:` block resolves to: what it declared, or the capsule
155/// name when it declared nothing. `None` when `context.record: off` — the switch wins over a
156/// `record_store` declared beside it.
157pub(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
173/// Where `record`'s conversations live. Resolves only — nothing is created and nothing is checked
174/// for existence — so a launch that never appends a message leaves no directory behind.
175pub(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
182/// The record file one context writes under `root`. Resolves only, like [`record_root`]: nothing
183/// is created and nothing is checked for existence.
184///
185/// `mur run --resume` asks whether this path exists before staging goes any further, which is the
186/// one question that separates "continue this conversation" from "start fresh and say nothing".
187pub(crate) fn record_file(root: &Path, context_id: &str) -> PathBuf {
188    root.join(context_id).join(RECORD_FILE_NAME)
189}
190
191/// One context's durable record, and the ids it already holds.
192///
193/// Held by the agent loop for one attempt. Every message the loop puts in the context goes through
194/// [`Self::append`], which is what makes the record the loop's own message list rather than a
195/// summary of it.
196pub(crate) struct ConversationRecord {
197    /// `<home>/.murmur/conversations/<record>/<context-id>`.
198    dir: PathBuf,
199    /// Session directory the failures are reported into, beside stderr.
200    workdir: PathBuf,
201    /// The capsule this record's header names, or `None` for a capsule that declares no
202    /// `context.retain` — which writes no header at all. See [`Self::ensure_header`].
203    capsule: Option<String>,
204    /// Whether the header has been settled for this instance. One check per attempt, not one per
205    /// message: the header is written or adopted at most once and then never looked at again.
206    header_ensured: bool,
207    /// Ids already on disk: those loaded from the record and those this attempt appended. A
208    /// compaction hook that hands a message back verbatim returns its id with it, and a threaded
209    /// reload starts from messages that are already lines — neither may be written twice.
210    recorded: HashSet<String>,
211    /// A write failure is reported once, then survived silently: one full disk must not produce
212    /// one stderr line per message.
213    failure_reported: bool,
214    /// A malformed line is reported once for this record, however many times it is read.
215    malformed_reported: bool,
216}
217
218impl ConversationRecord {
219    /// The record for `context_id` under `root`, or `None` when the context id is not a usable
220    /// path segment.
221    ///
222    /// A context id arrives from an A2A client as well as from `mur run --context`, so it is
223    /// checked here rather than trusted: a rejected id costs that conversation its record and
224    /// nothing else.
225    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    /// Every message the record already holds, oldest first, and never appended again.
257    ///
258    /// The agent loop starts a `threaded` task's message list from this. Each message keeps the
259    /// `id` its line carries, byte for byte.
260    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    /// Append every message not already in this record, in the order given.
285    ///
286    /// Called at each point a message enters the agent loop's context. A message whose id is
287    /// already recorded is skipped, which is what a compaction hook returning part of the context
288    /// verbatim produces.
289    ///
290    /// An id joins `recorded` only once its line is on disk. A failure the caller survives — a
291    /// disk that fills and is freed again, a descriptor limit that passes — is followed by an
292    /// append carrying the same messages, and a message marked as written before its write
293    /// succeeded would be skipped by that retry and lost from the record for good.
294    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    /// Create the directory chain if it is missing and append one line.
323    ///
324    /// Creation happens here rather than at staging so `context.record: off`, and a launch that
325    /// never reaches a message, both leave nothing behind.
326    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    /// Put a [`RecordHeader`] at the front of this record, once, before its first new line.
344    ///
345    /// A capsule that declares no `context.retain` writes no header: there is nothing to own a
346    /// record for, and a record that gains a line on upgrade is a behaviour change an operator
347    /// did not ask for. Everything below is therefore conditional on [`Self::capsule`].
348    ///
349    /// Three cases, and the third is the whole reason this is not just a write:
350    ///
351    /// * A record that does not exist yet gets its header as its first line.
352    /// * A record that already carries one is left exactly as it is.
353    /// * A record written before the header existed is **adopted**: the header is prepended
354    ///   through the same atomic temp-and-rename rewrite a truncation uses, so a crash leaves the
355    ///   original whole. Until that adoption the record is unowned and automatic pruning skips
356    ///   it, however old it is.
357    ///
358    /// Adoption is what brings a headerless record under a policy without ever guessing at
359    /// ownership: the capsule that writes to a record is the capsule that owns it.
360    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        // The flag is set only once the header is on disk: a failure the caller survives is
387        // followed by an append carrying the same messages, and a record marked as headered
388        // before its header was written would take those messages headerless and stay unowned.
389        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
408/// Open a record file for appending, or for replacing when `truncate`, held at
409/// [`RECORD_FILE_MODE`] whether or not this call created it.
410fn 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/// The parsed record one reader holds between pages.
429///
430/// A hook pages a record it does not shrink and cannot write, so re-reading and re-parsing the
431/// whole file for each page makes a walk quadratic in a file designed to grow without bound. One
432/// cache serves a whole launch: [`crate::conversation_import::ConversationState`] holds it across
433/// every task, which is why the key carries the path as well as the length.
434#[derive(Default)]
435pub(crate) struct RecordCache {
436    /// Record the messages below were parsed from, and `None` while no parse is trusted.
437    path: Option<PathBuf>,
438    /// Length of that file at the moment it was stat'd for the parse below.
439    len: u64,
440    /// The record, oldest first, as [`read_record`] returned it.
441    messages: Vec<Value>,
442    /// A malformed line is reported once for this reader, however many pages it walks.
443    malformed_reported: bool,
444    /// Reads of the record file that reached the filesystem. What pins the cost of a paging loop.
445    #[cfg(test)]
446    reads: u32,
447}
448
449impl RecordCache {
450    /// The whole record, oldest first, parsed at most once per length the file has held.
451    ///
452    /// The runtime is the record's only writer and only ever appends, so a file whose length is
453    /// unchanged holds the same bytes and the parse above it still stands. A missing file is an
454    /// empty record of length 0, exactly as a read of one is.
455    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        // Cleared before the read and set again only after the messages are in place, so a read
466        // that fails leaves a cache that re-reads rather than one record's messages under
467        // another's key.
468        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
485/// One page of a record, newest first, as `murmur:conversation/read` serves it.
486///
487/// `path` is `None` for a session that writes no record — `context.record: off`, a
488/// `process`-transport capsule, or a task whose context id was refused — and reads as an empty
489/// page rather than an error: there is nothing wrong with a conversation that has said nothing
490/// yet. That case leaves `cache` alone, so a hook dispatched between two tasks does not cost the
491/// next one its parse.
492pub(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    // A cursor is the exclusive upper bound of the next page, counted from the oldest message, so
507    // it stays valid while the runtime appends to the record underneath a paging hook.
508    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    // `murmur:conversation/read` re-uses `murmur:hook/lifecycle`'s `message`, but bindgen
520    // generates the imported and exported views of it as two Rust types. The lowering itself is
521    // `to_wit_messages`, so a "tool" message reaches a reader folded exactly as it reaches a
522    // compaction hook.
523    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
541/// What a record with nothing in it reads as.
542fn empty_page() -> MessagePage {
543    MessagePage {
544        messages: Vec::new(),
545        next_cursor: None,
546        total: 0,
547    }
548}
549
550/// Every message in `path`, oldest first.
551///
552/// The single parser both readers go through — the `murmur:conversation/read` page and the
553/// `threaded` reload — so a line the hook can see is a line the next task will load. A line that
554/// is not a JSON object with a string `role` is skipped; a truncated final line is exactly that
555/// case. A missing file is an empty record, not an error.
556///
557/// `reported` is the reader's own once-only flag: the first skipped line it meets is reported
558/// naming the path and the 1-based line number, and every line after it, in this read and in the
559/// reader's later ones, is silent. One such line is worth one log entry however many times the
560/// reader goes back to the file.
561fn 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
593/// One record line as a message, or `None` when it is not one. A message is a JSON object
594/// carrying a string `role`: everything the runtime writes is, and nothing else can be lowered
595/// into the WIT `message` record. The header line is the deliberate non-message — it is how a
596/// truncation marker rides in the file without being visible to anything that reads messages.
597pub(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
614/// Create the conversation root, the record directory and `dir` itself if they are missing,
615/// oldest ancestor first, so a context directory is never reachable through a parent a wider mode
616/// left open.
617///
618/// When the conversation root sits in `~/.murmur`, the home itself is held at `0700` first.
619///
620/// `dir` is `<conversation root>/<record>/<context-id>` — the directory holding this context's
621/// record, and the one [`crate::harness_session`] puts its map file in for a capsule whose harness
622/// owns the conversation instead.
623pub(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
644/// Both places an operator looks for what the runtime did with a record. Never fatal.
645pub(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    /// The capsule every record below is opened for, and therefore the name its header carries.
656    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    /// Adopting a headerless record rewrites it by rename; the replacement is owner-only too.
697    #[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    /// The header is the first line a new record gets, and it names the capsule that wrote it —
748    /// which is what makes automatic pruning store-safe when two capsules share one record store.
749    #[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    /// The header is invisible to everything that reads messages: `read_record` skips it, so the
770    /// `total` a `murmur:conversation/read` page reports counts messages and nothing else.
771    #[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    /// A record written before the header existed is unowned, and is adopted on the next append
797    /// by the capsule that writes to it — through the same atomic rewrite a truncation uses, so
798    /// every line it already held survives byte for byte.
799    #[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    /// A record already carrying a header is left exactly as it is — the header is written once,
831    /// not once per launch, and a truncation marker on it is never cleared by an append.
832    #[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    /// A record written by one session and read back by the next: the ids are the same bytes, and
861    /// nothing loaded is appended a second time.
862    #[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    /// Every directory on the record path is owner-only, and none of them exists before the first
891    /// message is appended.
892    #[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    /// A context id that is not one path segment costs that conversation its record, and refuses
918    /// before any path is built from it.
919    #[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    /// Newest first, paged, with a cursor that neither repeats nor skips a message.
939    #[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    /// `limit` is the host's to bound: `0` still makes progress and a huge value cannot ask for
978    /// more than the ceiling.
979    #[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    /// A value that is not `mc_<number>`, and a position past the end of the record, are both
1022    /// `invalid-cursor` rather than a page.
1023    #[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    /// A record that was never written, and a session with no record at all, are both an empty
1050    /// page — never an error.
1051    #[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    /// A read for a session that keeps no record leaves the cache as it found it, so a hook
1068    /// dispatched between two tasks does not cost the next one its parse.
1069    #[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    /// A malformed line is skipped, reported once naming the path and its 1-based number, and
1100    /// costs the read nothing else.
1101    #[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    /// A write that fails and then can succeed leaves the record whole: the message the failure
1139    /// cost is written by the next append that carries it, in its own order.
1140    ///
1141    /// The failure is a regular file sitting where the context directory has to go, which no
1142    /// permission bit is involved in — the suite reads the same as root and as anyone else.
1143    #[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    /// A whole paging walk costs one read of the record and one parse of it.
1181    #[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        // The record grows underneath the same reader: the next walk sees the new messages and
1220        // pays for exactly one more read.
1221        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    /// One truncated line is one log entry, however many pages are walked over it.
1251    #[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    /// `context.record: off` resolves to no record name at all, whatever `record_store` says
1299    /// beside it.
1300    #[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    /// Every refusal names the value the operator wrote and the rule it broke.
1328    #[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}