1use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8pub const SPEC_VERSION: &str = "2";
14
15pub const SUPPORTED_SPEC_VERSIONS: &[&str] = &["1", "2"];
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct FlowNode {
23 pub id: String,
25 pub node_type: FlowNodeType,
27 pub data: serde_json::Value,
29 #[serde(default)]
31 pub position: [f64; 2],
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FlowNodeType {
41 Core(CoreNodeType),
43 Custom(String),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum CoreNodeType {
56 Entry,
58 Prompt,
60 Conditional,
65 Branch,
72 SetVariable,
75 Tool,
77 Http,
79 SubAgent,
81 Approval,
83 Wait,
85 Foreach,
87 End,
89 BranchTool,
93}
94
95impl CoreNodeType {
96 pub fn as_str(self) -> &'static str {
98 match self {
99 CoreNodeType::Entry => "entry",
100 CoreNodeType::Prompt => "prompt",
101 CoreNodeType::Conditional => "conditional",
102 CoreNodeType::Branch => "branch",
103 CoreNodeType::SetVariable => "set_variable",
104 CoreNodeType::Tool => "tool",
105 CoreNodeType::Http => "http",
106 CoreNodeType::SubAgent => "sub_agent",
107 CoreNodeType::Approval => "approval",
108 CoreNodeType::Wait => "wait",
109 CoreNodeType::Foreach => "foreach",
110 CoreNodeType::End => "end",
111 CoreNodeType::BranchTool => "branch_tool",
112 }
113 }
114
115 pub fn from_wire(s: &str) -> Option<Self> {
117 match s {
118 "entry" => Some(CoreNodeType::Entry),
119 "prompt" => Some(CoreNodeType::Prompt),
120 "conditional" => Some(CoreNodeType::Conditional),
121 "branch" => Some(CoreNodeType::Branch),
122 "set_variable" => Some(CoreNodeType::SetVariable),
123 "tool" => Some(CoreNodeType::Tool),
124 "http" => Some(CoreNodeType::Http),
125 "sub_agent" => Some(CoreNodeType::SubAgent),
126 "approval" => Some(CoreNodeType::Approval),
127 "wait" => Some(CoreNodeType::Wait),
128 "foreach" => Some(CoreNodeType::Foreach),
129 "end" => Some(CoreNodeType::End),
130 "branch_tool" => Some(CoreNodeType::BranchTool),
131 _ => None,
132 }
133 }
134
135 pub fn is_v2(self) -> bool {
138 !matches!(
139 self,
140 CoreNodeType::Entry
141 | CoreNodeType::Prompt
142 | CoreNodeType::BranchTool
143 )
144 }
145}
146
147impl FlowNodeType {
148 pub fn as_wire(&self) -> &str {
150 match self {
151 FlowNodeType::Core(c) => c.as_str(),
152 FlowNodeType::Custom(s) => s.as_str(),
153 }
154 }
155}
156
157impl Serialize for FlowNodeType {
158 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
159 ser.serialize_str(self.as_wire())
160 }
161}
162
163impl<'de> Deserialize<'de> for FlowNodeType {
164 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
165 let s = String::deserialize(de)?;
166 if let Some(core) = CoreNodeType::from_wire(&s) {
167 Ok(FlowNodeType::Core(core))
168 } else {
169 Ok(FlowNodeType::Custom(s))
170 }
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct FlowEdge {
177 pub id: String,
179 pub source: String,
181 pub target: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub source_handle: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub target_handle: Option<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
194pub struct FlowDefinition {
195 pub nodes: Vec<FlowNode>,
197 pub edges: Vec<FlowEdge>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct SavedFlow {
204 #[serde(default = "default_spec_version")]
206 pub spec_version: String,
207 pub id: String,
209 pub name: String,
211 pub created_at: String,
213 pub updated_at: String,
215 #[serde(default)]
220 pub enabled: bool,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub schedules: Vec<FlowScheduleSpec>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub requires: Option<crate::requires::Requires>,
234 pub flow: FlowDefinition,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct FlowScheduleSpec {
245 pub id: String,
249 #[serde(default = "default_true")]
252 pub enabled: bool,
253 #[serde(flatten)]
255 pub trigger: ScheduleTrigger,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub name: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub timezone: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub inputs: Option<serde_json::Value>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub persona: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[serde(tag = "type", rename_all = "snake_case")]
281pub enum ScheduleTrigger {
282 Manual,
284 Minutes {
286 interval: u64,
288 },
289 Hours {
291 interval: u64,
293 },
294 Cron {
297 cron: String,
299 },
300}
301
302fn default_true() -> bool {
303 true
304}
305
306impl SavedFlow {
307 pub fn effective_schedules(&self) -> Vec<FlowScheduleSpec> {
320 if !self.schedules.is_empty() {
321 return self.schedules.clone();
322 }
323 if let Some(spec) = self.entry_schedule_from_node() {
324 return vec![spec];
325 }
326 vec![FlowScheduleSpec {
327 id: "default".to_string(),
328 enabled: true,
329 trigger: ScheduleTrigger::Manual,
330 name: None,
331 timezone: None,
332 inputs: None,
333 persona: None,
334 }]
335 }
336
337 fn entry_schedule_from_node(&self) -> Option<FlowScheduleSpec> {
341 let entry = self
342 .flow
343 .nodes
344 .iter()
345 .find(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))?;
346 let schedule_type = entry.data.get("schedule_type").and_then(|v| v.as_str())?;
347 let interval = entry
348 .data
349 .get("interval")
350 .and_then(|v| v.as_u64())
351 .unwrap_or(0);
352 let trigger = match schedule_type {
353 "minutes" => ScheduleTrigger::Minutes { interval },
354 "hours" => ScheduleTrigger::Hours { interval },
355 "cron" => ScheduleTrigger::Cron {
356 cron: entry
357 .data
358 .get("cron")
359 .and_then(|v| v.as_str())
360 .unwrap_or_default()
361 .to_string(),
362 },
363 _ => ScheduleTrigger::Manual,
365 };
366 let persona = entry
367 .data
368 .get("persona")
369 .and_then(|v| v.as_str())
370 .map(|s| s.to_string());
371 Some(FlowScheduleSpec {
372 id: "default".to_string(),
373 enabled: true,
374 trigger,
375 name: None,
376 timezone: None,
377 inputs: None,
378 persona,
379 })
380 }
381}
382
383pub const DEFAULT_SPEC_VERSION: &str = "1";
390
391fn default_spec_version() -> String {
392 DEFAULT_SPEC_VERSION.to_string()
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399pub struct FlowSummary {
400 pub id: String,
402 pub name: String,
404 pub node_count: usize,
406 pub created_at: String,
408 pub updated_at: String,
410 #[serde(default)]
412 pub enabled: bool,
413 #[serde(default)]
416 pub schedule_count: usize,
417}
418
419pub(crate) fn is_safe_id(id: &str) -> bool {
421 !id.is_empty()
422 && id.len() <= 64
423 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
424}
425
426pub(crate) fn is_valid_vendor(prefix: &str) -> bool {
428 let mut chars = prefix.chars();
429 let Some(first) = chars.next() else { return false };
430 if !first.is_ascii_lowercase() {
431 return false;
432 }
433 if prefix.len() > 32 {
434 return false;
435 }
436 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use serde_json::json;
443
444 #[test]
445 fn core_node_type_round_trips() {
446 for ct in [
447 CoreNodeType::Entry,
448 CoreNodeType::Prompt,
449 CoreNodeType::Branch,
450 CoreNodeType::BranchTool,
451 ] {
452 let nt = FlowNodeType::Core(ct);
453 let j = serde_json::to_string(&nt).unwrap();
454 let back: FlowNodeType = serde_json::from_str(&j).unwrap();
455 assert_eq!(nt, back);
456 }
457 }
458
459 #[test]
460 fn custom_node_type_round_trips() {
461 let nt = FlowNodeType::Custom("slack:send_message".to_string());
462 let j = serde_json::to_string(&nt).unwrap();
463 assert_eq!(j, "\"slack:send_message\"");
464 let back: FlowNodeType = serde_json::from_str(&j).unwrap();
465 assert_eq!(nt, back);
466 }
467
468 #[test]
469 fn unknown_bare_node_type_becomes_custom() {
470 let back: FlowNodeType = serde_json::from_str("\"future_core_type\"").unwrap();
471 assert_eq!(back, FlowNodeType::Custom("future_core_type".into()));
472 }
473
474 #[test]
475 fn missing_spec_version_defaults_to_v1() {
476 let doc = json!({
477 "id": "x",
478 "name": "X",
479 "created_at": "2026-01-01T00:00:00Z",
480 "updated_at": "2026-01-01T00:00:00Z",
481 "flow": { "nodes": [], "edges": [] }
482 });
483 let parsed: SavedFlow = serde_json::from_value(doc).unwrap();
484 assert_eq!(parsed.spec_version, "1");
485 assert!(!parsed.enabled);
486 }
487
488 #[test]
489 fn saved_flow_round_trips() {
490 let sf = SavedFlow {
491 spec_version: "1".into(),
492 id: "f1".into(),
493 name: "F1".into(),
494 created_at: "2026-01-01T00:00:00Z".into(),
495 updated_at: "2026-01-02T00:00:00Z".into(),
496 enabled: true,
497 schedules: vec![],
498 requires: None,
499 flow: FlowDefinition {
500 nodes: vec![FlowNode {
501 id: "n1".into(),
502 node_type: FlowNodeType::Core(CoreNodeType::Entry),
503 data: json!({"schedule_type": "manual"}),
504 position: [10.0, 20.0],
505 }],
506 edges: vec![],
507 },
508 };
509 let j = serde_json::to_string(&sf).unwrap();
510 let back: SavedFlow = serde_json::from_str(&j).unwrap();
511 assert_eq!(sf, back);
512 }
513
514 #[test]
515 fn effective_schedules_prefers_top_level_array() {
516 let mut sf: SavedFlow = serde_json::from_value(json!({
517 "id": "f", "name": "F",
518 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
519 "schedules": [
520 { "id": "morning", "type": "cron", "cron": "0 8 * * *" },
521 { "id": "evening", "type": "cron", "cron": "0 18 * * *", "enabled": false }
522 ],
523 "flow": { "nodes": [
524 { "id": "entry", "node_type": "entry", "data": { "schedule_type": "cron", "cron": "0 0 * * *" }, "position": [0,0] }
525 ], "edges": [] }
526 }))
527 .unwrap();
528 let eff = sf.effective_schedules();
529 assert_eq!(eff.len(), 2, "top-level array wins over the entry node");
530 assert_eq!(eff[0].id, "morning");
531 assert!(eff[0].enabled);
532 assert!(!eff[1].enabled);
533 assert_eq!(eff[0].trigger, ScheduleTrigger::Cron { cron: "0 8 * * *".into() });
534
535 sf.schedules.clear();
537 let eff = sf.effective_schedules();
538 assert_eq!(eff.len(), 1);
539 assert_eq!(eff[0].trigger, ScheduleTrigger::Cron { cron: "0 0 * * *".into() });
540 }
541
542 #[test]
543 fn effective_schedules_legacy_entry_and_manual_fallback() {
544 let sf: SavedFlow = serde_json::from_value(json!({
546 "id": "f", "name": "F",
547 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
548 "flow": { "nodes": [
549 { "id": "entry", "node_type": "entry", "data": {}, "position": [0,0] }
550 ], "edges": [] }
551 }))
552 .unwrap();
553 let eff = sf.effective_schedules();
554 assert_eq!(eff.len(), 1);
555 assert_eq!(eff[0].trigger, ScheduleTrigger::Manual);
556
557 let sf: SavedFlow = serde_json::from_value(json!({
559 "id": "f", "name": "F",
560 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
561 "flow": { "nodes": [
562 { "id": "entry", "node_type": "entry", "data": { "schedule_type": "minutes", "interval": 15, "persona": "briefer" }, "position": [0,0] }
563 ], "edges": [] }
564 }))
565 .unwrap();
566 let eff = sf.effective_schedules();
567 assert_eq!(eff[0].trigger, ScheduleTrigger::Minutes { interval: 15 });
568 assert_eq!(eff[0].persona.as_deref(), Some("briefer"));
569 }
570
571 #[test]
572 fn schedule_trigger_serializes_with_type_tag() {
573 let spec = FlowScheduleSpec {
574 id: "s".into(),
575 enabled: true,
576 trigger: ScheduleTrigger::Cron { cron: "0 8 * * *".into() },
577 name: Some("Morning".into()),
578 timezone: Some("America/Detroit".into()),
579 inputs: None,
580 persona: None,
581 };
582 let v = serde_json::to_value(&spec).unwrap();
583 assert_eq!(v["type"], "cron");
584 assert_eq!(v["cron"], "0 8 * * *");
585 assert_eq!(v["timezone"], "America/Detroit");
586 let back: FlowScheduleSpec =
588 serde_json::from_value(json!({ "id": "s", "type": "manual" })).unwrap();
589 assert!(back.enabled);
590 }
591
592 #[test]
593 fn id_validation() {
594 assert!(is_safe_id("ok-id"));
595 assert!(is_safe_id("a"));
596 assert!(!is_safe_id(""));
597 assert!(!is_safe_id("has space"));
598 assert!(!is_safe_id("../escape"));
599 assert!(!is_safe_id(&"x".repeat(65)));
600 }
601
602 #[test]
603 fn vendor_validation() {
604 assert!(is_valid_vendor("slack"));
605 assert!(is_valid_vendor("my-co"));
606 assert!(is_valid_vendor("my_co"));
607 assert!(is_valid_vendor("co0"));
608 assert!(!is_valid_vendor(""));
609 assert!(!is_valid_vendor("0starts-with-digit"));
610 assert!(!is_valid_vendor("Capital"));
611 assert!(!is_valid_vendor(&"a".repeat(33)));
612 }
613}