agentmux_srv\server/
reactive.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use axum::{
5    extract::{Query, State},
6    http::StatusCode,
7    response::{IntoResponse, Json, Response},
8};
9use serde_json::json;
10
11use crate::backend::reactive::InjectionRequest;
12use crate::backend::reactive::registry as agent_registry;
13use crate::backend::subagent_watcher;
14use crate::backend::base;
15
16use super::AppState;
17
18pub(super) async fn handle_reactive_inject(
19    State(state): State<AppState>,
20    Json(req): Json<InjectionRequest>,
21) -> Json<serde_json::Value> {
22    tracing::info!(
23        target_agent = %req.target_agent,
24        source_agent = ?req.source_agent,
25        msg_len = req.message.len(),
26        "reactive inject request received"
27    );
28
29    // 1. Try local ReactiveHandler first (fast path — same instance).
30    let resp = state.reactive_handler.inject_message(req.clone());
31    if resp.success {
32        return Json(serde_json::to_value(&resp).unwrap_or_default());
33    }
34
35    // 2. On "agent not found", check cross-instance file registry and forward.
36    let is_not_found = resp
37        .error
38        .as_deref()
39        .map(|e| e.starts_with("agent not found"))
40        .unwrap_or(false);
41
42    if is_not_found {
43        // Tier 2: same-host, different sidecar (file registry → HTTP loopback)
44        let data_dir = base::get_wave_data_dir();
45        if let Some(entry) = agent_registry::lookup(&data_dir, &req.target_agent) {
46            // Guard against self-forwarding loops.
47            if entry.local_url != state.local_web_url {
48                let forward_url = format!("{}/agentmux/reactive/inject", entry.local_url);
49                tracing::debug!(
50                    target = %req.target_agent,
51                    url = %forward_url,
52                    "cross-instance inject forward"
53                );
54                let mut fwd = state.http_client.post(&forward_url).json(&req);
55                if !entry.auth_key.is_empty() {
56                    fwd = fwd.header("X-AuthKey", &entry.auth_key);
57                }
58                match fwd.send().await {
59                    Ok(r) if r.status().is_success() => {
60                        if let Ok(body) = r.json::<serde_json::Value>().await {
61                            return Json(body);
62                        }
63                    }
64                    Ok(r) => {
65                        tracing::warn!(
66                            target = %req.target_agent,
67                            status = %r.status(),
68                            url = %forward_url,
69                            "cross-instance forward: non-success status"
70                        );
71                    }
72                    Err(e) => {
73                        tracing::warn!(
74                            target = %req.target_agent,
75                            error = %e,
76                            url = %forward_url,
77                            "cross-instance forward failed — removing stale registry entry"
78                        );
79                        agent_registry::remove(&data_dir, &req.target_agent);
80                    }
81                }
82            }
83        }
84
85        // Tier 3: LAN peer (mDNS lookup → HTTP). Runs when tier 2 had no registry
86        // entry or its forward failed. Queries each discovered LAN peer for the
87        // agent; result is cached for 60s to avoid per-inject mDNS fan-out.
88        if let Some((peer_url, peer_auth_key)) = state
89            .lan_discovery
90            .find_agent(&req.target_agent, &state.http_client)
91            .await
92        {
93            let forward_url = format!("{}/agentmux/reactive/inject", peer_url);
94            tracing::debug!(
95                target = %req.target_agent,
96                url = %forward_url,
97                "LAN peer inject forward"
98            );
99            let mut fwd = state.http_client.post(&forward_url).json(&req);
100            if !peer_auth_key.is_empty() {
101                fwd = fwd.header("X-AuthKey", &peer_auth_key);
102            }
103            match fwd.send().await {
104                Ok(r) if r.status().is_success() => {
105                    if let Ok(body) = r.json::<serde_json::Value>().await {
106                        // /reactive/inject always returns HTTP 200; check body.success
107                        // to detect "agent not found on that peer" (e.g. after migration).
108                        if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
109                            tracing::warn!(
110                                target = %req.target_agent,
111                                url = %forward_url,
112                                "LAN peer inject: success=false — evicting stale cache entry"
113                            );
114                            state.lan_discovery.evict_agent(&req.target_agent);
115                        } else {
116                            return Json(body);
117                        }
118                    }
119                }
120                Ok(r) => {
121                    tracing::warn!(
122                        target = %req.target_agent,
123                        status = %r.status(),
124                        url = %forward_url,
125                        "LAN peer forward: non-success HTTP status"
126                    );
127                }
128                Err(e) => {
129                    tracing::warn!(
130                        target = %req.target_agent,
131                        error = %e,
132                        url = %forward_url,
133                        "LAN peer forward failed — evicting cache entry"
134                    );
135                    state.lan_discovery.evict_agent(&req.target_agent);
136                }
137            }
138        }
139    }
140
141    // 4. Return original error (muxbus-client will fall back to cloud relay).
142    Json(serde_json::to_value(&resp).unwrap_or_default())
143}
144
145pub(super) async fn handle_reactive_agents(
146    State(state): State<AppState>,
147) -> Json<serde_json::Value> {
148    let agents = state.reactive_handler.list_agents();
149    Json(serde_json::to_value(&agents).unwrap_or(json!([])))
150}
151
152#[derive(serde::Deserialize)]
153pub(super) struct AgentQuery {
154    id: Option<String>,
155}
156
157pub(super) async fn handle_reactive_agent(
158    State(state): State<AppState>,
159    Query(params): Query<AgentQuery>,
160) -> Response {
161    let id = match &params.id {
162        Some(id) if !id.is_empty() => id.as_str(),
163        _ => {
164            return (
165                StatusCode::BAD_REQUEST,
166                Json(json!({"error": "missing id param"})),
167            )
168                .into_response()
169        }
170    };
171    match state.reactive_handler.get_agent(id) {
172        Some(agent) => Json(serde_json::to_value(&agent).unwrap_or_default()).into_response(),
173        None => (
174            StatusCode::NOT_FOUND,
175            Json(json!({"error": "agent not found"})),
176        )
177            .into_response(),
178    }
179}
180
181#[derive(serde::Deserialize)]
182pub(super) struct AuditQuery {
183    #[serde(default = "default_audit_limit")]
184    limit: usize,
185}
186fn default_audit_limit() -> usize {
187    100
188}
189
190pub(super) async fn handle_reactive_audit(
191    State(state): State<AppState>,
192    Query(params): Query<AuditQuery>,
193) -> Json<serde_json::Value> {
194    let log = state.reactive_handler.get_audit_log(params.limit);
195    Json(serde_json::to_value(&log).unwrap_or(json!([])))
196}
197
198#[derive(serde::Deserialize)]
199pub(super) struct RegisterRequest {
200    agent_id: String,
201    block_id: String,
202    tab_id: Option<String>,
203}
204
205pub(super) async fn handle_reactive_register(
206    State(state): State<AppState>,
207    Json(req): Json<RegisterRequest>,
208) -> Response {
209    tracing::info!(
210        agent_id = %req.agent_id,
211        block_id = %req.block_id,
212        "reactive register request"
213    );
214    match state
215        .reactive_handler
216        .register_agent(&req.agent_id, &req.block_id, req.tab_id.as_deref())
217    {
218        Ok(()) => {
219            // Also write to cross-instance file registry so other AgentMux
220            // instances can forward inject requests to this one.
221            let data_dir = base::get_wave_data_dir();
222            agent_registry::write(&data_dir, &req.agent_id, &state.local_web_url, &req.block_id);
223
224            // Auto-watch this agent's Claude Code config dir for subagent JSONL files.
225            // Pass block_id so subagent events are stamped with the owning pane,
226            // letting the frontend route ⚡ panels to that pane only.
227            if let Some(config_dir) = subagent_watcher::derive_claude_config_dir(&req.agent_id) {
228                state.subagent_watcher.watch_agent(&req.agent_id, &req.block_id, config_dir);
229            }
230
231            // Notify cloud subscriber so it can subscribe for cloud-push delivery
232            if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
233                sub.add_agent(&req.agent_id);
234            }
235
236            // Notify the Swarm view so it calls AgentTrackedBlocksCommand and
237            // shows this pane. We use a dedicated event name so useProcessCount
238            // (which subscribes to agent:process-added / agent:process-exited)
239            // doesn't treat this as a phantom OS process and show a spurious ⚙ N
240            // badge or trigger the kill-tree modal on pane close.
241            state.broker.publish(crate::backend::wps::WaveEvent {
242                event: "agent:reactive-registered".to_string(),
243                scopes: vec![format!("block:{}", req.block_id)],
244                sender: String::new(),
245                persist: 0,
246                data: Some(json!({ "block_id": req.block_id })),
247            });
248
249            Json(json!({"success": true})).into_response()
250        }
251        Err(e) => (
252            StatusCode::BAD_REQUEST,
253            Json(json!({"error": e})),
254        )
255            .into_response(),
256    }
257}
258
259#[derive(serde::Deserialize)]
260pub(super) struct UnregisterRequest {
261    agent_id: String,
262}
263
264pub(super) async fn handle_reactive_unregister(
265    State(state): State<AppState>,
266    Json(req): Json<UnregisterRequest>,
267) -> Json<serde_json::Value> {
268    // Capture block_id before unregistering so we can emit the Swarm refresh event.
269    let block_id = state.reactive_handler.get_agent(&req.agent_id)
270        .map(|r| r.block_id.clone());
271
272    state.reactive_handler.unregister_agent(&req.agent_id);
273    // Also remove from cross-instance file registry.
274    let data_dir = base::get_wave_data_dir();
275    agent_registry::remove(&data_dir, &req.agent_id);
276    // Drop the subagent filesystem watcher (handle + channel + task) — the
277    // symmetric teardown for the watch_agent() call in the register handler.
278    state.subagent_watcher.unwatch_agent(&req.agent_id);
279    // Notify cloud subscriber so it stops subscribing for this agent
280    if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
281        sub.remove_agent(&req.agent_id);
282    }
283
284    // Symmetric refresh: tell the Swarm view this pane is gone.
285    if let Some(bid) = block_id {
286        state.broker.publish(crate::backend::wps::WaveEvent {
287            event: "agent:reactive-unregistered".to_string(),
288            scopes: vec![format!("block:{}", bid)],
289            sender: String::new(),
290            persist: 0,
291            data: Some(json!({ "block_id": bid })),
292        });
293    }
294
295    Json(json!({"success": true}))
296}
297
298pub(super) async fn handle_reactive_poller_stats(
299    State(state): State<AppState>,
300) -> Json<serde_json::Value> {
301    let stats = state.poller.stats();
302    Json(serde_json::to_value(&stats).unwrap_or(json!({})))
303}
304
305#[derive(serde::Deserialize)]
306pub(super) struct PollerConfigRequest {
307    url: Option<String>,
308    token: Option<String>,
309}
310
311pub(super) async fn handle_reactive_poller_config(
312    State(state): State<AppState>,
313    Json(req): Json<PollerConfigRequest>,
314) -> Json<serde_json::Value> {
315    state.poller.reconfigure(req.url, req.token);
316    Json(json!({"success": true}))
317}
318
319pub(super) async fn handle_reactive_poller_status(
320    State(state): State<AppState>,
321) -> Json<serde_json::Value> {
322    let status = state.poller.status();
323    Json(serde_json::to_value(&status).unwrap_or(json!({})))
324}