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}