xml-pipeline/bloxserver/domain/edges.py
dullfig 9ba77b843d Add Flow domain model for BloxServer
Domain model bridges frontend canvas, database, and xml-pipeline:

- nodes.py: AgentNode, ToolNode, GatewayNode with serialization
  - Built-in tool mappings (calculate, fetch, shell, etc.)
  - Agent config (prompt, model, temperature)
  - Gateway config (federation + REST API)

- edges.py: Edge connections with conditions
  - Auto-compute peers from edges
  - Cycle detection, entry/exit node finding

- triggers.py: Webhook, Schedule, Manual, Event triggers
  - Config dataclasses for each type
  - Factory functions for common patterns

- flow.py: Main Flow class aggregating all components
  - to_organism_yaml(): Generate xml-pipeline config
  - from_canvas_json() / to_canvas_json(): React Flow compat
  - validate(): Check for errors before execution
  - to_db_dict(): Database serialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:15:51 -08:00

220 lines
6.4 KiB
Python

"""
Edge model for connections between nodes.
Edges define how messages flow between nodes in a flow.
They can optionally have conditions for conditional routing.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
class EdgeCondition(str, Enum):
"""Types of edge conditions."""
ALWAYS = "always" # Always route (default)
ON_SUCCESS = "on_success" # Route only if source succeeds
ON_ERROR = "on_error" # Route only if source fails
CONDITIONAL = "conditional" # Custom condition expression
@dataclass
class Edge:
"""
Connection between two nodes.
Edges define the flow of messages between nodes. In organism.yaml terms,
they define the `peers` list for agents and the routing paths.
"""
id: UUID
source_node_id: UUID
target_node_id: UUID
# Optional label for UI
label: str | None = None
# Condition for when this edge is followed
condition: EdgeCondition = EdgeCondition.ALWAYS
# Custom condition expression (when condition == CONDITIONAL)
# Example: "payload.status == 'approved'"
condition_expression: str | None = None
# Visual properties for canvas
source_handle: str | None = None # Which output port
target_handle: str | None = None # Which input port
animated: bool = False
edge_type: str = "default" # default, smoothstep, step, straight
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization (React Flow format)."""
return {
"id": str(self.id),
"source": str(self.source_node_id),
"target": str(self.target_node_id),
"label": self.label,
"data": {
"condition": self.condition.value,
"conditionExpression": self.condition_expression,
},
"sourceHandle": self.source_handle,
"targetHandle": self.target_handle,
"animated": self.animated,
"type": self.edge_type,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Edge:
"""Create edge from dictionary (React Flow format)."""
edge_data = data.get("data", {})
# Handle condition
condition_str = edge_data.get("condition", "always")
try:
condition = EdgeCondition(condition_str)
except ValueError:
condition = EdgeCondition.ALWAYS
return cls(
id=UUID(data["id"]) if isinstance(data.get("id"), str) else data.get("id", uuid4()),
source_node_id=UUID(data["source"]) if isinstance(data.get("source"), str) else data["source"],
target_node_id=UUID(data["target"]) if isinstance(data.get("target"), str) else data["target"],
label=data.get("label"),
condition=condition,
condition_expression=edge_data.get("conditionExpression") or edge_data.get("condition_expression"),
source_handle=data.get("sourceHandle") or data.get("source_handle"),
target_handle=data.get("targetHandle") or data.get("target_handle"),
animated=data.get("animated", False),
edge_type=data.get("type", "default"),
)
def compute_peers(
node_id: UUID,
edges: list[Edge],
node_map: dict[UUID, str],
) -> list[str]:
"""
Compute the peers list for a node based on outgoing edges.
Args:
node_id: The node to compute peers for.
edges: All edges in the flow.
node_map: Mapping of node IDs to node names.
Returns:
List of peer names (target nodes this node can send to).
"""
peers: list[str] = []
for edge in edges:
if edge.source_node_id == node_id:
target_name = node_map.get(edge.target_node_id)
if target_name and target_name not in peers:
peers.append(target_name)
return peers
def compute_incoming(
node_id: UUID,
edges: list[Edge],
node_map: dict[UUID, str],
) -> list[str]:
"""
Compute the list of nodes that can send to this node.
Args:
node_id: The target node.
edges: All edges in the flow.
node_map: Mapping of node IDs to node names.
Returns:
List of source node names.
"""
incoming: list[str] = []
for edge in edges:
if edge.target_node_id == node_id:
source_name = node_map.get(edge.source_node_id)
if source_name and source_name not in incoming:
incoming.append(source_name)
return incoming
def find_entry_nodes(
nodes: list[UUID],
edges: list[Edge],
) -> list[UUID]:
"""
Find nodes with no incoming edges (entry points).
These are typically where external triggers connect.
"""
nodes_with_incoming = {edge.target_node_id for edge in edges}
return [node_id for node_id in nodes if node_id not in nodes_with_incoming]
def find_exit_nodes(
nodes: list[UUID],
edges: list[Edge],
) -> list[UUID]:
"""
Find nodes with no outgoing edges (exit points).
These are typically terminal handlers or response nodes.
"""
nodes_with_outgoing = {edge.source_node_id for edge in edges}
return [node_id for node_id in nodes if node_id not in nodes_with_outgoing]
def detect_cycles(
nodes: list[UUID],
edges: list[Edge],
) -> list[list[UUID]]:
"""
Detect cycles in the flow graph.
Returns a list of cycles (each cycle is a list of node IDs).
Cycles are allowed (agents can self-loop) but should be flagged for review.
"""
cycles: list[list[UUID]] = []
# Build adjacency list
adj: dict[UUID, list[UUID]] = {node_id: [] for node_id in nodes}
for edge in edges:
if edge.source_node_id in adj:
adj[edge.source_node_id].append(edge.target_node_id)
# DFS for cycle detection
visited: set[UUID] = set()
rec_stack: set[UUID] = set()
path: list[UUID] = []
def dfs(node: UUID) -> None:
visited.add(node)
rec_stack.add(node)
path.append(node)
for neighbor in adj.get(node, []):
if neighbor not in visited:
dfs(neighbor)
elif neighbor in rec_stack:
# Found a cycle
cycle_start = path.index(neighbor)
cycles.append(path[cycle_start:] + [neighbor])
path.pop()
rec_stack.remove(node)
for node_id in nodes:
if node_id not in visited:
dfs(node_id)
return cycles