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}