use alloc::collections::{BTreeMap, BTreeSet};
use alloc::string::String;
use core::hash::BuildHasherDefault;
use fnv::FnvHasher;
use indexmap::IndexMap;
use serde_json::Value;
use crate::{ComponentId, FlowId, NodeId};
pub type FlowHasher = BuildHasherDefault<FnvHasher>;
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub enum FlowKind {
Messaging,
Event,
ComponentConfig,
Job,
Http,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "schemars",
derive(JsonSchema),
schemars(
title = "Greentic Flow v1",
description = "Canonical flow model with components, routing and telemetry.",
rename = "greentic.flow.v1"
)
)]
pub struct Flow {
pub schema_version: String,
pub id: FlowId,
pub kind: FlowKind,
#[cfg_attr(feature = "serde", serde(default))]
#[cfg_attr(
feature = "schemars",
schemars(with = "alloc::collections::BTreeMap<String, Value>")
)]
pub entrypoints: BTreeMap<String, Value>,
#[cfg_attr(feature = "serde", serde(default))]
#[cfg_attr(
feature = "schemars",
schemars(with = "alloc::collections::BTreeMap<NodeId, Node>")
)]
pub nodes: IndexMap<NodeId, Node, FlowHasher>,
#[cfg_attr(feature = "serde", serde(default))]
pub metadata: FlowMetadata,
}
impl Flow {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn ingress(&self) -> Option<(&NodeId, &Node)> {
self.nodes.iter().next()
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct Node {
pub id: NodeId,
pub component: ComponentRef,
#[cfg_attr(feature = "serde", serde(alias = "in_map"))]
pub input: InputMapping,
#[cfg_attr(feature = "serde", serde(alias = "out_map"))]
pub output: OutputMapping,
#[cfg_attr(
feature = "serde",
serde(
default,
skip_serializing_if = "Option::is_none",
rename = "err_map",
alias = "error_output"
)
)]
pub err_map: Option<OutputMapping>,
pub routing: Routing,
#[cfg_attr(feature = "serde", serde(default))]
pub telemetry: TelemetryHints,
}
impl Node {
pub fn in_map(&self) -> &InputMapping {
&self.input
}
pub fn out_map(&self) -> &OutputMapping {
&self.output
}
pub fn err_map(&self) -> Option<&OutputMapping> {
self.err_map.as_ref()
}
pub fn is_builtin(&self) -> bool {
let id = self.component.id.as_str();
let effective = if id.is_empty() || id == "component.exec" {
self.component.operation.as_deref().unwrap_or(id)
} else {
id
};
is_builtin_component_id(effective)
}
}
pub const BUILTIN_NODE_KINDS: &[&str] = &[
"session.wait",
"flow.call",
"provider.invoke",
"dw.agent",
"dw.agent_graph",
"sorla.call",
"operala.call",
"agentic.call",
];
pub fn is_builtin_component_id(id: &str) -> bool {
id.starts_with("emit.")
|| BUILTIN_NODE_KINDS.iter().any(|kind| {
id == *kind
|| id
.strip_prefix(kind)
.is_some_and(|rest| rest.starts_with('.'))
})
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct ComponentRef {
pub id: ComponentId,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub pack_alias: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub operation: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct InputMapping {
#[cfg_attr(feature = "serde", serde(default))]
pub mapping: Value,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct OutputMapping {
#[cfg_attr(feature = "serde", serde(default))]
pub mapping: Value,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct FlowMetadata {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub title: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub description: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub tags: BTreeSet<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub extra: Value,
}
impl Default for FlowMetadata {
fn default() -> Self {
Self {
title: None,
description: None,
tags: BTreeSet::new(),
extra: Value::Null,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub enum Routing {
Next {
node_id: NodeId,
},
Branch {
#[cfg_attr(feature = "serde", serde(default))]
#[cfg_attr(
feature = "schemars",
schemars(with = "alloc::collections::BTreeMap<String, NodeId>")
)]
on_status: BTreeMap<String, NodeId>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
default: Option<NodeId>,
},
End,
Reply,
Custom(Value),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct TelemetryHints {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub span_name: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub attributes: BTreeMap<String, String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub sampling: Option<String>,
}
#[cfg(test)]
mod builtin_tests {
use super::is_builtin_component_id;
#[test]
fn builtin_component_ids_cover_runner_node_kinds() {
for id in [
"session.wait",
"flow.call",
"provider.invoke",
"emit.response",
"dw.agent",
"dw.agent.support",
"dw.agent_graph",
"dw.agent_graph.triage",
"sorla.call",
"operala.call",
"agentic.call",
] {
assert!(is_builtin_component_id(id), "{id} should be builtin");
}
for id in [
"qa.process",
"templating.handlebars",
"dw.agentish",
"agentic",
] {
assert!(!is_builtin_component_id(id), "{id} must NOT be builtin");
}
}
}