use std::fmt::{Display, Formatter};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "snake_case")]
pub enum NodeCapability {
Network,
Filesystem,
Database,
Credential,
Process,
}
impl NodeCapability {
pub const ALL: &'static [Self] = &[
Self::Network,
Self::Filesystem,
Self::Database,
Self::Credential,
Self::Process,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Filesystem => "filesystem",
Self::Database => "database",
Self::Credential => "credential",
Self::Process => "process",
}
}
}
impl Display for NodeCapability {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DslFlagKind {
#[default]
Scalar,
RepeatedList,
Bool,
KeyValuePairs,
SchemaField,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct DslFlag {
pub flag: String,
pub config_key: String,
pub description: String,
pub kind: DslFlagKind,
pub required: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub value: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_repeat: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub secret: bool,
}
pub const FLAG_VALUE_TYPES: &[&str] = &[
"text", "number", "duration", "size", "json", "expression", "ref", "file", "file:image",
"file:video", "file:audio", "file:pdf", "file:archive", "file:geojson", "file:csv",
"file:parquet", "file:json", "process",
];
pub fn engine_common_dsl_flags() -> Vec<DslFlag> {
vec![
DslFlag {
flag: "--title".to_string(),
config_key: "title".to_string(),
description: "Custom display title for this node instance. \
Overrides the default node kind title shown on the canvas."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
},
DslFlag {
flag: "--timeout".to_string(),
config_key: NODE_TIMEOUT_KEY.to_string(),
description: "How long this node may run, e.g. 30s, 2m or 1h (1s to 1h). \
Omitted: the project's node timeout."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
value: "duration".to_string(),
..Default::default()
},
DslFlag {
flag: "--preview".to_string(),
config_key: "preview.out".to_string(),
description: "Render this node's output under its box on the canvas: \
`<as>[:<path>]` where as is image|video|audio|pdf|json|text|table|html \
and path is a dot path into the payload. `off` removes it. \
Presentation only — never read by the engine."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
},
DslFlag {
flag: "--preview-in".to_string(),
config_key: "preview.in".to_string(),
description: "Render this node's input under its box on the canvas: \
`<as>[:<path>]`, same kinds and path rule as `--preview`. \
`off` removes it. Presentation only — never read by the engine."
.to_string(),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
},
]
}
pub const NODE_TIMEOUT_KEY: &str = "timeout";
pub const MIN_NODE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
pub const MAX_NODE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
pub const NODE_TIMEOUT_CONFIG_CODE: &str = "FW_NODE_TIMEOUT_CONFIG";
pub fn node_timeout(config: &serde_json::Value) -> Result<Option<std::time::Duration>, PipelineError> {
let refuse = |shown: &str| {
PipelineError::new(
NODE_TIMEOUT_CONFIG_CODE,
format!("--timeout '{shown}' must be a duration from 1s to 1h, such as 30s, 2m or 1h"),
)
};
let text = match config.get(NODE_TIMEOUT_KEY) {
None | Some(serde_json::Value::Null) => return Ok(None),
Some(serde_json::Value::String(text)) if text.trim().is_empty() => return Ok(None),
Some(serde_json::Value::String(text)) => text.trim().to_string(),
Some(other) => return Err(refuse(&other.to_string())),
};
let duration = crate::pipeline::nodes::shared::units::duration(&text, "--timeout", NODE_TIMEOUT_CONFIG_CODE)
.map_err(|_| refuse(&text))?;
if duration < MIN_NODE_TIMEOUT || duration > MAX_NODE_TIMEOUT {
return Err(refuse(&text));
}
Ok(Some(duration))
}
pub fn engine_common_fields() -> Vec<NodeFieldDef> {
vec![NodeFieldDef {
name: NODE_TIMEOUT_KEY.to_string(),
label: "Timeout".to_string(),
field_type: NodeFieldType::Text,
help: Some(
"How long this node may run, e.g. 30s, 2m or 1h (1s to 1h). Empty: the project's node timeout."
.to_string(),
),
placeholder: Some("30s".to_string()),
..Default::default()
}]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeFieldType {
Text,
Textarea,
CodeEditor,
Select,
Datalist,
MethodButtons,
CopyUrl,
Checkbox,
Section,
MultiCheckbox,
KeyValuePairs,
ClaimsPairs,
ParamsBuilder,
Number,
SecureRequestBindings,
MatchCases,
SourceBindings,
}
impl Default for NodeFieldType {
fn default() -> Self {
Self::Text
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeFieldDataSource {
CredentialsAll,
CredentialsPostgres,
CredentialsJwt,
TemplatesPages,
CredentialsBrowser,
CredentialsOpenAi,
CredentialsSecureRequest,
AiTools,
FunctionPipelines,
CredentialJwtRoles,
CredentialsHttpAuth,
CredentialsWebhookAuth,
CredentialsByKind(String),
}
impl serde::Serialize for NodeFieldDataSource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::CredentialsAll => serializer.serialize_str("credentials_all"),
Self::CredentialsPostgres => serializer.serialize_str("credentials_postgres"),
Self::CredentialsJwt => serializer.serialize_str("credentials_jwt"),
Self::TemplatesPages => serializer.serialize_str("templates_pages"),
Self::CredentialsBrowser => serializer.serialize_str("credentials_browser"),
Self::CredentialsOpenAi => serializer.serialize_str("credentials_open_ai"),
Self::CredentialsSecureRequest => {
serializer.serialize_str("credentials_secure_request")
}
Self::AiTools => serializer.serialize_str("ai_tools"),
Self::FunctionPipelines => serializer.serialize_str("function_pipelines"),
Self::CredentialJwtRoles => serializer.serialize_str("credential_jwt_roles"),
Self::CredentialsHttpAuth => serializer.serialize_str("credentials_http_auth"),
Self::CredentialsWebhookAuth => serializer.serialize_str("credentials_webhook_auth"),
Self::CredentialsByKind(kind) => {
serializer.serialize_str(&format!("credentials:{}", kind))
}
}
}
}
impl<'de> serde::Deserialize<'de> for NodeFieldDataSource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.as_str() {
"credentials_all" => Ok(Self::CredentialsAll),
"credentials_postgres" => Ok(Self::CredentialsPostgres),
"credentials_jwt" => Ok(Self::CredentialsJwt),
"templates_pages" => Ok(Self::TemplatesPages),
"credentials_browser" => Ok(Self::CredentialsBrowser),
"credentials_open_ai" => Ok(Self::CredentialsOpenAi),
"credentials_secure_request" => Ok(Self::CredentialsSecureRequest),
"ai_tools" => Ok(Self::AiTools),
"function_pipelines" => Ok(Self::FunctionPipelines),
"credential_jwt_roles" => Ok(Self::CredentialJwtRoles),
"credentials_http_auth" => Ok(Self::CredentialsHttpAuth),
"credentials_webhook_auth" => Ok(Self::CredentialsWebhookAuth),
other => {
if let Some(kind) = other.strip_prefix("credentials:") {
Ok(Self::CredentialsByKind(kind.to_string()))
} else {
Err(serde::de::Error::unknown_variant(
other,
&[
"credentials_all",
"credentials_postgres",
"credentials_jwt",
"templates_pages",
"credentials_browser",
"credentials_open_ai",
"credentials_secure_request",
"ai_tools",
"function_pipelines",
"credential_jwt_roles",
"credentials_http_auth",
"credentials_webhook_auth",
"credentials:<kind>",
],
))
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SelectOptionDef {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SidebarItem {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub type_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SidebarSection {
pub title: String,
pub items: Vec<SidebarItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct NodeFieldDef {
pub name: String,
pub label: String,
#[serde(rename = "type", default)]
pub field_type: NodeFieldType,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(default)]
pub readonly: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rows: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<SelectOptionDef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_source: Option<NodeFieldDataSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sidebar: Vec<SidebarSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum LayoutItem {
Field(String),
Row { row: Vec<LayoutItem> },
Col { col: Vec<LayoutItem> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineGraph {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<PipelineGraphMetadata>,
#[serde(default)]
pub entry_nodes: Vec<String>,
pub nodes: Vec<PipelineNode>,
pub edges: Vec<PipelineEdge>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<PipelineNote>,
}
impl PipelineGraph {
pub fn entry_node_ids(&self) -> Vec<&str> {
if !self.entry_nodes.is_empty() {
return self.entry_nodes.iter().map(String::as_str).collect();
}
let targets: std::collections::HashSet<&str> = self
.edges
.iter()
.map(|edge| edge.to_node.as_str())
.collect();
let roots: Vec<&str> = self
.nodes
.iter()
.map(|node| node.id.as_str())
.filter(|id| !targets.contains(id))
.collect();
if roots.is_empty() {
return self
.nodes
.first()
.map(|node| vec![node.id.as_str()])
.unwrap_or_default();
}
roots
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PipelineGraphMetadata {
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub settings: PipelineGraphSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PipelineGraphSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invocation_retention: Option<PipelineInvocationRetention>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_capture: Option<crate::pipeline::trace_capture::TraceCaptureSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct PipelineInvocationRetention {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_invocations: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_age_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineNode {
pub id: String,
pub kind: String,
#[serde(default)]
pub input_pins: Vec<String>,
#[serde(default)]
pub output_pins: Vec<String>,
#[serde(default)]
pub config: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PipelineNote {
pub id: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub x: f64,
#[serde(default)]
pub y: f64,
#[serde(default)]
pub width: f64,
#[serde(default)]
pub height: f64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PipelineEdge {
pub from_node: String,
pub from_pin: String,
pub to_node: String,
pub to_pin: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct NodeScriptBridge {
pub name: String,
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct NodeAiToolDefinition {
#[serde(default)]
pub registered: bool,
#[serde(default)]
pub tool_name: String,
#[serde(default)]
pub tool_description: String,
#[serde(default)]
pub tool_input_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct NodeDefinition {
pub kind: String,
pub title: String,
pub description: String,
#[serde(default)]
pub config_schema: Value,
#[serde(default)]
pub input_schema: Value,
#[serde(default)]
pub output_schema: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<NodeExample>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failure_semantics: Vec<NodeFailureSemantic>,
#[serde(default)]
pub input_pins: Vec<String>,
#[serde(default)]
pub output_pins: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<NodeCapability>,
#[serde(default)]
pub script_available: bool,
#[serde(default)]
pub script_bridge: Option<NodeScriptBridge>,
#[serde(default)]
pub ai_tool: NodeAiToolDefinition,
#[serde(default)]
pub dsl_flags: Vec<DslFlag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<NodeFieldDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layout: Vec<LayoutItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret_paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub positional: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ui_category: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ui_category_label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub profiles: Vec<ProviderProfile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ProviderProfile {
pub provider: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_kinds: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub roles: std::collections::BTreeMap<String, ProfileRole>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub choices: std::collections::BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<ProfileOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ProfileRole {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_repeat: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ProfileOption {
pub key: String,
pub value: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct NodeExample {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub dsl: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub config: Value,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub input: Value,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub output: Value,
}
impl NodeExample {
pub fn dsl(title: &str, dsl: &str) -> Self {
Self { title: title.to_string(), dsl: dsl.to_string(), ..Default::default() }
}
pub fn output(mut self, output: Value) -> Self {
self.output = output;
self
}
pub fn input(mut self, input: Value) -> Self {
self.input = input;
self
}
pub fn note(mut self, description: &str) -> Self {
self.description = description.to_string();
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct NodeFailureSemantic {
pub code: String,
pub description: String,
#[serde(default)]
pub retryable: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub retry_hint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeScriptUsageContract {
pub available: bool,
#[serde(default)]
pub bridge_name: String,
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeToolUsageContract {
pub registered: bool,
#[serde(default)]
pub tool_name: String,
#[serde(default)]
pub tool_description: String,
#[serde(default)]
pub tool_input_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeUsageMatrix {
pub pipeline_node: bool,
pub script_usage: NodeScriptUsageContract,
pub tool_usage: NodeToolUsageContract,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeContractItem {
pub kind: String,
pub title: String,
pub description: String,
#[serde(default)]
pub config_schema: Value,
#[serde(default)]
pub input_schema: Value,
#[serde(default)]
pub output_schema: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<NodeExample>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failure_semantics: Vec<NodeFailureSemantic>,
#[serde(default)]
pub input_pins: Vec<String>,
#[serde(default)]
pub output_pins: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<NodeCapability>,
pub usage_matrix: NodeUsageMatrix,
#[serde(default)]
pub dsl_flags: Vec<DslFlag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<NodeFieldDef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layout: Vec<LayoutItem>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ui_category: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ui_category_label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub icon_url: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub icon_hash: String,
#[serde(default = "default_node_source")]
pub source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tier: String,
#[serde(default = "default_node_available")]
pub available: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_requirements: Vec<NodeCredentialRequirement>,
}
fn default_node_available() -> bool {
true
}
fn default_node_source() -> String {
"native".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeCredentialRequirement {
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub config_key: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub placeholder_names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NodeContractDocument {
pub ok: bool,
pub schema_version: &'static str,
pub source: &'static str,
#[serde(default)]
pub items: Vec<NodeContractItem>,
}
impl From<NodeDefinition> for NodeContractItem {
fn from(value: NodeDefinition) -> Self {
let (bridge_name, bridge_enabled) = value
.script_bridge
.as_ref()
.map(|bridge| (bridge.name.clone(), bridge.enabled))
.unwrap_or_else(|| (String::new(), false));
let source = default_node_source();
let ui_category = value.ui_category;
let ui_category_label = value.ui_category_label;
Self {
available: true,
kind: value.kind,
title: value.title,
description: value.description,
config_schema: value.config_schema,
input_schema: value.input_schema,
output_schema: value.output_schema,
examples: value.examples,
failure_semantics: value.failure_semantics,
input_pins: value.input_pins,
output_pins: value.output_pins,
capabilities: value.capabilities,
dsl_flags: value.dsl_flags,
fields: value.fields,
layout: value.layout,
usage_matrix: NodeUsageMatrix {
pipeline_node: true,
script_usage: NodeScriptUsageContract {
available: value.script_available,
bridge_name,
enabled: bridge_enabled,
},
tool_usage: NodeToolUsageContract {
registered: value.ai_tool.registered,
tool_name: value.ai_tool.tool_name,
tool_description: value.ai_tool.tool_description,
tool_input_schema: value.ai_tool.tool_input_schema,
},
},
ui_category,
ui_category_label,
icon_url: String::new(),
icon_hash: String::new(),
source,
tier: String::new(),
credential_requirements: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signal {
pub kind: String,
pub message: String,
#[serde(default)]
pub node_id: String,
#[serde(default)]
pub node_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub at: String,
}
pub type StepEvent = Signal;
#[derive(Debug, Clone)]
pub struct ExecutionBus {
tx: tokio::sync::broadcast::Sender<Signal>,
}
impl ExecutionBus {
pub fn new(capacity: usize) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(capacity);
Self { tx }
}
pub fn emit(&self, signal: Signal) {
let _ = self.tx.send(signal);
}
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Signal> {
self.tx.subscribe()
}
}
#[derive(Debug, Clone, Default)]
pub struct ExecuteOptions {
pub bus: Option<std::sync::Arc<ExecutionBus>>,
pub responder: Option<Responder>,
}
#[derive(Clone, Default)]
pub struct Responder(
std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<Value>>>>,
);
impl Responder {
pub fn channel() -> (Self, tokio::sync::oneshot::Receiver<Value>) {
let (tx, rx) = tokio::sync::oneshot::channel();
(Self(std::sync::Arc::new(std::sync::Mutex::new(Some(tx)))), rx)
}
pub fn send(&self, envelope: Value) -> bool {
let sender = self.0.lock().unwrap_or_else(|e| e.into_inner()).take();
sender.is_some_and(|tx| tx.send(envelope).is_ok())
}
}
impl std::fmt::Debug for Responder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Responder")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineContext {
pub owner: String,
pub project: String,
pub pipeline: String,
pub request_id: String,
#[serde(default)]
pub route: String,
pub input: Value,
#[serde(default)]
pub trigger: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeTraceEntry {
pub node_id: String,
pub node_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<Value>,
pub duration_ms: u64,
pub input: Value,
pub output: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default = "default_trace_status")]
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview_snapshot: Option<Value>,
}
fn default_trace_status() -> String {
"ok".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineOutput {
pub value: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<Value>,
pub trace: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub node_trace: Vec<NodeTraceEntry>,
}
impl PipelineOutput {
pub fn function_result(&self) -> Value {
let last_key = self
.node_trace
.iter()
.rev()
.find(|entry| entry.status == "ok")
.and_then(|entry| crate::pipeline::nodes::answer_key(&entry.node_kind));
if let Some(value) = last_key.and_then(|key| self.value.get(&key).cloned()) {
return value;
}
let mut rest = self.value.clone();
if let Some(map) = rest.as_object_mut() {
map.remove("function");
}
rest
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineError {
pub code: &'static str,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_kind: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub node_trace: Vec<NodeTraceEntry>,
}
impl From<crate::zebfs::ZebFsError> for PipelineError {
fn from(err: crate::zebfs::ZebFsError) -> Self {
Self::new(err.code, err.message)
}
}
impl PipelineError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
node_id: None,
node_kind: None,
node_trace: vec![],
}
}
pub fn node(node_id: &str, node_kind: &str, code: &'static str, msg: &str) -> Self {
Self {
code,
message: msg.to_string(),
node_id: Some(node_id.to_string()),
node_kind: Some(node_kind.to_string()),
node_trace: vec![],
}
}
}
impl Display for PipelineError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for PipelineError {}
#[cfg(test)]
mod node_timeout_tests {
use std::time::Duration;
use serde_json::json;
use super::{NODE_TIMEOUT_CONFIG_CODE, engine_common_dsl_flags, node_timeout};
#[test]
fn the_engine_timeout_is_a_duration_from_one_second_to_one_hour() {
assert_eq!(node_timeout(&json!({})).unwrap(), None);
assert_eq!(node_timeout(&json!({ "timeout": "" })).unwrap(), None);
assert_eq!(node_timeout(&json!({ "timeout": "30s" })).unwrap(), Some(Duration::from_secs(30)));
assert_eq!(node_timeout(&json!({ "timeout": "1h" })).unwrap(), Some(Duration::from_secs(3600)));
for bad in [json!("30"), json!(30), json!("500ms"), json!("2h"), json!("soon")] {
let err = node_timeout(&json!({ "timeout": bad })).expect_err("refused");
assert_eq!(err.code, NODE_TIMEOUT_CONFIG_CODE, "{bad}");
}
let flag = engine_common_dsl_flags().into_iter().find(|f| f.flag == "--timeout").expect("--timeout");
assert_eq!((flag.config_key.as_str(), flag.value.as_str()), ("timeout", "duration"));
}
}
#[cfg(test)]
mod function_result_tests {
use serde_json::{Value, json};
use super::PipelineOutput;
fn output(value: Value, trace: &[(&str, &str)]) -> PipelineOutput {
let node_trace = trace
.iter()
.map(|(kind, status)| {
serde_json::from_value(json!({
"node_id": "n", "node_kind": kind, "duration_ms": 0,
"input": null, "output": null, "status": status
}))
.expect("trace entry")
})
.collect();
PipelineOutput { value, response: None, trace: Vec::new(), node_trace }
}
#[test]
fn a_functions_result_is_its_last_answer() {
let value = json!({ "function": { "a": 1 }, "response": { "status": 200 }, "script": { "ok": true } });
let ran = [("trigger.function", "ok"), ("http.response.fetch", "ok"), ("javascript.script.run", "ok")];
assert_eq!(output(value.clone(), &ran).function_result(), json!({ "ok": true }));
let skipped_last = [("trigger.function", "ok"), ("http.response.fetch", "ok"), ("javascript.script.run", "empty")];
assert_eq!(output(value.clone(), &skipped_last).function_result(), json!({ "status": 200 }));
let control_last = [("trigger.function", "ok"), ("javascript.script.run", "ok"), ("logic.if", "ok")];
assert_eq!(
output(value, &control_last).function_result(),
json!({ "response": { "status": 200 }, "script": { "ok": true } })
);
}
}