Skip to main content

basis_host/
source.rs

1//! What basis's own sessions are built on: one runtime for the server process,
2//! one workspace for each directory a client asks about.
3//!
4//! ADR-0018's host shape. The original ACP source used to open a
5//! whole workspace per
6//! session, minting one run from it and dropping it — so
7//! a server holding N editor sessions held N mentra runtimes, N provider
8//! resolutions and N store handles, and no session outlived the workspace
9//! whose MCP connections and hooks it was supposed to be running with. What is
10//! here instead is the two lifetimes the ADR named: the runtime belongs to the
11//! process, the workspace belongs to the repository, and a session is minted
12//! from both.
13//!
14//! # Built late, and once
15//!
16//! Both are built on the first session that needs them, not at
17//! a server is constructed. Neither cell remembers a failure: a request that
18//! failed for want of a credential must fail the same way next time rather
19//! than be answered out of a poisoned cache.
20//!
21//! # What a cached workspace holds open
22//!
23//! Its MCP connections and its hook registrations on the runtime, both of
24//! which have to outlive every session minted from it, and — unless the
25//! template turned it off — a watch on its A2A files, so an agent the
26//! operator adds reaches sessions already running (ADR-0031 §4). A workspace
27//! dropped while a session still runs takes that session's servers and its
28//! `.basis/hooks.json` with it. Nothing here evicts, therefore: `session/close`
29//! reaches basis after the fact, and a turn still unwinding would be the one
30//! paying for the tidiness. The bound is the number of distinct directories one
31//! server is asked about, which is the number of repositories a person has
32//! open.
33
34use std::{
35    collections::{BTreeMap, HashMap},
36    hash::{DefaultHasher, Hash, Hasher},
37    path::{Path, PathBuf},
38    sync::{Arc, Mutex},
39};
40
41use basis::{
42    A2aWatch, McpConfig, McpServer, McpSseServerConfig, McpStreamableHttpServerConfig,
43    PersistedSession, PreparedRun, RunError, RunSpec, Runtime, RuntimeBuilder, Workspace,
44    WorkspaceBuilder,
45    mcp::{McpSseLimits, McpStreamableHttpLimits, SecretString},
46};
47use tokio::sync::OnceCell;
48
49use crate::{SessionSource, SessionTemplate};
50
51/// The default source: sessions basis builds itself, on the process's runtime.
52pub struct ConfiguredSource {
53    /// What the operator said and a client cannot: which model and endpoint,
54    /// whether commands are granted, the product's voice. No workspace lives
55    /// here — every session brings its own `cwd`.
56    template: SessionTemplate,
57    /// One per process, however many sessions and workspaces it carries.
58    runtime: OnceCell<Arc<Runtime>>,
59    /// One per [`WorkspaceKey`], each behind its own cell.
60    ///
61    /// A sync lock on the map and a cell per entry, rather than one async lock
62    /// around the whole thing: opening a workspace spawns MCP servers and may
63    /// resolve a model over the network, and two clients on two repositories
64    /// must not queue behind each other for that. The map lock is only ever
65    /// held long enough to look one entry up.
66    workspaces: Mutex<HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>>,
67}
68
69/// One cached workspace, and the watch on its A2A files that lives exactly as
70/// long as it is cached.
71struct Opened {
72    workspace: Arc<Workspace>,
73    /// Held for its `Drop`, which stops the poll.
74    _a2a_watch: Option<A2aWatch>,
75}
76
77impl ConfiguredSource {
78    pub fn new(template: Option<SessionTemplate>) -> Self {
79        Self {
80            template: template.unwrap_or_default(),
81            runtime: OnceCell::new(),
82            workspaces: Mutex::new(HashMap::new()),
83        }
84    }
85
86    /// The same source over a runtime the caller already built.
87    ///
88    /// Test-only because building one resolves a provider credential, which is
89    /// exactly what an offline test cannot do and exactly what
90    /// [`RuntimeBuilder::with_api_key`](basis::RuntimeBuilder::with_api_key)
91    /// answers — and it is not on [`SessionTemplate`], which is the only thing
92    /// a configured source takes. A host that wants to supply its own runtime
93    /// supplies its own [`SessionSource`], which is what that seam is for.
94    #[cfg(test)]
95    pub fn on_runtime(runtime: Arc<Runtime>, template: Option<SessionTemplate>) -> Self {
96        let source = Self::new(template);
97        source
98            .runtime
99            .set(runtime)
100            .unwrap_or_else(|_| unreachable!("a runtime that was just constructed is empty"));
101        source
102    }
103
104    /// Builds the two halves of one session, in the client's working
105    /// directory: the workspace to open and the per-run spec to mint on it.
106    ///
107    /// Nothing here says anything about approval. A runtime's authorizer is
108    /// fixed for its life, so basis installs one that surfaces every
109    /// consequential call and answers none of them; which of those the client
110    /// actually sees is the session's mode, which can still change (see
111    /// [`SessionApproval`](crate::SessionApproval)).
112    fn parts_for(
113        &self,
114        cwd: PathBuf,
115        mcp: Vec<McpServer>,
116    ) -> Result<(WorkspaceBuilder, RunSpec), RunError> {
117        let template = &self.template;
118        // Read here rather than left to the open, because the open reads none:
119        // this is the operator's choice of lookup applied to this `cwd`.
120        let discovery = &template.discovery;
121        let config = discovery
122            .config
123            .load(&cwd, discovery.context.global_dir.as_deref())?;
124
125        let mut builder = Workspace::builder(cwd)
126            .with_shell(template.shell)
127            .with_config(config);
128        if let Some(model) = &template.model {
129            builder = builder.with_model(model.clone());
130        }
131        if let Some(system_prompt) = &template.system_prompt {
132            builder = builder.with_system_prompt(system_prompt.clone());
133        }
134        // Discovery is the template's promise (`SessionTemplate::with_discovery`)
135        // — the operator's to point, defaulting to basis's own roots.
136        let builder = builder
137            .with_context(discovery.context.clone())
138            .with_skills(discovery.skills.clone())
139            .with_templates(discovery.templates.clone())
140            .with_hooks(discovery.hooks.clone())
141            .with_tools(discovery.tools.clone());
142
143        // The client's servers outrank the workspace's own: it is answering
144        // for this session in particular. Discovery still runs, so a
145        // `.mcp.json` the client said nothing about is still honored.
146        let builder = builder.with_mcp(self.session_mcp(mcp));
147
148        let mut spec = RunSpec::default();
149        if let Some(session_name) = &template.session_name {
150            spec = spec.with_session_name(session_name.clone());
151        }
152        if let Some(effort) = template.effort {
153            spec = spec.with_effort(effort);
154        }
155
156        Ok((builder, spec))
157    }
158
159    /// One session's MCP config: where discovery starts, with the client's
160    /// servers landing on top — they outrank the workspace's own, because the
161    /// client is answering for this session in particular.
162    fn session_mcp(&self, mcp: Vec<McpServer>) -> McpConfig {
163        self.template.discovery.mcp.clone().with_supplied(mcp)
164    }
165
166    /// The workspace this session is minted from, and the per-run half that
167    /// mints it.
168    async fn workspace_for(
169        &self,
170        cwd: PathBuf,
171        mcp: Vec<McpServer>,
172    ) -> Result<(Arc<Workspace>, RunSpec), RunError> {
173        let key = WorkspaceKey::new(&cwd, &mcp);
174        let (builder, spec) = self.parts_for(cwd, mcp)?;
175
176        Ok((self.open(key, builder).await?, spec))
177    }
178
179    /// The workspace for `key`, opening it if this is the first session to ask.
180    async fn open(
181        &self,
182        key: WorkspaceKey,
183        builder: WorkspaceBuilder,
184    ) -> Result<Arc<Workspace>, RunError> {
185        let cell = Arc::clone(self.lock().entry(key).or_default());
186
187        // Concurrent openers of one key wait on this cell and share its result;
188        // openers of different keys never meet. A failed open leaves the cell
189        // empty, so the next session tries again rather than inheriting a
190        // failure that may have been about the network.
191        let opened = cell
192            .get_or_try_init(|| async move {
193                let runtime = Arc::clone(self.runtime().await?);
194                let workspace = Arc::new(builder.with_runtime(runtime).open().await?);
195                let a2a_watch = self.template.a2a_watch_interval().and_then(|interval| {
196                    match workspace.watch_a2a(interval) {
197                        Ok(watch) => Some(watch),
198                        // Opened inside the runtime the server runs on, so
199                        // this is a host wired unusually: say so and serve on.
200                        Err(error) => {
201                            eprintln!("Warning: {error}; A2A files will not be watched");
202                            None
203                        }
204                    }
205                });
206
207                Ok::<_, RunError>(Opened {
208                    workspace,
209                    _a2a_watch: a2a_watch,
210                })
211            })
212            .await?;
213
214        Ok(Arc::clone(&opened.workspace))
215    }
216
217    /// The process's runtime, built on the first session that needs one.
218    async fn runtime(&self) -> Result<&Arc<Runtime>, RunError> {
219        self.runtime
220            .get_or_try_init(|| async { Ok(Arc::new(self.recipe().build()?)) })
221            .await
222    }
223
224    /// The process half of the template: what ADR-0018 moved onto the runtime.
225    ///
226    /// Provider and endpoint seed the private runtime's recipe, plus the model
227    /// as this runtime's *policy*. [`parts_for`](Self::parts_for) restates
228    /// that model per workspace as an override, so the policy is belt to its
229    /// braces — but a runtime that reported a model none of its workspaces use
230    /// would be describing a process nobody is running.
231    fn recipe(&self) -> RuntimeBuilder {
232        let template = &self.template;
233
234        let mut recipe = Runtime::builder();
235        if let Some(model) = &template.model {
236            recipe = recipe.with_model(model.clone());
237        }
238        if let Some(provider) = template.provider {
239            recipe = recipe.with_provider(provider);
240        }
241        if let Some(base_url) = &template.base_url {
242            recipe = recipe.with_base_url(base_url.clone());
243        }
244
245        recipe
246    }
247
248    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>> {
249        // Poisoned means some other task panicked mid-insert. The map is still
250        // structurally sound, and refusing every later session over one panic
251        // would turn it into a dead connection — the same ruling the session
252        // registry makes.
253        self.workspaces
254            .lock()
255            .unwrap_or_else(|poisoned| poisoned.into_inner())
256    }
257
258    /// The workspaces this source has open, for the acceptance test that one
259    /// directory is one workspace however many sessions were minted on it.
260    #[cfg(test)]
261    pub fn opened(&self) -> Vec<Arc<Workspace>> {
262        self.lock()
263            .values()
264            .filter_map(|cell| cell.get().map(|opened| Arc::clone(&opened.workspace)))
265            .collect()
266    }
267}
268
269#[async_trait::async_trait]
270impl SessionSource for ConfiguredSource {
271    async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError> {
272        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
273
274        workspace.prepare(spec)
275    }
276
277    /// The workspace comes from the client's `cwd` and the conversation from
278    /// an id the client kept, and nothing here pairs them: a client is free to
279    /// send a `cwd` that has nothing to do with the session it names.
280    /// `Workspace::resume` is what refuses that pairing — it compares the
281    /// persisted agent's own base directory against the workspace's identity
282    /// and answers `RunError::WorkspaceMismatch` — which is where the check
283    /// belongs, since a resume states *that* workspace's policy and tool
284    /// audience onto whatever it picks up.
285    async fn resume(
286        &self,
287        agent_id: &str,
288        cwd: PathBuf,
289        mcp: Vec<McpServer>,
290    ) -> Result<PreparedRun, RunError> {
291        let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
292
293        workspace.resume(agent_id, spec)
294    }
295
296    fn lists_sessions(&self) -> bool {
297        true
298    }
299
300    /// Reads mentra's store directly. Building a session to enumerate sessions
301    /// would resolve a model over the network to answer a question about files
302    /// on disk.
303    ///
304    /// This depends on a conversation being tagged with
305    /// [`store::runtime_identifier`](basis::store::runtime_identifier) for its
306    /// workspace, and every session opened here is: `Runtime::mint` states the
307    /// identifier per session, so the shared runtime this source runs on files
308    /// each `session/new` under the `cwd` it was opened for.
309    ///
310    /// A conversation that has been *resumed* and run again stays on this
311    /// list too: mentra 0.27 retains a persisted agent's own stored runtime
312    /// identifier through every later save ([`basis::store`] has the account),
313    /// so a row this source's shared runtime resumed re-files under this
314    /// workspace's own tag rather than the runtime's.
315    async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
316        basis::store::list(&cwd)
317    }
318
319    fn deletes_sessions(&self) -> bool {
320        true
321    }
322
323    /// Writes to mentra's store directly, for the reason
324    /// [`list_sessions`](Self::list_sessions) reads it directly: opening a
325    /// workspace to delete a record would resolve a model over the network to
326    /// answer a question about files on disk — and would fail for want of a
327    /// credential on a connection that has done nothing but list.
328    async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
329        basis::store::forget(agent_id)
330    }
331}
332
333/// What makes two sessions the same workspace.
334///
335/// The directory is ADR-0018's key and the obvious half. The servers are the
336/// other half: `mcpServers` arrives per session, and it is the client
337/// answering for *this session in particular*, so two sessions on one
338/// repository need not answer alike. Keying on the directory alone would hand
339/// the second session the first one's servers and silently drop its own —
340/// because a session that came up without its servers looks exactly like one
341/// whose servers had nothing to offer.
342///
343/// Two workspaces on one directory is the cost, and it is bounded: they differ
344/// only in their supplied servers, so they discover the same hooks and carry
345/// the same command posture.
346///
347/// That equality is load-bearing now, and worth stating. A workspace registers
348/// its interception chain live, for its own tool audience, and two opens of one
349/// directory share that audience — so basis counts holders of one chain rather
350/// than registering two, and refuses a same-root open whose chain differs
351/// (`RunError::WorkspaceGuardConflict`). Nothing *this source decides* can
352/// provoke that refusal: every session's hooks come from one `SessionTemplate`'s
353/// discovery configuration, which does not vary per session, and the servers a
354/// key varies on are not part of a chain at all. What the configuration points
355/// at is another matter — the chain is read off disk at each open, so editing
356/// `.basis/hooks.json` between two opens of one directory *does* refuse the
357/// second, and the refusal names the directory. That is the honest boundary:
358/// this source never asks for two chains, and cannot promise a repository
359/// stopped changing under it.
360///
361/// What two live opens of one directory cost is what they always cost — a
362/// second set of MCP connections and a second discovery — and not a second pass
363/// through the repository's hook programs on every call.
364#[derive(Debug, PartialEq, Eq, Hash)]
365struct WorkspaceKey {
366    workspace: PathBuf,
367    /// A digest, so a `{:?}` of this key repeats nothing a client configured.
368    supplied: u64,
369}
370
371impl WorkspaceKey {
372    fn new(cwd: &Path, supplied: &[McpServer]) -> Self {
373        Self {
374            // Canonicalized so a symlinked spelling and its target are one
375            // workspace rather than two, and used as written when it does not
376            // resolve — the same ruling as `store::runtime_identifier`, for the
377            // same reason: keying a map is not the place to validate a path.
378            workspace: std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
379            supplied: digest(supplied),
380        }
381    }
382}
383
384/// A digest of the servers a client supplied, values included.
385///
386/// Everything that decides *what runs with what authority* is hashed: two
387/// sessions that named one server with different commands, or one command with
388/// a different token, are not asking for the same workspace. A digest rather
389/// than the values because this is a map key that outlives the request, and
390/// nothing a client configured should be held here in the clear — the same
391/// line [`McpServer`]'s own `Debug` draws.
392fn digest(servers: &[McpServer]) -> u64 {
393    let mut hasher = DefaultHasher::new();
394
395    for server in servers {
396        match server {
397            McpServer::Stdio(config) => {
398                "stdio".hash(&mut hasher);
399                config.name.hash(&mut hasher);
400                config.command.hash(&mut hasher);
401                config.args.hash(&mut hasher);
402                config.cwd.hash(&mut hasher);
403
404                // A `HashMap` has no order of its own, and a key that depended
405                // on one would differ between two identical requests.
406                let mut env: Vec<(&String, &String)> = config.env.iter().collect();
407                env.sort_unstable();
408                env.hash(&mut hasher);
409            }
410            // Both remote arms destructure exhaustively — config and limits —
411            // so a field mentra adds upstream is a compile error here rather
412            // than a value two different sessions silently share a workspace
413            // over. The discriminant is hashed like the values: two servers
414            // sharing a name and URL but not a transport are not asking for
415            // the same workspace.
416            McpServer::Sse(config) => {
417                let McpSseServerConfig {
418                    name,
419                    url,
420                    headers,
421                    allow_plaintext_credentials,
422                    limits,
423                } = config;
424                hash_remote(
425                    &mut hasher,
426                    "sse",
427                    name,
428                    url,
429                    headers,
430                    *allow_plaintext_credentials,
431                );
432
433                let McpSseLimits {
434                    connect_timeout,
435                    initialize_timeout,
436                    list_tools_timeout,
437                    call_tool_timeout,
438                    stream_idle_timeout,
439                    max_event_bytes,
440                    max_endpoint_bytes,
441                    max_tool_pages,
442                    max_tools,
443                } = limits;
444                (
445                    connect_timeout,
446                    initialize_timeout,
447                    list_tools_timeout,
448                    call_tool_timeout,
449                    stream_idle_timeout,
450                )
451                    .hash(&mut hasher);
452                (
453                    max_event_bytes,
454                    max_endpoint_bytes,
455                    max_tool_pages,
456                    max_tools,
457                )
458                    .hash(&mut hasher);
459            }
460            McpServer::Http(config) => {
461                let McpStreamableHttpServerConfig {
462                    name,
463                    url,
464                    headers,
465                    allow_plaintext_credentials,
466                    limits,
467                } = config;
468                hash_remote(
469                    &mut hasher,
470                    "http",
471                    name,
472                    url,
473                    headers,
474                    *allow_plaintext_credentials,
475                );
476
477                let McpStreamableHttpLimits {
478                    connect_timeout,
479                    initialize_timeout,
480                    list_tools_timeout,
481                    call_tool_timeout,
482                    stream_idle_timeout,
483                    max_event_bytes,
484                    max_response_bytes,
485                    max_tool_pages,
486                    max_tools,
487                } = limits;
488                (
489                    connect_timeout,
490                    initialize_timeout,
491                    list_tools_timeout,
492                    call_tool_timeout,
493                    stream_idle_timeout,
494                )
495                    .hash(&mut hasher);
496                (
497                    max_event_bytes,
498                    max_response_bytes,
499                    max_tool_pages,
500                    max_tools,
501                )
502                    .hash(&mut hasher);
503            }
504            // A transport this build does not know — the enum is
505            // `#[non_exhaustive]`. The discriminant separates two unknown
506            // *variants*; the name separates servers within one. What neither
507            // separates — two unknown servers of one variant differing only
508            // below the name — will key one workspace until this match learns
509            // the variant, and the consequence is bounded: the second session
510            // inherits the first one's connections rather than being handed a
511            // credential. basis's own same-crate matches (`McpServer::name`,
512            // its hand-written `Debug`) fail to compile the moment the
513            // variant lands, which is what forces this arm to become a real
514            // one.
515            server => {
516                std::mem::discriminant(server).hash(&mut hasher);
517                "unknown-transport".hash(&mut hasher);
518                server.name().hash(&mut hasher);
519            }
520        }
521    }
522
523    hasher.finish()
524}
525
526/// The remote transports' shared shape: discriminant tag, name, URL, ordered
527/// headers with their secrets fed into the hash, and the plaintext override —
528/// which decides where a credential may travel, so two configs differing only
529/// there are not the same authority.
530fn hash_remote(
531    hasher: &mut DefaultHasher,
532    transport: &str,
533    name: &str,
534    url: &str,
535    headers: &BTreeMap<String, SecretString>,
536    allow_plaintext_credentials: bool,
537) {
538    transport.hash(hasher);
539    name.hash(hasher);
540    url.hash(hasher);
541    // Already ordered: mentra types these as a `BTreeMap`.
542    for (header, value) in headers {
543        header.hash(hasher);
544        value.expose_secret().hash(hasher);
545    }
546    allow_plaintext_credentials.hash(hasher);
547}
548
549#[cfg(test)]
550mod tests;