Skip to main content

greentic_types/
flow.rs

1//! Unified flow model used by packs and runtimes.
2
3use 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
13/// Build hasher used for flow node maps (Fnv for `no_std` friendliness).
14pub type FlowHasher = BuildHasherDefault<FnvHasher>;
15
16#[cfg(feature = "schemars")]
17use schemars::JsonSchema;
18#[cfg(feature = "serde")]
19use serde::{Deserialize, Serialize};
20
21/// Supported flow kinds across Greentic packs.
22#[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    /// Inbound messaging flows (Telegram, Teams, HTTP chat).
28    Messaging,
29    /// Event-driven flows (webhooks, NATS, cron, etc.).
30    Event,
31    /// Flows that configure components/providers/infrastructure.
32    ComponentConfig,
33    /// Batch/background jobs.
34    Job,
35    /// HTTP-style request/response flows.
36    Http,
37}
38
39/// Canonical flow representation embedded in packs.
40#[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    /// Schema version for the flow document.
53    pub schema_version: String,
54    /// Flow identifier inside the pack.
55    pub id: FlowId,
56    /// Flow execution kind.
57    pub kind: FlowKind,
58    /// Entrypoints for this flow keyed by name (for example `default`, `telegram`, `http:/path`).
59    #[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    /// Ordered node map describing the flow graph.
66    #[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    /// Optional metadata for authoring/UX.
73    #[cfg_attr(feature = "serde", serde(default))]
74    pub metadata: FlowMetadata,
75}
76
77impl Flow {
78    /// Returns `true` when no nodes are defined.
79    pub fn is_empty(&self) -> bool {
80        self.nodes.is_empty()
81    }
82
83    /// Returns the implicit ingress node (first user-declared entry).
84    pub fn ingress(&self) -> Option<(&NodeId, &Node)> {
85        self.nodes.iter().next()
86    }
87}
88
89/// Flow node representation.
90#[derive(Clone, Debug, PartialEq)]
91#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
92#[cfg_attr(feature = "schemars", derive(JsonSchema))]
93pub struct Node {
94    /// Node identifier.
95    pub id: NodeId,
96    /// Component binding referenced by the node.
97    pub component: ComponentRef,
98    /// Component input mapping configuration.
99    #[cfg_attr(feature = "serde", serde(alias = "in_map"))]
100    pub input: InputMapping,
101    /// Component output mapping configuration.
102    #[cfg_attr(feature = "serde", serde(alias = "out_map"))]
103    pub output: OutputMapping,
104    /// Optional error mapping configuration.
105    #[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    /// Routing behaviour after this node.
116    pub routing: Routing,
117    /// Optional telemetry hints for this node.
118    #[cfg_attr(feature = "serde", serde(default))]
119    pub telemetry: TelemetryHints,
120}
121
122impl Node {
123    /// Returns the canonical input mapping surface.
124    pub fn in_map(&self) -> &InputMapping {
125        &self.input
126    }
127
128    /// Returns the canonical output mapping surface.
129    pub fn out_map(&self) -> &OutputMapping {
130        &self.output
131    }
132
133    /// Returns the canonical error mapping surface when configured.
134    pub fn err_map(&self) -> Option<&OutputMapping> {
135        self.err_map.as_ref()
136    }
137
138    /// Whether this node is a runner builtin (engine-handled, resolving to no
139    /// pack component): `dw.agent[.<id>]`, `dw.agent_graph[.<id>]`,
140    /// `sorla.call`, `operala.call`, `agentic.call`, `session.wait`,
141    /// `flow.call`, `provider.invoke`, `emit.*`.
142    ///
143    /// A `dw.agent.<id>` (and other dispatch) node compiles to the generic
144    /// `component.exec` placeholder with the real kind in `operation`, so the
145    /// operation is authoritative when the component id is empty/`component.exec`.
146    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
157/// Builtin/dispatch flow-node kinds handled by the runner engine itself, with
158/// no pack component to resolve. MUST mirror the runner's `NodeKind` dispatch.
159/// Agentic/dispatch kinds appear as `dw.agent.<id>` etc., so callers match both
160/// the bare kind and the dotted `<kind>.<suffix>` form (see [`Node::is_builtin`]).
161pub 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
172/// Whether a component-id string names a runner builtin (bare or dotted form;
173/// `emit.*` is always builtin).
174pub 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/// Component reference within a flow.
185#[derive(Clone, Debug, PartialEq)]
186#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
187#[cfg_attr(feature = "schemars", derive(JsonSchema))]
188pub struct ComponentRef {
189    /// Component identifier.
190    pub id: ComponentId,
191    /// Dependency pack alias when referencing external components.
192    #[cfg_attr(
193        feature = "serde",
194        serde(default, skip_serializing_if = "Option::is_none")
195    )]
196    pub pack_alias: Option<String>,
197    /// Optional operation name within the component.
198    #[cfg_attr(
199        feature = "serde",
200        serde(default, skip_serializing_if = "Option::is_none")
201    )]
202    pub operation: Option<String>,
203}
204
205/// Opaque component input mapping configuration.
206#[derive(Clone, Debug, PartialEq)]
207#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
208#[cfg_attr(feature = "schemars", derive(JsonSchema))]
209pub struct InputMapping {
210    /// Mapping configuration (templates, expressions, etc.).
211    #[cfg_attr(feature = "serde", serde(default))]
212    pub mapping: Value,
213}
214
215/// Opaque component output mapping configuration.
216#[derive(Clone, Debug, PartialEq)]
217#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
218#[cfg_attr(feature = "schemars", derive(JsonSchema))]
219pub struct OutputMapping {
220    /// Mapping configuration (templates, expressions, etc.).
221    #[cfg_attr(feature = "serde", serde(default))]
222    pub mapping: Value,
223}
224
225/// Optional authoring metadata for flows.
226#[derive(Clone, Debug, PartialEq)]
227#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
228#[cfg_attr(feature = "schemars", derive(JsonSchema))]
229pub struct FlowMetadata {
230    /// Optional human-friendly title.
231    #[cfg_attr(
232        feature = "serde",
233        serde(default, skip_serializing_if = "Option::is_none")
234    )]
235    pub title: Option<String>,
236    /// Optional human-friendly description.
237    #[cfg_attr(
238        feature = "serde",
239        serde(default, skip_serializing_if = "Option::is_none")
240    )]
241    pub description: Option<String>,
242    /// Optional tags.
243    #[cfg_attr(feature = "serde", serde(default))]
244    pub tags: BTreeSet<String>,
245    /// Free-form metadata.
246    #[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/// Routing behaviour for a node.
262#[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    /// Continue to the specified node.
268    Next {
269        /// Destination node identifier.
270        node_id: NodeId,
271    },
272    /// Branch based on status string -> node id.
273    Branch {
274        /// Mapping of status value to destination node.
275        #[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        /// Default node when no status matches.
282        #[cfg_attr(
283            feature = "serde",
284            serde(default, skip_serializing_if = "Option::is_none")
285        )]
286        default: Option<NodeId>,
287    },
288    /// Flow terminates successfully.
289    End,
290    /// Reply to origin (Messaging/Http flows).
291    Reply,
292    /// Component- or runtime-specific routing.
293    Custom(Value),
294}
295
296/// Optional telemetry hints for a node.
297#[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    /// Optional span name.
302    #[cfg_attr(
303        feature = "serde",
304        serde(default, skip_serializing_if = "Option::is_none")
305    )]
306    pub span_name: Option<String>,
307    /// Attributes to attach to spans/logs.
308    #[cfg_attr(feature = "serde", serde(default))]
309    pub attributes: BTreeMap<String, String>,
310    /// Sampling hint (`high`, `normal`, `low`).
311    #[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}