Skip to main content

a3s_flow/workflow_dsl/
model.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::{WorkflowDagPlan, WorkflowDslCompatibility, WorkflowDslError};
7
8/// Maximum accepted UTF-8 byte length for one workflow DSL document or graph.
9pub const WORKFLOW_DSL_MAX_BYTES: usize = 10 * 1024 * 1024;
10/// Latest workflow DSL version covered by compatibility tests.
11pub const TESTED_WORKFLOW_DSL_VERSION: &str = "0.7.0";
12
13/// Lossless representation of an imported A3S workflow DSL document.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct WorkflowDsl {
16    version: String,
17    kind: String,
18    app: WorkflowDslApp,
19    #[serde(default)]
20    dependencies: Vec<Value>,
21    workflow: WorkflowDslBody,
22    #[serde(flatten)]
23    extensions: BTreeMap<String, Value>,
24}
25
26impl WorkflowDsl {
27    /// Parses and validates a workflow DSL document from YAML.
28    pub fn from_yaml(source: &str) -> Result<Self, WorkflowDslError> {
29        check_size(source)?;
30        let document: Self =
31            serde_yaml_ng::from_str(source).map_err(|error| WorkflowDslError::InvalidYaml {
32                message: error.to_string(),
33            })?;
34        document.validate_document()?;
35        Ok(document)
36    }
37
38    /// Parses and validates a workflow DSL document from JSON.
39    pub fn from_json(source: &str) -> Result<Self, WorkflowDslError> {
40        check_size(source)?;
41        let document: Self =
42            serde_json::from_str(source).map_err(|error| WorkflowDslError::InvalidJson {
43                message: error.to_string(),
44            })?;
45        document.validate_document()?;
46        Ok(document)
47    }
48
49    /// Serializes the complete document, including extensions, as YAML.
50    pub fn to_yaml(&self) -> Result<String, WorkflowDslError> {
51        serde_yaml_ng::to_string(self).map_err(|error| WorkflowDslError::Serialization {
52            message: error.to_string(),
53        })
54    }
55
56    /// Serializes the complete document, including extensions, as JSON.
57    pub fn to_json(&self) -> Result<String, WorkflowDslError> {
58        serde_json::to_string(self).map_err(|error| WorkflowDslError::Serialization {
59            message: error.to_string(),
60        })
61    }
62
63    /// Returns the declared workflow DSL version.
64    pub fn version(&self) -> &str {
65        &self.version
66    }
67
68    /// Returns the top-level document kind.
69    pub fn kind(&self) -> &str {
70        &self.kind
71    }
72
73    /// Returns application identity and mode metadata.
74    pub fn app(&self) -> &WorkflowDslApp {
75        &self.app
76    }
77
78    /// Returns dependency declarations preserved from the source document.
79    pub fn dependencies(&self) -> &[Value] {
80        &self.dependencies
81    }
82
83    /// Returns the workflow body.
84    pub fn workflow(&self) -> &WorkflowDslBody {
85        &self.workflow
86    }
87
88    /// Returns the workflow DAG contained in the body.
89    pub fn graph(&self) -> &WorkflowDag {
90        &self.workflow.graph
91    }
92
93    /// Returns unknown top-level fields preserved during round trips.
94    pub fn extensions(&self) -> &BTreeMap<String, Value> {
95        &self.extensions
96    }
97
98    /// Computes a stable digest of executable document semantics.
99    pub fn execution_digest(&self) -> Result<String, WorkflowDslError> {
100        super::digest::document_execution_digest(self)
101    }
102
103    /// Classify the imported DSL version. Newer releases and a different major
104    /// need explicit confirmation, while an older minor remains importable
105    /// with warnings.
106    pub fn compatibility(&self) -> Result<WorkflowDslCompatibility, WorkflowDslError> {
107        super::version::classify_dsl_version(&self.version)
108    }
109
110    fn validate_document(&self) -> Result<(), WorkflowDslError> {
111        if self.version.trim().is_empty() {
112            return Err(invalid_document("version is empty"));
113        }
114        if self.kind != "app" {
115            return Err(invalid_document(format!(
116                "kind {:?} is not a workflow app",
117                self.kind
118            )));
119        }
120        if self.app.name.trim().is_empty() {
121            return Err(invalid_document("app.name is empty"));
122        }
123        if self.app.mode != "workflow" {
124            return Err(invalid_document(format!(
125                "app.mode {:?} must be workflow",
126                self.app.mode
127            )));
128        }
129        self.compatibility()?;
130        Ok(())
131    }
132}
133
134/// Application identity and authoring mode from a workflow DSL document.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct WorkflowDslApp {
137    name: String,
138    mode: String,
139    #[serde(flatten)]
140    extensions: BTreeMap<String, Value>,
141}
142
143impl WorkflowDslApp {
144    /// Returns the application name.
145    pub fn name(&self) -> &str {
146        &self.name
147    }
148
149    /// Returns the authoring mode that owns the workflow graph.
150    pub fn mode(&self) -> &str {
151        &self.mode
152    }
153
154    /// Returns unknown application fields preserved during round trips.
155    pub fn extensions(&self) -> &BTreeMap<String, Value> {
156        &self.extensions
157    }
158}
159
160/// Workflow-specific body of a DSL document.
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162pub struct WorkflowDslBody {
163    graph: WorkflowDag,
164    #[serde(flatten)]
165    extensions: BTreeMap<String, Value>,
166}
167
168impl WorkflowDslBody {
169    /// Returns the workflow DAG.
170    pub fn graph(&self) -> &WorkflowDag {
171        &self.graph
172    }
173
174    /// Returns unknown body fields preserved during round trips.
175    pub fn extensions(&self) -> &BTreeMap<String, Value> {
176        &self.extensions
177    }
178}
179
180/// Authoring graph containing workflow nodes, edges, and canvas metadata.
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
182pub struct WorkflowDag {
183    #[serde(default)]
184    nodes: Vec<WorkflowDagNode>,
185    #[serde(default)]
186    edges: Vec<WorkflowDagEdge>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    viewport: Option<Value>,
189    #[serde(flatten)]
190    extensions: BTreeMap<String, Value>,
191}
192
193impl WorkflowDag {
194    /// Construct a DAG programmatically. Hosts that compile an authoritative
195    /// product format can use this path without serializing through YAML or
196    /// JSON first.
197    pub fn new(nodes: Vec<WorkflowDagNode>, edges: Vec<WorkflowDagEdge>) -> Self {
198        Self {
199            nodes,
200            edges,
201            viewport: None,
202            extensions: BTreeMap::new(),
203        }
204    }
205
206    /// Parses a graph from JSON without document-level validation.
207    pub fn from_json(source: &str) -> Result<Self, WorkflowDslError> {
208        check_size(source)?;
209        serde_json::from_str(source).map_err(|error| WorkflowDslError::InvalidJson {
210            message: error.to_string(),
211        })
212    }
213
214    /// Parses a graph from YAML without document-level validation.
215    pub fn from_yaml(source: &str) -> Result<Self, WorkflowDslError> {
216        check_size(source)?;
217        serde_yaml_ng::from_str(source).map_err(|error| WorkflowDslError::InvalidYaml {
218            message: error.to_string(),
219        })
220    }
221
222    /// Serializes the graph, including presentation fields, as JSON.
223    pub fn to_json(&self) -> Result<String, WorkflowDslError> {
224        serde_json::to_string(self).map_err(|error| WorkflowDslError::Serialization {
225            message: error.to_string(),
226        })
227    }
228
229    /// Returns nodes in their source authoring order.
230    pub fn nodes(&self) -> &[WorkflowDagNode] {
231        &self.nodes
232    }
233
234    /// Returns edges in their source authoring order.
235    pub fn edges(&self) -> &[WorkflowDagEdge] {
236        &self.edges
237    }
238
239    /// Returns optional canvas viewport metadata.
240    pub fn viewport(&self) -> Option<&Value> {
241        self.viewport.as_ref()
242    }
243
244    /// Returns unknown graph fields preserved during round trips.
245    pub fn extensions(&self) -> &BTreeMap<String, Value> {
246        &self.extensions
247    }
248
249    /// Looks up a node by its stable graph identity.
250    pub fn node(&self, id: &str) -> Option<&WorkflowDagNode> {
251        self.nodes.iter().find(|node| node.id == id)
252    }
253
254    /// Validates graph structure and derives deterministic per-scope order.
255    pub fn execution_plan(&self) -> Result<WorkflowDagPlan, WorkflowDslError> {
256        super::plan::build_execution_plan(self)
257    }
258
259    /// Derive a stable identity for executable graph semantics. Canvas layout
260    /// and authoring order do not affect this digest.
261    pub fn execution_digest(&self) -> Result<String, WorkflowDslError> {
262        super::digest::graph_execution_digest(self)
263    }
264}
265
266/// One semantic node in a workflow authoring graph.
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268pub struct WorkflowDagNode {
269    id: String,
270    data: Value,
271    #[serde(rename = "parentId", default, skip_serializing_if = "Option::is_none")]
272    parent_id: Option<String>,
273    #[serde(flatten)]
274    presentation: BTreeMap<String, Value>,
275}
276
277impl WorkflowDagNode {
278    /// Construct a node with the canonical `data.type` discriminator.
279    pub fn new(id: impl Into<String>, node_type: impl Into<String>) -> Self {
280        Self::from_data(
281            id,
282            Value::Object(serde_json::Map::from_iter([(
283                "type".to_owned(),
284                Value::String(node_type.into()),
285            )])),
286        )
287    }
288
289    /// Construct a node from its complete semantic data object.
290    pub fn from_data(id: impl Into<String>, data: Value) -> Self {
291        Self {
292            id: id.into(),
293            data,
294            parent_id: None,
295            presentation: BTreeMap::new(),
296        }
297    }
298
299    /// Place this node inside an iteration or loop container scope.
300    pub fn with_parent_id(mut self, parent_id: impl Into<String>) -> Self {
301        self.parent_id = Some(parent_id.into());
302        self
303    }
304
305    /// Returns the stable graph identity.
306    pub fn id(&self) -> &str {
307        &self.id
308    }
309
310    /// Returns the canonical `data.type` discriminator or an empty string.
311    pub fn node_type(&self) -> &str {
312        self.data.get("type").and_then(Value::as_str).unwrap_or("")
313    }
314
315    /// Returns complete semantic node data.
316    pub fn data(&self) -> &Value {
317        &self.data
318    }
319
320    /// Returns the containing iteration or loop identity.
321    pub fn parent_id(&self) -> Option<&str> {
322        self.parent_id.as_deref()
323    }
324
325    /// Returns presentation-only fields preserved during round trips.
326    pub fn presentation(&self) -> &BTreeMap<String, Value> {
327        &self.presentation
328    }
329}
330
331/// One directed connection between workflow DAG nodes.
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
333pub struct WorkflowDagEdge {
334    id: String,
335    source: String,
336    target: String,
337    #[serde(
338        rename = "sourceHandle",
339        default,
340        skip_serializing_if = "Option::is_none"
341    )]
342    source_handle: Option<String>,
343    #[serde(
344        rename = "targetHandle",
345        default,
346        skip_serializing_if = "Option::is_none"
347    )]
348    target_handle: Option<String>,
349    #[serde(default)]
350    data: Value,
351    #[serde(flatten)]
352    presentation: BTreeMap<String, Value>,
353}
354
355impl WorkflowDagEdge {
356    /// Construct a directed edge between two node identities.
357    pub fn new(
358        id: impl Into<String>,
359        source: impl Into<String>,
360        target: impl Into<String>,
361    ) -> Self {
362        Self {
363            id: id.into(),
364            source: source.into(),
365            target: target.into(),
366            source_handle: None,
367            target_handle: None,
368            data: Value::Null,
369            presentation: BTreeMap::new(),
370        }
371    }
372
373    /// Sets the source connector handle used by the authoring canvas.
374    pub fn with_source_handle(mut self, source_handle: impl Into<String>) -> Self {
375        self.source_handle = Some(source_handle.into());
376        self
377    }
378
379    /// Sets the target connector handle used by the authoring canvas.
380    pub fn with_target_handle(mut self, target_handle: impl Into<String>) -> Self {
381        self.target_handle = Some(target_handle.into());
382        self
383    }
384
385    /// Sets semantic edge data.
386    pub fn with_data(mut self, data: Value) -> Self {
387        self.data = data;
388        self
389    }
390
391    /// Returns the stable edge identity.
392    pub fn id(&self) -> &str {
393        &self.id
394    }
395
396    /// Returns the source node identity.
397    pub fn source(&self) -> &str {
398        &self.source
399    }
400
401    /// Returns the target node identity.
402    pub fn target(&self) -> &str {
403        &self.target
404    }
405
406    /// Returns the optional source connector handle.
407    pub fn source_handle(&self) -> Option<&str> {
408        self.source_handle.as_deref()
409    }
410
411    /// Returns the optional target connector handle.
412    pub fn target_handle(&self) -> Option<&str> {
413        self.target_handle.as_deref()
414    }
415
416    /// Returns semantic edge data.
417    pub fn data(&self) -> &Value {
418        &self.data
419    }
420
421    /// Returns presentation-only fields preserved during round trips.
422    pub fn presentation(&self) -> &BTreeMap<String, Value> {
423        &self.presentation
424    }
425}
426
427fn check_size(source: &str) -> Result<(), WorkflowDslError> {
428    let actual_bytes = source.len();
429    if actual_bytes > WORKFLOW_DSL_MAX_BYTES {
430        return Err(WorkflowDslError::DocumentTooLarge {
431            actual_bytes,
432            maximum_bytes: WORKFLOW_DSL_MAX_BYTES,
433        });
434    }
435    Ok(())
436}
437
438fn invalid_document(message: impl Into<String>) -> WorkflowDslError {
439    WorkflowDslError::InvalidDocument {
440        message: message.into(),
441    }
442}