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;