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}