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}