use std::{
collections::{BTreeMap, HashMap},
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use basis::{
A2aWatch, McpConfig, McpServer, McpSseServerConfig, McpStreamableHttpServerConfig,
PersistedSession, PreparedRun, RunError, RunSpec, Runtime, RuntimeBuilder, Workspace,
WorkspaceBuilder,
mcp::{McpSseLimits, McpStreamableHttpLimits, SecretString},
};
use tokio::sync::OnceCell;
use crate::{SessionSource, SessionTemplate};
pub struct ConfiguredSource {
template: SessionTemplate,
runtime: OnceCell<Arc<Runtime>>,
workspaces: Mutex<HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>>,
}
struct Opened {
workspace: Arc<Workspace>,
_a2a_watch: Option<A2aWatch>,
}
impl ConfiguredSource {
pub fn new(template: Option<SessionTemplate>) -> Self {
Self {
template: template.unwrap_or_default(),
runtime: OnceCell::new(),
workspaces: Mutex::new(HashMap::new()),
}
}
#[cfg(test)]
pub fn on_runtime(runtime: Arc<Runtime>, template: Option<SessionTemplate>) -> Self {
let source = Self::new(template);
source
.runtime
.set(runtime)
.unwrap_or_else(|_| unreachable!("a runtime that was just constructed is empty"));
source
}
fn parts_for(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> (WorkspaceBuilder, RunSpec) {
let template = &self.template;
let mut builder = Workspace::builder(cwd).with_shell(template.shell);
if let Some(model) = &template.model {
builder = builder.with_model(model.clone());
}
if let Some(system_prompt) = &template.system_prompt {
builder = builder.with_system_prompt(system_prompt.clone());
}
let discovery = &template.discovery;
let builder = builder
.with_context(discovery.context.clone())
.with_skills(discovery.skills.clone())
.with_templates(discovery.templates.clone())
.with_hooks(discovery.hooks.clone())
.with_tools(discovery.tools.clone());
let builder = builder.with_mcp(self.session_mcp(mcp));
let mut spec = RunSpec::default();
if let Some(session_name) = &template.session_name {
spec = spec.with_session_name(session_name.clone());
}
if let Some(effort) = template.effort {
spec = spec.with_effort(effort);
}
(builder, spec)
}
fn session_mcp(&self, mcp: Vec<McpServer>) -> McpConfig {
self.template.discovery.mcp.clone().with_supplied(mcp)
}
async fn workspace_for(
&self,
cwd: PathBuf,
mcp: Vec<McpServer>,
) -> Result<(Arc<Workspace>, RunSpec), RunError> {
let key = WorkspaceKey::new(&cwd, &mcp);
let (builder, spec) = self.parts_for(cwd, mcp);
Ok((self.open(key, builder).await?, spec))
}
async fn open(
&self,
key: WorkspaceKey,
builder: WorkspaceBuilder,
) -> Result<Arc<Workspace>, RunError> {
let cell = Arc::clone(self.lock().entry(key).or_default());
let opened = cell
.get_or_try_init(|| async move {
let runtime = Arc::clone(self.runtime().await?);
let workspace = Arc::new(builder.with_runtime(runtime).open().await?);
let a2a_watch = self.template.a2a_watch_interval().and_then(|interval| {
match workspace.watch_a2a(interval) {
Ok(watch) => Some(watch),
Err(error) => {
eprintln!("Warning: {error}; A2A files will not be watched");
None
}
}
});
Ok::<_, RunError>(Opened {
workspace,
_a2a_watch: a2a_watch,
})
})
.await?;
Ok(Arc::clone(&opened.workspace))
}
async fn runtime(&self) -> Result<&Arc<Runtime>, RunError> {
self.runtime
.get_or_try_init(|| async { Ok(Arc::new(self.recipe().build()?)) })
.await
}
fn recipe(&self) -> RuntimeBuilder {
let template = &self.template;
let mut recipe = Runtime::builder();
if let Some(model) = &template.model {
recipe = recipe.with_model(model.clone());
}
if let Some(provider) = template.provider {
recipe = recipe.with_provider(provider);
}
if let Some(base_url) = &template.base_url {
recipe = recipe.with_base_url(base_url.clone());
}
recipe
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<WorkspaceKey, Arc<OnceCell<Opened>>>> {
self.workspaces
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
pub fn opened(&self) -> Vec<Arc<Workspace>> {
self.lock()
.values()
.filter_map(|cell| cell.get().map(|opened| Arc::clone(&opened.workspace)))
.collect()
}
}
#[async_trait::async_trait]
impl SessionSource for ConfiguredSource {
async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError> {
let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
workspace.prepare(spec)
}
async fn resume(
&self,
agent_id: &str,
cwd: PathBuf,
mcp: Vec<McpServer>,
) -> Result<PreparedRun, RunError> {
let (workspace, spec) = self.workspace_for(cwd, mcp).await?;
workspace.resume(agent_id, spec)
}
fn lists_sessions(&self) -> bool {
true
}
async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
basis::store::list(&cwd)
}
fn deletes_sessions(&self) -> bool {
true
}
async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
basis::store::forget(agent_id)
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
struct WorkspaceKey {
workspace: PathBuf,
supplied: u64,
}
impl WorkspaceKey {
fn new(cwd: &Path, supplied: &[McpServer]) -> Self {
Self {
workspace: std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
supplied: digest(supplied),
}
}
}
fn digest(servers: &[McpServer]) -> u64 {
let mut hasher = DefaultHasher::new();
for server in servers {
match server {
McpServer::Stdio(config) => {
"stdio".hash(&mut hasher);
config.name.hash(&mut hasher);
config.command.hash(&mut hasher);
config.args.hash(&mut hasher);
config.cwd.hash(&mut hasher);
let mut env: Vec<(&String, &String)> = config.env.iter().collect();
env.sort_unstable();
env.hash(&mut hasher);
}
McpServer::Sse(config) => {
let McpSseServerConfig {
name,
url,
headers,
allow_plaintext_credentials,
limits,
} = config;
hash_remote(
&mut hasher,
"sse",
name,
url,
headers,
*allow_plaintext_credentials,
);
let McpSseLimits {
connect_timeout,
initialize_timeout,
list_tools_timeout,
call_tool_timeout,
stream_idle_timeout,
max_event_bytes,
max_endpoint_bytes,
max_tool_pages,
max_tools,
} = limits;
(
connect_timeout,
initialize_timeout,
list_tools_timeout,
call_tool_timeout,
stream_idle_timeout,
)
.hash(&mut hasher);
(
max_event_bytes,
max_endpoint_bytes,
max_tool_pages,
max_tools,
)
.hash(&mut hasher);
}
McpServer::Http(config) => {
let McpStreamableHttpServerConfig {
name,
url,
headers,
allow_plaintext_credentials,
limits,
} = config;
hash_remote(
&mut hasher,
"http",
name,
url,
headers,
*allow_plaintext_credentials,
);
let McpStreamableHttpLimits {
connect_timeout,
initialize_timeout,
list_tools_timeout,
call_tool_timeout,
stream_idle_timeout,
max_event_bytes,
max_response_bytes,
max_tool_pages,
max_tools,
} = limits;
(
connect_timeout,
initialize_timeout,
list_tools_timeout,
call_tool_timeout,
stream_idle_timeout,
)
.hash(&mut hasher);
(
max_event_bytes,
max_response_bytes,
max_tool_pages,
max_tools,
)
.hash(&mut hasher);
}
server => {
std::mem::discriminant(server).hash(&mut hasher);
"unknown-transport".hash(&mut hasher);
server.name().hash(&mut hasher);
}
}
}
hasher.finish()
}
fn hash_remote(
hasher: &mut DefaultHasher,
transport: &str,
name: &str,
url: &str,
headers: &BTreeMap<String, SecretString>,
allow_plaintext_credentials: bool,
) {
transport.hash(hasher);
name.hash(hasher);
url.hash(hasher);
for (header, value) in headers {
header.hash(hasher);
value.expose_secret().hash(hasher);
}
allow_plaintext_credentials.hash(hasher);
}
#[cfg(test)]
mod tests;