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