1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
41#[ts(rename = "DroneFlowNode")]
45#[ts(export, export_to = "../../../frontend/types/rpc/")]
46pub struct FlowNode {
47 pub id: String,
48 #[serde(default)]
50 pub position: NodePosition,
51 #[ts(type = "Record<string, unknown> & { kind: string }")]
58 pub data: serde_json::Value,
59 #[serde(default, rename = "type", skip_serializing_if = "is_blank")]
68 #[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#[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#[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#[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#[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 pub status: String,
178 #[ts(type = "number")]
179 pub started_at: i64,
180 #[ts(type = "number")]
181 pub ended_at: i64,
182 #[serde(default)]
184 pub block_states: HashMap<String, BlockState>,
185 #[serde(default)]
187 pub output: String,
188 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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}