use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::{Path as AxumPath, State};
use axum::response::IntoResponse;
use chrono::Utc;
use serde_json::Value;
use tempfile::TempDir;
use super::*;
use crate::daemon::state::DaemonState;
use crate::session_manager::ManagedSessionId;
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn fake_home() -> (TempDir, HomeGuard) {
let home = TempDir::new().unwrap();
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home.path()) };
(home, HomeGuard(prior))
}
async fn seed(state: &Arc<DaemonState>, record: SessionRecord) {
let mgr = state.session_manager().await;
mgr.store
.write()
.await
.upsert(record)
.await
.expect("seed upsert");
}
fn base_record(id: ManagedSessionId, state: ManagedSessionState, cwd: PathBuf) -> SessionRecord {
SessionRecord {
id,
tmux_name: format!("tmpm-test-{id}"),
cwd,
task: "test".to_string(),
state,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
async fn test_state() -> (Arc<DaemonState>, TempDir) {
let tmp = TempDir::new().expect("tempdir");
let state = Arc::new(DaemonState::with_root_isolated_managed(tmp.path().to_owned()).await);
(state, tmp)
}
#[tokio::test]
#[serial_test::serial]
async fn sync_route_redeploys_stale_agent() {
let (home, _guard) = fake_home();
let fw = FrameworkPaths::default();
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let (state, _tmp) = test_state().await;
let id = ManagedSessionId::new();
let workspace = home.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let mut record = base_record(id, ManagedSessionState::Active, workspace.clone());
record.workspace_path = Some(workspace.clone());
seed(&state, record).await;
let resp = sync_session_assets_route(State(state.clone()), AxumPath(id.to_string()))
.await
.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::OK, "{:?}", resp);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["agents_deployed"],
serde_json::json!(["rust-engineer.md"]),
"the freshly-deployed agent must be reported: {json}"
);
assert!(
FrameworkPaths::for_managed_workspace(&workspace)
.agent_deploy_dir()
.join("rust-engineer.md")
.exists(),
"the agent must actually land on disk in the tm-managed agent tier"
);
assert!(
!workspace.join(".claude").join("agents").exists(),
"sync-assets must not deploy bundled agents into the workspace (#4409)"
);
}
#[tokio::test]
async fn sync_route_missing_session_404() {
let (state, _tmp) = test_state().await;
let id = ManagedSessionId::new();
let resp = sync_session_assets_route(State(state), AxumPath(id.to_string()))
.await
.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn sync_route_decommissioned_session_blocked_no_fs_write() {
let (state, tmp) = test_state().await;
let id = ManagedSessionId::new();
let vanished_workspace = tmp.path().join("vanished-workspace");
assert!(
!vanished_workspace.exists(),
"precondition: the path must not exist before the route call"
);
let record = base_record(
id,
ManagedSessionState::Decommissioned,
vanished_workspace.clone(),
);
seed(&state, record).await;
let resp = sync_session_assets_route(State(state), AxumPath(id.to_string()))
.await
.into_response();
assert_eq!(
resp.status(),
axum::http::StatusCode::CONFLICT,
"a decommissioned session must be refused, not silently synced"
);
assert!(
!vanished_workspace.exists(),
"the gate must block BEFORE any deployer runs — the deleted workspace \
directory must never be recreated on disk"
);
}
#[tokio::test]
async fn sync_route_provisioning_session_returns_conflict() {
let (state, tmp) = test_state().await;
let id = ManagedSessionId::new();
let ws = tmp.path().join("provisioning-ws");
let record = base_record(id, ManagedSessionState::Provisioning, ws);
seed(&state, record).await;
let resp = sync_session_assets_route(State(state), AxumPath(id.to_string()))
.await
.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::CONFLICT);
}
#[tokio::test]
#[serial_test::serial]
async fn sync_all_route_skips_provisioning_and_decommissioned() {
let (home, _guard) = fake_home();
let fw = FrameworkPaths::default();
let bundled = fw.agent_source_dir();
std::fs::create_dir_all(&bundled).unwrap();
std::fs::write(
bundled.join("rust-engineer.md"),
"---\nname: rust-engineer\n---\nv1",
)
.unwrap();
let (state, tmp) = test_state().await;
let active_id = ManagedSessionId::new();
let active_ws = home.path().join("active-ws");
std::fs::create_dir_all(&active_ws).unwrap();
let mut active = base_record(active_id, ManagedSessionState::Active, active_ws.clone());
active.workspace_path = Some(active_ws);
seed(&state, active).await;
let provisioning_id = ManagedSessionId::new();
let provisioning = base_record(
provisioning_id,
ManagedSessionState::Provisioning,
tmp.path().join("provisioning-ws"),
);
seed(&state, provisioning).await;
let decommissioned_id = ManagedSessionId::new();
let decommissioned = base_record(
decommissioned_id,
ManagedSessionState::Decommissioned,
tmp.path().join("gone-ws"),
);
seed(&state, decommissioned).await;
let resp = sync_all_session_assets_route(State(state))
.await
.into_response();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
let synced_ids: Vec<&str> = json["synced"]
.as_array()
.unwrap()
.iter()
.map(|s| s["id"].as_str().unwrap())
.collect();
assert_eq!(
synced_ids,
vec![active_id.to_string()],
"only the Active session must be synced: {json}"
);
let skipped: Vec<&str> = json["skipped"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap())
.collect();
assert!(skipped.contains(&provisioning_id.to_string().as_str()));
assert!(skipped.contains(&decommissioned_id.to_string().as_str()));
assert_eq!(
skipped.len(),
2,
"exactly the two non-syncable sessions: {json}"
);
}
#[test]
fn syncable_gates_exactly_active_stopped_errored() {
assert!(syncable(&ManagedSessionState::Active));
assert!(syncable(&ManagedSessionState::Stopped));
assert!(syncable(&ManagedSessionState::Errored));
assert!(!syncable(&ManagedSessionState::Provisioning));
assert!(!syncable(&ManagedSessionState::Decommissioned));
}