Skip to main content

agentmux_srv/server/
http_app_api.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP adapters over app_api: memory, global memory, fleet stop and stop forwarding.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9// ---------------------------------------------------------------------------
10// MuxAPI REST handlers (identity / preset / memory).
11//
12// `agent_id` is the agent slug, supplied by agentmux-mcp from its trusted
13// AGENTMUX_AGENT_ID env. Each handler maps a 4xx for caller/validation errors
14// (FORBIDDEN, "not found", "provide …", "not a regular file") and 5xx otherwise,
15// then delegates to the shared `app_api::*_impl`.
16// ---------------------------------------------------------------------------
17
18/// Classify an app-API or naming error string into an HTTP status. The
19/// service layer returns plain `String` errors, so this matches on text.
20/// FORBIDDEN is a 403. "not found" is a 404: a caller holding a stale or
21/// unknown id names a missing resource, not a malformed request
22/// (SPEC_WINDOW_NAME_API_HARDENING_2026_08_08.md §3.2). Argument errors are
23/// a 400. Everything unrecognized is a 500 (genuine service faults, e.g.
24/// SQLite write failures). The one classifier for MuxAPI routes here
25/// and the naming routes in `http_naming.rs`.
26pub(super) fn app_api_error_status(e: &str) -> StatusCode {
27    if e.starts_with("FORBIDDEN") {
28        StatusCode::FORBIDDEN
29    } else if e.contains("not found") {
30        StatusCode::NOT_FOUND
31    } else if e.contains("provide ")
32        || e.contains("not a regular file")
33        || e.contains("too large")
34        || e.contains("invalid")
35    {
36        StatusCode::BAD_REQUEST
37    } else {
38        StatusCode::INTERNAL_SERVER_ERROR
39    }
40}
41
42pub(super) fn app_api_response<T: serde::Serialize>(result: Result<T, String>) -> axum::response::Response {
43    match result {
44        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
45        Err(e) => (app_api_error_status(&e), Json(json!({ "error": e }))).into_response(),
46    }
47}
48
49#[derive(serde::Deserialize)]
50pub(super) struct AgentMemoryListQuery {
51    pub(super) agent_id: String,
52}
53
54/// `GET /api/v1/agent/memory/list?agent_id=<slug>` — list the agent's own
55/// native-memory markdown files. Backs the `MemoryList` MCP tool.
56pub(super) async fn handle_agent_memory_list(
57    State(state): State<AppState>,
58    caller: Option<Extension<caller::Caller>>,
59    Query(q): Query<AgentMemoryListQuery>,
60) -> impl IntoResponse {
61    actor::check_actor(
62        &state,
63        caller.as_deref(),
64        actor::ActorSite::MemoryList,
65        Some(&q.agent_id),
66    );
67    app_api_response(app_api::memory_list_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &q.agent_id, "m4c.memory_owner_by_name")))
68}
69
70#[derive(serde::Deserialize)]
71pub(super) struct AgentMemoryReadQuery {
72    pub(super) agent_id: String,
73    pub(super) filename: String,
74}
75
76/// `GET /api/v1/agent/memory/read?agent_id=<slug>&filename=<f>` — read one of
77/// the agent's own memory files. Backs the `MemoryRead` MCP tool.
78pub(super) async fn handle_agent_memory_read(
79    State(state): State<AppState>,
80    caller: Option<Extension<caller::Caller>>,
81    Query(q): Query<AgentMemoryReadQuery>,
82) -> impl IntoResponse {
83    actor::check_actor(
84        &state,
85        caller.as_deref(),
86        actor::ActorSite::MemoryRead,
87        Some(&q.agent_id),
88    );
89    app_api_response(app_api::memory_read_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &q.agent_id, "m4c.memory_owner_by_name"), &q.filename))
90}
91
92#[derive(serde::Deserialize)]
93pub(super) struct AgentMemoryWriteRequest {
94    pub(super) agent_id: String,
95    pub(super) filename: String,
96    pub(super) content: String,
97    /// Same wire shape as the WebSocket RPC's provenance, including a missing
98    /// `detail` defaulting to `{}` rather than `null`.
99    #[serde(default)]
100    pub(super) provenance: Option<crate::backend::rpc_types::NativeMemoryWriteProvenance>,
101}
102
103/// `POST /api/v1/agent/memory/write` — create/overwrite one of the agent's own
104/// memory files (atomic tmp→rename). Backs the `MemoryWrite` MCP tool — the
105/// primary write path an agent actually uses, so `provenance` (optional,
106/// see docs/specs/SPEC_MEMORY_VERSION_CONTROL_AND_ARMORY_AUDIT_2026_08_19.md
107/// §4.1) threads through to the version history here, not just on the
108/// WebSocket RPC's `agent:memory:write_file`.
109pub(super) async fn handle_agent_memory_write(
110    State(state): State<AppState>,
111    caller: Option<Extension<caller::Caller>>,
112    Json(req): Json<AgentMemoryWriteRequest>,
113) -> impl IntoResponse {
114    actor::check_actor(
115        &state,
116        caller.as_deref(),
117        actor::ActorSite::MemoryWrite,
118        Some(&req.agent_id),
119    );
120    let detail_str;
121    let provenance = if let Some(p) = req.provenance.as_ref() {
122        detail_str = p.detail.to_string();
123        Some(app_api::MemoryWriteProvenance { source: &p.source, detail: &detail_str })
124    } else {
125        None
126    };
127    match app_api::memory_write_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &req.agent_id, "m4c.memory_owner_by_name"), &req.filename, &req.content, provenance) {
128        Ok(()) => (StatusCode::OK, Json(json!({ "ok": true }))).into_response(),
129        Err(e) => (app_api_error_status(&e), Json(json!({ "error": e }))).into_response(),
130    }
131}
132
133/// `POST /api/v1/fleet/bulk-stop` — stop many agent panes (block ids) at
134/// once. Backs the `FleetBulkStop` MCP tool. Infallible per-target (never
135/// a single bool) — see `FleetActionResult`'s doc comment.
136///
137/// Agents' stops wait for each target's user override (§6.5 of
138/// SPEC_AGENT_SELF_QUIT_2026_09_24.md): 202 with a request per target, or 200
139/// as before when nothing had to wait. `auth` (optional, signed by
140/// agentmux-mcp) names the caller on the banner and in the audit log.
141pub(super) async fn handle_fleet_bulk_stop(
142    State(state): State<AppState>,
143    caller: Option<axum::Extension<crate::server::caller::Caller>>,
144    Json(body): Json<serde_json::Value>,
145) -> impl IntoResponse {
146    let req: crate::backend::rpc_types::CommandFleetBulkStopData = match serde_json::from_value(body.clone()) {
147        Ok(r) => r,
148        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": e.to_string() }))).into_response(),
149    };
150    let by = body
151        .get("auth")
152        .and_then(|a| serde_json::from_value::<agentmux_common::api_types::UiAutomationAuth>(a.clone()).ok())
153        .filter(|a| ui_handlers::verified_block_id(&state, caller.as_deref(), a).is_ok())
154        .map(|a| a.agent_id)
155        .unwrap_or_else(|| "an agent".to_string());
156    let (result, pending) =
157        app_api::fleet::fleet_bulk_stop_with_override(&state, &by, req.targets, req.signal.as_deref(), req.staged).await;
158    if pending.is_empty() {
159        return (StatusCode::OK, Json(result)).into_response();
160    }
161    (
162        StatusCode::ACCEPTED,
163        Json(json!({
164            "status": "pending_user_override",
165            "wait_at_least_ms": crate::sagas::pending_shutdown::OVERRIDE_WINDOW.as_millis() as u64,
166            "deadline_ms": pending.iter().map(|p| p.deadline_ms).max(),
167            "pending": pending.iter().map(|p| json!({ "block_id": p.block_id, "request_id": p.request_id })).collect::<Vec<_>>(),
168            "succeeded": result.succeeded,
169            "failed": result.failed,
170            "aborted_early": result.aborted_early,
171        })),
172    )
173        .into_response()
174}
175
176#[derive(serde::Deserialize)]
177pub(super) struct AgentStopForwardRequest {
178    pub(super) block_id: String,
179    pub(super) signal: Option<String>,
180}
181
182#[derive(serde::Deserialize)]
183pub(super) struct AgentBroadcastForwardRequest {
184    pub(super) block_id: String,
185    pub(super) body: String,
186    pub(super) msg_id: String,
187    pub(super) recipients: usize,
188}
189
190/// `POST /agentmux/agent/broadcast`: another channel on this machine asks this
191/// one to deliver a Swarm broadcast to one of its agent panes (same trust model
192/// as the stop forward above: loopback, this instance's own auth key). The
193/// header is built here; the caller supplies only the body.
194pub(super) async fn handle_agent_broadcast_forward(
195    State(state): State<AppState>,
196    Json(req): Json<AgentBroadcastForwardRequest>,
197) -> impl IntoResponse {
198    match app_api::fleet::deliver_forwarded_broadcast(&state, &req.block_id, &req.body, &req.msg_id, req.recipients).await {
199        Ok(()) => (StatusCode::OK, Json(serde_json::json!({ "success": true }))).into_response(),
200        Err(e) => (StatusCode::OK, Json(serde_json::json!({ "success": false, "error": e }))).into_response(),
201    }
202}
203
204#[derive(serde::Deserialize)]
205pub(super) struct AgentStopPendingForwardRequest {
206    pub(super) block_id: String,
207    pub(super) signal: Option<String>,
208    /// The asking agent, as the forwarding instance verified it. Shown on the
209    /// banner; this instance takes it as claimed.
210    #[serde(default)]
211    pub(super) by: String,
212}
213
214/// `POST /agentmux/agent/stop-pending` — another instance on this machine
215/// forwards an agent's `FleetBulkStop` for a block running HERE: open this
216/// instance's own override window (banner, chime, Keep running) rather than
217/// stopping at once. The caller polls `/api/v1/agent/shutdown/{request_id}`
218/// here. Same trust model and gate as `/agentmux/agent/stop`.
219pub(super) async fn handle_agent_stop_pending_forward(
220    State(state): State<AppState>,
221    Json(req): Json<AgentStopPendingForwardRequest>,
222) -> impl IntoResponse {
223    if crate::backend::blockcontroller::get_controller(&req.block_id).is_none() {
224        let error = format!("NOT_RUNNING: no controller for block {}", req.block_id);
225        return (StatusCode::OK, Json(json!({ "success": false, "error": error }))).into_response();
226    }
227    let by = if req.by.trim().is_empty() { "an agent on another AgentMux" } else { req.by.trim() };
228    let v = crate::sagas::pending_shutdown::request(
229        &state,
230        &req.block_id,
231        by,
232        "FleetBulkStop",
233        "",
234        crate::sagas::pending_shutdown::Action::Stop { signal: req.signal },
235    );
236    state.reactive_handler.log_fleet_action_audit(
237        Some(by),
238        &v.target,
239        &req.block_id,
240        "fleet.bulk-stop",
241        true,
242        None,
243        &v.request_id,
244        Some(&format!("{} (forwarded from another channel)", crate::sagas::pending_shutdown::audit_note(&v, by, "FleetBulkStop"))),
245    );
246    let mut pending = serde_json::to_value(&v).unwrap_or_default();
247    pending["joined"] = json!(v.joined);
248    (StatusCode::OK, Json(json!({ "success": true, "pending": pending }))).into_response()
249}
250
251/// `POST /agentmux/agent/stop` — the cross-channel bulk-stop forward
252/// target. See `fleet_bulk_stop_impl`'s doc comment for the caller side;
253/// this is just `stop_one_agent_block` run against THIS instance's own
254/// (in-process) controller registry, for a target the CALLING channel
255/// couldn't find in its own — same one-hop-forward shape as
256/// `/agentmux/reactive/inject`'s cross-channel tier, no further forwarding.
257pub(super) async fn handle_agent_stop_forward(
258    State(state): State<AppState>,
259    Json(req): Json<AgentStopForwardRequest>,
260) -> impl IntoResponse {
261    match app_api::agent_io::stop_one_agent_block(&state, &req.block_id, req.signal.as_deref(), None).await {
262        Ok(result) => (StatusCode::OK, Json(serde_json::json!({ "success": true, "result": result }))).into_response(),
263        Err(e) => (StatusCode::OK, Json(serde_json::json!({ "success": false, "error": e }))).into_response(),
264    }
265}
266
267#[derive(serde::Deserialize)]
268pub(super) struct AgentMemoryHistoryQuery {
269    pub(super) agent_id: String,
270    pub(super) filename: String,
271}
272
273/// `GET /api/v1/agent/memory/history?agent_id=<slug>&filename=<f>` — list
274/// every recorded version of one memory file, newest first. Backs the
275/// `MemoryHistory` MCP tool.
276pub(super) async fn handle_agent_memory_history(
277    State(state): State<AppState>,
278    caller: Option<Extension<caller::Caller>>,
279    Query(q): Query<AgentMemoryHistoryQuery>,
280) -> impl IntoResponse {
281    actor::check_actor(
282        &state,
283        caller.as_deref(),
284        actor::ActorSite::MemoryHistory,
285        Some(&q.agent_id),
286    );
287    app_api_response(app_api::memory_history_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &q.agent_id, "m4c.memory_owner_by_name"), &q.filename))
288}
289
290#[derive(serde::Deserialize)]
291pub(super) struct AgentMemoryDiffQuery {
292    pub(super) agent_id: String,
293    pub(super) from_version_id: String,
294    pub(super) to_version_id: String,
295}
296
297/// `GET /api/v1/agent/memory/diff?agent_id=<slug>&from_version_id=&to_version_id=`
298/// — a line-based diff between two recorded versions, both of which must
299/// belong to `agent_id` (reagent P1 — see `memory_diff_impl`'s own doc for
300/// why). Backs the `MemoryDiff` MCP tool.
301pub(super) async fn handle_agent_memory_diff(
302    State(state): State<AppState>,
303    caller: Option<Extension<caller::Caller>>,
304    Query(q): Query<AgentMemoryDiffQuery>,
305) -> impl IntoResponse {
306    actor::check_actor(
307        &state,
308        caller.as_deref(),
309        actor::ActorSite::MemoryDiff,
310        Some(&q.agent_id),
311    );
312    app_api_response(app_api::memory_diff_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &q.agent_id, "m4c.memory_owner_by_name"), &q.from_version_id, &q.to_version_id))
313}
314
315#[derive(serde::Deserialize)]
316pub(super) struct AgentMemoryRevertRequest {
317    pub(super) agent_id: String,
318    pub(super) filename: String,
319    pub(super) target_version_id: String,
320}
321
322/// `POST /api/v1/agent/memory/revert` — restore a memory file's live
323/// content to a prior version, recorded as a NEW version (`source:
324/// "revert"`) — never rewrites or deletes history. Backs the
325/// `MemoryRevert` MCP tool.
326pub(super) async fn handle_agent_memory_revert(
327    State(state): State<AppState>,
328    caller: Option<Extension<caller::Caller>>,
329    Json(req): Json<AgentMemoryRevertRequest>,
330) -> impl IntoResponse {
331    actor::check_actor(
332        &state,
333        caller.as_deref(),
334        actor::ActorSite::MemoryRevert,
335        Some(&req.agent_id),
336    );
337    app_api_response(app_api::memory_revert_impl(&state, app_api::SelfOwner::of(caller.as_deref(), &req.agent_id, "m4c.memory_owner_by_name"), &req.filename, &req.target_version_id))
338}
339
340#[derive(serde::Deserialize)]
341pub(super) struct AgentGlobalMemoryWriteProvenanceReq {
342    pub(super) source: String,
343    // Same "default() on a bare Value yields Null, not {}" trap as
344    // NativeMemoryWriteProvenance in rpc_types — see `detail` there.
345    #[serde(default = "default_agent_globalmemory_write_detail")]
346    pub(super) detail: serde_json::Value,
347}
348
349pub(super) fn default_agent_globalmemory_write_detail() -> serde_json::Value {
350    serde_json::json!({})
351}
352
353#[derive(serde::Deserialize)]
354pub(super) struct AgentGlobalMemoryWriteRequest {
355    pub(super) agent_id: String,
356    #[serde(default)]
357    pub(super) id: Option<String>,
358    pub(super) name: String,
359    pub(super) content: String,
360    #[serde(default)]
361    pub(super) provenance: Option<AgentGlobalMemoryWriteProvenanceReq>,
362}
363
364/// `POST /api/v1/agent/globalmemory/write` — create a new ordinary Global
365/// Memory entry, or update an existing one by `id`. Never reaches a
366/// system-tier row either way — see `global_memory_write_impl`'s own doc
367/// comment for the full invariant. Backs the `GlobalMemoryWrite` MCP tool.
368pub(super) async fn handle_agent_globalmemory_write(
369    State(state): State<AppState>,
370    caller: Option<Extension<caller::Caller>>,
371    Json(req): Json<AgentGlobalMemoryWriteRequest>,
372) -> impl IntoResponse {
373    actor::check_actor(
374        &state,
375        caller.as_deref(),
376        actor::ActorSite::GlobalMemoryWrite,
377        Some(&req.agent_id),
378    );
379    let detail_str;
380    let provenance = if let Some(p) = req.provenance.as_ref() {
381        detail_str = p.detail.to_string();
382        Some(app_api::GlobalMemoryWriteProvenance { source: &p.source, detail: &detail_str })
383    } else {
384        None
385    };
386    // Identity M4c-1 (§6.5.9): the writer's UID is the request's `Caller` —
387    // its token — never a body field; `""` when Unattributed.
388    let written_by_uid = caller::attributed_uid(caller.as_deref());
389    app_api_response(app_api::global_memory_write_impl(
390        &state,
391        &req.agent_id,
392        &written_by_uid,
393        req.id.as_deref(),
394        &req.name,
395        &req.content,
396        provenance,
397    ))
398}
399
400/// `GET /api/v1/agent/globalmemory/list` — every Global Memory entry, system
401/// rows first and flagged `system`, summary only (id/name/updated_at/system, no
402/// content). Read, write and remove still refuse a system id. Backs the
403/// `GlobalMemoryList` MCP tool. See
404/// `docs/specs/SPEC_GLOBAL_MEMORY_LIST_SYSTEM_ROWS_2026_10_02.md`.
405pub(super) async fn handle_agent_globalmemory_list(State(state): State<AppState>) -> impl IntoResponse {
406    app_api_response(app_api::global_memory_list_impl(&state))
407}
408
409#[derive(serde::Deserialize)]
410pub(super) struct AgentGlobalMemoryReadQuery {
411    pub(super) id: String,
412}
413
414/// `GET /api/v1/agent/globalmemory/read?id=<id>` — full content of one
415/// ordinary Global Memory entry. Backs the `GlobalMemoryRead` MCP tool.
416pub(super) async fn handle_agent_globalmemory_read(
417    State(state): State<AppState>,
418    Query(q): Query<AgentGlobalMemoryReadQuery>,
419) -> impl IntoResponse {
420    app_api_response(app_api::global_memory_read_impl(&state, &q.id))
421}
422
423#[derive(serde::Deserialize)]
424pub(super) struct AgentGlobalMemoryRemoveRequest {
425    pub(super) id: String,
426}
427
428/// `POST /api/v1/agent/globalmemory/remove` — demote a Global Memory entry
429/// (clears `is_global`; the bundle row itself survives, same as Memory →
430/// Global's own "Remove" button). Backs the `GlobalMemoryRemove` MCP tool.
431pub(super) async fn handle_agent_globalmemory_remove(
432    State(state): State<AppState>,
433    Json(req): Json<AgentGlobalMemoryRemoveRequest>,
434) -> impl IntoResponse {
435    app_api_response(app_api::global_memory_remove_impl(&state, &req.id))
436}
437
438#[derive(serde::Deserialize)]
439pub(super) struct AgentGlobalMemoryHistoryQuery {
440    pub(super) id: String,
441}
442
443/// `GET /api/v1/agent/globalmemory/history?id=<id>` — list every recorded
444/// version of one Global Memory entry, newest first. Refuses a system-tier,
445/// blank, or non-global id the same way `read`/`remove` already do — see
446/// `global_memory_history_impl`'s own doc comment. Backs the
447/// `GlobalMemoryHistory` MCP tool.
448pub(super) async fn handle_agent_globalmemory_history(
449    State(state): State<AppState>,
450    Query(q): Query<AgentGlobalMemoryHistoryQuery>,
451) -> impl IntoResponse {
452    app_api_response(app_api::global_memory_history_impl(&state, &q.id))
453}
454
455#[derive(serde::Deserialize)]
456pub(super) struct AgentGlobalMemoryDiffQuery {
457    pub(super) id: String,
458    pub(super) from_version_id: String,
459    pub(super) to_version_id: String,
460}
461
462/// `GET /api/v1/agent/globalmemory/diff?id=<id>&from_version_id=&to_version_id=`
463/// — a line-based diff between two recorded versions, both of which must
464/// belong to `id` (same ownership check as `/api/v1/agent/memory/diff`'s
465/// `agent_id` scoping — see `global_memory_diff_impl`'s own doc comment for
466/// why). Backs the `GlobalMemoryDiff` MCP tool.
467pub(super) async fn handle_agent_globalmemory_diff(
468    State(state): State<AppState>,
469    Query(q): Query<AgentGlobalMemoryDiffQuery>,
470) -> impl IntoResponse {
471    app_api_response(app_api::global_memory_diff_impl(&state, &q.id, &q.from_version_id, &q.to_version_id))
472}
473
474#[derive(serde::Deserialize)]
475pub(super) struct AgentGlobalMemoryRevertRequest {
476    pub(super) agent_id: String,
477    pub(super) id: String,
478    pub(super) version_id: String,
479}
480
481/// `POST /api/v1/agent/globalmemory/revert` — restore a Global Memory
482/// entry's live content to a prior recorded version, recorded as a NEW
483/// version (`source: "revert"`) — never rewrites or deletes history, same
484/// as `/api/v1/agent/memory/revert`. Never reaches a system-tier row — see
485/// `global_memory_revert_impl`'s own doc comment. Backs the
486/// `GlobalMemoryRevert` MCP tool.
487pub(super) async fn handle_agent_globalmemory_revert(
488    State(state): State<AppState>,
489    caller: Option<Extension<caller::Caller>>,
490    Json(req): Json<AgentGlobalMemoryRevertRequest>,
491) -> impl IntoResponse {
492    actor::check_actor(
493        &state,
494        caller.as_deref(),
495        actor::ActorSite::GlobalMemoryRevert,
496        Some(&req.agent_id),
497    );
498    // Identity M4c-1: as for write, the writer's UID is the `Caller`'s.
499    let written_by_uid = caller::attributed_uid(caller.as_deref());
500    app_api_response(app_api::global_memory_revert_impl(
501        &state,
502        &req.agent_id,
503        &written_by_uid,
504        &req.id,
505        &req.version_id,
506    ))
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    /// One classification for MuxAPI and naming routes: a missing
514    /// resource is a 404 on both, where MuxAPI used to answer 400.
515    #[test]
516    fn app_api_error_status_classifies_each_kind_once() {
517        assert_eq!(app_api_error_status("FORBIDDEN: not your memory"), StatusCode::FORBIDDEN);
518        assert_eq!(app_api_error_status("globalmemory.read: not found id=x"), StatusCode::NOT_FOUND);
519        assert_eq!(app_api_error_status("tab not found: tab-1"), StatusCode::NOT_FOUND);
520        for bad in ["provide id or name", "not a regular file", "file too large", "invalid filename"] {
521            assert_eq!(app_api_error_status(bad), StatusCode::BAD_REQUEST, "{bad}");
522        }
523        assert_eq!(app_api_error_status("database is locked"), StatusCode::INTERNAL_SERVER_ERROR);
524    }
525}