use std::sync::Arc;
use async_trait::async_trait;
use crate::client::proxy::{
FocusOutcome, FocusTarget, InjectOutcome as ProxyInjectOutcome, SessionProxy,
SummarizeOutcome as ProxySummarizeOutcome,
};
use crate::daemon::managed_routes::{SpawnParams, spawn_managed};
use crate::daemon::state::DaemonState;
use crate::session_manager::record::ManagedSessionId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchOutcome {
pub session_id: String,
pub name: String,
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectOutcome {
Sent {
target: FocusTarget,
text: String,
},
NotFound {
session: String,
error: String,
},
Vanished {
target: FocusTarget,
error: String,
},
Failed {
target: FocusTarget,
error: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SummarizeOutcome {
Summary {
target: FocusTarget,
state: String,
summary: String,
pending_decision: Option<String>,
},
NotFound {
session: String,
error: String,
},
Vanished {
target: FocusTarget,
error: String,
},
Failed {
target: FocusTarget,
error: String,
},
}
#[async_trait]
pub trait SessionLauncher: Send + Sync {
async fn launch(&self, project: &str, task: &str) -> Result<LaunchOutcome, String>;
}
pub struct DaemonLauncher {
state: Arc<DaemonState>,
}
impl DaemonLauncher {
pub fn new(state: Arc<DaemonState>) -> Self {
Self { state }
}
}
#[async_trait]
impl SessionLauncher for DaemonLauncher {
async fn launch(&self, project: &str, task: &str) -> Result<LaunchOutcome, String> {
let registry = self.state.project_registry().await;
let projects = registry
.list()
.await
.map_err(|e| format!("project registry read failed: {e}"))?;
let Some(found) = projects.iter().find(|p| p.name == project) else {
return Err(format!("project '{project}' is not registered"));
};
let params = SpawnParams {
repo_url: found.repo_url.clone(),
git_ref: found.default_branch.clone(),
task: task.to_string(),
name_hint: None,
runtime: None,
ephemeral: None,
mcp_initiated: false,
inject_task: None,
deliverable_id: None,
force_new: true,
worktree: false,
};
let record = spawn_managed(&self.state, ManagedSessionId::new(), params).await?;
Ok(LaunchOutcome {
session_id: record.id.to_string(),
name: record.tmux_name.clone(),
state: record.state.to_string(),
})
}
}
pub fn resolve_actuator(state: &Arc<DaemonState>) -> Arc<dyn ManagerActuator> {
match state.manager_state().actuator_override() {
Some(actuator) => actuator,
None => Arc::new(ProxyActuator::production(state)),
}
}
#[async_trait]
pub trait ManagerActuator: Send + Sync {
async fn launch(&self, project: &str, task: &str) -> Result<LaunchOutcome, String>;
async fn inject(&self, conversation_key: &str, session: &str, text: &str) -> InjectOutcome;
async fn summarize(&self, conversation_key: &str, session: &str) -> SummarizeOutcome;
}
pub struct ProxyActuator {
launcher: Arc<dyn SessionLauncher>,
proxy: SessionProxy,
}
impl ProxyActuator {
pub fn new(launcher: Arc<dyn SessionLauncher>, proxy: SessionProxy) -> Self {
Self { launcher, proxy }
}
pub fn production(state: &Arc<DaemonState>) -> Self {
Self::new(
Arc::new(DaemonLauncher::new(Arc::clone(state))),
crate::daemon::managed_routes::proxy::local_proxy(state),
)
}
}
#[async_trait]
impl ManagerActuator for ProxyActuator {
async fn launch(&self, project: &str, task: &str) -> Result<LaunchOutcome, String> {
self.launcher.launch(project, task).await
}
async fn inject(&self, conversation_key: &str, session: &str, text: &str) -> InjectOutcome {
match self.proxy.focus(conversation_key, session).await {
FocusOutcome::NotFound { target, error } => InjectOutcome::NotFound {
session: target,
error,
},
FocusOutcome::Focused(_) | FocusOutcome::Current(_) => {
match self.proxy.inject(conversation_key, text).await {
ProxyInjectOutcome::Sent { target, text } => {
InjectOutcome::Sent { target, text }
}
ProxyInjectOutcome::AutoUnfocused { target, error } => {
InjectOutcome::Vanished { target, error }
}
ProxyInjectOutcome::Failed { target, error } => {
InjectOutcome::Failed { target, error }
}
ProxyInjectOutcome::NoFocus => InjectOutcome::NotFound {
session: session.to_string(),
error: "focus was cleared before inject".to_string(),
},
}
}
}
}
async fn summarize(&self, conversation_key: &str, session: &str) -> SummarizeOutcome {
match self.proxy.focus(conversation_key, session).await {
FocusOutcome::NotFound { target, error } => SummarizeOutcome::NotFound {
session: target,
error,
},
FocusOutcome::Focused(_) | FocusOutcome::Current(_) => {
match self.proxy.summarize(conversation_key).await {
ProxySummarizeOutcome::Summary {
target,
state,
summary,
pending_decision,
} => SummarizeOutcome::Summary {
target,
state,
summary,
pending_decision,
},
ProxySummarizeOutcome::AutoUnfocused { target, error } => {
SummarizeOutcome::Vanished { target, error }
}
ProxySummarizeOutcome::Failed { target, error } => {
SummarizeOutcome::Failed { target, error }
}
ProxySummarizeOutcome::NoFocus => SummarizeOutcome::NotFound {
session: session.to_string(),
error: "focus was cleared before summarize".to_string(),
},
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn daemon_launcher_unknown_project_errors() {
let root = tempfile::tempdir().unwrap().keep();
let state = Arc::new(DaemonState::with_root_isolated_managed(root).await);
let launcher = DaemonLauncher::new(Arc::clone(&state));
let err = launcher
.launch("does-not-exist", "some task")
.await
.expect_err("unknown project must error");
assert!(err.contains("not registered"), "{err}");
}
}