use trusty_mpm_core::compress::{CompressionLevel, compress_output};
use trusty_mpm_core::session::{Session, SessionStatus};
use crate::error::DaemonError;
use crate::services::tmux_service::TmuxService;
use crate::state::DaemonState;
use crate::tmux::TmuxDriver;
const PAUSE_CAPTURE_LINES: u32 = 50;
const PAUSE_SUMMARY_CHARS: usize = 500;
#[derive(Debug)]
pub struct ReapResult {
pub reaped: usize,
pub sessions: Vec<String>,
pub stopped: usize,
}
#[derive(Debug)]
pub struct PauseResult {
pub session_id: String,
pub summary: String,
}
pub struct SessionService<'s> {
state: &'s DaemonState,
}
impl<'s> SessionService<'s> {
pub fn new(state: &'s DaemonState) -> Self {
Self { state }
}
pub fn resolve(&self, key: &str) -> Result<Session, DaemonError> {
self.state
.find_session(key)
.ok_or_else(|| DaemonError::SessionNotFound {
id: key.to_string(),
})
}
pub fn reap(&self) -> ReapResult {
let before: Vec<String> = self
.state
.list_sessions()
.into_iter()
.map(|s| s.tmux_name)
.collect();
let outcome = match TmuxDriver::discover() {
Ok(driver) => self.state.reap_dead_sessions(&driver),
Err(_) => {
tracing::info!("tmux unavailable; skipping dead-session reap");
crate::state::ReapResult::default()
}
};
let after: std::collections::HashSet<String> = self
.state
.list_sessions()
.into_iter()
.map(|s| s.tmux_name)
.collect();
let sessions: Vec<String> = before
.into_iter()
.filter(|name| !after.contains(name))
.collect();
ReapResult {
reaped: outcome.reaped,
sessions,
stopped: outcome.stopped,
}
}
pub fn pause(
&self,
key: &str,
operator_note: Option<String>,
) -> Result<PauseResult, DaemonError> {
let session = self.resolve(key)?;
let summary = operator_note.unwrap_or_else(|| {
let captured = TmuxService::capture(&session, PAUSE_CAPTURE_LINES);
let (compressed, _) = compress_output(&captured, CompressionLevel::Summarise);
compressed.chars().take(PAUSE_SUMMARY_CHARS).collect()
});
let now = std::time::SystemTime::now();
self.state.update_session(&session.id, |s| {
s.status = SessionStatus::Paused;
s.paused_at = Some(now);
s.pause_summary = Some(summary.clone());
});
if let Some(updated) = self.state.session(session.id)
&& let Err(e) = trusty_mpm_core::session_store::save_pause(&updated)
{
tracing::warn!(
"failed to persist pause state for {}: {e}",
session.tmux_name
);
}
Ok(PauseResult {
session_id: session.id.0.to_string(),
summary,
})
}
pub fn resume(&self, key: &str) -> Result<(), DaemonError> {
let session = self.resolve(key)?;
if session.status != SessionStatus::Paused {
return Err(DaemonError::SessionNotActive {
id: key.to_string(),
status: format!("{:?}", session.status).to_lowercase(),
});
}
self.state.update_session(&session.id, |s| {
s.status = SessionStatus::Active;
s.paused_at = None;
s.pause_summary = None;
});
if let Err(e) = trusty_mpm_core::session_store::clear_pause(&session.id) {
tracing::warn!("failed to clear pause state for {}: {e}", session.tmux_name);
}
Ok(())
}
pub fn command_target(&self, key: &str) -> Result<Session, DaemonError> {
let session = self.resolve(key)?;
if session.status == SessionStatus::Stopped {
return Err(DaemonError::SessionNotActive {
id: key.to_string(),
status: "stopped".to_string(),
});
}
Ok(session)
}
}
pub fn spawn_pid_capture(
state: std::sync::Arc<DaemonState>,
id: trusty_mpm_core::session::SessionId,
tmux_name: String,
) {
tokio::spawn(async move {
let captured = tokio::task::spawn_blocking(move || {
trusty_mpm_core::process::find_claude_pid_in_tmux(
&tmux_name,
10,
std::time::Duration::from_millis(500),
)
})
.await;
match captured {
Ok(Some(pid)) => {
if state.set_session_pid(id, pid) {
tracing::info!("tracked claude PID {pid} for session {id:?}");
}
}
Ok(None) => {
tracing::warn!("could not find claude PID for session {id:?}");
}
Err(e) => {
tracing::warn!("PID-capture task failed for session {id:?}: {e}");
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm_core::session::{ControlModel, SessionId};
fn active_session(state: &DaemonState) -> SessionId {
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Active;
state.register_session(s);
id
}
#[test]
fn resolve_hits_by_id_and_name() {
let state = DaemonState::new();
let id = active_session(&state);
let name = state.session(id).unwrap().tmux_name;
let svc = SessionService::new(&state);
assert_eq!(svc.resolve(&id.0.to_string()).unwrap().id, id);
assert_eq!(svc.resolve(&name).unwrap().id, id);
}
#[test]
fn resolve_miss_is_not_found() {
let state = DaemonState::new();
let svc = SessionService::new(&state);
let err = svc.resolve("tmpm-no-such").unwrap_err();
assert!(matches!(err, DaemonError::SessionNotFound { .. }));
}
#[test]
fn pause_then_resume_transitions_status() {
let state = DaemonState::new();
let id = active_session(&state);
let svc = SessionService::new(&state);
let result = svc
.pause(&id.0.to_string(), Some("mid-task".into()))
.expect("pause succeeds");
assert_eq!(result.summary, "mid-task");
assert_eq!(state.session(id).unwrap().status, SessionStatus::Paused);
svc.resume(&id.0.to_string()).expect("resume succeeds");
let after = state.session(id).unwrap();
assert_eq!(after.status, SessionStatus::Active);
assert_eq!(after.pause_summary, None);
}
#[test]
fn resume_unpaused_errors() {
let state = DaemonState::new();
let id = active_session(&state);
let svc = SessionService::new(&state);
let err = svc.resume(&id.0.to_string()).unwrap_err();
assert!(matches!(err, DaemonError::SessionNotActive { .. }));
}
#[test]
fn command_target_rejects_stopped() {
let state = DaemonState::new();
let id = SessionId::new();
let mut s = Session::new(id, "/tmp/p", ControlModel::Tmux, None);
s.status = SessionStatus::Stopped;
state.register_session(s);
let svc = SessionService::new(&state);
let err = svc.command_target(&id.0.to_string()).unwrap_err();
assert!(matches!(err, DaemonError::SessionNotActive { .. }));
}
#[test]
fn reap_removes_sessions_absent_from_tmux() {
let state = DaemonState::new();
active_session(&state);
let svc = SessionService::new(&state);
let result = svc.reap();
assert_eq!(result.reaped, result.sessions.len());
}
}