Skip to main content

workflow_graph_shared/
lib.rs

1pub mod yaml;
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7/// Direction of a port on a node.
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum PortDirection {
11    Input,
12    Output,
13}
14
15/// A typed input or output port on a node.
16/// Ports define connection points — edges connect from an output port to an input port.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct Port {
19    /// Unique identifier within the node (e.g., "message", "response").
20    pub id: String,
21    /// Display label.
22    pub label: String,
23    /// Whether this is an input or output port.
24    pub direction: PortDirection,
25    /// Type tag for connection compatibility (e.g., "text", "json", "tool_call").
26    /// Only ports with matching types can be connected.
27    #[serde(default)]
28    pub port_type: String,
29    /// Optional color override for the port dot.
30    #[serde(default)]
31    pub color: Option<String>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum JobStatus {
37    Queued,
38    Running,
39    Success,
40    Failure,
41    Skipped,
42    Cancelled,
43}
44
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct Job {
47    pub id: String,
48    pub name: String,
49    pub status: JobStatus,
50    pub command: String,
51    pub duration_secs: Option<u64>,
52    /// Epoch milliseconds when the job started running (for live timer).
53    #[serde(default)]
54    pub started_at: Option<f64>,
55    pub depends_on: Vec<String>,
56    pub output: Option<String>,
57    /// Worker labels required to execute this job.
58    #[serde(default)]
59    pub required_labels: Vec<String>,
60    /// Maximum number of retries on failure.
61    #[serde(default)]
62    pub max_retries: u32,
63    /// Current attempt number (0-indexed).
64    #[serde(default)]
65    pub attempt: u32,
66    /// Arbitrary metadata for custom renderers (e.g., node_type, icon, color).
67    #[serde(default)]
68    pub metadata: HashMap<String, serde_json::Value>,
69    /// Input and output ports for node-graph-style connections.
70    #[serde(default)]
71    pub ports: Vec<Port>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct Workflow {
76    pub id: String,
77    pub name: String,
78    pub trigger: String,
79    pub jobs: Vec<Job>,
80}
81
82impl Workflow {
83    /// Returns a sample workflow matching the GitHub Actions screenshot.
84    pub fn sample() -> Self {
85        Workflow {
86            id: "ci-1".into(),
87            name: "ci.yml".into(),
88            trigger: "on: push".into(),
89            jobs: vec![
90                Job {
91                    id: "unit-tests".into(),
92                    name: "Unit Tests".into(),
93                    status: JobStatus::Queued,
94                    command: "echo 'Running unit tests' && sleep 2".into(),
95                    duration_secs: None,
96                    depends_on: vec![],
97                    started_at: None,
98                    output: None,
99                    required_labels: vec![],
100                    max_retries: 0,
101                    attempt: 0,
102                    metadata: HashMap::new(),
103                    ports: vec![],
104                },
105                Job {
106                    id: "lint".into(),
107                    name: "Lint".into(),
108                    status: JobStatus::Queued,
109                    command: "echo 'Running linter' && sleep 1".into(),
110                    duration_secs: None,
111                    depends_on: vec![],
112                    started_at: None,
113                    output: None,
114                    required_labels: vec![],
115                    max_retries: 0,
116                    attempt: 0,
117                    metadata: HashMap::new(),
118                    ports: vec![],
119                },
120                Job {
121                    id: "typecheck".into(),
122                    name: "Typecheck".into(),
123                    status: JobStatus::Queued,
124                    command: "echo 'Running typecheck' && sleep 2".into(),
125                    duration_secs: None,
126                    depends_on: vec![],
127                    started_at: None,
128                    output: None,
129                    required_labels: vec![],
130                    max_retries: 0,
131                    attempt: 0,
132                    metadata: HashMap::new(),
133                    ports: vec![],
134                },
135                Job {
136                    id: "build".into(),
137                    name: "Build".into(),
138                    status: JobStatus::Queued,
139                    command: "echo 'Building project' && sleep 3".into(),
140                    duration_secs: None,
141                    depends_on: vec!["unit-tests".into(), "lint".into(), "typecheck".into()],
142                    started_at: None,
143                    output: None,
144                    required_labels: vec![],
145                    max_retries: 0,
146                    attempt: 0,
147                    metadata: HashMap::new(),
148                    ports: vec![],
149                },
150                Job {
151                    id: "deploy-db".into(),
152                    name: "Deploy DB Migrations".into(),
153                    status: JobStatus::Queued,
154                    command: "echo 'Deploying DB migrations' && sleep 1".into(),
155                    duration_secs: None,
156                    depends_on: vec!["build".into()],
157                    started_at: None,
158                    output: None,
159                    required_labels: vec![],
160                    max_retries: 0,
161                    attempt: 0,
162                    metadata: HashMap::new(),
163                    ports: vec![],
164                },
165                Job {
166                    id: "e2e-tests".into(),
167                    name: "E2E Tests".into(),
168                    status: JobStatus::Queued,
169                    command: "echo 'Running E2E tests' && sleep 5".into(),
170                    duration_secs: None,
171                    depends_on: vec!["build".into()],
172                    started_at: None,
173                    output: None,
174                    required_labels: vec![],
175                    max_retries: 0,
176                    attempt: 0,
177                    metadata: HashMap::new(),
178                    ports: vec![],
179                },
180                Job {
181                    id: "deploy-preview".into(),
182                    name: "Deploy Preview".into(),
183                    status: JobStatus::Queued,
184                    command: "echo 'Deploying preview' && sleep 1".into(),
185                    duration_secs: None,
186                    depends_on: vec!["build".into()],
187                    started_at: None,
188                    output: None,
189                    required_labels: vec![],
190                    max_retries: 0,
191                    attempt: 0,
192                    metadata: HashMap::new(),
193                    ports: vec![],
194                },
195                Job {
196                    id: "deploy-web".into(),
197                    name: "Deploy Web".into(),
198                    status: JobStatus::Queued,
199                    command: "echo 'Deploying to production' && sleep 3".into(),
200                    duration_secs: None,
201                    depends_on: vec!["deploy-db".into()],
202                    started_at: None,
203                    output: None,
204                    required_labels: vec![],
205                    max_retries: 0,
206                    attempt: 0,
207                    metadata: HashMap::new(),
208                    ports: vec![],
209                },
210            ],
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn make_job(id: &str, metadata: HashMap<String, serde_json::Value>) -> Job {
220        Job {
221            id: id.into(),
222            name: id.into(),
223            status: JobStatus::Queued,
224            command: "echo test".into(),
225            duration_secs: None,
226            started_at: None,
227            depends_on: vec![],
228            output: None,
229            required_labels: vec![],
230            max_retries: 0,
231            attempt: 0,
232            metadata,
233            ports: vec![],
234        }
235    }
236
237    #[test]
238    fn job_metadata_serializes_roundtrip() {
239        let mut meta = HashMap::new();
240        meta.insert("node_type".into(), serde_json::json!("deploy"));
241        meta.insert("icon".into(), serde_json::json!("rocket"));
242        meta.insert("priority".into(), serde_json::json!(42));
243
244        let job = make_job("deploy-1", meta);
245        let json = serde_json::to_string(&job).unwrap();
246        let deserialized: Job = serde_json::from_str(&json).unwrap();
247
248        assert_eq!(deserialized.metadata.len(), 3);
249        assert_eq!(
250            deserialized.metadata["node_type"],
251            serde_json::json!("deploy")
252        );
253        assert_eq!(deserialized.metadata["icon"], serde_json::json!("rocket"));
254        assert_eq!(deserialized.metadata["priority"], serde_json::json!(42));
255    }
256
257    #[test]
258    fn job_metadata_defaults_to_empty() {
259        let json = r#"{
260            "id": "test",
261            "name": "Test",
262            "status": "queued",
263            "command": "echo hi",
264            "depends_on": []
265        }"#;
266        let job: Job = serde_json::from_str(json).unwrap();
267        assert!(job.metadata.is_empty());
268    }
269
270    #[test]
271    fn job_metadata_with_nested_values() {
272        let mut meta = HashMap::new();
273        meta.insert(
274            "config".into(),
275            serde_json::json!({"timeout": 30, "retries": true}),
276        );
277        meta.insert("tags".into(), serde_json::json!(["ci", "deploy"]));
278
279        let job = make_job("complex", meta);
280        let json = serde_json::to_string(&job).unwrap();
281        let deserialized: Job = serde_json::from_str(&json).unwrap();
282
283        assert_eq!(
284            deserialized.metadata["config"],
285            serde_json::json!({"timeout": 30, "retries": true})
286        );
287        assert_eq!(
288            deserialized.metadata["tags"],
289            serde_json::json!(["ci", "deploy"])
290        );
291    }
292
293    #[test]
294    fn job_metadata_from_json_string() {
295        let json = r##"{
296            "id": "styled",
297            "name": "Styled Node",
298            "status": "running",
299            "command": "echo hi",
300            "depends_on": [],
301            "metadata": {
302                "color": "#ff0000",
303                "weight": 1.5,
304                "visible": true
305            }
306        }"##;
307        let job: Job = serde_json::from_str(json).unwrap();
308        assert_eq!(job.metadata.len(), 3);
309        assert_eq!(job.metadata["color"], serde_json::json!("#ff0000"));
310        assert_eq!(job.metadata["weight"], serde_json::json!(1.5));
311        assert_eq!(job.metadata["visible"], serde_json::json!(true));
312    }
313
314    #[test]
315    fn workflow_sample_has_empty_metadata() {
316        let wf = Workflow::sample();
317        for job in &wf.jobs {
318            assert!(
319                job.metadata.is_empty(),
320                "Expected empty metadata for job '{}'",
321                job.id
322            );
323        }
324    }
325}