use std::sync::Arc;
use async_trait::async_trait;
use crate::activity::cache::ActivityState;
pub use crate::core::sm::control::{
LaunchParams, RawObservation, SessionControl, SessionControlError, Submit, Summary,
};
use crate::daemon::managed_routes::{SpawnParams, record_to_json, resume_managed, spawn_managed};
use crate::daemon::state::DaemonState;
use crate::session_manager::{ManagedError, ManagedSessionId};
pub struct DaemonSessionControl {
state: Arc<DaemonState>,
}
impl DaemonSessionControl {
pub fn new(state: Arc<DaemonState>) -> Self {
Self { state }
}
fn parse_id(session_id: &str) -> Result<ManagedSessionId, SessionControlError> {
session_id
.parse::<uuid::Uuid>()
.map(ManagedSessionId::from)
.map_err(|_| SessionControlError::NotFound(session_id.to_string()))
}
fn map_managed_err(e: ManagedError) -> SessionControlError {
match e {
ManagedError::SessionNotFound(msg) => SessionControlError::NotFound(msg),
other => SessionControlError::Backend(other.to_string()),
}
}
}
#[async_trait]
impl SessionControl for DaemonSessionControl {
async fn launch(&self, params: LaunchParams) -> Result<serde_json::Value, SessionControlError> {
let task = params
.prompt
.filter(|p| !p.trim().is_empty())
.unwrap_or_else(|| "session-manager launched task".to_string());
let repo_url = params.repo_url.unwrap_or(params.workdir);
let git_ref = params.ref_.unwrap_or_default();
let spawn = SpawnParams {
repo_url,
git_ref,
task,
name_hint: None,
runtime: params.model,
ephemeral: params.ephemeral,
mcp_initiated: false,
inject_task: None,
deliverable_id: None,
force_new: false,
worktree: false,
};
let record = spawn_managed(
&self.state,
crate::session_manager::ManagedSessionId::new(),
spawn,
)
.await
.map_err(SessionControlError::Backend)?;
Ok(serde_json::json!({ "session_id": record.id.to_string() }))
}
async fn list(&self) -> Result<serde_json::Value, SessionControlError> {
let mgr = self.state.session_manager().await;
let sessions: Vec<serde_json::Value> =
mgr.list().await.iter().map(record_to_json).collect();
Ok(serde_json::json!({ "sessions": sessions }))
}
async fn get(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
let record = mgr.get(&id).await.map_err(Self::map_managed_err)?;
Ok(serde_json::json!({ "session": record_to_json(&record) }))
}
async fn send(
&self,
session_id: &str,
text: &str,
) -> Result<serde_json::Value, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
mgr.send_input(&id, text)
.await
.map_err(|e| SessionControlError::Backend(e.to_string()))?;
Ok(serde_json::json!({ "ok": true }))
}
async fn stop(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
let report = mgr
.stop_reporting(&id, crate::session_manager::StopCause::Deliberate)
.await
.map_err(Self::map_managed_err)?;
Ok(serde_json::json!({
"ok": true,
"runtime_left_running": report.runtime.left_running(),
}))
}
async fn resume(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
let id = Self::parse_id(session_id)?;
resume_managed(&self.state, &id)
.await
.map_err(|e| SessionControlError::Backend(e.to_string()))?;
Ok(serde_json::json!({ "ok": true }))
}
async fn kill(&self, session_id: &str) -> Result<serde_json::Value, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
let report = mgr
.decommission_reporting(
&id,
None,
crate::session_manager::decommission_force::ProvisioningDirt::Refuse,
)
.await
.map_err(Self::map_managed_err)?;
Ok(serde_json::json!({
"ok": true,
"workspace_removed": report.workspace_removed,
"workspace_kept_reason": report.workspace_kept_reason,
"workspace_kept_by_design": report.workspace_kept_by_design,
}))
}
async fn inject_text(
&self,
session_id: &str,
text: &str,
submit: Submit,
) -> Result<(), SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
mgr.inject(&id, text, submit)
.await
.map_err(|e| SessionControlError::Backend(e.to_string()))
}
async fn observe(
&self,
session_id: &str,
lines: usize,
) -> Result<RawObservation, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
mgr.observe(&id, lines).await.map_err(Self::map_managed_err)
}
async fn summarize(&self, session_id: &str) -> Result<Summary, SessionControlError> {
let id = Self::parse_id(session_id)?;
let mgr = self.state.session_manager().await;
let pane_text = mgr.capture_pane(&id, 60).await.unwrap_or_default();
let monitor = self.state.activity_monitor();
let result = monitor
.check(session_id, &pane_text)
.await
.map_err(|e| SessionControlError::Backend(e.to_string()))?;
Ok(verdict_to_summary(result.verdict))
}
async fn adopt(
&self,
tmux_name: &str,
cwd: &str,
task: Option<&str>,
runtime: Option<&str>,
) -> Result<serde_json::Value, SessionControlError> {
let runtime_kind = match runtime {
None => crate::runtime::RuntimeKind::default(),
Some(raw) => raw
.parse::<crate::runtime::RuntimeKind>()
.map_err(|e| SessionControlError::Backend(e.to_string()))?,
};
let mgr = self.state.session_manager().await;
let record = mgr
.adopt_existing(
tmux_name,
std::path::PathBuf::from(cwd),
task.unwrap_or_default().to_string(),
runtime_kind,
false,
)
.await
.map_err(|e| match e {
ManagedError::TmuxSessionMissing(msg) => SessionControlError::NotFound(msg),
other => SessionControlError::Backend(other.to_string()),
})?;
Ok(serde_json::json!({ "session_id": record.id.to_string() }))
}
}
fn verdict_to_summary(verdict: crate::activity::cache::ActivityVerdict) -> Summary {
let summary = if verdict.state == ActivityState::Unknown {
None
} else {
Some(verdict.summary)
};
Summary {
state: verdict.state,
summary,
confidence: verdict.confidence,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::activity::cache::ActivityVerdict;
#[test]
fn verdict_to_summary_drops_unknown_summary_to_none() {
let v = ActivityVerdict {
state: ActivityState::Unknown,
summary: "OPENROUTER_API_KEY not configured".into(),
confidence: 0.0,
};
let s = verdict_to_summary(v);
assert_eq!(s.state, ActivityState::Unknown);
assert_eq!(s.summary, None);
assert_eq!(s.confidence, 0.0);
}
#[test]
fn verdict_to_summary_keeps_real_summary() {
let v = ActivityVerdict {
state: ActivityState::Working,
summary: "writing code".into(),
confidence: 0.87,
};
let s = verdict_to_summary(v);
assert_eq!(s.state, ActivityState::Working);
assert_eq!(s.summary.as_deref(), Some("writing code"));
assert!((s.confidence - 0.87).abs() < 1e-6);
}
#[derive(Default)]
struct LiveTmux(std::sync::Mutex<Vec<String>>);
impl crate::session_manager::ManagedTmuxDriver for LiveTmux {
fn create_session(&self, name: &str, _: &str) -> Result<(), ManagedError> {
self.0.lock().unwrap().push(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.0.lock().unwrap().retain(|n| n != name);
Ok(())
}
fn send_line(&self, _: &str, _: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _: &str, _: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.0.lock().unwrap().clone())
}
}
#[tokio::test]
async fn stop_says_an_unproven_runtime_was_left_running() {
let tmp = tempfile::tempdir().expect("daemon root");
let ws = tempfile::tempdir().expect("user directory");
let tmux = Arc::new(LiveTmux::default());
let state = Arc::new(
DaemonState::with_root_isolated_managed_and_driver(
tmp.path().to_path_buf(),
tmux.clone(),
)
.await,
);
let id = ManagedSessionId::new();
state
.session_manager()
.await
.create_with_id(
id,
"regression: #8935 sm stop".to_string(),
Some(ws.path().to_path_buf()),
None,
Some(ws.path().to_path_buf()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let json = DaemonSessionControl::new(state)
.stop(&id.to_string())
.await
.expect("stop");
assert_eq!(json["ok"], serde_json::Value::Bool(true), "{json}");
let why = json["runtime_left_running"]
.as_str()
.unwrap_or_else(|| panic!("the stop names the live session: {json}"));
assert!(why.contains("no pane id"), "{why}");
assert_eq!(tmux.0.lock().unwrap().len(), 1, "the session was killed");
}
#[tokio::test]
async fn kill_reports_the_workspace_verdict() {
let tmp = tempfile::tempdir().expect("daemon root");
let ws = tempfile::tempdir().expect("user directory");
let state =
Arc::new(DaemonState::with_root_isolated_managed(tmp.path().to_path_buf()).await);
let id = ManagedSessionId::new();
state
.session_manager()
.await
.create_with_id(
id,
"regression: #8663 sm kill".to_string(),
Some(ws.path().to_path_buf()),
None,
Some(ws.path().to_path_buf()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
let json = DaemonSessionControl::new(state)
.kill(&id.to_string())
.await
.expect("kill");
assert_eq!(json["ok"], serde_json::Value::Bool(true), "{json}");
assert_eq!(
json.get("workspace_removed"),
Some(&serde_json::Value::Bool(false)),
"{json}"
);
assert_eq!(
json.get("workspace_kept_reason"),
Some(&serde_json::Value::Null),
"{json}"
);
let by_design = json["workspace_kept_by_design"]
.as_str()
.unwrap_or_else(|| panic!("the by-design reason is a string: {json}"));
assert!(by_design.contains("kept by design"), "{by_design}");
}
}