Skip to main content

agentmux_srv/drone/
types.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Drone + run types. Mirrors the frontend shape so RPC payloads
5//! flow through `serde_json::to_value` without manual mapping.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11/// Block (node) kinds for Phase 1. Phase 2 adds Function, Loop, Parallel,
12/// Router, Subdrone. Stored as `kind` field on `FlowNode.data`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
14#[serde(rename_all = "snake_case")]
15#[ts(export, export_to = "../../../frontend/types/rpc/")]
16pub enum BlockKind {
17    Agent,
18    Condition,
19    Api,
20    Response,
21    Variables,
22}
23
24impl BlockKind {
25    pub fn parse(s: &str) -> Option<Self> {
26        match s {
27            "agent" => Some(Self::Agent),
28            "condition" => Some(Self::Condition),
29            "api" => Some(Self::Api),
30            "response" => Some(Self::Response),
31            "variables" => Some(Self::Variables),
32            _ => None,
33        }
34    }
35}
36
37/// Position-and-data shape of a node on the canvas. Mirrors xyflow's
38/// `Node` — id, position, data, type are the fields the canvas reads.
39/// Anything inside `data` is block-specific config.
40#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
41// The TS name the frontend has always used. Renaming here rather than renaming
42// the Rust type keeps every existing consumer untouched while the declaration
43// becomes generated.
44#[ts(rename = "DroneFlowNode")]
45#[ts(export, export_to = "../../../frontend/types/rpc/")]
46pub struct FlowNode {
47    pub id: String,
48    /// xyflow position. Saved as-is.
49    #[serde(default)]
50    pub position: NodePosition,
51    /// Block kind + per-kind config (`task`, `url`, `expr`, etc.).
52    ///
53    /// `serde_json::Value` says nothing about the one field every block
54    /// actually has, and the hand-written declaration did: keep the narrower
55    /// TS. A `Value` here is not a promise that the contents are arbitrary,
56    /// it is the absence of a per-kind Rust enum (Phase 2).
57    #[ts(type = "Record<string, unknown> & { kind: string }")]
58    pub data: serde_json::Value,
59    /// Optional xyflow node type — keeps the canvas configurable.
60    ///
61    /// `Option<String>`, not `String`: the server omits the key entirely
62    /// when it is blank, so a required `type: string` in the generated TS
63    /// would be wrong in the read direction -- which is the direction the
64    /// canvas uses. `is_blank` rather than `Option::is_none` keeps the
65    /// pre-existing wire behaviour exactly, where an empty string is also
66    /// omitted.
67    #[serde(default, rename = "type", skip_serializing_if = "is_blank")]
68    // ts-rs does not read `serde(rename)` -- without this the generated key is
69    // `node_type`, which is not what goes on the wire and not what the canvas
70    // reads. `flow_node_type_field_is_named_type_on_the_wire` pins it.
71    #[ts(optional, rename = "type")]
72    pub node_type: Option<String>,
73}
74
75fn is_blank(v: &Option<String>) -> bool {
76    v.as_deref().unwrap_or("").is_empty()
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default, ts_rs::TS)]
80#[ts(rename = "DroneNodePosition")]
81#[ts(export, export_to = "../../../frontend/types/rpc/")]
82pub struct NodePosition {
83    pub x: f64,
84    pub y: f64,
85}
86
87/// xyflow `Edge` — source/target ids, optional handle ids.
88///
89/// Wire format matches xyflow's TS shape (camelCase: `sourceHandle` /
90/// `targetHandle`) so JSON roundtrips through the canvas + frontend
91/// `DroneFlowEdge` type without field-name translation.
92#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
93#[serde(rename_all = "camelCase")]
94#[ts(rename = "DroneFlowEdge")]
95#[ts(export, export_to = "../../../frontend/types/rpc/")]
96pub struct FlowEdge {
97    pub id: String,
98    pub source: String,
99    pub target: String,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    #[ts(optional)]
102    pub source_handle: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    #[ts(optional)]
105    pub target_handle: Option<String>,
106}
107
108/// Top-level graph payload — what the canvas saves and the executor reads.
109#[derive(Debug, Clone, Serialize, Deserialize, Default, ts_rs::TS)]
110#[ts(export, export_to = "../../../frontend/types/rpc/")]
111pub struct DroneGraph {
112    #[serde(default)]
113    pub nodes: Vec<FlowNode>,
114    #[serde(default)]
115    pub edges: Vec<FlowEdge>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
119#[ts(export, export_to = "../../../frontend/types/rpc/")]
120pub struct DroneViewport {
121    pub x: f64,
122    pub y: f64,
123    pub zoom: f64,
124}
125
126impl Default for DroneViewport {
127    fn default() -> Self {
128        Self { x: 0.0, y: 0.0, zoom: 1.0 }
129    }
130}
131
132/// Wstore row shape. Matches `db_drone_definitions` schema.
133#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
134#[ts(export, export_to = "../../../frontend/types/rpc/")]
135pub struct DroneDefinition {
136    pub id: String,
137    pub name: String,
138    #[serde(default)]
139    pub description: String,
140    #[serde(default)]
141    pub graph: DroneGraph,
142    #[serde(default)]
143    pub viewport: DroneViewport,
144    #[ts(type = "number")]
145    pub created_at: i64,
146    #[ts(type = "number")]
147    pub updated_at: i64,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
151#[serde(rename_all = "snake_case")]
152#[ts(export, export_to = "../../../frontend/types/rpc/")]
153pub enum RunStatus {
154    Running,
155    Done,
156    Failed,
157}
158
159impl RunStatus {
160    pub fn as_str(&self) -> &'static str {
161        match self {
162            Self::Running => "running",
163            Self::Done => "done",
164            Self::Failed => "failed",
165        }
166    }
167}
168
169/// One row in `db_drone_runs`. Append-only history of executions.
170#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
171#[ts(export, export_to = "../../../frontend/types/rpc/")]
172pub struct DroneRun {
173    pub id: String,
174    pub drone_id: String,
175    /// Deliberately not the `RunStatus` union: rows written by older builds
176    /// are read back through this field, so it has to stay open.
177    pub status: String,
178    #[ts(type = "number")]
179    pub started_at: i64,
180    #[ts(type = "number")]
181    pub ended_at: i64,
182    /// Map of block_id → BlockState snapshot at run completion.
183    #[serde(default)]
184    pub block_states: HashMap<String, BlockState>,
185    /// Final output captured by the Response block (stringified JSON).
186    #[serde(default)]
187    pub output: String,
188    /// Top-level error message if the run failed before reaching Response.
189    #[serde(default)]
190    pub error: String,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
194#[ts(rename = "DroneBlockState")]
195#[ts(export, export_to = "../../../frontend/types/rpc/")]
196pub struct BlockState {
197    /// A `String` in Rust so a row written by a newer build still reads back,
198    /// but the closed set on the TS side is real and the frontend branches on
199    /// it -- a bare `string` here would be a downgrade. Keep the union and let
200    /// `block_status_union_covers_every_value_the_executor_writes` fail if the
201    /// executor ever starts writing something outside it.
202    #[ts(type = "\"pending\" | \"running\" | \"done\" | \"error\" | \"skipped\"")]
203    pub status: String,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    #[ts(optional, type = "unknown")]
206    pub output: Option<serde_json::Value>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    #[ts(optional)]
209    pub error: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    #[ts(optional, type = "number")]
212    pub started_at: Option<i64>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    #[ts(optional, type = "number")]
215    pub completed_at: Option<i64>,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// The wire key is `type`, not `node_type`.
223    ///
224    /// ts-rs does not read `serde(rename)`, so the generated binding needs its
225    /// own `#[ts(rename)]` — and nothing else would catch the two drifting
226    /// apart, because both halves compile fine and the canvas just stops
227    /// finding the field. Same failure as the identity `oauth_config_dir` tag.
228    #[test]
229    fn flow_node_type_field_is_named_type_on_the_wire() {
230        let node = FlowNode {
231            id: "n1".into(),
232            position: NodePosition::default(),
233            data: serde_json::json!({ "kind": "agent" }),
234            node_type: Some("agent".into()),
235        };
236        let v = serde_json::to_value(&node).unwrap();
237        assert_eq!(v["type"], "agent");
238        assert!(
239            v.get("node_type").is_none(),
240            "the Rust field name must never reach the wire, got {v}",
241        );
242
243        // And the generated TS says the same thing.
244        let ts = <FlowNode as ts_rs::TS>::inline(&ts_rs::Config::from_env());
245        assert!(ts.contains("type?: string"), "generated TS lost the rename: {ts}");
246        assert!(!ts.contains("node_type"), "generated TS kept the Rust name: {ts}");
247    }
248
249    /// A blank node type is omitted rather than written as `""`, which is what
250    /// made `type` optional in TS in the first place. `is_blank` (not
251    /// `Option::is_none`) is what keeps `Some("")` omitted too, so that
252    /// switching the field to `Option` changed no bytes on the wire.
253    #[test]
254    fn a_blank_node_type_is_omitted_from_the_wire() {
255        for blank in [None, Some(String::new())] {
256            let node = FlowNode {
257                id: "n1".into(),
258                position: NodePosition::default(),
259                data: serde_json::json!({ "kind": "agent" }),
260                node_type: blank.clone(),
261            };
262            let v = serde_json::to_value(&node).unwrap();
263            assert!(v.get("type").is_none(), "{blank:?} should be omitted, got {v}");
264        }
265    }
266
267    /// The generated `DroneBlockState.status` is a closed union, which is only
268    /// honest as long as the executor writes nothing outside it. It is a
269    /// `String` in Rust — so if someone adds a sixth status, the compiler will
270    /// not notice and the frontend's exhaustive branches will silently miss it.
271    /// This is the thing that notices.
272    #[test]
273    fn block_status_union_covers_every_value_the_executor_writes() {
274        let ts = <BlockState as ts_rs::TS>::inline(&ts_rs::Config::from_env());
275        // Keep in sync with the status literals in
276        // drone/executor/engine.rs. "pending" is in the union but never
277        // written by the server; the frontend uses it for blocks it has not
278        // heard about yet, which is why the union is wider than this list.
279        for written in ["running", "done", "error", "skipped"] {
280            assert!(
281                ts.contains(&format!("\"{written}\"")),
282                "the executor writes {written:?} but the generated union omits it: {ts}",
283            );
284        }
285    }
286
287    /// `BlockKind` and `RunStatus` are snake_case on the wire, and ts-rs
288    /// derives that from `serde(rename_all)` by itself -- no `ts(rename_all)`
289    /// needed. This test is what proves that, and is why the redundant
290    /// attribute could be removed: it asserts the generated union against what
291    /// serde actually writes rather than against a second attribute that was
292    /// only ever agreeing with the first.
293    #[test]
294    fn enum_variants_are_snake_case_in_both_serde_and_the_generated_union() {
295        let kind_ts = <BlockKind as ts_rs::TS>::inline(&ts_rs::Config::from_env());
296        for k in [
297            BlockKind::Agent,
298            BlockKind::Condition,
299            BlockKind::Api,
300            BlockKind::Response,
301            BlockKind::Variables,
302        ] {
303            let wire = serde_json::to_value(k).unwrap();
304            let wire = wire.as_str().unwrap();
305            assert_eq!(wire, wire.to_lowercase(), "{k:?} is not snake_case on the wire");
306            assert!(
307                kind_ts.contains(&format!("\"{wire}\"")),
308                "generated BlockKind is missing {wire:?}: {kind_ts}",
309            );
310        }
311
312        let run_ts = <RunStatus as ts_rs::TS>::inline(&ts_rs::Config::from_env());
313        for r in [RunStatus::Running, RunStatus::Done, RunStatus::Failed] {
314            let wire = serde_json::to_value(r).unwrap();
315            let wire = wire.as_str().unwrap();
316            assert_eq!(wire, r.as_str(), "serde and as_str disagree for {r:?}");
317            assert!(
318                run_ts.contains(&format!("\"{wire}\"")),
319                "generated RunStatus is missing {wire:?}: {run_ts}",
320            );
321        }
322    }
323
324    /// 64-bit timestamps must generate as `number`, not `bigint`: the frontend
325    /// does arithmetic on them and `JSON.parse` never produces a `bigint`.
326    #[test]
327    fn timestamps_generate_as_number() {
328        for ts in [
329            <DroneDefinition as ts_rs::TS>::inline(&ts_rs::Config::from_env()),
330            <DroneRun as ts_rs::TS>::inline(&ts_rs::Config::from_env()),
331            <BlockState as ts_rs::TS>::inline(&ts_rs::Config::from_env()),
332        ] {
333            assert!(!ts.contains("bigint"), "a 64-bit field leaked as bigint: {ts}");
334        }
335    }
336}