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/// Type of inline field rendered inside a node body.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum FieldType {
38    Text,
39    Textarea,
40    Select,
41    Toggle,
42    Badge,
43    Slider,
44}
45
46/// Definition of an inline field rendered inside a node.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct FieldDef {
49    /// Key used to read/write the field value in `Job.metadata`.
50    pub key: String,
51    /// What kind of control to render.
52    pub field_type: FieldType,
53    /// Display label.
54    pub label: String,
55    /// Available options (for `Select` fields).
56    #[serde(default)]
57    pub options: Vec<String>,
58    /// Default value (serialized as JSON).
59    #[serde(default)]
60    pub default_value: Option<serde_json::Value>,
61    /// Minimum value (for `Slider` fields).
62    #[serde(default)]
63    pub min: Option<f64>,
64    /// Maximum value (for `Slider` fields).
65    #[serde(default)]
66    pub max: Option<f64>,
67}
68
69/// Declarative definition of a node type.
70///
71/// Registered via `WorkflowGraph.registerNodeType()`. The renderer uses this
72/// to draw colored headers, inline fields, and type-specific visuals.
73/// Consumers can define any number of custom node types.
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct NodeDefinition {
76    /// Unique type key (e.g., "agent", "tool", "my-custom-node").
77    /// Matched against `Job.metadata["node_type"]`.
78    pub node_type: String,
79    /// Display label shown in the header bar.
80    pub label: String,
81    /// Icon character (emoji or Unicode) rendered in the header.
82    #[serde(default)]
83    pub icon: String,
84    /// Hex color for the header bar (e.g., "#3b82f6").
85    #[serde(default)]
86    pub header_color: String,
87    /// Category for grouping in palettes (consumer-defined, no constraints).
88    #[serde(default)]
89    pub category: String,
90    /// Inline fields rendered in the node body.
91    #[serde(default)]
92    pub fields: Vec<FieldDef>,
93    /// Default input ports for this node type.
94    #[serde(default)]
95    pub inputs: Vec<Port>,
96    /// Default output ports for this node type.
97    #[serde(default)]
98    pub outputs: Vec<Port>,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum JobStatus {
104    Queued,
105    Running,
106    Success,
107    Failure,
108    Skipped,
109    Cancelled,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct Job {
114    pub id: String,
115    pub name: String,
116    pub status: JobStatus,
117    pub command: String,
118    pub duration_secs: Option<u64>,
119    /// Epoch milliseconds when the job started running (for live timer).
120    #[serde(default)]
121    pub started_at: Option<f64>,
122    pub depends_on: Vec<String>,
123    pub output: Option<String>,
124    /// Worker labels required to execute this job.
125    #[serde(default)]
126    pub required_labels: Vec<String>,
127    /// Maximum number of retries on failure.
128    #[serde(default)]
129    pub max_retries: u32,
130    /// Current attempt number (0-indexed).
131    #[serde(default)]
132    pub attempt: u32,
133    /// Arbitrary metadata for custom renderers (e.g., node_type, icon, color).
134    #[serde(default)]
135    pub metadata: HashMap<String, serde_json::Value>,
136    /// Input and output ports for node-graph-style connections.
137    #[serde(default)]
138    pub ports: Vec<Port>,
139    /// If this is a compound node (node group), contains the child nodes.
140    /// When collapsed, renders as a single node with aggregated ports.
141    /// When expanded, renders children with a dashed border.
142    #[serde(default)]
143    pub children: Option<Vec<Job>>,
144    /// Whether this compound node is collapsed (shows as single node).
145    #[serde(default)]
146    pub collapsed: bool,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct Workflow {
151    pub id: String,
152    pub name: String,
153    pub trigger: String,
154    pub jobs: Vec<Job>,
155}
156
157impl Workflow {
158    /// Returns a sample workflow matching the GitHub Actions screenshot.
159    pub fn sample() -> Self {
160        Workflow {
161            id: "ci-1".into(),
162            name: "ci.yml".into(),
163            trigger: "on: push".into(),
164            jobs: vec![
165                Job {
166                    id: "unit-tests".into(),
167                    name: "Unit Tests".into(),
168                    status: JobStatus::Queued,
169                    command: "echo 'Running unit tests' && sleep 2".into(),
170                    duration_secs: None,
171                    depends_on: vec![],
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                    children: None,
180                    collapsed: false,
181                },
182                Job {
183                    id: "lint".into(),
184                    name: "Lint".into(),
185                    status: JobStatus::Queued,
186                    command: "echo 'Running linter' && sleep 1".into(),
187                    duration_secs: None,
188                    depends_on: vec![],
189                    started_at: None,
190                    output: None,
191                    required_labels: vec![],
192                    max_retries: 0,
193                    attempt: 0,
194                    metadata: HashMap::new(),
195                    ports: vec![],
196                    children: None,
197                    collapsed: false,
198                },
199                Job {
200                    id: "typecheck".into(),
201                    name: "Typecheck".into(),
202                    status: JobStatus::Queued,
203                    command: "echo 'Running typecheck' && sleep 2".into(),
204                    duration_secs: None,
205                    depends_on: vec![],
206                    started_at: None,
207                    output: None,
208                    required_labels: vec![],
209                    max_retries: 0,
210                    attempt: 0,
211                    metadata: HashMap::new(),
212                    ports: vec![],
213                    children: None,
214                    collapsed: false,
215                },
216                Job {
217                    id: "build".into(),
218                    name: "Build".into(),
219                    status: JobStatus::Queued,
220                    command: "echo 'Building project' && sleep 3".into(),
221                    duration_secs: None,
222                    depends_on: vec!["unit-tests".into(), "lint".into(), "typecheck".into()],
223                    started_at: None,
224                    output: None,
225                    required_labels: vec![],
226                    max_retries: 0,
227                    attempt: 0,
228                    metadata: HashMap::new(),
229                    ports: vec![],
230                    children: None,
231                    collapsed: false,
232                },
233                Job {
234                    id: "deploy-db".into(),
235                    name: "Deploy DB Migrations".into(),
236                    status: JobStatus::Queued,
237                    command: "echo 'Deploying DB migrations' && sleep 1".into(),
238                    duration_secs: None,
239                    depends_on: vec!["build".into()],
240                    started_at: None,
241                    output: None,
242                    required_labels: vec![],
243                    max_retries: 0,
244                    attempt: 0,
245                    metadata: HashMap::new(),
246                    ports: vec![],
247                    children: None,
248                    collapsed: false,
249                },
250                Job {
251                    id: "e2e-tests".into(),
252                    name: "E2E Tests".into(),
253                    status: JobStatus::Queued,
254                    command: "echo 'Running E2E tests' && sleep 5".into(),
255                    duration_secs: None,
256                    depends_on: vec!["build".into()],
257                    started_at: None,
258                    output: None,
259                    required_labels: vec![],
260                    max_retries: 0,
261                    attempt: 0,
262                    metadata: HashMap::new(),
263                    ports: vec![],
264                    children: None,
265                    collapsed: false,
266                },
267                Job {
268                    id: "deploy-preview".into(),
269                    name: "Deploy Preview".into(),
270                    status: JobStatus::Queued,
271                    command: "echo 'Deploying preview' && sleep 1".into(),
272                    duration_secs: None,
273                    depends_on: vec!["build".into()],
274                    started_at: None,
275                    output: None,
276                    required_labels: vec![],
277                    max_retries: 0,
278                    attempt: 0,
279                    metadata: HashMap::new(),
280                    ports: vec![],
281                    children: None,
282                    collapsed: false,
283                },
284                Job {
285                    id: "deploy-web".into(),
286                    name: "Deploy Web".into(),
287                    status: JobStatus::Queued,
288                    command: "echo 'Deploying to production' && sleep 3".into(),
289                    duration_secs: None,
290                    depends_on: vec!["deploy-db".into()],
291                    started_at: None,
292                    output: None,
293                    required_labels: vec![],
294                    max_retries: 0,
295                    attempt: 0,
296                    metadata: HashMap::new(),
297                    ports: vec![],
298                    children: None,
299                    collapsed: false,
300                },
301            ],
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn make_job(id: &str, metadata: HashMap<String, serde_json::Value>) -> Job {
311        Job {
312            id: id.into(),
313            name: id.into(),
314            status: JobStatus::Queued,
315            command: "echo test".into(),
316            duration_secs: None,
317            started_at: None,
318            depends_on: vec![],
319            output: None,
320            required_labels: vec![],
321            max_retries: 0,
322            attempt: 0,
323            metadata,
324            ports: vec![],
325            children: None,
326            collapsed: false,
327        }
328    }
329
330    #[test]
331    fn job_metadata_serializes_roundtrip() {
332        let mut meta = HashMap::new();
333        meta.insert("node_type".into(), serde_json::json!("deploy"));
334        meta.insert("icon".into(), serde_json::json!("rocket"));
335        meta.insert("priority".into(), serde_json::json!(42));
336
337        let job = make_job("deploy-1", meta);
338        let json = serde_json::to_string(&job).unwrap();
339        let deserialized: Job = serde_json::from_str(&json).unwrap();
340
341        assert_eq!(deserialized.metadata.len(), 3);
342        assert_eq!(
343            deserialized.metadata["node_type"],
344            serde_json::json!("deploy")
345        );
346        assert_eq!(deserialized.metadata["icon"], serde_json::json!("rocket"));
347        assert_eq!(deserialized.metadata["priority"], serde_json::json!(42));
348    }
349
350    #[test]
351    fn job_metadata_defaults_to_empty() {
352        let json = r#"{
353            "id": "test",
354            "name": "Test",
355            "status": "queued",
356            "command": "echo hi",
357            "depends_on": []
358        }"#;
359        let job: Job = serde_json::from_str(json).unwrap();
360        assert!(job.metadata.is_empty());
361    }
362
363    #[test]
364    fn job_metadata_with_nested_values() {
365        let mut meta = HashMap::new();
366        meta.insert(
367            "config".into(),
368            serde_json::json!({"timeout": 30, "retries": true}),
369        );
370        meta.insert("tags".into(), serde_json::json!(["ci", "deploy"]));
371
372        let job = make_job("complex", meta);
373        let json = serde_json::to_string(&job).unwrap();
374        let deserialized: Job = serde_json::from_str(&json).unwrap();
375
376        assert_eq!(
377            deserialized.metadata["config"],
378            serde_json::json!({"timeout": 30, "retries": true})
379        );
380        assert_eq!(
381            deserialized.metadata["tags"],
382            serde_json::json!(["ci", "deploy"])
383        );
384    }
385
386    #[test]
387    fn job_metadata_from_json_string() {
388        let json = r##"{
389            "id": "styled",
390            "name": "Styled Node",
391            "status": "running",
392            "command": "echo hi",
393            "depends_on": [],
394            "metadata": {
395                "color": "#ff0000",
396                "weight": 1.5,
397                "visible": true
398            }
399        }"##;
400        let job: Job = serde_json::from_str(json).unwrap();
401        assert_eq!(job.metadata.len(), 3);
402        assert_eq!(job.metadata["color"], serde_json::json!("#ff0000"));
403        assert_eq!(job.metadata["weight"], serde_json::json!(1.5));
404        assert_eq!(job.metadata["visible"], serde_json::json!(true));
405    }
406
407    #[test]
408    fn workflow_sample_has_empty_metadata() {
409        let wf = Workflow::sample();
410        for job in &wf.jobs {
411            assert!(
412                job.metadata.is_empty(),
413                "Expected empty metadata for job '{}'",
414                job.id
415            );
416        }
417    }
418}