Skip to main content

agentmux_srv/server/
drone_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WSH RPC handlers for the Drone pane (issue #753 Phase 1).
5//!
6//! Commands:
7//!   * `listdrones`       → `Vec<DroneDefinition>`
8//!   * `getdrone`         → `Option<DroneDefinition>`
9//!   * `upsertdrone`      → `DroneDefinition` (echoed back, normalized)
10//!   * `deletedrone`      → `{ deleted: bool }`
11//!   * `rundrone`         → `{ run_id: String }` (synchronous; SSE
12//!                              streaming added in Phase 1 PR-4)
13//!   * `listdroneruns`    → `Vec<DroneRun>`
14//!
15//! Run streaming: the executor emits `RunEvent`s over an mpsc channel.
16//! Phase 1 of this PR drains the channel server-side and stores the
17//! final block-state snapshot in `db_drone_runs`. A future commit
18//! will tee the channel to the renderer via the existing `mps` event
19//! broker so `RunPanel` shows live per-block status.
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use serde::{Deserialize, Serialize};
25
26use crate::backend::rpc_types::{
27    COMMAND_DELETE_DRONE, COMMAND_GET_DRONE, COMMAND_LIST_DRONES,
28    COMMAND_LIST_DRONE_RUNS, COMMAND_RUN_DRONE, COMMAND_UPSERT_DRONE,
29};
30use crate::server::AppState;
31use crate::backend::rpc::engine::WshRpcEngine;
32use crate::drone::executor::{run_drone, RunEvent};
33use crate::drone::storage::DroneStore;
34use crate::drone::types::{RunStatus, DroneDefinition, DroneRun};
35
36/// Request for `listdrones`, which ignores its payload.
37///
38/// Registered as `Option<Self>`: the stub sends `{}` and a client that omits
39/// `data` sends `null`, and serde accepts each of those from only one of
40/// `()` and a struct. `Option<Self>` takes both.
41#[derive(Debug, Default, Serialize, Deserialize, ts_rs::TS)]
42#[ts(export, export_to = "../../../frontend/types/rpc/")]
43pub struct ListDronesReq {}
44
45#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
46#[ts(export, export_to = "../../../frontend/types/rpc/")]
47pub struct GetDroneReq {
48    pub id: String,
49}
50
51#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
52#[ts(export, export_to = "../../../frontend/types/rpc/")]
53pub struct DeleteDroneReq {
54    pub id: String,
55}
56
57#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
58#[ts(export, export_to = "../../../frontend/types/rpc/")]
59pub struct RunDroneReq {
60    pub drone_id: String,
61}
62
63#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
64#[ts(export, export_to = "../../../frontend/types/rpc/")]
65pub struct ListRunsReq {
66    pub drone_id: String,
67    /// `serde(default)`, so a caller may omit it -- which ts-rs cannot express
68    /// on a non-`Option` field. The stub derives `ListDroneRunsInput` from the
69    /// generated type to restore that; see the comment there.
70    #[serde(default = "default_limit")]
71    #[ts(type = "number")]
72    pub limit: i64,
73}
74
75fn default_limit() -> i64 {
76    50
77}
78
79/// Hard cap on `ListRunsReq.limit` — guards against a malicious or
80/// buggy client passing e.g. `i64::MAX` and pulling the entire run
81/// history (DoS / memory blow-up). 200 covers any plausible UI page
82/// size with headroom; Phase 2 pagination cursor work will move this
83/// to client-driven slicing. (kimi P1 on PR #755.)
84const MAX_LIST_LIMIT: i64 = 200;
85
86#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
87#[ts(rename = "DeleteDroneResp")]
88#[ts(export, export_to = "../../../frontend/types/rpc/")]
89pub struct DeleteResp {
90    pub deleted: bool,
91}
92
93#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
94#[ts(rename = "RunDroneResp")]
95#[ts(export, export_to = "../../../frontend/types/rpc/")]
96pub struct RunResp {
97    pub run_id: String,
98}
99
100pub fn register_drone_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
101    // Drone definition CRUD — routed to id_store (global shared store).
102    let id_store = state.id_store.clone();
103    engine.register_typed(
104        COMMAND_LIST_DRONES,
105        // `Option<_>` -- see `ListDronesReq`: it is what makes both encodings
106        // of "no argument" deserialize.
107        move |_req: Option<ListDronesReq>, _ctx| {
108            let id_store = id_store.clone();
109            async move {
110                let list = id_store
111                    .drone_list()
112                    .map_err(|e| format!("listdrones: {e}"))?;
113                Ok(list)
114            }
115        },
116    );
117
118    let id_store = state.id_store.clone();
119    engine.register_typed(
120        COMMAND_GET_DRONE,
121        move |cmd: GetDroneReq, _ctx| {
122            let id_store = id_store.clone();
123            async move {
124                let row = id_store
125                    .drone_get(&cmd.id)
126                    .map_err(|e| format!("getdrone: {e}"))?;
127                Ok(row)
128            }
129        },
130    );
131
132    let id_store = state.id_store.clone();
133    let broker = state.broker.clone();
134    engine.register_typed(
135        COMMAND_UPSERT_DRONE,
136        move |mut cmd: DroneDefinition, _ctx| {
137            let id_store = id_store.clone();
138            let broker = broker.clone();
139            async move {
140                let now = now_ms();
141                if cmd.created_at == 0 {
142                    cmd.created_at = now;
143                }
144                cmd.updated_at = now;
145                if cmd.id.is_empty() {
146                    cmd.id = uuid::Uuid::new_v4().to_string();
147                }
148                id_store
149                    .drone_upsert(&cmd)
150                    .map_err(|e| format!("upsertdrone: {e}"))?;
151                broker.publish_event::<crate::backend::mps_events::DronesChanged>(&(), vec![], 0);
152                Ok(cmd)
153            }
154        },
155    );
156
157    let id_store = state.id_store.clone();
158    let broker = state.broker.clone();
159    engine.register_typed(
160        COMMAND_DELETE_DRONE,
161        move |cmd: DeleteDroneReq, _ctx| {
162            let id_store = id_store.clone();
163            let broker = broker.clone();
164            async move {
165                let deleted = id_store
166                    .drone_delete(&cmd.id)
167                    .map_err(|e| format!("deletedrone: {e}"))?;
168                if deleted {
169                    broker.publish_event::<crate::backend::mps_events::DronesChanged>(&(), vec![], 0);
170                }
171                Ok(DeleteResp { deleted })
172            }
173        },
174    );
175
176    // COMMAND_RUN_DRONE: reads drone definition from id_store (global),
177    // writes drone run rows to mstore (per-channel).
178    let id_store = state.id_store.clone();
179    let mstore = state.mstore.clone();
180    let broker = state.broker.clone();
181    engine.register_typed(
182        COMMAND_RUN_DRONE,
183        move |cmd: RunDroneReq, _ctx| {
184            let id_store = id_store.clone();
185            let mstore = mstore.clone();
186            let broker = broker.clone();
187            async move {
188                let wf = id_store
189                    .drone_get(&cmd.drone_id)
190                    .map_err(|e| format!("rundrone: {e}"))?
191                    .ok_or_else(|| {
192                        format!("rundrone: drone {} not found", cmd.drone_id)
193                    })?;
194                let started_at = now_ms();
195                let mut handle = run_drone(wf.id.clone(), wf.graph.clone())
196                    .await
197                    .map_err(|e| format!("rundrone: {e}"))?;
198                let run_id = handle.run_id.clone();
199                let drone_id = wf.id.clone();
200
201                // Persist a `running` placeholder row synchronously
202                // BEFORE returning. Guarantees:
203                //   1. The frontend's `refreshRuns` (called right
204                //      after this RPC resolves) always sees the row,
205                //      no spawn-race against the drain (codex+reagent
206                //      P2 from earlier rounds).
207                //   2. The drain can run as a background spawn
208                //      again — so drones longer than the RPC
209                //      timeout (5s default) don't get their drain
210                //      truncated mid-flight (codex P1 v0.33.842).
211                //   3. Server-restart safety: an orphaned `running`
212                //      row signals "drain was interrupted" — a
213                //      future startup task can mark stale rows as
214                //      `interrupted` (cleaner than the prior
215                //      fire-and-forget that left no row at all).
216                let placeholder = DroneRun {
217                    id: run_id.clone(),
218                    drone_id: drone_id.clone(),
219                    status: RunStatus::Running.as_str().to_string(),
220                    started_at,
221                    ended_at: 0,
222                    block_states: HashMap::new(),
223                    output: String::new(),
224                    error: String::new(),
225                };
226                mstore
227                    .drone_run_insert(&placeholder)
228                    .map_err(|e| format!("rundrone placeholder: {e}"))?;
229
230                // Drain on a background task; on completion, UPDATE
231                // the placeholder row in place.
232                let mstore_for_drain = mstore.clone();
233                let broker_for_drain = broker.clone();
234                let run_id_for_drain = run_id.clone();
235                let drone_id_for_drain = drone_id.clone();
236                tokio::spawn(async move {
237                    let mut last_status;
238                    let mut output = String::new();
239                    let mut error = String::new();
240                    while let Some(ev) = handle.events.recv().await {
241                        // For terminal events, persist the final row
242                        // BEFORE publishing the event. The frontend
243                        // subscription path refreshes the runs list on
244                        // RunDone / RunFailed, and if the publish
245                        // happens first the refresh sees a stale
246                        // `running` row (codex P2 on PR #843).
247                        let is_terminal = matches!(
248                            ev,
249                            RunEvent::RunDone { .. } | RunEvent::RunFailed { .. }
250                        );
251                        if is_terminal {
252                            match &ev {
253                                RunEvent::RunDone { output: o, .. } => {
254                                    last_status = RunStatus::Done;
255                                    // Engine already unwraps Response's
256                                    // `{ "value": ... }` wrapper.
257                                    output = match o {
258                                        serde_json::Value::String(s) => s.clone(),
259                                        other => {
260                                            serde_json::to_string(other).unwrap_or_default()
261                                        }
262                                    };
263                                }
264                                RunEvent::RunFailed { error: e, .. } => {
265                                    last_status = RunStatus::Failed;
266                                    error = e.clone();
267                                }
268                                _ => unreachable!(),
269                            }
270                            let states = handle.final_states.lock().await.clone();
271                            let row = DroneRun {
272                                id: run_id_for_drain.clone(),
273                                drone_id: drone_id_for_drain.clone(),
274                                status: last_status.as_str().to_string(),
275                                started_at,
276                                ended_at: now_ms(),
277                                block_states: states,
278                                output: output.clone(),
279                                error: error.clone(),
280                            };
281                            match mstore_for_drain.drone_run_update(&row) {
282                                Ok(0) => tracing::warn!(
283                                    run_id = %run_id_for_drain,
284                                    "drone_run_update: placeholder row missing (race?)"
285                                ),
286                                Ok(_) => {}
287                                Err(e) => tracing::warn!(
288                                    run_id = %run_id_for_drain,
289                                    error = %e,
290                                    "drone_run_update failed"
291                                ),
292                            }
293                        }
294                        broker_for_drain.publish_event_for::<crate::backend::mps_events::DroneRun>(&run_id_for_drain, &ev, vec![], 0);
295                    }
296                });
297
298                Ok(RunResp { run_id })
299            }
300        },
301    );
302
303    let mstore = state.mstore.clone();
304    engine.register_typed(
305        COMMAND_LIST_DRONE_RUNS,
306        move |cmd: ListRunsReq, _ctx| {
307            let mstore = mstore.clone();
308            async move {
309                let limit = cmd.limit.clamp(0, MAX_LIST_LIMIT);
310                let list = mstore
311                    .drone_runs_for(&cmd.drone_id, limit)
312                    .map_err(|e| format!("listdroneruns: {e}"))?;
313                Ok(list)
314            }
315        },
316    );
317}
318
319use agentmux_common::time::now_ms;
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    /// Every drone command records its request and response type, which is the
326    /// property the generated bindings are derived from — a command that
327    /// silently falls back to `register_handler` disappears from here rather
328    /// than being recorded wrong, and that absence is what this catches.
329    // `tokio::test`, not `test`: `test_state()` builds the fs-watch pool,
330    // which spawns, so it needs a runtime.
331    #[tokio::test]
332    async fn register_typed_records_every_drone_command() {
333        let (engine, _rx) = WshRpcEngine::new();
334        let state = crate::server::tests::test_state();
335        register_drone_handlers(&engine, &state);
336        let schema = engine.schema_json();
337        let rows = schema.as_array().unwrap();
338        let find = |cmd: &str| {
339            rows.iter()
340                .find(|r| r["command"] == cmd)
341                .unwrap_or_else(|| panic!("{cmd} missing from the schema — did it fall back to register_handler?"))
342        };
343
344        for (cmd, req, resp) in [
345            (COMMAND_GET_DRONE, "GetDroneReq", "DroneDefinition"),
346            (COMMAND_UPSERT_DRONE, "DroneDefinition", "DroneDefinition"),
347            (COMMAND_DELETE_DRONE, "DeleteDroneReq", "DeleteResp"),
348            (COMMAND_RUN_DRONE, "RunDroneReq", "RunResp"),
349        ] {
350            let row = find(cmd);
351            assert_eq!(row["requestName"], req, "{cmd} request");
352            assert!(
353                row["responseName"].as_str().unwrap().contains(resp),
354                "{cmd} response should mention {resp}, got {:?}",
355                row["responseName"],
356            );
357        }
358
359        // The two list commands answer `Vec<_>`, whose recorded name keeps its
360        // full path (truncating at the last `::` would yield `DroneRun>`).
361        for (cmd, elem) in [
362            (COMMAND_LIST_DRONES, "DroneDefinition"),
363            (COMMAND_LIST_DRONE_RUNS, "DroneRun"),
364        ] {
365            let got = find(cmd)["responseName"].as_str().unwrap().to_string();
366            assert!(
367                got.starts_with("alloc::vec::Vec<") && got.contains(elem),
368                "{cmd} should answer Vec<{elem}>, got {got:?}",
369            );
370        }
371    }
372
373    /// `listdrones` takes no argument, and the two encodings of that both
374    /// reach the server: the stub sends `{}`, a client that omits `data`
375    /// sends `null`. A bare struct rejects the second, a `()` rejects the
376    /// first — `Option<ListDronesReq>` is what takes both.
377    #[test]
378    fn listdrones_request_accepts_both_encodings_of_no_argument() {
379        for payload in [serde_json::json!({}), serde_json::Value::Null] {
380            serde_json::from_value::<Option<ListDronesReq>>(payload.clone())
381                .unwrap_or_else(|e| panic!("{payload} should deserialize, got {e}"));
382        }
383        // ...and the bare struct really does reject null, so the Option is
384        // load-bearing rather than decoration.
385        assert!(serde_json::from_value::<ListDronesReq>(serde_json::Value::Null).is_err());
386    }
387
388    /// `limit` is `serde(default)`, which ts-rs cannot express on a non-Option
389    /// field — the stub's `ListDroneRunsInput` restores the optionality by
390    /// deriving from the generated type. Pin the server half: drop the default
391    /// and that derived TS type becomes a lie.
392    #[test]
393    fn listruns_request_accepts_a_missing_limit() {
394        let req: ListRunsReq =
395            serde_json::from_value(serde_json::json!({ "drone_id": "d1" }))
396                .expect("limit is serde(default) and may be omitted");
397        assert_eq!(req.drone_id, "d1");
398        assert_eq!(req.limit, default_limit(), "an omitted limit falls back to the default");
399    }
400}