1use alloc::collections::{BTreeMap, BTreeSet};
4use alloc::string::String;
5use core::hash::BuildHasherDefault;
6
7use fnv::FnvHasher;
8use indexmap::IndexMap;
9use serde_json::Value;
10
11use crate::{ComponentId, FlowId, NodeId};
12
13pub type FlowHasher = BuildHasherDefault<FnvHasher>;
15
16#[cfg(feature = "schemars")]
17use schemars::JsonSchema;
18#[cfg(feature = "serde")]
19use serde::{Deserialize, Serialize};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
25#[cfg_attr(feature = "schemars", derive(JsonSchema))]
26pub enum FlowKind {
27 Messaging,
29 Event,
31 ComponentConfig,
33 Job,
35 Http,
37}
38
39#[derive(Clone, Debug, PartialEq)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(
43 feature = "schemars",
44 derive(JsonSchema),
45 schemars(
46 title = "Greentic Flow v1",
47 description = "Canonical flow model with components, routing and telemetry.",
48 rename = "greentic.flow.v1"
49 )
50)]
51pub struct Flow {
52 pub schema_version: String,
54 pub id: FlowId,
56 pub kind: FlowKind,
58 #[cfg_attr(feature = "serde", serde(default))]
60 #[cfg_attr(
61 feature = "schemars",
62 schemars(with = "alloc::collections::BTreeMap<String, Value>")
63 )]
64 pub entrypoints: BTreeMap<String, Value>,
65 #[cfg_attr(feature = "serde", serde(default))]
67 #[cfg_attr(
68 feature = "schemars",
69 schemars(with = "alloc::collections::BTreeMap<NodeId, Node>")
70 )]
71 pub nodes: IndexMap<NodeId, Node, FlowHasher>,
72 #[cfg_attr(feature = "serde", serde(default))]
74 pub metadata: FlowMetadata,
75}
76
77impl Flow {
78 pub fn is_empty(&self) -> bool {
80 self.nodes.is_empty()
81 }
82
83 pub fn ingress(&self) -> Option<(&NodeId, &Node)> {
85 self.nodes.iter().next()
86 }
87}
88
89#[derive(Clone, Debug, PartialEq)]
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92#[cfg_attr(feature = "schemars", derive(JsonSchema))]
93pub struct Node {
94 pub id: NodeId,
96 pub component: ComponentRef,
98 #[cfg_attr(feature = "serde", serde(alias = "in_map"))]
100 pub input: InputMapping,
101 #[cfg_attr(feature = "serde", serde(alias = "out_map"))]
103 pub output: OutputMapping,
104 #[cfg_attr(
106 feature = "serde",
107 serde(
108 default,
109 skip_serializing_if = "Option::is_none",
110 rename = "err_map",
111 alias = "error_output"
112 )
113 )]
114 pub err_map: Option<OutputMapping>,
115 pub routing: Routing,
117 #[cfg_attr(feature = "serde", serde(default))]
119 pub telemetry: TelemetryHints,
120}
121
122impl Node {
123 pub fn in_map(&self) -> &InputMapping {
125 &self.input
126 }
127
128 pub fn out_map(&self) -> &OutputMapping {
130 &self.output
131 }
132
133 pub fn err_map(&self) -> Option<&OutputMapping> {
135 self.err_map.as_ref()
136 }
137
138 pub fn is_builtin(&self) -> bool {
147 let id = self.component.id.as_str();
148 let effective = if id.is_empty() || id == "component.exec" {
149 self.component.operation.as_deref().unwrap_or(id)
150 } else {
151 id
152 };
153 is_builtin_component_id(effective)
154 }
155}
156
157pub const BUILTIN_NODE_KINDS: &[&str] = &[
162 "session.wait",
163 "flow.call",
164 "provider.invoke",
165 "dw.agent",
166 "dw.agent_graph",
167 "sorla.call",
168 "operala.call",
169 "agentic.call",
170];
171
172pub fn is_builtin_component_id(id: &str) -> bool {
175 id.starts_with("emit.")
176 || BUILTIN_NODE_KINDS.iter().any(|kind| {
177 id == *kind
178 || id
179 .strip_prefix(kind)
180 .is_some_and(|rest| rest.starts_with('.'))
181 })
182}
183
184#[derive(Clone, Debug, PartialEq)]
186#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
187#[cfg_attr(feature = "schemars", derive(JsonSchema))]
188pub struct ComponentRef {
189 pub id: ComponentId,
191 #[cfg_attr(
193 feature = "serde",
194 serde(default, skip_serializing_if = "Option::is_none")
195 )]
196 pub pack_alias: Option<String>,
197 #[cfg_attr(
199 feature = "serde",
200 serde(default, skip_serializing_if = "Option::is_none")
201 )]
202 pub operation: Option<String>,
203}
204
205#[derive(Clone, Debug, PartialEq)]
207#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
208#[cfg_attr(feature = "schemars", derive(JsonSchema))]
209pub struct InputMapping {
210 #[cfg_attr(feature = "serde", serde(default))]
212 pub mapping: Value,
213}
214
215#[derive(Clone, Debug, PartialEq)]
217#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
218#[cfg_attr(feature = "schemars", derive(JsonSchema))]
219pub struct OutputMapping {
220 #[cfg_attr(feature = "serde", serde(default))]
222 pub mapping: Value,
223}
224
225#[derive(Clone, Debug, PartialEq)]
227#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
228#[cfg_attr(feature = "schemars", derive(JsonSchema))]
229pub struct FlowMetadata {
230 #[cfg_attr(
232 feature = "serde",
233 serde(default, skip_serializing_if = "Option::is_none")
234 )]
235 pub title: Option<String>,
236 #[cfg_attr(
238 feature = "serde",
239 serde(default, skip_serializing_if = "Option::is_none")
240 )]
241 pub description: Option<String>,
242 #[cfg_attr(feature = "serde", serde(default))]
244 pub tags: BTreeSet<String>,
245 #[cfg_attr(feature = "serde", serde(default))]
247 pub extra: Value,
248}
249
250impl Default for FlowMetadata {
251 fn default() -> Self {
252 Self {
253 title: None,
254 description: None,
255 tags: BTreeSet::new(),
256 extra: Value::Null,
257 }
258 }
259}
260
261#[derive(Clone, Debug, PartialEq)]
263#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
264#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
265#[cfg_attr(feature = "schemars", derive(JsonSchema))]
266pub enum Routing {
267 Next {
269 node_id: NodeId,
271 },
272 Branch {
274 #[cfg_attr(feature = "serde", serde(default))]
276 #[cfg_attr(
277 feature = "schemars",
278 schemars(with = "alloc::collections::BTreeMap<String, NodeId>")
279 )]
280 on_status: BTreeMap<String, NodeId>,
281 #[cfg_attr(
283 feature = "serde",
284 serde(default, skip_serializing_if = "Option::is_none")
285 )]
286 default: Option<NodeId>,
287 },
288 End,
290 Reply,
292 Custom(Value),
294}
295
296#[derive(Clone, Debug, Default, PartialEq, Eq)]
298#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
299#[cfg_attr(feature = "schemars", derive(JsonSchema))]
300pub struct TelemetryHints {
301 #[cfg_attr(
303 feature = "serde",
304 serde(default, skip_serializing_if = "Option::is_none")
305 )]
306 pub span_name: Option<String>,
307 #[cfg_attr(feature = "serde", serde(default))]
309 pub attributes: BTreeMap<String, String>,
310 #[cfg_attr(
312 feature = "serde",
313 serde(default, skip_serializing_if = "Option::is_none")
314 )]
315 pub sampling: Option<String>,
316}
317
318#[cfg(test)]
319mod builtin_tests {
320 use super::is_builtin_component_id;
321
322 #[test]
323 fn builtin_component_ids_cover_runner_node_kinds() {
324 for id in [
325 "session.wait",
326 "flow.call",
327 "provider.invoke",
328 "emit.response",
329 "dw.agent",
330 "dw.agent.support",
331 "dw.agent_graph",
332 "dw.agent_graph.triage",
333 "sorla.call",
334 "operala.call",
335 "agentic.call",
336 ] {
337 assert!(is_builtin_component_id(id), "{id} should be builtin");
338 }
339 for id in [
340 "qa.process",
341 "templating.handlebars",
342 "dw.agentish",
343 "agentic",
344 ] {
345 assert!(!is_builtin_component_id(id), "{id} must NOT be builtin");
346 }
347 }
348}