1use 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#[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 = "default_limit")]
71 #[ts(type = "number")]
72 pub limit: i64,
73}
74
75fn default_limit() -> i64 {
76 50
77}
78
79const 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 let id_store = state.id_store.clone();
103 engine.register_typed(
104 COMMAND_LIST_DRONES,
105 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 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 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 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 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 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 #[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 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 #[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 assert!(serde_json::from_value::<ListDronesReq>(serde_json::Value::Null).is_err());
386 }
387
388 #[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}