use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use iroh_blobs::Hash;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use specta::Type;
use super::registration::NodeKind;
use super::types::FlowNodeId;
const DEFAULT_SCRIPT_FUEL_LIMIT: u64 = 1_000_000;
const DEFAULT_SCRIPT_TIMEOUT_SECS: u64 = 30;
#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct ContentAddressedRoot {
pub digest: ContentHash,
}
#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Type)]
pub struct ContentHash(String);
impl<'de> Deserialize<'de> for ContentHash {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Self::try_from(value).map_err(serde::de::Error::custom)
}
}
impl TryFrom<String> for ContentHash {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Hash::from_str(&value)
.map(|_| Self(value))
.map_err(|_| "invalid content hash".to_owned())
}
}
impl ContentHash {
pub(super) fn as_str(&self) -> &str {
&self.0
}
}
impl Display for ContentHash {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct ContentAddressedPath {
pub root: ContentAddressedRoot,
pub path: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct NodeDataSpace {
pub code_paths: Vec<ContentAddressedPath>,
pub state_paths: Vec<ContentAddressedPath>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct RuntimeConstraints {
pub max_ticks: Option<u64>,
pub max_duration_ms: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct CapabilityRequest {
pub name: String,
pub required: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct NodeConstraints {
pub runtime: RuntimeConstraints,
pub capabilities: Vec<CapabilityRequest>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct BlackboardBinding {
pub name: String,
pub key: String,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct ScriptNodeConfig {
pub source_root: Option<ContentAddressedRoot>,
pub entry_point: String,
pub fuel_limit: u64,
pub timeout_secs: u64,
}
pub(crate) fn resolve_script_limits(fuel_limit: u64, timeout_secs: u64) -> (u64, u64) {
(
if fuel_limit == 0 {
DEFAULT_SCRIPT_FUEL_LIMIT
} else {
fuel_limit
},
if timeout_secs == 0 {
DEFAULT_SCRIPT_TIMEOUT_SECS
} else {
timeout_secs
},
)
}
impl ScriptNodeConfig {
pub(crate) fn resolved_limits(&self) -> (u64, u64) {
resolve_script_limits(self.fuel_limit, self.timeout_secs)
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields)]
pub struct OrderNodeConfig {
pub command: String,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Type)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct AgentNodeConfig {
pub provider_name: String,
pub agent_name: String,
pub prompt: String,
pub max_turns: Option<u64>,
pub max_tool_calls: Option<u64>,
#[serde(rename = "maxDuration")]
pub max_duration_secs: Option<u64>,
pub config_overrides: BTreeMap<String, String>,
pub sluice_mcp_enabled: bool,
pub workspace: Option<ContentAddressedRoot>,
}
#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Type)]
pub struct FlowNode {
pub id: FlowNodeId,
pub label: String,
pub constraints: NodeConstraints,
pub data_space: NodeDataSpace,
pub inputs: Vec<BlackboardBinding>,
pub outputs: Vec<BlackboardBinding>,
pub node_kind: NodeKind,
pub configuration: Value,
}
#[derive(Serialize)]
struct SerializedFlowNode<'a> {
id: FlowNodeId,
label: &'a str,
constraints: &'a NodeConstraints,
data_space: &'a NodeDataSpace,
inputs: &'a [BlackboardBinding],
outputs: &'a [BlackboardBinding],
node_kind: &'a NodeKind,
configuration: &'a Value,
}
impl Serialize for FlowNode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
SerializedFlowNode {
id: self.id,
label: &self.label,
constraints: &self.constraints,
data_space: &self.data_space,
inputs: &self.inputs,
outputs: &self.outputs,
node_kind: &self.node_kind,
configuration: &self.configuration,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FlowNode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Envelope {
id: FlowNodeId,
label: String,
constraints: NodeConstraints,
data_space: NodeDataSpace,
#[serde(default)]
inputs: Vec<BlackboardBinding>,
#[serde(default)]
outputs: Vec<BlackboardBinding>,
node_kind: NodeKind,
configuration: Value,
}
let envelope = Envelope::deserialize(deserializer)?;
Ok(Self {
id: envelope.id,
label: envelope.label,
constraints: envelope.constraints,
data_space: envelope.data_space,
inputs: envelope.inputs,
outputs: envelope.outputs,
node_kind: envelope.node_kind,
configuration: envelope.configuration,
})
}
}
impl FlowNode {
pub fn id(&self) -> FlowNodeId {
self.id
}
pub fn label(&self) -> &str {
&self.label
}
pub(super) fn bindings(&self) -> (&[BlackboardBinding], &[BlackboardBinding]) {
(&self.inputs, &self.outputs)
}
pub(super) fn constraints(&self) -> &NodeConstraints {
&self.constraints
}
pub(super) fn data_space(&self) -> &NodeDataSpace {
&self.data_space
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn script_limits_default_to_finite_values() {
assert_eq!(
resolve_script_limits(0, 0),
(DEFAULT_SCRIPT_FUEL_LIMIT, DEFAULT_SCRIPT_TIMEOUT_SECS)
);
assert_eq!(resolve_script_limits(17, 2), (17, 2));
}
#[test]
fn node_envelopes_roundtrip_for_builtins_and_unknown_kinds() {
let bindings = json!([{"name":"price","key":"market.price"}]);
let cases = [
(
"ScriptNode",
json!({"source_root":null,"entry_point":"main","fuel_limit":17,"timeout_secs":2}),
),
("OrderNode", json!({"command":"place"})),
(
"AgentNode",
json!({"providerName":"test","agentName":"analyst","prompt":"review","maxTurns":3,"maxToolCalls":4,"maxDuration":5,"configOverrides":{},"sluiceMcpEnabled":false,"workspace":null}),
),
("EchoNode", json!({"message":"hello"})),
];
for (kind, configuration) in cases {
let envelope = json!({
"id": "00000000-0000-4000-8000-000000000001",
"label": kind,
"constraints": {"runtime":{"max_ticks":null,"max_duration_ms":null},"capabilities":[]},
"data_space": {"code_paths":[],"state_paths":[]},
"inputs": bindings,
"outputs": bindings,
"node_kind": kind,
"configuration": configuration,
});
let node: FlowNode = serde_json::from_value(envelope.clone()).unwrap();
assert_eq!(node.node_kind.as_str(), kind);
assert_eq!(serde_json::to_value(node).unwrap(), envelope);
}
}
#[test]
fn legacy_builtin_leaf_shapes_are_rejected() {
for (kind, configuration) in [
(
"ScriptNode",
json!({"entry_point":"main","fuel_limit":17,"timeout_secs":2}),
),
("OrderNode", json!({"command":"place"})),
(
"AgentNode",
json!({"providerName":"test","agentName":"analyst","prompt":"review","configOverrides":{},"sluiceMcpEnabled":false}),
),
] {
let mut legacy = configuration.as_object().unwrap().clone();
legacy.insert("node_type".into(), json!(kind));
legacy.insert("id".into(), json!("00000000-0000-4000-8000-000000000001"));
legacy.insert("label".into(), json!(kind));
legacy.insert("constraints".into(), json!({"runtime":{},"capabilities":[]}));
legacy.insert("data_space".into(), json!({"code_paths":[],"state_paths":[]}));
legacy.insert("inputs".into(), json!([]));
legacy.insert("outputs".into(), json!([]));
assert!(serde_json::from_value::<FlowNode>(Value::Object(legacy)).is_err());
}
}
}