1use crate::dag::{DagError, WorkflowDag, WorkflowEdge, WorkflowNode};
9use crate::error::{Result, WorkflowError};
10use crate::task::{Task, TaskType};
11use crate::workflow::{Workflow, WorkflowConfig};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum BundleFormat {
18 Json,
20 Yaml,
22 JsonPretty,
24}
25
26const BUNDLE_FORMAT_VERSION: &str = "1.0";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkflowBundle {
32 pub version: String,
34 pub created_at: String,
36 #[serde(default)]
38 pub description: String,
39 pub workflow: WorkflowDefinition,
41 #[serde(default)]
43 pub metadata: HashMap<String, String>,
44 #[serde(default)]
46 pub tags: Vec<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct WorkflowDefinition {
55 pub name: String,
57 #[serde(default)]
59 pub description: String,
60 pub tasks: Vec<TaskDefinition>,
62 #[serde(default)]
64 pub edges: Vec<EdgeDefinition>,
65 #[serde(default)]
67 pub config: WorkflowConfigDef,
68 #[serde(default)]
70 pub metadata: HashMap<String, String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct TaskDefinition {
76 pub name: String,
78 pub task_type: TaskType,
80 #[serde(default = "default_priority")]
82 pub priority: String,
83 #[serde(default = "default_timeout_secs")]
85 pub timeout_secs: u64,
86 #[serde(default)]
88 pub conditions: Vec<String>,
89 #[serde(default)]
91 pub metadata: HashMap<String, String>,
92}
93
94fn default_priority() -> String {
95 "normal".to_string()
96}
97
98fn default_timeout_secs() -> u64 {
99 3600
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct EdgeDefinition {
105 pub from: String,
107 pub to: String,
109 #[serde(default)]
111 pub condition: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct WorkflowConfigDef {
117 #[serde(default = "default_max_concurrent")]
119 pub max_concurrent_tasks: usize,
120 #[serde(default)]
122 pub fail_fast: bool,
123 #[serde(default)]
125 pub continue_on_error: bool,
126 #[serde(default)]
128 pub variables: HashMap<String, serde_json::Value>,
129}
130
131fn default_max_concurrent() -> usize {
132 4
133}
134
135#[must_use]
141pub fn export_workflow(workflow: &Workflow) -> WorkflowBundle {
142 let mut task_defs = Vec::new();
144 let mut id_to_name: HashMap<crate::task::TaskId, String> = HashMap::new();
145
146 for task in workflow.tasks.values() {
147 id_to_name.insert(task.id, task.name.clone());
148
149 task_defs.push(TaskDefinition {
150 name: task.name.clone(),
151 task_type: task.task_type.clone(),
152 priority: format!("{:?}", task.priority).to_lowercase(),
153 timeout_secs: task.timeout.as_secs(),
154 conditions: task.conditions.clone(),
155 metadata: task.metadata.clone(),
156 });
157 }
158
159 task_defs.sort_by(|a, b| a.name.cmp(&b.name));
161
162 let edges: Vec<EdgeDefinition> = workflow
163 .edges
164 .iter()
165 .filter_map(|edge| {
166 let from_name = id_to_name.get(&edge.from)?.clone();
167 let to_name = id_to_name.get(&edge.to)?.clone();
168 Some(EdgeDefinition {
169 from: from_name,
170 to: to_name,
171 condition: edge.condition.clone(),
172 })
173 })
174 .collect();
175
176 let config_def = WorkflowConfigDef {
177 max_concurrent_tasks: workflow.config.max_concurrent_tasks,
178 fail_fast: workflow.config.fail_fast,
179 continue_on_error: workflow.config.continue_on_error,
180 variables: workflow.config.variables.clone(),
181 };
182
183 let now = chrono::Utc::now().to_rfc3339();
184
185 WorkflowBundle {
186 version: BUNDLE_FORMAT_VERSION.to_string(),
187 created_at: now,
188 description: workflow.description.clone(),
189 workflow: WorkflowDefinition {
190 name: workflow.name.clone(),
191 description: workflow.description.clone(),
192 tasks: task_defs,
193 edges,
194 config: config_def,
195 metadata: workflow.metadata.clone(),
196 },
197 metadata: HashMap::new(),
198 tags: Vec::new(),
199 }
200}
201
202pub fn serialize_bundle(bundle: &WorkflowBundle, format: BundleFormat) -> Result<String> {
208 match format {
209 BundleFormat::Json => serde_json::to_string(bundle).map_err(WorkflowError::Serialization),
210 BundleFormat::JsonPretty => {
211 serde_json::to_string_pretty(bundle).map_err(WorkflowError::Serialization)
212 }
213 BundleFormat::Yaml => serde_yaml::to_string(bundle).map_err(WorkflowError::YamlParsing),
214 }
215}
216
217pub fn deserialize_bundle(data: &str) -> Result<WorkflowBundle> {
227 let trimmed = data.trim();
228
229 if trimmed.starts_with('{') {
231 return serde_json::from_str(trimmed).map_err(WorkflowError::Serialization);
232 }
233
234 serde_yaml::from_str(trimmed).map_err(WorkflowError::YamlParsing)
236}
237
238pub fn import_workflow(bundle: &WorkflowBundle) -> Result<Workflow> {
247 let def = &bundle.workflow;
248
249 let mut workflow = Workflow::new(&def.name).with_description(&def.description);
250
251 workflow.config = WorkflowConfig {
253 max_concurrent_tasks: def.config.max_concurrent_tasks,
254 fail_fast: def.config.fail_fast,
255 continue_on_error: def.config.continue_on_error,
256 variables: def.config.variables.clone(),
257 global_timeout: None,
258 };
259
260 for (k, v) in &def.metadata {
262 workflow.metadata.insert(k.clone(), v.clone());
263 }
264
265 let mut name_to_id: HashMap<String, crate::task::TaskId> = HashMap::new();
267
268 for task_def in &def.tasks {
269 let mut task = Task::new(&task_def.name, task_def.task_type.clone());
270 task.timeout = std::time::Duration::from_secs(task_def.timeout_secs);
271 task.conditions = task_def.conditions.clone();
272 task.metadata = task_def.metadata.clone();
273
274 task.priority = match task_def.priority.to_lowercase().as_str() {
276 "low" => crate::task::TaskPriority::Low,
277 "high" => crate::task::TaskPriority::High,
278 "critical" => crate::task::TaskPriority::Critical,
279 _ => crate::task::TaskPriority::Normal,
280 };
281
282 let task_id = task.id;
283 name_to_id.insert(task_def.name.clone(), task_id);
284 workflow.add_task(task);
285 }
286
287 for edge_def in &def.edges {
289 let from_id = name_to_id
290 .get(&edge_def.from)
291 .ok_or_else(|| WorkflowError::TaskNotFound(edge_def.from.clone()))?;
292 let to_id = name_to_id
293 .get(&edge_def.to)
294 .ok_or_else(|| WorkflowError::TaskNotFound(edge_def.to.clone()))?;
295
296 if let Some(ref condition) = edge_def.condition {
297 workflow.add_conditional_edge(*from_id, *to_id, condition.clone())?;
298 } else {
299 workflow.add_edge(*from_id, *to_id)?;
300 }
301 }
302
303 Ok(workflow)
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct DagBundle {
313 pub version: String,
315 #[serde(default)]
317 pub description: String,
318 pub nodes: Vec<DagNodeDef>,
320 pub edges: Vec<DagEdgeDef>,
322 #[serde(default)]
324 pub metadata: HashMap<String, String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct DagNodeDef {
330 pub name: String,
332 pub task_type: String,
334 #[serde(default)]
336 pub parameters: HashMap<String, serde_json::Value>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct DagEdgeDef {
342 pub from: String,
344 pub to: String,
346 pub data_type: String,
348 #[serde(default)]
350 pub condition: Option<String>,
351}
352
353#[must_use]
355pub fn export_dag(dag: &WorkflowDag, description: &str) -> DagBundle {
356 use crate::dag::NodeId;
357
358 let mut node_names: HashMap<NodeId, String> = HashMap::new();
360 let mut used_names: HashMap<String, usize> = HashMap::new();
361
362 for (node_id, node) in &dag.nodes {
363 let base_name = node.task_type.clone();
364 let count = used_names.entry(base_name.clone()).or_insert(0);
365 let name = if *count == 0 {
366 base_name.clone()
367 } else {
368 format!("{base_name}_{count}")
369 };
370 *count += 1;
371 node_names.insert(*node_id, name);
372 }
373
374 let nodes: Vec<DagNodeDef> = dag
375 .nodes
376 .iter()
377 .map(|(id, node)| DagNodeDef {
378 name: node_names.get(id).cloned().unwrap_or_default(),
379 task_type: node.task_type.clone(),
380 parameters: node.parameters.clone(),
381 })
382 .collect();
383
384 let edges: Vec<DagEdgeDef> = dag
385 .edges
386 .iter()
387 .filter_map(|edge| {
388 let from = node_names.get(&edge.from_node)?.clone();
389 let to = node_names.get(&edge.to_node)?.clone();
390 Some(DagEdgeDef {
391 from,
392 to,
393 data_type: edge.data_type.clone(),
394 condition: edge.condition.clone(),
395 })
396 })
397 .collect();
398
399 DagBundle {
400 version: BUNDLE_FORMAT_VERSION.to_string(),
401 description: description.to_string(),
402 nodes,
403 edges,
404 metadata: HashMap::new(),
405 }
406}
407
408pub fn import_dag(bundle: &DagBundle) -> std::result::Result<WorkflowDag, DagError> {
415 let mut dag = WorkflowDag::new();
416 let mut name_to_id: HashMap<String, crate::dag::NodeId> = HashMap::new();
417
418 for node_def in &bundle.nodes {
419 let mut node = WorkflowNode::new(&node_def.task_type);
420 node.parameters = node_def.parameters.clone();
421 let id = dag.add_node(node)?;
422 name_to_id.insert(node_def.name.clone(), id);
423 }
424
425 for edge_def in &bundle.edges {
426 let from_id = name_to_id
427 .get(&edge_def.from)
428 .ok_or_else(|| DagError::NodeNotFound(crate::dag::NodeId::new()))?;
429 let to_id = name_to_id
430 .get(&edge_def.to)
431 .ok_or_else(|| DagError::NodeNotFound(crate::dag::NodeId::new()))?;
432
433 let edge = if let Some(ref condition) = edge_def.condition {
434 WorkflowEdge::with_condition(*from_id, *to_id, &edge_def.data_type, condition)
435 } else {
436 WorkflowEdge::new(*from_id, *to_id, &edge_def.data_type)
437 };
438 dag.add_edge(edge)?;
439 }
440
441 Ok(dag)
442}
443
444pub fn serialize_dag_bundle(bundle: &DagBundle, format: BundleFormat) -> Result<String> {
450 match format {
451 BundleFormat::Json => serde_json::to_string(bundle).map_err(WorkflowError::Serialization),
452 BundleFormat::JsonPretty => {
453 serde_json::to_string_pretty(bundle).map_err(WorkflowError::Serialization)
454 }
455 BundleFormat::Yaml => serde_yaml::to_string(bundle).map_err(WorkflowError::YamlParsing),
456 }
457}
458
459pub fn deserialize_dag_bundle(data: &str) -> Result<DagBundle> {
465 let trimmed = data.trim();
466 if trimmed.starts_with('{') {
467 serde_json::from_str(trimmed).map_err(WorkflowError::Serialization)
468 } else {
469 serde_yaml::from_str(trimmed).map_err(WorkflowError::YamlParsing)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::task::{TaskPriority, TaskType};
477 use std::time::Duration;
478
479 fn make_test_workflow() -> Workflow {
480 let mut workflow = Workflow::new("test-pipeline")
481 .with_description("A test pipeline")
482 .with_metadata("env", "test");
483
484 let task1 = Task::new(
485 "ingest",
486 TaskType::Wait {
487 duration: Duration::from_secs(5),
488 },
489 )
490 .with_priority(TaskPriority::High);
491 let task2 = Task::new(
492 "transcode",
493 TaskType::Wait {
494 duration: Duration::from_secs(60),
495 },
496 );
497 let task3 = Task::new(
498 "deliver",
499 TaskType::Wait {
500 duration: Duration::from_secs(10),
501 },
502 )
503 .with_priority(TaskPriority::Low);
504
505 let id1 = workflow.add_task(task1);
506 let id2 = workflow.add_task(task2);
507 let id3 = workflow.add_task(task3);
508
509 workflow.add_edge(id1, id2).expect("add edge");
510 workflow.add_edge(id2, id3).expect("add edge");
511
512 workflow
513 }
514
515 #[test]
518 fn test_export_workflow() {
519 let workflow = make_test_workflow();
520 let bundle = export_workflow(&workflow);
521
522 assert_eq!(bundle.version, "1.0");
523 assert_eq!(bundle.workflow.name, "test-pipeline");
524 assert_eq!(bundle.workflow.tasks.len(), 3);
525 assert_eq!(bundle.workflow.edges.len(), 2);
526 }
527
528 #[test]
529 fn test_export_preserves_metadata() {
530 let workflow = make_test_workflow();
531 let bundle = export_workflow(&workflow);
532
533 assert_eq!(
534 bundle.workflow.metadata.get("env"),
535 Some(&"test".to_string())
536 );
537 }
538
539 #[test]
540 fn test_export_edges_use_names() {
541 let workflow = make_test_workflow();
542 let bundle = export_workflow(&workflow);
543
544 let edge_names: Vec<(&str, &str)> = bundle
546 .workflow
547 .edges
548 .iter()
549 .map(|e| (e.from.as_str(), e.to.as_str()))
550 .collect();
551 assert!(edge_names.contains(&("ingest", "transcode")));
552 assert!(edge_names.contains(&("transcode", "deliver")));
553 }
554
555 #[test]
558 fn test_serialize_json() {
559 let workflow = make_test_workflow();
560 let bundle = export_workflow(&workflow);
561 let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
562
563 assert!(json.contains("test-pipeline"));
564 assert!(!json.contains('\n')); }
566
567 #[test]
568 fn test_serialize_json_pretty() {
569 let workflow = make_test_workflow();
570 let bundle = export_workflow(&workflow);
571 let json = serialize_bundle(&bundle, BundleFormat::JsonPretty).expect("serialize");
572
573 assert!(json.contains("test-pipeline"));
574 assert!(json.contains('\n')); }
576
577 #[test]
578 fn test_serialize_yaml() {
579 let workflow = make_test_workflow();
580 let bundle = export_workflow(&workflow);
581 let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
582
583 assert!(yaml.contains("test-pipeline"));
584 assert!(yaml.contains("name:"));
585 }
586
587 #[test]
588 fn test_deserialize_json() {
589 let workflow = make_test_workflow();
590 let bundle = export_workflow(&workflow);
591 let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
592
593 let restored = deserialize_bundle(&json).expect("deserialize");
594 assert_eq!(restored.workflow.name, "test-pipeline");
595 assert_eq!(restored.workflow.tasks.len(), 3);
596 }
597
598 #[test]
599 fn test_deserialize_yaml() {
600 let workflow = make_test_workflow();
601 let bundle = export_workflow(&workflow);
602 let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
603
604 let restored = deserialize_bundle(&yaml).expect("deserialize");
605 assert_eq!(restored.workflow.name, "test-pipeline");
606 }
607
608 #[test]
611 fn test_import_workflow() {
612 let workflow = make_test_workflow();
613 let bundle = export_workflow(&workflow);
614 let imported = import_workflow(&bundle).expect("import");
615
616 assert_eq!(imported.name, "test-pipeline");
617 assert_eq!(imported.tasks.len(), 3);
618 assert_eq!(imported.edges.len(), 2);
619 }
620
621 #[test]
622 fn test_import_preserves_config() {
623 let mut workflow = make_test_workflow();
624 workflow.config.fail_fast = true;
625 workflow.config.max_concurrent_tasks = 8;
626
627 let bundle = export_workflow(&workflow);
628 let imported = import_workflow(&bundle).expect("import");
629
630 assert!(imported.config.fail_fast);
631 assert_eq!(imported.config.max_concurrent_tasks, 8);
632 }
633
634 #[test]
635 fn test_import_preserves_priority() {
636 let workflow = make_test_workflow();
637 let bundle = export_workflow(&workflow);
638 let imported = import_workflow(&bundle).expect("import");
639
640 let ingest_task = imported
641 .tasks
642 .values()
643 .find(|t| t.name == "ingest")
644 .expect("find task");
645 assert_eq!(ingest_task.priority, TaskPriority::High);
646 }
647
648 #[test]
649 fn test_roundtrip_json() {
650 let workflow = make_test_workflow();
651 let bundle = export_workflow(&workflow);
652 let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
653 let restored = deserialize_bundle(&json).expect("deserialize");
654 let imported = import_workflow(&restored).expect("import");
655
656 assert_eq!(imported.name, workflow.name);
657 assert_eq!(imported.tasks.len(), workflow.tasks.len());
658 assert_eq!(imported.edges.len(), workflow.edges.len());
659 }
660
661 #[test]
662 fn test_roundtrip_yaml() {
663 let workflow = make_test_workflow();
664 let bundle = export_workflow(&workflow);
665 let yaml = serialize_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
666 let restored = deserialize_bundle(&yaml).expect("deserialize");
667 let imported = import_workflow(&restored).expect("import");
668
669 assert_eq!(imported.name, workflow.name);
670 assert_eq!(imported.tasks.len(), workflow.tasks.len());
671 }
672
673 #[test]
674 fn test_import_bad_edge_reference() {
675 let mut bundle = export_workflow(&make_test_workflow());
676 bundle.workflow.edges.push(EdgeDefinition {
677 from: "nonexistent".to_string(),
678 to: "ingest".to_string(),
679 condition: None,
680 });
681
682 let result = import_workflow(&bundle);
683 assert!(result.is_err());
684 }
685
686 #[test]
689 fn test_conditional_edge_roundtrip() {
690 let mut workflow = Workflow::new("conditional-wf");
691 let task1 = Task::new(
692 "check",
693 TaskType::Wait {
694 duration: Duration::from_secs(1),
695 },
696 );
697 let task2 = Task::new(
698 "branch_a",
699 TaskType::Wait {
700 duration: Duration::from_secs(1),
701 },
702 );
703 let id1 = workflow.add_task(task1);
704 let id2 = workflow.add_task(task2);
705 workflow
706 .add_conditional_edge(id1, id2, "result == success".to_string())
707 .expect("add edge");
708
709 let bundle = export_workflow(&workflow);
710 assert_eq!(
711 bundle.workflow.edges[0].condition,
712 Some("result == success".to_string())
713 );
714
715 let imported = import_workflow(&bundle).expect("import");
716 assert_eq!(
717 imported.edges[0].condition,
718 Some("result == success".to_string())
719 );
720 }
721
722 #[test]
725 fn test_dag_export() {
726 let mut dag = WorkflowDag::new();
727 let a = dag.add_node(WorkflowNode::new("ingest")).expect("add node");
728 let b = dag
729 .add_node(WorkflowNode::new("transcode"))
730 .expect("add node");
731 dag.add_edge(WorkflowEdge::new(a, b, "raw_media"))
732 .expect("add edge");
733
734 let bundle = export_dag(&dag, "test dag");
735 assert_eq!(bundle.nodes.len(), 2);
736 assert_eq!(bundle.edges.len(), 1);
737 assert_eq!(bundle.description, "test dag");
738 }
739
740 #[test]
741 fn test_dag_roundtrip() {
742 let mut dag = WorkflowDag::new();
743 let a = dag.add_node(WorkflowNode::new("ingest")).expect("add node");
744 let b = dag.add_node(WorkflowNode::new("encode")).expect("add node");
745 let c = dag
746 .add_node(WorkflowNode::new("deliver"))
747 .expect("add node");
748 dag.add_edge(WorkflowEdge::new(a, b, "raw")).expect("edge");
749 dag.add_edge(WorkflowEdge::new(b, c, "encoded"))
750 .expect("edge");
751
752 let bundle = export_dag(&dag, "pipeline");
753 let json = serialize_dag_bundle(&bundle, BundleFormat::JsonPretty).expect("serialize");
754 let restored_bundle = deserialize_dag_bundle(&json).expect("deserialize");
755 let restored_dag = import_dag(&restored_bundle).expect("import");
756
757 assert_eq!(restored_dag.nodes.len(), 3);
758 assert_eq!(restored_dag.edges.len(), 2);
759 assert!(!restored_dag.has_cycle());
760 }
761
762 #[test]
763 fn test_dag_yaml_roundtrip() {
764 let mut dag = WorkflowDag::new();
765 let a = dag.add_node(WorkflowNode::new("src")).expect("add node");
766 let b = dag.add_node(WorkflowNode::new("dst")).expect("add node");
767 dag.add_edge(WorkflowEdge::new(a, b, "data")).expect("edge");
768
769 let bundle = export_dag(&dag, "simple");
770 let yaml = serialize_dag_bundle(&bundle, BundleFormat::Yaml).expect("serialize");
771 let restored = deserialize_dag_bundle(&yaml).expect("deserialize");
772 let imported = import_dag(&restored).expect("import");
773
774 assert_eq!(imported.nodes.len(), 2);
775 }
776
777 #[test]
778 fn test_dag_with_parameters() {
779 let mut dag = WorkflowDag::new();
780 let node = WorkflowNode::new("encode")
781 .with_parameter("preset", serde_json::json!("slow"))
782 .with_parameter("crf", serde_json::json!(23));
783 let _ = dag.add_node(node).expect("add node");
784
785 let bundle = export_dag(&dag, "params test");
786 let json = serialize_dag_bundle(&bundle, BundleFormat::Json).expect("serialize");
787 let restored = deserialize_dag_bundle(&json).expect("deserialize");
788 let imported = import_dag(&restored).expect("import");
789
790 let imported_node = imported.nodes.values().next().expect("get node");
791 assert_eq!(
792 imported_node.parameters["preset"],
793 serde_json::json!("slow")
794 );
795 assert_eq!(imported_node.parameters["crf"], serde_json::json!(23));
796 }
797
798 #[test]
799 fn test_bundle_format_version() {
800 let workflow = make_test_workflow();
801 let bundle = export_workflow(&workflow);
802 assert_eq!(bundle.version, "1.0");
803 }
804
805 #[test]
806 fn test_bundle_tags() {
807 let workflow = make_test_workflow();
808 let mut bundle = export_workflow(&workflow);
809 bundle.tags = vec!["media".to_string(), "transcode".to_string()];
810
811 let json = serialize_bundle(&bundle, BundleFormat::Json).expect("serialize");
812 let restored = deserialize_bundle(&json).expect("deserialize");
813 assert_eq!(restored.tags, vec!["media", "transcode"]);
814 }
815}