Skip to main content

basis/
store.rs

1//! Where basis's conversations are persisted, and how they are scoped.
2//!
3//! mentra persists every agent to its runtime store — since basis 0.7 the
4//! **file-backed** one, plain files under one root, no database (ADR-0023;
5//! upstream `mentra#28`) — and tags each record with a **runtime
6//! identifier**. basis uses that tag to answer one question: *which
7//! conversations belong to this workspace?* — which is what ACP's
8//! `session/list` asks and the only reading of "my sessions" that is both
9//! honest and useful, since ACP scopes a session to a `cwd` from the moment
10//! `session/new` opens it.
11//!
12//! # Why the workspace path, verbatim
13//!
14//! mentra's default identifier is the literal string `"default"`, and its
15//! default store is one shared root. Listing under `"default"` would
16//! therefore enumerate the agents of *every* mentra program on the machine —
17//! worse than returning nothing, because a client would offer a user
18//! conversations that are not theirs.
19//!
20//! The identifier is the canonicalized workspace path with a `basis:` prefix. No
21//! hash: the identifier never becomes a filename — mentra keeps it as a field
22//! of each agent's `agent.json` and filters listings by comparing it — so
23//! every character survives, and a readable value is one a person debugging
24//! the store can understand by opening the file. The prefix keeps basis's
25//! records from ever colliding with another program's `"default"`.
26//!
27//! Changing the identifier does not move the store — mentra's default path is
28//! independent of it — so nothing already written is lost. Records created
29//! before this scheme carry `"default"` and do not appear in any workspace's
30//! list, which is the correct answer for a conversation whose workspace was
31//! never recorded. They are not stranded: mentra loads an agent by id alone,
32//! so resuming one still works. [`WorkspaceBuilder::open`](crate::WorkspaceBuilder::open)
33//! is where the tag is set, and where that ruling is written down.
34//!
35//! **They stopped re-tagging themselves in mentra 0.27, and mentra 0.28 turns
36//! that back on as an explicit basis-side choice.** Every persist used to
37//! re-derive the tag from the live runtime's own current identifier, so the
38//! first resume-and-run of an old `"default"`-tagged conversation silently
39//! adopted it into the resuming workspace's list. mentra 0.27's fix for
40//! [mentra#54](https://github.com/oops-rs/mentra/issues/54) rebound a
41//! resumed agent to its *own stored* identifier and carried that forward
42//! instead, which was the right fix for #54's bug (a shared runtime's
43//! resumed-then-run rows re-filing under its generic tag) but also closed
44//! this door: a legacy record stayed `"default"`-tagged forever, with no
45//! code path left to adopt it — filed as
46//! [mentra#59](https://github.com/oops-rs/mentra/issues/59). mentra 0.28
47//! reopens it explicitly: `SessionResumeOptions::runtime_identifier` rehomes
48//! a resumed agent under a chosen identifier on its next persist, without
49//! retagging any other agent or the shared runtime's own identifier.
50//! `Runtime::resume_minted` (crate-private) passes
51//! this workspace's own identifier on every resume, not only for a legacy
52//! `"default"` row — the check it makes immediately after already refuses to
53//! hand back a session whose `base_dir` disagrees with this workspace, so
54//! every row a resume succeeds on is provably this workspace's own, and
55//! restating its identifier is always correct.
56//!
57//! Every workspace tags its own rows, on a private runtime and on a shared
58//! one alike: [`Runtime`](crate::Runtime)'s `mint` states the identifier per
59//! session, so one store file serving five repositories still lists each one's
60//! conversations apart.
61//!
62//! **The gap that used to live here is closed.** A *resumed* session used to
63//! carry no identifier — mentra's `SessionResumeOptions` had no field for one
64//! — so a resumed conversation re-persisted under the runtime's own tag on
65//! its next save, taking its row out of the workspace's list on a shared
66//! runtime the moment it was used again. mentra 0.27 fixes this at the
67//! source rather than by adding that field: every persisted-agent
68//! reconstruction now retains the row's own stored runtime identifier
69//! through every later save
70//! ([mentra#54](https://github.com/oops-rs/mentra/issues/54)), so
71//! `Runtime::resume_minted` needed no change to pick it up.
72//!
73//! # Where the files go
74//!
75//! mentra's default directory is keyed by the *process's* current directory,
76//! not by the workspace basis opened, so every program started from one place
77//! shares one store root whatever workspace it went on to open — including
78//! every test binary in one `cargo test`.
79//! [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
80//! is how a caller says otherwise, and [`list_in`] is how the same caller reads
81//! back what it wrote. The directory the caller names **is** the store's root
82//! — mentra lays `agents/`, `rules.json` and `runs.jsonl` inside it — and the
83//! root is bound to the store in exactly one place, `store_in`, because two
84//! places would eventually disagree and a conversation written under one root
85//! and looked for under another is simply missing.
86//!
87//! # When the directory holds a database instead
88//!
89//! basis 0.6 and earlier kept conversations in `runtime.sqlite` under the
90//! same directory. This build links no SQLite and does not migrate
91//! (ADR-0023's E2 precedent): opening, listing or deleting against a
92//! directory that holds one is refused with [`RunError::LegacyStore`], which
93//! names the two ways forward, rather than starting an empty file store
94//! beside data it cannot see — the refusal is `refuse_legacy_store`, and
95//! every path that opens a store the caller pointed somewhere runs it.
96//!
97//! # When there is no file
98//!
99//! [`RuntimeBuilder::with_ephemeral_history`](crate::RuntimeBuilder::with_ephemeral_history)
100//! answers *where* with *nowhere*, and opens mentra's in-memory store instead.
101//! Nothing in this module can see one of those conversations: there is no file
102//! for [`list_in`] to read and no row for [`list`] to filter, whichever
103//! directory either is pointed at. One file still gets written even then — a
104//! compaction snapshot, which mentra persists without consulting the store —
105//! and it goes to a per-runtime directory under the OS temp directory, which
106//! is as close to *nowhere* as that file gets. Everything else below is about
107//! the durable case.
108
109use std::{
110    path::{Path, PathBuf},
111    sync::atomic::{AtomicU64, Ordering},
112};
113
114use mentra::{
115    BuiltinProvider, Runtime,
116    runtime::{FileRuntimeStore, PermissionRuleStore, VolatileRuntimeStore},
117};
118
119use crate::error::RunError;
120
121/// Distinguishes basis's records from every other mentra program sharing the
122/// store.
123const IDENTIFIER_PREFIX: &str = "basis:";
124
125/// What basis 0.6 and earlier kept conversations in — mentra's SQLite
126/// database, which this build can no longer read (ADR-0023).
127///
128/// Named so `refuse_legacy_store` can look for exactly the file the old
129/// layout put where the file store's root now goes, and refuse by name
130/// instead of shadowing it with an empty store.
131const LEGACY_SQLITE_FILENAME: &str = "runtime.sqlite";
132
133/// What basis calls the directory of compaction snapshots inside whichever
134/// directory holds the store.
135///
136/// mentra's own name for it: `transcripts/` beside the store's own entries is
137/// the layout mentra lays down under its default root, so a workspace pointed
138/// at [`default_directory`] lands on exactly the paths it would have used had
139/// nobody said anything.
140const TRANSCRIPTS_DIRNAME: &str = "transcripts";
141
142/// Where a session's conversations with remote agents are remembered,
143/// beside the store that holds the session itself (ADR-0029 §5): one file per
144/// agent id, so a resumed session picks up the remote work it had handed off
145/// rather than handing it off again.
146// The name records were first written under, kept so a store from before
147// ADR-0038 still finds them.
148// The name records were first written under, kept so a store from before
149// ADR-0038 still finds them.
150const REMOTE_CONVERSATIONS_DIRNAME: &str = "a2a-conversations";
151
152/// The runtime identifier for conversations in `workspace`.
153///
154/// Every caller that creates or enumerates a conversation must derive it from
155/// here — a session filed under one spelling of a path and looked for under
156/// another is simply missing.
157pub fn runtime_identifier(workspace: &Path) -> String {
158    // Canonicalizing is what makes a symlinked path and its target one
159    // workspace rather than two. A path that does not exist yet cannot be
160    // canonicalized, and is used as written rather than rejected: naming the
161    // store is not the place to validate a workspace.
162    let resolved = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
163
164    format!("{IDENTIFIER_PREFIX}{}", resolved.display())
165}
166
167/// A conversation mentra has on disk, as basis reports it.
168///
169/// basis's own shape rather than a re-export of mentra's `PersistedAgentSummary`,
170/// for the same reason [`Event`](crate::Event) is basis's own type: what basis
171/// publishes should not move because a runtime internal did.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct PersistedSession {
174    /// The persisted agent id, which is also the ACP session id (ADR-0007).
175    pub agent_id: String,
176    /// The name the session was opened under.
177    pub name: String,
178    /// How many messages the conversation holds. Zero means it was opened and
179    /// never used.
180    pub messages: usize,
181    /// When the conversation was first written, in seconds since the epoch.
182    ///
183    /// Optional because mentra's summary is: a store that keeps nothing
184    /// across process lifetimes has no record and therefore no answer, and
185    /// basis carries that rather than inventing a number to fill the gap.
186    /// Nothing reached through [`list_in`] is one of those — listing opens a
187    /// file store of its own, whatever the workspace was running on — so in
188    /// practice both of these arrive set. [`list_in`]'s ordering still handles `None`, because a
189    /// rule that only works for the values it happens to see is not a rule.
190    pub created_at: Option<u64>,
191    /// When it was last written, on the same clock and absent in the same case.
192    ///
193    /// This is *last persisted*, not last spoken: mentra rewrites an agent's
194    /// row on every turn and on every `set_model`, `set_effort` or `set_name`,
195    /// so it moves for a rename as readily as for an exchange. Close enough to
196    /// "last activity" to sort a list by, and not close enough to bill by.
197    pub updated_at: Option<u64>,
198}
199
200/// Every conversation persisted for `workspace`, most recently used first.
201///
202/// Reads the default directory. A workspace opened with
203/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir) is read by
204/// [`list_in`] instead — the two have to name the same place, and nothing here
205/// can guess which one a caller chose.
206///
207/// Teammates are left out. mentra spawns those as an agent's own collaborators;
208/// they are internal to a conversation rather than conversations a person
209/// started, and offering one to be resumed would be offering something that was
210/// never theirs to resume.
211pub fn list(workspace: &Path) -> Result<Vec<PersistedSession>, RunError> {
212    list_in(&default_directory(), workspace)
213}
214
215/// The same, for conversations kept somewhere of the caller's choosing.
216///
217/// `dir` is what was passed to
218/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir). A directory
219/// nothing was ever written to lists nothing rather than failing: an empty
220/// history and a store that does not exist yet are the same answer.
221pub fn list_in(dir: &Path, workspace: &Path) -> Result<Vec<PersistedSession>, RunError> {
222    let identifier = runtime_identifier(workspace);
223
224    let mut sessions: Vec<PersistedSession> = enumerating_runtime(&identifier, store_in(dir)?)?
225        .list_persisted_agents(&identifier)?
226        .into_iter()
227        .filter(|agent| !agent.is_teammate)
228        .map(|agent| PersistedSession {
229            agent_id: agent.id,
230            name: agent.name,
231            messages: agent.history_len,
232            created_at: agent.created_at,
233            updated_at: agent.updated_at,
234        })
235        .collect();
236    by_recency(&mut sessions);
237
238    Ok(sessions)
239}
240
241/// Orders conversations the way a person looks for one: the last thing they
242/// touched at the top.
243///
244/// Ordered here rather than at each surface, because there are two of them —
245/// `basis list` and ACP's `session/list` — and a client that sorted for itself
246/// would need a timestamp basis might not have. mentra returns creation order
247/// (sorted `(created_at, id)` for the file store, insertion order for the
248/// volatile one), which answers "which is oldest" and never "which was I just
249/// in".
250///
251/// Two rules, and the second is what makes the answer usable at all:
252///
253/// - A conversation with no timestamp sorts **last**, not first. `None` comes
254///   from a store that persists nothing, so it is *unknown*, and floating an
255///   unknown to the top of a list sorted by recency claims the one thing it
256///   cannot know.
257/// - The sort is **stable**, so conversations that tie — the same second, or a
258///   volatile store where every one of them is `None` — keep the order mentra
259///   gave them, which is deterministic in both backends. A tiebreak of basis's
260///   own would replace a meaningful order with an arbitrary one.
261fn by_recency(sessions: &mut [PersistedSession]) {
262    sessions.sort_by(|left, right| match (left.updated_at, right.updated_at) {
263        (Some(left), Some(right)) => right.cmp(&left),
264        (Some(_), None) => std::cmp::Ordering::Less,
265        (None, Some(_)) => std::cmp::Ordering::Greater,
266        (None, None) => std::cmp::Ordering::Equal,
267    });
268}
269
270/// Removes a conversation from the default directory's store for good.
271///
272/// The writing counterpart to [`list`], and the second half of what a client
273/// with a session list can do with it: pick one to resume, or decide it is
274/// finished with. mentra removes the record *and* its memory, because a record
275/// without its memory is a row `resume` refuses with "missing persisted
276/// memory" — a listing entry that cannot be opened.
277///
278/// Deleting a conversation that is not there is **not** an error. A caller
279/// deleting by an id it read from a list is racing anyone else holding the
280/// same store, and "it is gone" is the outcome both of them asked for.
281///
282/// Keyed by the conversation, not by a workspace: mentra's store is indexed by
283/// agent id, so this does not check that the id belongs anywhere in particular.
284/// [`Workspace::resume`](crate::Workspace::resume) *does* check, and the two
285/// differ for a reason — a resume states a workspace's policy and tool audience
286/// onto the conversation it picks up, and a deletion states nothing. A caller
287/// that means "one of mine" takes the id from [`list`] for its own workspace,
288/// which is where a client got it anyway.
289///
290/// **A live conversation is not stopped by this.** mentra deletes rows; an
291/// agent still in memory keeps running and writes its row back on its next
292/// persist. A caller holding a [`PreparedRun`](crate::PreparedRun) on this id
293/// must drop it first, or the row returns.
294pub fn forget(agent_id: &str) -> Result<(), RunError> {
295    forget_in(&default_directory(), agent_id)
296}
297
298/// The same, for conversations kept somewhere of the caller's choosing.
299///
300/// `dir` is what was passed to
301/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir), exactly as for
302/// [`list_in`]: a conversation is deleted from the file it was listed out of,
303/// and nothing here can guess which one a caller chose.
304pub fn forget_in(dir: &Path, agent_id: &str) -> Result<(), RunError> {
305    let store = store_in(dir)?;
306    // A clone of one file store shares its in-process lock, so the clear
307    // below mutates through the same concurrency boundary the runtime's
308    // deletion does. That boundary used to be narrower than it looked: a
309    // *live workspace* on the same root holds its own independently
310    // constructed store, and before mentra 0.27 two independent stores on one
311    // root were outside its concurrency contract — so a forget racing a live
312    // session's remembered answer could lose one side's write. mentra 0.27
313    // closed that: `FileRuntimeStore` now serializes every `rules.json`
314    // read-modify-replace behind a stable advisory `rules.lock`, so
315    // independently constructed stores and cooperating processes alike
316    // cannot silently overwrite one another's rows (mentra#50). basis adds no
317    // locking of its own over a boundary upstream now owns end to end.
318    let rules = store.clone();
319
320    // The identifier tags rows on write and filters them on `list_agents_by_
321    // runtime`; deletion is keyed by id alone, so what is passed here cannot
322    // change which conversation goes. It is still derived rather than invented,
323    // so nothing in this module opens a store under a tag no workspace uses.
324    enumerating_runtime(&runtime_identifier(dir), store)?.delete_agent(agent_id)?;
325
326    // mentra 0.27 made `delete_agent` clean up after itself for the session
327    // scope (mentra#51): it now also removes that agent's durable
328    // session-scoped rows in the same operation, sibling, project, and global
329    // rules preserved by design — the right default for a deletion whose
330    // caller only ever names one agent. Forgetting is a wider promise than
331    // that, though: it means every row this conversation *wrote*, whatever
332    // its scope — a host-seeded Global rule keeps answering prompts
333    // store-wide after its writer is gone, which is worse than a dead Session
334    // row ever was, and #51 leaves project- and global-scope rows alone on
335    // purpose. mentra's creator-oriented `clear_rules` still deletes by
336    // writer id regardless of scope, which is exactly that wider reading, and
337    // it stays: correctness across every scope a conversation could have
338    // written to is worth more than the redundant rewrite this now costs in
339    // the common case (an agent with no project/global rules) — mentra's
340    // `clear_rules` always performs one full `rules.json` read, filter, and
341    // atomic replace, unconditionally, whether or not anything is actually
342    // removed (unlike `clear_scope`, which reports and skips the write when
343    // nothing changed). There is no cheaper existence check available
344    // without reading the same file basis would then be reimplementing the
345    // upstream read against, so this pays that cost every time rather than
346    // risk a leftover Global rule outliving the conversation that wrote it.
347    rules.clear_rules(agent_id)?;
348
349    // The conversation's record of remote work it handed off is a row it
350    // wrote, too; it goes with the rest. Absent is the ordinary case.
351    crate::agents::remote::forget(&remote_conversations_in(dir), agent_id)
352        .map_err(|(path, source)| RunError::Forget { path, source })?;
353
354    Ok(())
355}
356
357/// A runtime that exists only to reach the store.
358///
359/// Reading or removing a persisted agent needs a `Runtime`, and
360/// `RuntimeBuilder::build` refuses to produce one with an empty provider
361/// registry — so a provider is registered to satisfy the builder, with a
362/// placeholder key. Nothing here resolves a model or reaches the network:
363/// listing reads the store's files and deleting removes some, and the runtime
364/// is dropped as soon as it has. Requiring a real credential to touch local
365/// records would make `session/list` and `session/delete` fail for a reason
366/// that has nothing to do with either.
367///
368/// The store is passed in — built by the caller through [`store_in`], so this
369/// and [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
370/// read and write one file, and a caller that also mutates the store directly
371/// ([`forget_in`]'s rule clear) can clone the same instance rather than
372/// constructing a second one outside the backend's concurrency boundary. The
373/// identifier is the other thing that has to agree, and it comes from
374/// [`runtime_identifier`] on both sides.
375fn enumerating_runtime(identifier: &str, store: FileRuntimeStore) -> Result<Runtime, RunError> {
376    Ok(Runtime::empty_builder()
377        .with_runtime_identifier(identifier.to_string())
378        .with_store(store)
379        .with_provider(BuiltinProvider::OpenAI, "unused-for-listing")
380        .build()?)
381}
382
383/// The store basis keeps a workspace's conversations in, under `dir` — or the
384/// refusal, when `dir` still holds a basis ≤0.6 conversation database.
385///
386/// The one place the directory becomes a store root: `RuntimeBuilder` writes
387/// through this and [`list_in`] reads through it, so the two cannot drift.
388/// `dir` itself is the root — mentra lays `agents/`, `rules.json` and
389/// `runs.jsonl` inside it — which is what makes 0.7's layout land in exactly
390/// the directory 0.6's database sat in.
391///
392/// **That is why this is fallible, and why the check lives here rather than
393/// beside each caller.** A file store started in a directory holding
394/// `runtime.sqlite` would look exactly like every conversation being lost, so
395/// the check has to happen every time one is opened — and a check a caller
396/// has to remember is one a future caller will forget. Constructing the store
397/// *is* the check: there is no way to reach a `FileRuntimeStore` for a named
398/// directory without passing it.
399///
400/// mentra's own file store detects the same file, and that is not enough on
401/// its own: it errors in mentra's words — naming its `store-sqlite` cargo
402/// feature, a fix for a mentra embedder rather than for the person whose
403/// history is sitting there — and it raises it from `prepare_recovery`, which
404/// `RuntimeHandle::prepare_recovery` treats as best-effort and discards, so
405/// nothing legible reaches anyone through `build`. [`RunError::LegacyStore`]
406/// says what actually happened and names both ways forward.
407///
408/// Neither this store type nor [`volatile`]'s reaches basis's surface. A caller
409/// picks a *posture* — history in a directory, or history nowhere — and basis
410/// picks the backend that is it, rather than re-exporting `RuntimeStore` and
411/// the nine traits it composes (see
412/// [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)).
413pub(crate) fn store_in(dir: &Path) -> Result<FileRuntimeStore, RunError> {
414    if dir.join(LEGACY_SQLITE_FILENAME).exists() {
415        return Err(RunError::LegacyStore { dir: dir.into() });
416    }
417
418    Ok(FileRuntimeStore::new(dir))
419}
420
421/// The store that keeps a workspace's conversations nowhere.
422///
423/// mentra's in-memory `RuntimeStore`: no database file is opened, no directory
424/// is created, no tool output is spilled to disk, and dropping the runtime that
425/// holds it takes every conversation with it. The one thing it does not stop is
426/// a compaction snapshot, which mentra writes without asking the store —
427/// [`volatile_transcripts`] is where those go instead. Constructed fresh per
428/// workspace, which is
429/// what makes two ephemeral workspaces two histories rather than one — the type
430/// is `Clone` and clones share state, so a shared instance would be a shared
431/// store with none of the durability.
432///
433/// The backing for
434/// [`RuntimeBuilder::with_ephemeral_history`](crate::RuntimeBuilder::with_ephemeral_history),
435/// and named here rather than in the builder so that the two mentra store types
436/// basis can open are chosen in one file.
437pub(crate) fn volatile() -> VolatileRuntimeStore {
438    VolatileRuntimeStore::new()
439}
440
441/// Where a workspace's compaction snapshots go, under `dir`.
442///
443/// mentra writes the whole transcript to a file before it replaces a prefix of
444/// it with a summary, so *somewhere* is not optional; what is optional is
445/// whether basis chooses it. It should: a snapshot is a verbatim copy of the
446/// same conversation the store holds, and mentra's own default puts the two
447/// in one directory. Keeping that relationship is what makes
448/// [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
449/// move both — and pointing it at [`default_directory`] a no-op, exactly as it
450/// is for [`store_in`].
451pub(crate) fn transcripts_in(dir: &Path) -> PathBuf {
452    dir.join(TRANSCRIPTS_DIRNAME)
453}
454
455/// Where a store under `dir` remembers its sessions' remote conversations,
456/// beside the sessions themselves so [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
457/// moves both.
458pub(crate) fn remote_conversations_in(dir: &Path) -> PathBuf {
459    dir.join(REMOTE_CONVERSATIONS_DIRNAME)
460}
461
462/// Where they go when nobody said where the history lives.
463///
464/// Keyed by the process's current directory, like the store beside it — the
465/// hazard `with_store_dir` exists to answer, left in place for the caller that
466/// has not asked.
467pub(crate) fn default_transcripts() -> PathBuf {
468    transcripts_in(&default_directory())
469}
470
471/// Where they go for a runtime whose history is kept nowhere.
472///
473/// *Nowhere* is not on offer for these. mentra's `persist_transcript` writes
474/// the snapshot before it summarizes and does not consult the store first —
475/// `allows_disk_artifacts`, which the volatile store answers `false` to, gates
476/// tool-output spill and nothing else — and `max_persisted_transcripts: None`
477/// disables the *cleanup* of old snapshots rather than the writing of new ones.
478/// So the only lever basis holds is *where*, and the honest answer is the
479/// directory the operating system already treats as disposable: never the
480/// user's data directory, never the workspace.
481///
482/// Unique per call, because two runtimes each promised their own disposable
483/// history must not read each other's transcripts out of one directory. A
484/// counter and not the clock: two runtimes built in one tick would otherwise
485/// share a directory, which is the bug mentra's own mock runtime had.
486pub(crate) fn volatile_transcripts() -> PathBuf {
487    static NEXT: AtomicU64 = AtomicU64::new(0);
488
489    std::env::temp_dir()
490        .join("basis-ephemeral-transcripts")
491        .join(format!(
492            "process-{}-{}",
493            std::process::id(),
494            NEXT.fetch_add(1, Ordering::Relaxed)
495        ))
496}
497
498/// The directory mentra keeps basis's conversations in, for a caller that wants
499/// to say where the history lives.
500///
501/// Keyed by the process's current directory, which is why it is worth naming
502/// rather than assuming: a host that changes directory changes which store
503/// this answers with.
504pub fn default_directory() -> PathBuf {
505    FileRuntimeStore::default_root()
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn session(agent_id: &str, updated_at: Option<u64>) -> PersistedSession {
513        PersistedSession {
514            agent_id: agent_id.to_string(),
515            name: agent_id.to_string(),
516            messages: 2,
517            created_at: updated_at,
518            updated_at,
519        }
520    }
521
522    fn ordered(sessions: Vec<PersistedSession>) -> Vec<String> {
523        let mut sessions = sessions;
524        by_recency(&mut sessions);
525        sessions
526            .into_iter()
527            .map(|session| session.agent_id)
528            .collect()
529    }
530
531    #[test]
532    fn the_conversation_touched_last_is_listed_first() {
533        assert_eq!(
534            ordered(vec![
535                session("older", Some(100)),
536                session("newest", Some(300)),
537                session("middle", Some(200)),
538            ]),
539            vec!["newest", "middle", "older"]
540        );
541    }
542
543    #[test]
544    fn a_conversation_with_no_timestamp_sorts_last_rather_than_first() {
545        // `None` is a store that persists nothing, which means *unknown* — and
546        // an unknown floated to the top of a list sorted by recency claims the
547        // one thing it cannot know.
548        assert_eq!(
549            ordered(vec![
550                session("unknown", None),
551                session("known", Some(1)),
552                session("also-unknown", None),
553            ]),
554            vec!["known", "unknown", "also-unknown"]
555        );
556    }
557
558    #[test]
559    fn conversations_that_tie_keep_the_order_the_store_gave_them() {
560        // The volatile store's whole case — every timestamp `None` — plus the
561        // ordinary one of two conversations written in the same second. Both
562        // have to come back in mentra's own order, which is deterministic in
563        // either backend; a tiebreak of basis's own would replace a meaningful
564        // order with an arbitrary one.
565        assert_eq!(
566            ordered(vec![
567                session("first", None),
568                session("second", None),
569                session("third", None),
570            ]),
571            vec!["first", "second", "third"]
572        );
573        assert_eq!(
574            ordered(vec![
575                session("first", Some(7)),
576                session("second", Some(7)),
577                session("third", Some(9)),
578            ]),
579            vec!["third", "first", "second"]
580        );
581    }
582
583    #[test]
584    fn an_identifier_names_its_workspace_and_is_lans_own() {
585        let identifier = runtime_identifier(Path::new("/definitely/not/a/real/path"));
586
587        assert!(
588            identifier.starts_with(IDENTIFIER_PREFIX),
589            "basis's rows must not collide with another program's: {identifier}"
590        );
591        assert!(
592            identifier.contains("/definitely/not/a/real/path"),
593            "a path that cannot be canonicalized is used as written: {identifier}"
594        );
595    }
596
597    #[test]
598    fn two_workspaces_never_share_an_identifier() {
599        assert_ne!(
600            runtime_identifier(Path::new("/repo/one")),
601            runtime_identifier(Path::new("/repo/two"))
602        );
603    }
604
605    #[test]
606    fn one_workspace_reached_two_ways_is_one_identifier() {
607        let workspace = tempfile::tempdir().expect("tempdir");
608        let nested = workspace.path().join("inner");
609        std::fs::create_dir(&nested).expect("dir");
610
611        // The spelling a client sends is whatever it happened to have; the
612        // conversation it opened is the same either way.
613        assert_eq!(
614            runtime_identifier(&nested),
615            runtime_identifier(&workspace.path().join("inner").join(".").to_path_buf())
616        );
617    }
618
619    #[test]
620    fn an_unused_workspace_has_no_conversations() {
621        let workspace = tempfile::tempdir().expect("tempdir");
622        let store = tempfile::tempdir().expect("tempdir");
623
624        assert_eq!(
625            list_in(store.path(), workspace.path())
626                .expect("listing an empty workspace is not an error"),
627            Vec::new(),
628            "nothing has ever been persisted here"
629        );
630    }
631
632    #[test]
633    fn a_chosen_directory_holds_the_layout_the_default_one_would_have() {
634        // The identity that makes `with_store_dir` a relocation rather than a
635        // second scheme: pointing it at the default directory is a no-op.
636        // Asserted as path identity rather than by opening: a machine that ran
637        // basis 0.6 has a database in exactly that directory, so opening it is
638        // a legitimate refusal (`store_in`'s own guard) and would say nothing
639        // about where the two schemes put their roots.
640        assert_eq!(
641            default_directory(),
642            mentra::runtime::FileRuntimeStore::default().root(),
643            "basis's root must be mentra's, or moving the store would relocate it"
644        );
645    }
646
647    #[test]
648    fn listing_opens_the_store_it_was_pointed_at() {
649        // Listing is the one path that opens a store without a workspace
650        // having been opened first, so it is the one most likely to fall back
651        // to the machine-wide default without anyone noticing.
652        let workspace = tempfile::tempdir().expect("tempdir");
653        let store = tempfile::tempdir().expect("tempdir");
654
655        list_in(store.path(), workspace.path()).expect("listing is not an error");
656
657        assert!(
658            store.path().join("agents").is_dir(),
659            "the store basis was told to read is the store it opened"
660        );
661    }
662
663    #[test]
664    fn a_directory_holding_a_pre_07_database_is_refused_by_name() {
665        // basis ≤0.6 kept this workspace's conversations in `runtime.sqlite`
666        // under exactly this directory. Listing nothing over it would look
667        // like every conversation being lost; the refusal has to say what
668        // actually happened and name the ways forward, in basis's words.
669        let workspace = tempfile::tempdir().expect("tempdir");
670        let store = tempfile::tempdir().expect("tempdir");
671        std::fs::write(
672            store.path().join(LEGACY_SQLITE_FILENAME),
673            b"SQLite format 3\0",
674        )
675        .expect("plant the old database");
676
677        let error = list_in(store.path(), workspace.path())
678            .expect_err("an unreadable existing store must be named, not shadowed");
679
680        let message = error.to_string();
681        assert!(message.contains("0.6"), "{message}");
682        assert!(message.contains("runtime.sqlite"), "{message}");
683        assert!(
684            message.contains("not migrated"),
685            "the no-migration ruling (ADR-0023) is part of the message: {message}"
686        );
687        assert!(
688            !store.path().join("agents").exists(),
689            "a refused directory must not gain an empty store beside the database"
690        );
691    }
692
693    #[test]
694    fn forgetting_refuses_the_same_directory_listing_does() {
695        let store = tempfile::tempdir().expect("tempdir");
696        std::fs::write(
697            store.path().join(LEGACY_SQLITE_FILENAME),
698            b"SQLite format 3\0",
699        )
700        .expect("plant the old database");
701
702        forget_in(store.path(), "some-agent")
703            .expect_err("deleting from a database this build cannot read is not a no-op");
704    }
705}