Skip to main content

SessionStore

Struct SessionStore 

Source
pub struct SessionStore { /* private fields */ }
Expand description

The stores of one checkout.

Reading needs no permission. Writing needs SessionStore::writable, which is the flag ADR 0047 promised: while lib/session.sh owns the lifecycle, nothing that is merely served can write a session record, and a caller that has not said out loud that it means to is refused by StoreError::NotWritable.

use majordomus_cli::session::SessionStore;

let dir = tempfile::tempdir().unwrap();
let reading = SessionStore::read_only(dir.path());
assert!(!reading.is_writable());
assert!(SessionStore::writable(dir.path()).is_writable());

Implementations§

Source§

impl SessionStore

Source

pub fn read_only(root: impl Into<PathBuf>) -> Self

A store that reads. Every projection uses this one, and a caller that reaches the write path through it is refused rather than allowed to become a second writer.

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert!(!SessionStore::read_only(dir.path()).is_writable());
Source

pub fn writable(root: impl Into<PathBuf>) -> Self

A store that may write. Not reachable from any surface in this change: the cutover (ADR 0047) is what routes majordomus session close through it.

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert!(SessionStore::writable(dir.path()).is_writable());
Source

pub fn is_writable(&self) -> bool

May this store write? The question the closer asks first, before it touches the disk at all.

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert!(!SessionStore::read_only(dir.path()).is_writable());
Source

pub fn root(&self) -> &Path

The repository root it is over, which every path below is composed from.

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert_eq!(SessionStore::read_only(dir.path()).root(), dir.path());
Source

pub fn claim_dir(&self) -> PathBuf

Where the claims live: the local half, because a claim is a fact about this machine and the record store is the shared half of the layer (ADR 0014).

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert!(SessionStore::read_only(dir.path()).claim_dir().ends_with("sessions-closing"));
Source

pub fn record_dir(&self) -> PathBuf

Where the immutable records live: the tracked half, because a closed episode is a shared object of the layer and travels with a clone.

use majordomus_cli::session::SessionStore;
let dir = tempfile::tempdir().unwrap();
assert!(SessionStore::read_only(dir.path()).record_dir().ends_with(".ai/repo/sessions"));
Source

pub fn records_of(&self, episode: &EpisodeId) -> Vec<String>

Every record in the store that names episode, repository-relative, in canonical order — which for these filenames is chronological, because the name begins with a compact UTC timestamp.

The scan reads front matter rather than grepping the whole file, so a record whose body quotes another episode’s id is not counted as that episode’s record. The shell’s grep -rl "^session_id: <id>$" has the same intent and reaches it by anchoring; this reads the field.

use majordomus_cli::session::{EpisodeId, SessionStore};

let dir = tempfile::tempdir().unwrap();
let records = dir.path().join(".ai/repo/sessions");
std::fs::create_dir_all(&records).unwrap();
std::fs::write(
    records.join("20260910T210426Z--a.md"),
    "---\nschema: session/v1\nsession_id: s-20260910205542-e2a6\n---\n",
).unwrap();
std::fs::write(
    records.join("20260910T211503Z--b.md"),
    "---\nschema: session/v1\nsession_id: s-20260910210510-8f20\n---\n\nquotes s-20260910205542-e2a6 in prose\n",
).unwrap();

let store = SessionStore::read_only(dir.path());
let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
let found = store.records_of(&id);
assert_eq!(found.len(), 1, "the field, not the prose");
assert!(found[0].ends_with("20260910T210426Z--a.md"));
Source

pub fn close<F>( &self, episode: &Episode, outcome: Outcome, compose: F, ) -> Result<CloseReport, StoreError>
where F: Fn() -> String,

Close episode, exactly once.

compose is called only by the caller that took the claim, and only after it did: composing a record is the expensive part — a git status, a walk of the episode’s ledger lines — and doing it before the claim is what leaves the window the shell’s scan cannot close. It returns the record’s full text.

use majordomus_cli::session::{CloseOutcome, Episode, EpisodeId, Outcome, SessionStore};
use std::path::Path;

let dir = tempfile::tempdir().unwrap();
let store = SessionStore::writable(dir.path());
let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
let episode = Episode::opening(id.clone(), Path::new("/r"))
    .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);

let body = || format!("---\nschema: session/v1\nkind: session\nsession_id: {}\n---\n", id);

// the first close writes
let first = store.close(&episode, Outcome::Closed, body).unwrap();
assert_eq!(first.outcome, CloseOutcome::Closed);
assert!(first.record.starts_with(".ai/repo/sessions/"));

// the second, third and fourth do not, and each names the record that stands
for _ in 0..3 {
    let again = store.close(&episode, Outcome::Closed, body).unwrap();
    assert_eq!(again.outcome, CloseOutcome::AlreadyClosed);
    assert_eq!(again.record, first.record);
}
assert_eq!(store.records_of(&id).len(), 1, "one episode, one record");
Source

pub fn claim_path(&self, episode: &EpisodeId) -> PathBuf

Where the claim of episode is. The episode id is parsed, so it is one inert path segment and nothing a provider sends can name a file outside the directory.

use majordomus_cli::session::{EpisodeId, SessionStore};
let dir = tempfile::tempdir().unwrap();
let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
let claim = SessionStore::read_only(dir.path()).claim_path(&id);
assert!(claim.ends_with("s-20260910205542-e2a6.claim"));
assert!(!claim.exists(), "asking where it would be does not take it");

Trait Implementations§

Source§

impl Clone for SessionStore

Source§

fn clone(&self) -> SessionStore

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SessionStore

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DynClone for T
where T: Clone,

Source§

fn __clone_box(&self, _: Private) -> *mut ()

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more