Skip to main content

majordomus_cli/session/
store.rs

1//! Persistence: the open-episode store, the closed-record store, and closing exactly once.
2//!
3//! # The incident this module is built around
4//!
5//! Episode `s-20260909152316-024f` has four immutable records in `.ai/repo/sessions/`: one
6//! at 21:41:35 with 3 changed files and 94 commits, and three the next morning at 10:39:02,
7//! 10:39:18 and 10:39:31 with 120, 121 and 122 changed files and 296 commits. A provider's
8//! end event can fire more than once — thirteen and fifteen seconds apart, in this case —
9//! and `mj_publish_record` is deliberately designed never to collide, so each close wrote a
10//! *new file* rather than failing. The record is immutable by contract; the store had four
11//! of them.
12//!
13//! `lib/session.sh` was hardened after the incident: before publishing, it greps the store
14//! for a record already carrying the episode's id and, finding one, keeps it. That removes
15//! the *repeat* and not the *race*. Between the grep and the publish sit a `git status`, a
16//! ledger walk over every line of the episode, and the composition of the record — hundreds
17//! of milliseconds on a loaded machine. Two processes that grep inside that window both see
18//! nothing and both publish. A check without an atomic claim is a time-of-check-to-
19//! time-of-use window with the expensive part inside it.
20//!
21//! # The guarantee
22//!
23//! [`SessionStore::close`] takes an atomic claim **before** the expensive part:
24//! `state/sessions-closing/<episode id>.claim`, created with `O_EXCL`. The filesystem
25//! decides, once, which caller created it; every other caller — another thread, another
26//! process, another provider window on the same machine — observes the claim and returns a
27//! diagnosable outcome instead of writing. The claim is the whole mechanism: it is not an
28//! in-process mutex, so the test below that spawns real child processes exercises the same
29//! primitive as the one that spawns threads.
30//!
31//! Three outcomes, and only one of them writes:
32//!
33//! * [`CloseOutcome::Closed`] — this caller took the claim and published the record.
34//! * [`CloseOutcome::AlreadyClosed`] — a record for this episode already stands. Not an
35//!   error: a provider's second end event is that provider's normal behaviour. The existing
36//!   record's path is returned, because every reader downstream must name the record that
37//!   exists rather than the file that was not written.
38//! * [`CloseOutcome::AlreadyClosing`] — another caller holds the claim right now. The
39//!   caller is told which episode and that it may retry; nothing is written.
40//!
41//! And one diagnostic that is not an outcome of this close at all:
42//! [`CloseReport::duplicates`] names every *historical* record of the episode beyond the
43//! first. This repository already contains four records for one episode, and a closer that
44//! silently kept the first would leave a reader to discover the other three. Damage that
45//! already exists is reported, never added to.
46//!
47//! ```
48//! use majordomus_cli::session::{CloseOutcome, Episode, EpisodeId, Outcome, SessionStore};
49//! use std::path::Path;
50//!
51//! let dir = tempfile::tempdir().unwrap();
52//! let store = SessionStore::writable(dir.path());
53//! let id = EpisodeId::parse("s-20260909152316-024f").expect("the id from the incident");
54//! let episode = Episode::opening(id.clone(), Path::new("/r"))
55//!     .opened_at("2026-09-09T15:23:16Z", "master", "9f3a2a0", 1_000);
56//! let body = || format!("---\nschema: session/v1\nsession_id: {}\n---\n", id);
57//!
58//! let first = store.close(&episode, Outcome::Closed, body).unwrap();
59//! let second = store.close(&episode, Outcome::Closed, body).unwrap();
60//! assert_eq!(first.outcome, CloseOutcome::Closed);
61//! assert_eq!(second.outcome, CloseOutcome::AlreadyClosed);
62//! assert_eq!(store.records_of(&id).len(), 1, "the incident produced four");
63//! ```
64
65use std::path::{Path, PathBuf};
66
67use schemars::JsonSchema;
68use serde::{Deserialize, Serialize};
69
70use super::episode::{Episode, Outcome};
71use super::identity::EpisodeId;
72use super::state::EpisodeState;
73
74/// Where the open episodes live, relative to the repository root.
75pub const OPEN_DIR: &str = ".ai/local/state/sessions-open";
76
77/// Where a close takes its claim, relative to the repository root. A directory of its own
78/// rather than a lock beside the record, because the record store is the *shared* half of
79/// the layer and a claim is a fact about this machine (ADR 0014).
80pub const CLAIM_DIR: &str = ".ai/local/state/sessions-closing";
81
82/// Where the immutable records live, relative to the repository root. Tracked: a closed
83/// episode is a shared object of the layer.
84pub const RECORD_DIR: &str = ".ai/repo/sessions";
85
86/// What a close did: wrote the record, found one already standing, found another caller
87/// inside the window, or was refused by the machine.
88///
89/// ```
90/// use majordomus_cli::session::CloseOutcome;
91/// // a duplicate close is a diagnosable outcome, not an error: a provider's second end
92/// // event is that provider's normal behaviour
93/// assert!(CloseOutcome::Closed.wrote());
94/// assert!(!CloseOutcome::AlreadyClosed.wrote());
95/// ```
96#[derive(
97    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
98)]
99#[serde(rename_all = "snake_case")]
100pub enum CloseOutcome {
101    /// This caller took the claim and published the record. Exactly one caller per episode
102    /// ever sees this.
103    Closed,
104    /// A record for this episode already stands; this close added none.
105    AlreadyClosed,
106    /// Another caller holds the claim at this moment. Nothing was written.
107    AlreadyClosing,
108    /// The episode is not in a state the machine lets it leave. Nothing was written.
109    Refused,
110}
111
112impl CloseOutcome {
113    /// Did this call publish the record?
114    ///
115    /// ```
116    /// use majordomus_cli::session::CloseOutcome;
117    /// assert!(CloseOutcome::Closed.wrote());
118    /// assert!(!CloseOutcome::AlreadyClosed.wrote());
119    /// ```
120    pub fn wrote(self) -> bool {
121        matches!(self, CloseOutcome::Closed)
122    }
123
124    /// The word as serialised, which is what a caller prints and what a projection of a
125    /// close carries.
126    ///
127    /// ```
128    /// use majordomus_cli::session::CloseOutcome;
129    /// assert_eq!(CloseOutcome::AlreadyClosing.as_str(), "already_closing");
130    /// ```
131    pub fn as_str(self) -> &'static str {
132        match self {
133            CloseOutcome::Closed => "closed",
134            CloseOutcome::AlreadyClosed => "already_closed",
135            CloseOutcome::AlreadyClosing => "already_closing",
136            CloseOutcome::Refused => "refused",
137        }
138    }
139}
140
141/// What a close said: the episode, the outcome, the canonical record, any historical
142/// duplicates of it, and a line a person can act on.
143///
144/// ```
145/// use majordomus_cli::session::{CloseReport, Episode, EpisodeId, Outcome, SessionStore};
146/// use std::path::Path;
147///
148/// let dir = tempfile::tempdir().unwrap();
149/// let store = SessionStore::writable(dir.path());
150/// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
151/// let episode = Episode::opening(id.clone(), Path::new("/r"))
152///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
153/// let report: CloseReport = store
154///     .close(&episode, Outcome::Closed, || format!("---\nsession_id: {}\n---\n", id))
155///     .expect("a close");
156/// assert_eq!(report.episode, id);
157/// assert!(report.duplicates.is_empty(), "a clean store holds no damage to report");
158/// ```
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160pub struct CloseReport {
161    /// The episode.
162    pub episode: EpisodeId,
163    /// What happened.
164    pub outcome: CloseOutcome,
165    /// The canonical record of this episode: the one this call wrote, or the one that
166    /// already stood. Repository-relative. Empty when nothing was written and nothing was
167    /// found.
168    #[serde(default, skip_serializing_if = "String::is_empty")]
169    pub record: String,
170    /// Historical records of this episode beyond the canonical one, repository-relative
171    /// and in canonical order. Non-empty means the store already holds damage of the kind
172    /// this closer exists to prevent, and the caller is told rather than left to find it.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub duplicates: Vec<String>,
175    /// A line a person can act on, when there is one to say.
176    #[serde(default, skip_serializing_if = "String::is_empty")]
177    pub diagnostic: String,
178}
179
180/// What can go wrong closing an episode, as distinct from an outcome that is not `Closed`.
181/// An outcome is a fact about the episode; an error is a fact about the disk, or about a
182/// caller that reached the write path without saying it meant to.
183///
184/// ```
185/// use majordomus_cli::session::{Episode, EpisodeId, Outcome, SessionStore, StoreError};
186/// use std::path::Path;
187///
188/// let dir = tempfile::tempdir().unwrap();
189/// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
190/// let episode = Episode::opening(id, Path::new("/r"))
191///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
192/// let err = SessionStore::read_only(dir.path())
193///     .close(&episode, Outcome::Closed, String::new)
194///     .expect_err("a refusal");
195/// assert!(matches!(err, StoreError::NotWritable));
196/// ```
197#[derive(Debug, thiserror::Error)]
198pub enum StoreError {
199    /// A directory or file of the store could not be created, read or written.
200    #[error("the session store at {path} could not be used: {reason}")]
201    Io {
202        /// Where.
203        path: String,
204        /// What went wrong.
205        reason: String,
206    },
207    /// The domain's write path was reached without the opt-in. Additive by construction:
208    /// while the shell tool owns the writes, a caller that has not said so explicitly is
209    /// refused rather than allowed to produce a second writer for one record.
210    #[error(
211        "the session domain's write path is not enabled in this build path; \
212         construct the store with SessionStore::writable (ADR 0047: the shell keeps the writes \
213         until the cutover)"
214    )]
215    NotWritable,
216}
217
218/// The stores of one checkout.
219///
220/// Reading needs no permission. Writing needs [`SessionStore::writable`], which is the
221/// flag ADR 0047 promised: while `lib/session.sh` owns the lifecycle, nothing that is
222/// merely *served* can write a session record, and a caller that has not said out loud
223/// that it means to is refused by [`StoreError::NotWritable`].
224///
225/// ```
226/// use majordomus_cli::session::SessionStore;
227///
228/// let dir = tempfile::tempdir().unwrap();
229/// let reading = SessionStore::read_only(dir.path());
230/// assert!(!reading.is_writable());
231/// assert!(SessionStore::writable(dir.path()).is_writable());
232/// ```
233#[derive(Debug, Clone)]
234pub struct SessionStore {
235    root: PathBuf,
236    writable: bool,
237}
238
239impl SessionStore {
240    /// A store that reads. Every projection uses this one, and a caller that reaches the
241    /// write path through it is refused rather than allowed to become a second writer.
242    ///
243    /// ```
244    /// use majordomus_cli::session::SessionStore;
245    /// let dir = tempfile::tempdir().unwrap();
246    /// assert!(!SessionStore::read_only(dir.path()).is_writable());
247    /// ```
248    pub fn read_only(root: impl Into<PathBuf>) -> Self {
249        SessionStore {
250            root: root.into(),
251            writable: false,
252        }
253    }
254
255    /// A store that may write. Not reachable from any surface in this change: the cutover
256    /// (ADR 0047) is what routes `majordomus session close` through it.
257    ///
258    /// ```
259    /// use majordomus_cli::session::SessionStore;
260    /// let dir = tempfile::tempdir().unwrap();
261    /// assert!(SessionStore::writable(dir.path()).is_writable());
262    /// ```
263    pub fn writable(root: impl Into<PathBuf>) -> Self {
264        SessionStore {
265            root: root.into(),
266            writable: true,
267        }
268    }
269
270    /// May this store write? The question the closer asks first, before it touches the
271    /// disk at all.
272    ///
273    /// ```
274    /// use majordomus_cli::session::SessionStore;
275    /// let dir = tempfile::tempdir().unwrap();
276    /// assert!(!SessionStore::read_only(dir.path()).is_writable());
277    /// ```
278    pub fn is_writable(&self) -> bool {
279        self.writable
280    }
281
282    /// The repository root it is over, which every path below is composed from.
283    ///
284    /// ```
285    /// use majordomus_cli::session::SessionStore;
286    /// let dir = tempfile::tempdir().unwrap();
287    /// assert_eq!(SessionStore::read_only(dir.path()).root(), dir.path());
288    /// ```
289    pub fn root(&self) -> &Path {
290        &self.root
291    }
292
293    /// Where the claims live: the local half, because a claim is a fact about this machine
294    /// and the record store is the shared half of the layer (ADR 0014).
295    ///
296    /// ```
297    /// use majordomus_cli::session::SessionStore;
298    /// let dir = tempfile::tempdir().unwrap();
299    /// assert!(SessionStore::read_only(dir.path()).claim_dir().ends_with("sessions-closing"));
300    /// ```
301    pub fn claim_dir(&self) -> PathBuf {
302        self.root.join(CLAIM_DIR)
303    }
304
305    /// Where the immutable records live: the tracked half, because a closed episode is a
306    /// shared object of the layer and travels with a clone.
307    ///
308    /// ```
309    /// use majordomus_cli::session::SessionStore;
310    /// let dir = tempfile::tempdir().unwrap();
311    /// assert!(SessionStore::read_only(dir.path()).record_dir().ends_with(".ai/repo/sessions"));
312    /// ```
313    pub fn record_dir(&self) -> PathBuf {
314        self.root.join(RECORD_DIR)
315    }
316
317    /// Every record in the store that names `episode`, repository-relative, in canonical
318    /// order — which for these filenames is chronological, because the name begins with a
319    /// compact UTC timestamp.
320    ///
321    /// The scan reads front matter rather than grepping the whole file, so a record whose
322    /// *body* quotes another episode's id is not counted as that episode's record. The
323    /// shell's `grep -rl "^session_id: <id>$"` has the same intent and reaches it by
324    /// anchoring; this reads the field.
325    ///
326    /// ```
327    /// use majordomus_cli::session::{EpisodeId, SessionStore};
328    ///
329    /// let dir = tempfile::tempdir().unwrap();
330    /// let records = dir.path().join(".ai/repo/sessions");
331    /// std::fs::create_dir_all(&records).unwrap();
332    /// std::fs::write(
333    ///     records.join("20260910T210426Z--a.md"),
334    ///     "---\nschema: session/v1\nsession_id: s-20260910205542-e2a6\n---\n",
335    /// ).unwrap();
336    /// std::fs::write(
337    ///     records.join("20260910T211503Z--b.md"),
338    ///     "---\nschema: session/v1\nsession_id: s-20260910210510-8f20\n---\n\nquotes s-20260910205542-e2a6 in prose\n",
339    /// ).unwrap();
340    ///
341    /// let store = SessionStore::read_only(dir.path());
342    /// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
343    /// let found = store.records_of(&id);
344    /// assert_eq!(found.len(), 1, "the field, not the prose");
345    /// assert!(found[0].ends_with("20260910T210426Z--a.md"));
346    /// ```
347    pub fn records_of(&self, episode: &EpisodeId) -> Vec<String> {
348        let dir = self.record_dir();
349        let Ok(entries) = std::fs::read_dir(&dir) else {
350            return Vec::new();
351        };
352        let mut out: Vec<String> = Vec::new();
353        for entry in entries.filter_map(|e| e.ok()) {
354            let path = entry.path();
355            if path.extension().is_none_or(|x| x != "md") {
356                continue;
357            }
358            let Ok(text) = std::fs::read_to_string(&path) else {
359                continue;
360            };
361            if front_matter_field(&text, "session_id").as_deref() == Some(episode.as_str()) {
362                out.push(
363                    path.strip_prefix(&self.root)
364                        .unwrap_or(&path)
365                        .to_string_lossy()
366                        .to_string(),
367                );
368            }
369        }
370        // The names begin with a compact UTC timestamp, so a plain lexicographic order is
371        // chronological. `canonical` is the crate's one ordering, and it is used here so
372        // that the "first record wins" rule is decided the same way by every reader.
373        let mut sortable: Vec<RecordPath> = out.into_iter().map(RecordPath).collect();
374        crate::order::canonical(&mut sortable);
375        sortable.into_iter().map(|r| r.0).collect()
376    }
377
378    /// Close `episode`, exactly once.
379    ///
380    /// `compose` is called only by the caller that took the claim, and only after it did:
381    /// composing a record is the expensive part — a `git status`, a walk of the episode's
382    /// ledger lines — and doing it before the claim is what leaves the window the shell's
383    /// scan cannot close. It returns the record's full text.
384    ///
385    /// ```
386    /// use majordomus_cli::session::{CloseOutcome, Episode, EpisodeId, Outcome, SessionStore};
387    /// use std::path::Path;
388    ///
389    /// let dir = tempfile::tempdir().unwrap();
390    /// let store = SessionStore::writable(dir.path());
391    /// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
392    /// let episode = Episode::opening(id.clone(), Path::new("/r"))
393    ///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
394    ///
395    /// let body = || format!("---\nschema: session/v1\nkind: session\nsession_id: {}\n---\n", id);
396    ///
397    /// // the first close writes
398    /// let first = store.close(&episode, Outcome::Closed, body).unwrap();
399    /// assert_eq!(first.outcome, CloseOutcome::Closed);
400    /// assert!(first.record.starts_with(".ai/repo/sessions/"));
401    ///
402    /// // the second, third and fourth do not, and each names the record that stands
403    /// for _ in 0..3 {
404    ///     let again = store.close(&episode, Outcome::Closed, body).unwrap();
405    ///     assert_eq!(again.outcome, CloseOutcome::AlreadyClosed);
406    ///     assert_eq!(again.record, first.record);
407    /// }
408    /// assert_eq!(store.records_of(&id).len(), 1, "one episode, one record");
409    /// ```
410    pub fn close<F>(
411        &self,
412        episode: &Episode,
413        outcome: Outcome,
414        compose: F,
415    ) -> Result<CloseReport, StoreError>
416    where
417        F: Fn() -> String,
418    {
419        if !self.writable {
420            return Err(StoreError::NotWritable);
421        }
422        let id = episode.id.clone();
423
424        // 1. The machine, before anything touches the disk. An episode that is already
425        //    closed in memory is refused here; one that is closed on disk is caught by the
426        //    scan below, which is the authority across processes.
427        if !episode.may_move_to(EpisodeState::Closed) {
428            return Ok(CloseReport {
429                episode: id,
430                outcome: CloseOutcome::Refused,
431                record: String::new(),
432                duplicates: Vec::new(),
433                diagnostic: format!(
434                    "an episode in state `{}` does not move to `closed`",
435                    episode.state.as_str()
436                ),
437            });
438        }
439
440        // 2. The claim, before the expensive part. O_EXCL: the filesystem picks one caller.
441        let claim = self.claim_path(&id);
442        let io = |path: &Path, e: std::io::Error| StoreError::Io {
443            path: path.display().to_string(),
444            reason: e.to_string(),
445        };
446        std::fs::create_dir_all(self.claim_dir()).map_err(|e| io(&self.claim_dir(), e))?;
447        let held = match std::fs::OpenOptions::new()
448            .write(true)
449            .create_new(true)
450            .open(&claim)
451        {
452            Ok(_) => true,
453            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
454            Err(e) => return Err(io(&claim, e)),
455        };
456        if !held {
457            // Somebody else is inside the window, or was and did not clean up. Which of
458            // the two it is, is a question only the record store can answer.
459            let existing = self.records_of(&id);
460            let (outcome, record, diagnostic) = match existing.first() {
461                Some(path) => (
462                    CloseOutcome::AlreadyClosed,
463                    path.clone(),
464                    format!("session {id} already has a record at {path}; this close adds none"),
465                ),
466                None => (
467                    CloseOutcome::AlreadyClosing,
468                    String::new(),
469                    format!(
470                        "session {id} is being closed by another process (claim at {}); \
471                         nothing was written",
472                        display_relative(&self.root, &claim)
473                    ),
474                ),
475            };
476            return Ok(CloseReport {
477                episode: id,
478                outcome,
479                record,
480                duplicates: existing.into_iter().skip(1).collect(),
481                diagnostic,
482            });
483        }
484
485        // From here the claim is held and every path must release it.
486        let guard = ClaimGuard { path: claim };
487
488        // 3. A record may already stand from a close that finished before this one began —
489        //    the ordinary repeated end event, seconds apart, with no overlap at all.
490        let existing = self.records_of(&id);
491        if let Some(first) = existing.first().cloned() {
492            let duplicates: Vec<String> = existing.into_iter().skip(1).collect();
493            let mut diagnostic =
494                format!("session {id} already has a record at {first}; this close adds none");
495            if !duplicates.is_empty() {
496                diagnostic.push_str(&format!(
497                    "; the store also holds {} further record(s) of this episode, which is \
498                     damage that predates this close and is reported rather than added to: {}",
499                    duplicates.len(),
500                    duplicates.join(", ")
501                ));
502            }
503            drop(guard);
504            return Ok(CloseReport {
505                episode: id,
506                outcome: CloseOutcome::AlreadyClosed,
507                record: first,
508                duplicates,
509                diagnostic,
510            });
511        }
512
513        // 4. The expensive part, inside the claim.
514        let _ = outcome;
515        let text = compose();
516        let dir = self.record_dir();
517        std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
518        let name = format!("{}--{}.md", compact_stamp_of(&id), id);
519        let path = dir.join(&name);
520        std::fs::write(&path, text).map_err(|e| io(&path, e))?;
521
522        drop(guard);
523        let record = display_relative(&self.root, &path);
524        Ok(CloseReport {
525            episode: id,
526            outcome: CloseOutcome::Closed,
527            record,
528            duplicates: Vec::new(),
529            diagnostic: String::new(),
530        })
531    }
532
533    /// Where the claim of `episode` is. The episode id is parsed, so it is one inert path
534    /// segment and nothing a provider sends can name a file outside the directory.
535    ///
536    /// ```
537    /// use majordomus_cli::session::{EpisodeId, SessionStore};
538    /// let dir = tempfile::tempdir().unwrap();
539    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
540    /// let claim = SessionStore::read_only(dir.path()).claim_path(&id);
541    /// assert!(claim.ends_with("s-20260910205542-e2a6.claim"));
542    /// assert!(!claim.exists(), "asking where it would be does not take it");
543    /// ```
544    pub fn claim_path(&self, episode: &EpisodeId) -> PathBuf {
545        self.claim_dir().join(format!("{episode}.claim"))
546    }
547}
548
549/// The claim, released when the close leaves by any path — including a panic, which is
550/// what stops a crashed closer from locking an episode out of ever being closed.
551struct ClaimGuard {
552    path: PathBuf,
553}
554
555impl Drop for ClaimGuard {
556    fn drop(&mut self) {
557        let _ = std::fs::remove_file(&self.path);
558    }
559}
560
561/// A record path, ordered by the crate's one ordering.
562struct RecordPath(String);
563
564impl crate::order::Ordered for RecordPath {
565    fn order_key(&self) -> crate::order::OrderKey<'_> {
566        crate::order::OrderKey {
567            group: None,
568            rank: 0,
569            label: &self.0,
570            identity: &self.0,
571        }
572    }
573}
574
575/// One scalar of a record's front matter, read without a YAML parse of the whole document.
576/// The field is matched at the start of a line inside the fences, which is what stops the
577/// body's prose from answering.
578fn front_matter_field(text: &str, key: &str) -> Option<String> {
579    let mut fences = 0usize;
580    for line in text.lines() {
581        if line.trim_end() == "---" {
582            fences += 1;
583            if fences >= 2 {
584                return None;
585            }
586            continue;
587        }
588        if fences != 1 {
589            continue;
590        }
591        if let Some(rest) = line.strip_prefix(key) {
592            if let Some(value) = rest.strip_prefix(':') {
593                return Some(value.trim().trim_matches('"').to_string());
594            }
595        }
596    }
597    None
598}
599
600/// The compact UTC stamp a record file is named by, taken from the episode's own id so
601/// that the name is a function of the episode and two closes cannot produce two names.
602///
603/// This is the difference from `mj_publish_record`, which names a file by *now* plus
604/// sixteen random hex digits and is therefore guaranteed never to collide — the property
605/// that turned a repeated close into a second record instead of a refusal.
606fn compact_stamp_of(id: &EpisodeId) -> String {
607    let stamp = id.minted_at();
608    format!("{}T{}Z", &stamp[..8], &stamp[8..])
609}
610
611fn display_relative(root: &Path, path: &Path) -> String {
612    path.strip_prefix(root)
613        .unwrap_or(path)
614        .to_string_lossy()
615        .to_string()
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use std::path::Path;
622
623    fn episode(id: &str) -> Episode {
624        Episode::opening(EpisodeId::parse(id).expect("an id"), Path::new("/r")).opened_at(
625            "2026-09-10T20:55:42Z",
626            "master",
627            "9f3a2a0",
628            1_000,
629        )
630    }
631
632    fn body(id: &str) -> String {
633        format!("---\nschema: session/v1\nkind: session\nsession_id: {id}\n---\n")
634    }
635
636    #[test]
637    fn a_read_only_store_refuses_to_write() {
638        let dir = tempfile::tempdir().expect("a temp dir");
639        let store = SessionStore::read_only(dir.path());
640        let e = episode("s-20260910205542-e2a6");
641        let err = store
642            .close(&e, Outcome::Closed, || body("s-20260910205542-e2a6"))
643            .expect_err("a refusal");
644        assert!(matches!(err, StoreError::NotWritable));
645        assert!(!dir.path().join(RECORD_DIR).exists());
646    }
647
648    #[test]
649    fn four_repeated_closes_yield_exactly_one_record() {
650        // The incident, reproduced: s-20260909152316-024f was closed at 21:41:35 and then
651        // again at 10:39:02, 10:39:18 and 10:39:31.
652        let dir = tempfile::tempdir().expect("a temp dir");
653        let store = SessionStore::writable(dir.path());
654        let id = "s-20260909152316-024f";
655        let e = episode(id);
656
657        let mut wrote = 0usize;
658        let mut paths = std::collections::BTreeSet::new();
659        for _ in 0..4 {
660            let report = store
661                .close(&e, Outcome::Closed, || body(id))
662                .expect("a close");
663            if report.outcome.wrote() {
664                wrote += 1;
665            }
666            assert!(!report.record.is_empty(), "every close names the record");
667            paths.insert(report.record);
668        }
669        assert_eq!(wrote, 1, "exactly one close wrote");
670        assert_eq!(paths.len(), 1, "and every close named the same record");
671        assert_eq!(
672            store.records_of(&EpisodeId::parse(id).unwrap()).len(),
673            1,
674            "one episode, one record"
675        );
676    }
677
678    #[test]
679    fn simultaneous_closes_from_many_threads_yield_exactly_one_record() {
680        let dir = tempfile::tempdir().expect("a temp dir");
681        let store = SessionStore::writable(dir.path());
682        let id = "s-20260910205542-e2a6";
683        let e = episode(id);
684
685        let wrote = std::sync::atomic::AtomicUsize::new(0);
686        let closing = std::sync::atomic::AtomicUsize::new(0);
687        let start = std::sync::Barrier::new(8);
688        std::thread::scope(|scope| {
689            for _ in 0..8 {
690                let (store, e, wrote, closing, start) = (&store, &e, &wrote, &closing, &start);
691                scope.spawn(move || {
692                    start.wait();
693                    let report = store
694                        .close(e, Outcome::Closed, || {
695                            // the expensive part: long enough that every thread is inside
696                            // the window the shell's scan leaves open
697                            std::thread::sleep(std::time::Duration::from_millis(25));
698                            body(id)
699                        })
700                        .expect("a close");
701                    match report.outcome {
702                        CloseOutcome::Closed => {
703                            wrote.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
704                        }
705                        CloseOutcome::AlreadyClosing => {
706                            closing.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
707                        }
708                        CloseOutcome::AlreadyClosed => {}
709                        CloseOutcome::Refused => panic!("the machine allowed this move"),
710                    }
711                });
712            }
713        });
714
715        assert_eq!(
716            wrote.load(std::sync::atomic::Ordering::SeqCst),
717            1,
718            "exactly one of eight simultaneous closers wrote"
719        );
720        assert!(
721            closing.load(std::sync::atomic::Ordering::SeqCst) >= 1,
722            "and the others were told why, rather than silently doing nothing"
723        );
724        assert_eq!(
725            store.records_of(&EpisodeId::parse(id).unwrap()).len(),
726            1,
727            "one episode, one record"
728        );
729    }
730
731    #[test]
732    fn historical_duplicates_are_reported_and_never_added_to() {
733        // The store as this repository's actually is: four records of one episode, written
734        // before anything could refuse them.
735        let dir = tempfile::tempdir().expect("a temp dir");
736        let store = SessionStore::writable(dir.path());
737        let id = "s-20260909152316-024f";
738        let records = store.record_dir();
739        std::fs::create_dir_all(&records).expect("the store");
740        for stamp in [
741            "20260909T214135Z",
742            "20260910T103902Z",
743            "20260910T103918Z",
744            "20260910T103931Z",
745        ] {
746            std::fs::write(records.join(format!("{stamp}--{id}.md")), body(id)).expect("a record");
747        }
748
749        let report = store
750            .close(&episode(id), Outcome::Closed, || body(id))
751            .expect("a close");
752        assert_eq!(report.outcome, CloseOutcome::AlreadyClosed);
753        assert!(report
754            .record
755            .ends_with("20260909T214135Z--s-20260909152316-024f.md"));
756        assert_eq!(
757            report.duplicates.len(),
758            3,
759            "the three that should not exist"
760        );
761        assert!(report.diagnostic.contains("predates this close"));
762        assert_eq!(
763            store.records_of(&EpisodeId::parse(id).unwrap()).len(),
764            4,
765            "and nothing was added"
766        );
767    }
768
769    #[test]
770    fn a_claim_is_released_even_when_the_composer_panics() {
771        let dir = tempfile::tempdir().expect("a temp dir");
772        let store = SessionStore::writable(dir.path());
773        let id = "s-20260910205542-e2a6";
774        let e = episode(id);
775
776        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
777            let _ = store.close(&e, Outcome::Closed, || panic!("the git call died"));
778        }));
779        assert!(panicked.is_err(), "the panic propagated");
780        assert!(
781            !store.claim_path(&EpisodeId::parse(id).unwrap()).exists(),
782            "and the claim went with it, so the episode is still closable"
783        );
784
785        let report = store
786            .close(&e, Outcome::Closed, || body(id))
787            .expect("a close");
788        assert_eq!(report.outcome, CloseOutcome::Closed);
789    }
790
791    #[test]
792    fn a_records_prose_does_not_make_it_that_episodes_record() {
793        let dir = tempfile::tempdir().expect("a temp dir");
794        let store = SessionStore::read_only(dir.path());
795        let records = store.record_dir();
796        std::fs::create_dir_all(&records).expect("the store");
797        std::fs::write(
798            records.join("20260910T211503Z--other.md"),
799            "---\nschema: session/v1\nsession_id: s-20260910210510-8f20\n---\n\n\
800             continued from session_id: s-20260910205542-e2a6\n",
801        )
802        .expect("a record");
803        assert!(store
804            .records_of(&EpisodeId::parse("s-20260910205542-e2a6").unwrap())
805            .is_empty());
806    }
807}