use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::agent::is_subagent_dispatch_tool;
use crate::core::dispatch_isolation::{
blocked_by_shared_tree, dispatch_agent, dispatch_isolation, isolation_separates_working_tree,
};
use crate::core::hook::HookEvent;
use crate::core::session::SessionId;
use crate::daemon::error::DaemonError;
use crate::daemon::state::DaemonState;
use crate::daemon::state::sessions::SharedTreeQuestion;
pub const TREE_HOLDERS_MARKER: &str = "tree_holders";
#[derive(Debug, Deserialize)]
pub struct SharedTreeDispatchRequest {
pub payload: Value,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SharedTreeWritersResponse {
pub agents: Vec<SharedTreeWriter>,
pub total: usize,
#[serde(default)]
pub claimed: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub records: Vec<crate::daemon::services::delegation_records::DelegationRecordView>,
#[serde(default)]
pub tree_holders: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SharedTreeWriter {
pub agent: String,
pub count: usize,
}
pub fn router() -> Router<Arc<DaemonState>> {
Router::new()
.route(
"/api/v1/sessions/{id}/delegations/shared-tree-dispatch",
post(shared_tree_dispatch_route),
)
.route(
"/api/v1/sessions/{id}/delegations/granted-worktree",
post(granted_worktree_route),
)
.route(
"/api/v1/delegations/{agent_id}/repair",
post(repair_delegation_route),
)
.route(
"/api/v1/delegations/by-id/{delegation_id}/repair",
post(repair_delegation_by_id_route),
)
.route("/api/v1/delegations", get(list_delegations_route))
}
#[derive(Debug, Deserialize)]
pub struct ListDelegationsQuery {
pub cwd: PathBuf,
}
pub async fn list_delegations_route(
State(state): State<Arc<DaemonState>>,
Query(q): Query<ListDelegationsQuery>,
) -> Json<crate::daemon::services::delegation_records::DelegationListing> {
Json(list_delegations_op(&state, q.cwd))
}
pub fn list_delegations_op(
state: &DaemonState,
cwd: PathBuf,
) -> crate::daemon::services::delegation_records::DelegationListing {
let records = crate::daemon::services::delegation_records::list_for_dir(state, &cwd);
crate::daemon::services::delegation_records::DelegationListing { cwd, records }
}
pub async fn repair_delegation_by_id_route(
State(state): State<Arc<DaemonState>>,
Path(delegation_id): Path<String>,
body: Option<Json<crate::daemon::services::delegation_repair::RepairDelegationRequest>>,
) -> Result<Json<crate::daemon::services::delegation_repair::RepairOutcome>, DaemonError> {
let force = body.is_some_and(|Json(b)| b.force);
let peer = crate::daemon::services::delegation_repair_caller::RepairPeer::http();
Ok(Json(
repair_delegation_by_id_op(state, &delegation_id, force, peer).await?,
))
}
pub async fn repair_delegation_by_id_op(
state: Arc<DaemonState>,
delegation_id: &str,
force: bool,
peer: crate::daemon::services::delegation_repair_caller::RepairPeer,
) -> Result<crate::daemon::services::delegation_repair::RepairOutcome, DaemonError> {
let id = uuid::Uuid::parse_str(delegation_id)
.map(crate::core::agent::DelegationId)
.map_err(|_| {
DaemonError::InvalidRequest(format!("malformed delegation id: {delegation_id}"))
})?;
Ok(repair_off_worker(move || {
let caller = crate::daemon::services::delegation_repair_caller::establish_caller(
&state,
peer,
|d| d.id == id,
);
crate::daemon::services::delegation_repair::repair_delegation_by_id(
&state, id, force, &caller,
)
})
.await)
}
async fn repair_off_worker(
repair: impl FnOnce() -> crate::daemon::services::delegation_repair::RepairOutcome + Send + 'static,
) -> crate::daemon::services::delegation_repair::RepairOutcome {
tokio::task::spawn_blocking(repair)
.await
.unwrap_or_else(|e| {
tracing::error!("delegation: repair task failed before answering: {e} (#8257)");
crate::daemon::services::delegation_repair::RepairOutcome::Refused {
reason: format!(
"the repair task failed before it answered ({e}), so its result is \
unknown — `tm repair delegation --list` shows which records are still \
live (#8257)"
),
}
})
}
pub async fn repair_delegation_route(
State(state): State<Arc<DaemonState>>,
Path(agent_id): Path<String>,
body: Option<Json<crate::daemon::services::delegation_repair::RepairDelegationRequest>>,
) -> Json<crate::daemon::services::delegation_repair::RepairOutcome> {
let force = body.is_some_and(|Json(b)| b.force);
let peer = crate::daemon::services::delegation_repair_caller::RepairPeer::http();
Json(repair_delegation_op(state, agent_id, force, peer).await)
}
pub async fn repair_delegation_op(
state: Arc<DaemonState>,
agent_id: String,
force: bool,
peer: crate::daemon::services::delegation_repair_caller::RepairPeer,
) -> crate::daemon::services::delegation_repair::RepairOutcome {
repair_off_worker(move || {
let caller = crate::daemon::services::delegation_repair_caller::establish_caller(
&state,
peer,
|d| d.agent_id.as_deref() == Some(agent_id.as_str()),
);
crate::daemon::services::delegation_repair::repair_delegation_as(
&state, &agent_id, force, &caller,
)
})
.await
}
pub async fn granted_worktree_route(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Json(req): Json<SharedTreeDispatchRequest>,
) -> Result<Json<SharedTreeWritersResponse>, DaemonError> {
Ok(Json(granted_worktree_op(&state, &id, req)?))
}
pub fn granted_worktree_op(
state: &Arc<DaemonState>,
id: &str,
req: SharedTreeDispatchRequest,
) -> Result<SharedTreeWritersResponse, DaemonError> {
let session = uuid::Uuid::parse_str(id)
.map(SessionId)
.map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {id}")))?;
let payload = &req.payload;
let Some(cwd) = str_field(payload, "cwd").map(PathBuf::from) else {
return Ok(SharedTreeWritersResponse::default());
};
let exclude = str_field(payload, "tool_use_id");
let input = payload.get("input");
let eligible = exclude.is_some()
&& str_field(payload, "tool").is_some_and(is_subagent_dispatch_tool)
&& isolation_separates_working_tree(dispatch_isolation(input));
let (names, claimed) = state.claim_shared_tree_dispatch(
&cwd,
exclude,
eligible,
SharedTreeQuestion::Dispatch,
|s| {
crate::daemon::services::delegation_tracker::record_granted_isolation(
s, session, payload,
);
},
);
if eligible && !claimed {
crate::daemon::services::delegation_tracker::release_denied_dispatch(
state, session, payload,
);
}
let mut response = writers_response(&names, claimed);
response.records = blocking_records(state, &cwd, exclude, &names);
Ok(response)
}
fn blocking_records(
state: &DaemonState,
cwd: &std::path::Path,
exclude: Option<&str>,
names: &[String],
) -> Vec<crate::daemon::services::delegation_records::DelegationRecordView> {
if names.is_empty() {
return Vec::new();
}
let now = chrono::Utc::now();
state
.shared_tree_records(cwd, exclude, true, None)
.iter()
.map(|d| {
crate::daemon::services::delegation_records::DelegationRecordView::of(
state, d, now, true,
)
})
.collect()
}
pub async fn shared_tree_dispatch_route(
State(state): State<Arc<DaemonState>>,
Path(id): Path<String>,
Json(req): Json<SharedTreeDispatchRequest>,
) -> Result<Json<SharedTreeWritersResponse>, DaemonError> {
Ok(Json(shared_tree_dispatch_op(&state, &id, req)?))
}
pub fn shared_tree_dispatch_op(
state: &Arc<DaemonState>,
id: &str,
req: SharedTreeDispatchRequest,
) -> Result<SharedTreeWritersResponse, DaemonError> {
let session = uuid::Uuid::parse_str(id)
.map(SessionId)
.map_err(|_| DaemonError::InvalidRequest(format!("malformed session id: {id}")))?;
let payload = &req.payload;
let Some(cwd) = str_field(payload, "cwd").map(PathBuf::from) else {
return Ok(SharedTreeWritersResponse::default());
};
let exclude = str_field(payload, "tool_use_id");
let input = payload.get("input");
let is_dispatch = str_field(payload, "tool").is_some_and(is_subagent_dispatch_tool);
let roster = is_dispatch
.then(|| crate::core::content_source::agent_roster_for_query(&cwd))
.flatten();
let eligible = is_dispatch
&& dispatch_agent(input).is_some_and(|agent| {
blocked_by_shared_tree(roster.as_ref(), agent, dispatch_isolation(input))
});
let tree_holders = payload.get(TREE_HOLDERS_MARKER).and_then(Value::as_bool) == Some(true);
let question = if is_dispatch {
SharedTreeQuestion::Dispatch
} else {
SharedTreeQuestion::HeadWrite {
caller: (!tree_holders).then_some(session),
}
};
let (names, claimed) =
state.claim_shared_tree_dispatch(&cwd, exclude, eligible, question, |s| {
crate::daemon::services::delegation_tracker::observe(
s,
session,
HookEvent::PreToolUse,
payload,
);
});
if eligible && !claimed {
crate::daemon::services::delegation_tracker::release_denied_dispatch(
state, session, payload,
);
}
let mut response = writers_response(&names, claimed);
response.tree_holders = tree_holders;
if question == SharedTreeQuestion::Dispatch {
response.records = blocking_records(state, &cwd, exclude, &names);
}
Ok(response)
}
fn writers_response(names: &[String], claimed: bool) -> SharedTreeWritersResponse {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for name in names {
*counts.entry(name.as_str()).or_default() += 1;
}
SharedTreeWritersResponse {
agents: counts
.into_iter()
.map(|(agent, count)| SharedTreeWriter {
agent: agent.to_string(),
count,
})
.collect(),
total: names.len(),
claimed,
records: Vec::new(),
tree_holders: false,
}
}
fn str_field<'a>(payload: &'a Value, key: &str) -> Option<&'a str> {
payload
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
}
#[cfg(test)]
#[path = "delegation_routes_tests.rs"]
mod tests;