Skip to main content

agentmux_srv/server/
http_health.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Health, diagnostics, LAN instances, discovery, WPS publish.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9// ---- Health ----
10
11pub(super) async fn health_handler(State(state): State<AppState>) -> Json<serde_json::Value> {
12    Json(json!({
13        "status": "ok",
14        "version": state.version,
15    }))
16}
17
18/// Saga durability PR 2 — operator visibility into the durable saga
19/// log. Returns the most-recent 50 saga lifecycle rows + an in-flight
20/// count derived from `unresolved_sagas`.
21///
22/// **Why a JSON HTTP endpoint and not a launcher `--diag sagas`
23/// pipe-IPC client.** The `--diag srv` pipe transport (see
24/// `crates/launcher/src/diag.rs`) routes through `Tool` registration
25/// + a 2 s observation window with `GetSrvSnapshot` + `GetEvents`.
26/// Adding `GetSagaLogSnapshot` to the IPC `Command` enum + an
27/// `Event::SagaLogSnapshot` variant with a Vec of `SagaSnapshot`
28/// triples the touched-files surface for one operator command.
29/// JSON HTTP is the precedent for raw operator queries (cf
30/// `/api/lan-instances`) and matches the spec §9 PR 2 phrasing
31/// "tightened scope". Promoting to first-class `--diag sagas` via
32/// pipe IPC is a follow-up if anyone asks.
33///
34/// Operator workflow today:
35/// ```text
36/// curl -s -H "X-AuthKey: $KEY" http://127.0.0.1:$PORT/agentmux/diag/sagas | jq .
37/// ```
38/// Response shape:
39/// ```json
40/// {
41///   "recent": [ { "saga_id": ..., "name": ..., "state": ..., ... }, ... ],
42///   "in_flight_count": 1,
43///   "recently_failed_count": 0,
44///   "total_returned": 50
45/// }
46/// ```
47pub(super) async fn handle_diag_sagas(State(state): State<AppState>) -> Json<serde_json::Value> {
48    const LIMIT: u32 = 50;
49    let recent = match state.saga_log.snapshot_recent(LIMIT) {
50        Ok(rows) => rows,
51        Err(e) => {
52            return Json(json!({
53                "error": format!("snapshot_recent failed: {}", e),
54            }));
55        }
56    };
57    let in_flight = match state.saga_log.unresolved_sagas() {
58        Ok(rows) => rows.len(),
59        Err(e) => {
60            tracing::warn!("[diag/sagas] unresolved_sagas failed: {}", e);
61            0
62        }
63    };
64    let recently_failed = recent
65        .iter()
66        .filter(|s| s.state == "failed" || s.state == "failed_compensation")
67        .count();
68    Json(json!({
69        "recent": recent,
70        "in_flight_count": in_flight,
71        "recently_failed_count": recently_failed,
72        "total_returned": recent.len(),
73    }))
74}
75
76pub(super) async fn handle_lan_instances(State(state): State<AppState>) -> Json<serde_json::Value> {
77    Json(json!(state.lan_discovery.get_instances()))
78}
79
80/// `GET /agentmux/discovery` — a unified, agent-facing view of what exists and
81/// what is reachable across the muxbus delivery tiers, so an agent can resolve a
82/// target before sending. Aggregates:
83///   - `host.addressable`: the authoritative Tier-1/2 reachable set
84///     (`reactive_handler.list_agents()` — every entry has a live block_id).
85///   - `host.agents`: this host's agent directory (SQLite `instance_list`),
86///     each flagged `addressable` iff its name is in the reachable set.
87///   - `lan`: Tier-3 mDNS peers (each `LanInstance` carries its own `agents`).
88///   - `wan.local_agents_subscribed`: **this host's own** agents that are
89///     subscribed to the Tier-4 cloud relay (empty when no token). It is NOT a
90///     list of remote agents reachable over WAN — nothing here can answer that
91///     question, because the relay exposes no directory. Named
92///     `wan.subscribed_agents` until 2026-09-06, which read as "agents
93///     reachable via cloud" under a heading called `wan` in a response whose
94///     whole purpose is reachability, and produced a wrong diagnosis. See
95///     REPORT_NETWORK_ARCHITECTURE_DRYNESS_AND_ROBUST_LAN_2026_09_06.md §6.
96/// Addressing is case-insensitive (registration lowercases the key). Authed like
97/// the other reactive routes; agents reach it via AGENTMUX_LOCAL_URL + X-AuthKey.
98/// See SPEC_MUXBUS_AGENT_DISCOVERY_AND_PERSISTENT_DELIVERY_2026_06_16.
99pub(super) async fn handle_discovery(State(state): State<AppState>) -> Json<serde_json::Value> {
100    // Tier-1/2 reachable set — the authoritative "addressable" answer. Keep the
101    // live block_id per name (lowercased) so an addressable row can surface its
102    // real delivery block; the SQLite directory below does not carry one.
103    let reachable = state.reactive_handler.list_agents();
104    let reachable_block: std::collections::HashMap<String, String> = reachable
105        .iter()
106        .map(|a| (a.agent_id.to_lowercase(), a.block_id.clone()))
107        .collect();
108
109    // Host directory (live SQLite instances). `instance_list` already excludes
110    // hidden (user_hidden = 0) and template rows in SQL, and the consolidated
111    // path leaves block_id/status empty (storage/agents/instance.rs) — so addressability AND the
112    // live block_id come from the reachable set above. `block_id` is null for a
113    // known-but-unreachable agent; the always-empty `status` is omitted.
114    let instances = state.mstore.instance_list(None, None).unwrap_or_default();
115    let agents: Vec<serde_json::Value> = instances
116        .into_iter()
117        .map(|i| {
118            let live_block = if i.instance_name.is_empty() {
119                None
120            } else {
121                reachable_block.get(&i.instance_name.to_lowercase()).cloned()
122            };
123            json!({
124                "name": i.instance_name,
125                "id": i.id,
126                "definition_id": i.definition_id,
127                "working_directory": i.working_directory,
128                "addressable": live_block.is_some(),
129                "block_id": live_block,
130            })
131        })
132        .collect();
133
134    let wan_agents = crate::muxbus::cloud_subscriber::get_global_subscriber()
135        .map(|s| s.subscribed_agents())
136        .unwrap_or_default();
137
138    let lan = state.lan_discovery.get_instances();
139    let version = state.version.clone();
140    let local_url = state.local_web_url.clone();
141
142    // Tier 2b — other channels' agents on this same host (issue #1916), via
143    // the host-global shared registry. Excludes this instance's own channel
144    // (already covered by `agents`/`addressable` above) and this instance's
145    // own URL (a stale self-entry from a prior crash, if any).
146    let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
147    let cross_channel: Vec<serde_json::Value> = crate::registry::resolve_shared_reactive_dir()
148        .map(|shared_dir| {
149            crate::backend::reactive::registry::list_all_shared(&shared_dir)
150                .into_iter()
151                .filter(|e| e.channel != own_channel && e.local_url != local_url)
152                .map(|e| {
153                    json!({
154                        "name": e.agent_id,
155                        "channel": e.channel,
156                        "local_url": e.local_url,
157                        "block_id": e.block_id,
158                    })
159                })
160                .collect()
161        })
162        .unwrap_or_default();
163
164    Json(json!({
165        "host": {
166            "version": version,
167            "hostname": state.hostname.clone(),
168            // So a QR- or manually-paired client can name this channel
169            // (SPEC_LAN_FLEET_FEED_2026_10_03.md §5).
170            "channel": crate::backend::reactive::registry::local_channel_id(),
171            "local_url": local_url,
172            "addressable": reachable,
173            "agents": agents,
174            "cross_channel": cross_channel,
175        },
176        "lan": lan,
177        "wan": { "local_agents_subscribed": wan_agents },
178    }))
179}
180
181pub(super) async fn stub_501() -> impl IntoResponse {
182    (
183        StatusCode::NOT_IMPLEMENTED,
184        Json(json!({"error": "not implemented"})),
185    )
186}
187
188/// Auth-gated MPS publish endpoint
189/// (SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §3.2). `agentmux-bashwrap`
190/// POSTs here while running a Bash command; we forward to the
191/// in-process MPS broker so subscribed frontends receive the event.
192/// A client event declared in `mps_events` (`client_events!`) must carry its
193/// payload type, or it is refused with 400; any other event `mps_events`
194/// declares is srv's alone, and refused with 400.
195pub(super) async fn handle_wps_publish(
196    State(state): State<AppState>,
197    Json(req): Json<WpsPublishRequest>,
198) -> impl IntoResponse {
199    // Note: this endpoint used to special-case `compaction_started` to
200    // forward into the target block's `HealthMonitor` (silence-detector
201    // compaction awareness) — removed along with the unresponsive
202    // detector itself, see
203    // docs/specs/SPEC_REMOVE_AGENT_UNRESPONSIVE_DETECTION_2026_08_25.md.
204    // `agentmux-bashwrap`'s `precompact` POST still lands here and is now
205    // a harmless no-op broadcast like any other MPS event — left as-is
206    // rather than removing the route, since deleting it isn't warranted
207    // just to avoid one no-op publish.
208    // A wrapper's report of which tool call it runs (Tower's "started by"):
209    // kept here, not broadcast, since the pane would take an unknown op for
210    // output (`backend::tool_calls`).
211    if req.event == "tool_chunk" {
212        if let Some((pid, call)) = crate::backend::tool_calls::parse_report(&req.scopes, &req.data) {
213            crate::backend::tool_calls::record(pid, call);
214            return (StatusCode::OK, Json(json!({"ok": true})));
215        }
216    }
217    // A declared client event goes out typed, or not at all if its data isn't its payload.
218    match crate::backend::mps_events::publish_client_event(&state.broker, &req.event, &req.data, &req.scopes, req.persist) {
219        Some(Ok(())) => return (StatusCode::OK, Json(json!({"ok": true}))),
220        Some(Err(e)) => return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))),
221        None => {}
222    }
223    // Any other declared event is srv's: its subscribers read it as its payload type.
224    if crate::backend::mps_events::is_srv_event(&req.event) {
225        return (StatusCode::BAD_REQUEST, Json(json!({"error": format!("{}: only srv publishes this event", req.event)})));
226    }
227    // untyped-relay: publishes an undeclared event a process outside srv built (an external
228    // tool), as it was sent.
229    let event = crate::backend::mps::MuxEvent {
230        event: req.event,
231        scopes: req.scopes,
232        sender: String::new(),
233        persist: req.persist,
234        data: Some(req.data),
235    };
236    state.broker.publish(event);
237    (StatusCode::OK, Json(json!({"ok": true})))
238}