feat: add workflow graph constraints fixtures

This commit is contained in:
Abhishek Kumar 2026-05-08 16:02:51 +05:30
parent 6d93be3ef6
commit 5a358d4d29
38 changed files with 447 additions and 49 deletions

View file

@ -3,6 +3,7 @@ transcript after completion."""
from api.services.workflow.node_specs._base import (
DisplayOptions,
GraphConstraints,
NodeCategory,
NodeExample,
NodeSpec,
@ -193,4 +194,10 @@ SPEC = NodeSpec(
},
),
],
# QA runs post-call against the saved transcript (run_integrations
# scans by type), never as a graph step. Reject any edge into or out
# of a QA node.
graph_constraints=GraphConstraints(
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0
),
)

View file

@ -240,9 +240,11 @@ SPEC = NodeSpec(
},
),
],
# `min_outgoing` is intentionally unset: a startCall is allowed to
# sit on the canvas without an outgoing edge (e.g. a workflow with
# just a greeting). Only constraint: nothing flows INTO the start.
graph_constraints=GraphConstraints(
min_incoming=0,
max_incoming=0,
min_outgoing=1,
),
)

View file

@ -2,6 +2,7 @@
after the workflow completes."""
from api.services.workflow.node_specs._base import (
GraphConstraints,
NodeCategory,
NodeExample,
NodeSpec,
@ -132,4 +133,10 @@ SPEC = NodeSpec(
},
),
],
# Webhooks fire post-call (run_integrations scans nodes by type),
# never as a graph step. Reject any edge into or out of a webhook so
# the editor can't wire one into the conversation flow.
graph_constraints=GraphConstraints(
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0
),
)

View file

@ -18,7 +18,7 @@ from pipecat.utils.enums import EndTaskReason
from api.db import db_client
from api.services.pipecat.audio_playback import play_audio
from api.services.workflow.disposition_mapper import apply_disposition_mapping
from api.services.workflow.workflow import Node, WorkflowGraph
from api.services.workflow.workflow_graph import Node, WorkflowGraph
if TYPE_CHECKING:
from pipecat.frames.frames import Frame

View file

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Callable, Optional
if TYPE_CHECKING:
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
from api.services.workflow.workflow import Node, WorkflowGraph
from api.services.workflow.workflow_graph import Node, WorkflowGraph
from api.services.workflow.pipecat_engine_custom_tools import get_function_schema
from api.services.workflow.tools.knowledge_base import get_knowledge_base_tool

View file

@ -4,6 +4,7 @@ from typing import Dict, List, Set
from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO
from api.services.workflow.errors import ItemKind, WorkflowError
from api.services.workflow.node_specs import REGISTRY
# Regex for matching {{ variable }} template placeholders.
# Captures: group(1) = variable path, group(2) = filter name, group(3) = filter value.
@ -259,41 +260,67 @@ class WorkflowGraph:
return errors
def _assert_connection_counts(self):
"""Enforce per-type incoming/outgoing edge constraints.
Driven by `NodeSpec.graph_constraints` so a single source of truth
in the spec dictates what's legal. Types without a `graph_constraints`
block are unconstrained (e.g. agentNode on the outgoing side).
"""
errors: list[WorkflowError] = []
out_deg = Counter()
in_deg = Counter()
for n in self.nodes.values(): # init counters
for n in self.nodes.values():
out_deg[n.id] = in_deg[n.id] = 0
for src, n in self.nodes.items(): # compute degrees
for src, n in self.nodes.items():
for m in n.out.values():
out_deg[src] += 1
in_deg[m.id] += 1
for n in self.nodes.values():
spec = REGISTRY.get(n.node_type.value)
if spec is None or spec.graph_constraints is None:
continue
gc = spec.graph_constraints
in_d, out_d = in_deg[n.id], out_deg[n.id]
label = spec.display_name
match n.node_type:
case NodeType.endNode:
if in_d < 1 or out_d != 0:
errors.append(
WorkflowError(
kind=ItemKind.node,
id=n.id,
field=None,
message=f"EndNode must have at least 1 incoming edge",
)
)
case NodeType.agentNode:
if in_d < 1:
errors.append(
WorkflowError(
kind=ItemKind.node,
id=n.id,
field=None,
message=f"Worker must have at least 1 incoming edge",
)
)
if gc.max_incoming is not None and in_d > gc.max_incoming:
msg = (
f"{label} cannot have incoming edges"
if gc.max_incoming == 0
else f"{label} can have at most {gc.max_incoming} incoming edge(s)"
)
errors.append(
WorkflowError(kind=ItemKind.node, id=n.id, field=None, message=msg)
)
if gc.min_incoming is not None and in_d < gc.min_incoming:
errors.append(
WorkflowError(
kind=ItemKind.node,
id=n.id,
field=None,
message=f"{label} must have at least {gc.min_incoming} incoming edge(s)",
)
)
if gc.max_outgoing is not None and out_d > gc.max_outgoing:
msg = (
f"{label} cannot have outgoing edges"
if gc.max_outgoing == 0
else f"{label} can have at most {gc.max_outgoing} outgoing edge(s)"
)
errors.append(
WorkflowError(kind=ItemKind.node, id=n.id, field=None, message=msg)
)
if gc.min_outgoing is not None and out_d < gc.min_outgoing:
errors.append(
WorkflowError(
kind=ItemKind.node,
id=n.id,
field=None,
message=f"{label} must have at least {gc.min_outgoing} outgoing edge(s)",
)
)
return errors