Skip to main content

app_kit/workflow/
graph.rs

1use serde_json::{Value, json};
2
3use crate::AppError;
4use crate::workflow::definition::{JsonBoundary, MAX_SOURCE_BYTES};
5use crate::workflow::{
6    GraphComposition, GraphDescription, GraphFormat, NoActions, NoConditions, WorkflowDefinition,
7};
8
9impl WorkflowDefinition {
10    /// Describes authored nodes, ordered edges and declared state access.
11    ///
12    /// This projection owns metadata only; it never retains executable bindings
13    /// or lower handles. Callable metadata is resolved when bindings are supplied.
14    ///
15    /// # Example
16    ///
17    /// ```
18    /// use app_kit::workflow::WorkflowDefinition;
19    ///
20    /// let definition = WorkflowDefinition::parse(r#"{
21    ///     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
22    ///     "state":{"count":0}, "required":["count"],
23    ///     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
24    ///     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
25    ///         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
26    ///     ]}}], "routes":{}
27    /// }"#)?;
28    /// let graph = definition.graph();
29    /// assert_eq!(graph.nodes[0].id, "increment");
30    /// assert_eq!(graph.nodes[0].writes, ["count"]);
31    /// # Ok::<(), app_kit::AppError>(())
32    /// ```
33    pub fn graph(&self) -> GraphDescription {
34        self.compiled.graph::<NoActions, NoConditions>(&[], &[])
35    }
36
37    /// Renders deterministic inspection JSON, DOT or Mermaid without a subprocess.
38    ///
39    /// Caller text is escaped for the chosen format. This is an inspection
40    /// document, distinct from [`GraphDescription::tracking_record`].
41    ///
42    /// # Errors
43    ///
44    /// Returns `InvalidInput` when the projection exceeds JSON or output-size limits,
45    /// or `Integration` if graph metadata cannot be encoded.
46    ///
47    /// # Example
48    ///
49    /// ```
50    /// use app_kit::workflow::WorkflowDefinition;
51    ///
52    /// let definition = WorkflowDefinition::parse(r#"{
53    ///     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
54    ///     "state":{"count":0}, "required":["count"],
55    ///     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
56    ///     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
57    ///         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
58    ///     ]}}], "routes":{}
59    /// }"#)?;
60    /// use app_kit::workflow::GraphFormat;
61    /// let mermaid = definition.export(GraphFormat::Mermaid)?;
62    /// assert!(mermaid.starts_with("flowchart TD"));
63    /// # Ok::<(), app_kit::AppError>(())
64    /// ```
65    pub fn export(&self, format: GraphFormat) -> Result<String, AppError> {
66        format.render(&self.graph())
67    }
68
69    /// Imports graph pieces with explicit node prefixes and appended connections.
70    ///
71    /// The ordinary compiler checks the assembled definition. Conflicting state,
72    /// reducers, reused revisions, node names, entrypoints and edges fail before
73    /// any runtime or lower operation is admitted. Behavior binding keys retain
74    /// their original meaning; only graph-local node references are prefixed.
75    ///
76    /// # Errors
77    ///
78    /// Returns `InvalidInput` for conflicting state/reducers, incompatible revisions,
79    /// colliding prefixed nodes, invalid connections/entrypoints, graph errors, or
80    /// exceeded compiler bounds. No runtime or lower operation is admitted.
81    ///
82    /// # Example
83    ///
84    /// ```
85    /// use app_kit::workflow::WorkflowDefinition;
86    /// const SOURCE: &str = r#"{
87    ///     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
88    ///     "state":{"count":0}, "required":["count"],
89    ///     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
90    ///     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
91    ///         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
92    ///     ]}}], "routes":{}
93    /// }"#;
94    /// let definition = WorkflowDefinition::parse(SOURCE)?;
95    /// use app_kit::workflow::{GraphComposition, GraphConnection, GraphImport};
96    /// let composition = GraphComposition {
97    ///     id: "composed".parse()?, revision: "r1".into(), entry: "a_increment".into(),
98    ///     imports: vec![
99    ///         GraphImport { definition, prefix: "a_".into() },
100    ///         GraphImport { definition: WorkflowDefinition::parse(SOURCE)?, prefix: "b_".into() },
101    ///     ],
102    ///     connections: vec![GraphConnection { from: "a_increment".into(), to: "b_increment".into() }],
103    /// };
104    /// let composed = WorkflowDefinition::compose(composition)?;
105    /// assert_eq!(composed.graph().nodes.len(), 2);
106    /// assert_eq!(composed.graph().entry, "a_increment");
107    /// # Ok::<(), app_kit::AppError>(())
108    /// ```
109    pub fn compose(composition: GraphComposition) -> Result<Self, AppError> {
110        Ok(Self {
111            compiled: crate::workflow::definition::CompiledDefinition::compose(composition)?,
112        })
113    }
114}
115
116impl GraphDescription {
117    /// Produces the pinned rurr application-graph tracking object.
118    ///
119    /// Every node must carry actual source metadata. Missing source is an error;
120    /// no placeholder code is invented. Explicit end edges do not create a
121    /// synthetic action or transition. The caller owns transport and JSONL framing.
122    ///
123    /// # Errors
124    ///
125    /// Returns `InvalidInput` if a node lacks actual source metadata or the tracking
126    /// record exceeds JSON bounds. Unbound caller actions may have no source metadata;
127    /// the method does not invent callback source.
128    ///
129    /// # Example
130    ///
131    /// ```
132    /// use app_kit::workflow::WorkflowDefinition;
133    ///
134    /// let definition = WorkflowDefinition::parse(r#"{
135    ///     "schema":"app-kit.workflow/v2", "id":"counter", "revision":"r1",
136    ///     "state":{"count":0}, "required":["count"],
137    ///     "reducers":{"count":"reject_concurrent"}, "entry":"increment",
138    ///     "nodes":[{"id":"increment","step":{"kind":"state","mutations":[
139    ///         {"kind":"increment","target":"count","value":{"kind":"literal","value":1}}
140    ///     ]}}], "routes":{}
141    /// }"#)?;
142    /// let record = definition.graph().tracking_record()?;
143    /// assert_eq!(record["type"], "application");
144    /// assert_eq!(record["entrypoint"], "increment");
145    /// # Ok::<(), app_kit::AppError>(())
146    /// ```
147    pub fn tracking_record(&self) -> Result<Value, AppError> {
148        let mut actions = Vec::with_capacity(self.nodes.len());
149        for node in &self.nodes {
150            let source = node.source.as_ref().ok_or_else(|| {
151                AppError::InvalidInput(format!("graph node '{}' has no source metadata", node.id))
152            })?;
153            actions.push(json!({
154                "type": "action", "name": node.id, "reads": node.reads,
155                "writes": node.writes, "code": source, "inputs": node.required_inputs,
156                "optional_inputs": node.optional_inputs,
157            }));
158        }
159        let transitions = self
160            .edges
161            .iter()
162            .filter_map(|edge| {
163                edge.to.as_ref().map(|target| {
164                    json!({
165                        "type": "transition", "from_": edge.from, "to": target,
166                        "condition": edge.condition.as_deref().unwrap_or("default"),
167                    })
168                })
169            })
170            .collect::<Vec<_>>();
171        let record = json!({
172            "type": "application", "entrypoint": self.entry,
173            "actions": actions, "transitions": transitions,
174        });
175        JsonBoundary::validate(&record)?;
176        Ok(record)
177    }
178}
179
180impl GraphFormat {
181    fn render(&self, graph: &GraphDescription) -> Result<String, AppError> {
182        let output = match self {
183            Self::Json => {
184                let value = serde_json::to_value(graph).map_err(|_| {
185                    AppError::Integration("graph metadata could not be encoded".to_owned())
186                })?;
187                JsonBoundary::validate(&value)?;
188                serde_json::to_string(&value).map_err(|_| {
189                    AppError::Integration("graph metadata could not be encoded".to_owned())
190                })?
191            }
192            Self::Dot => Self::dot(graph),
193            Self::Mermaid => Self::mermaid(graph),
194        };
195        if output.len() > MAX_SOURCE_BYTES {
196            return Err(AppError::InvalidInput(
197                "graph document exceeds the private byte bound".to_owned(),
198            ));
199        }
200        Ok(output)
201    }
202
203    fn dot(graph: &GraphDescription) -> String {
204        let mut output = format!("digraph \"{}\" {{\n", graph.id);
205        for node in &graph.nodes {
206            let label = Self::Dot.escape(node.name.as_deref().unwrap_or(&node.id));
207            output.push_str(&format!("  \"{}\" [label=\"{label}\"];\n", node.id));
208        }
209        for edge in &graph.edges {
210            let Some(target) = &edge.to else { continue };
211            output.push_str(&format!("  \"{}\" -> \"{target}\"", edge.from));
212            if let Some(condition) = &edge.condition {
213                let label = Self::Dot.escape(condition);
214                output.push_str(&format!(" [label=\"{label}\"]"));
215            }
216            output.push_str(";\n");
217        }
218        output.push_str("}\n");
219        output
220    }
221
222    fn mermaid(graph: &GraphDescription) -> String {
223        let mut output = "flowchart TD\n".to_owned();
224        for node in &graph.nodes {
225            let label = Self::Mermaid.escape(node.name.as_deref().unwrap_or(&node.id));
226            let id = Self::mermaid_id(&node.id);
227            output.push_str(&format!("  {id}[\"{label}\"]\n"));
228        }
229        for edge in &graph.edges {
230            let Some(target) = &edge.to else { continue };
231            let from = Self::mermaid_id(&edge.from);
232            let target = Self::mermaid_id(target);
233            match &edge.condition {
234                Some(condition) => {
235                    let label = Self::Mermaid.escape(condition);
236                    output.push_str(&format!("  {from} -->|\"{label}\"| {target}\n"));
237                }
238                None => output.push_str(&format!("  {from} --> {target}\n")),
239            }
240        }
241        output
242    }
243
244    fn mermaid_id(id: &str) -> String {
245        let mut output = String::with_capacity(id.len() + 2);
246        output.push_str("n_");
247        for character in id.chars() {
248            match character {
249                '-' => output.push_str("_2d"),
250                '_' => output.push_str("_5f"),
251                character => output.push(character),
252            }
253        }
254        output
255    }
256
257    fn escape(&self, text: &str) -> String {
258        let mut output = String::with_capacity(text.len());
259        for character in text.chars() {
260            match self {
261                Self::Dot | Self::Json => match character {
262                    '\\' => output.push_str("\\\\"),
263                    '"' => output.push_str("\\\""),
264                    '\n' => output.push_str("\\n"),
265                    '\r' => output.push_str("\\r"),
266                    '\t' => output.push_str("\\t"),
267                    value if value.is_control() => {
268                        output.push_str(&format!("\\u{:04x}", u32::from(value)))
269                    }
270                    value => output.push(value),
271                },
272                Self::Mermaid => match character {
273                    '"' | '&' | '<' | '>' | '[' | ']' | '#' | '|' | '\\' => {
274                        output.push_str(&format!("#{};", u32::from(character)));
275                    }
276                    value if value.is_control() => {
277                        output.push_str(&format!("#{};", u32::from(value)))
278                    }
279                    value => output.push(value),
280                },
281            }
282        }
283        output
284    }
285}