deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/workflows/schemas.py
2026-09-07 18:24:55 +08:00

381 lines
12 KiB
Python

"""Frozen workflow graph schema v1.0 (internal DTO).
Coze canvas DTO is converted into this structure at the Gateway boundary.
The runtime must never depend on Coze-private fields.
Wire JSON uses camelCase aliases; Python code uses snake_case.
"""
from __future__ import annotations
from hashlib import sha256
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
SCHEMA_VERSION = "1.0"
NodeType = Literal[
"start",
"output",
"agent",
"skill",
"http",
"sql_read",
"code",
"transform",
"evidence_normalizer",
"deep_research_write",
"condition",
"merge",
"human_input",
"subworkflow",
"loop",
]
ALL_NODE_TYPES: tuple[NodeType, ...] = (
"start",
"output",
"agent",
"skill",
"http",
"sql_read",
"code",
"transform",
"evidence_normalizer",
"deep_research_write",
"condition",
"merge",
"human_input",
"subworkflow",
"loop",
)
RunStatus = Literal[
"queued",
"running",
"awaiting_input",
"completed",
"failed",
"cancel_requested",
"cancelled",
]
ALL_RUN_STATUSES: tuple[RunStatus, ...] = (
"queued",
"running",
"awaiting_input",
"completed",
"failed",
"cancel_requested",
"cancelled",
)
TERMINAL_RUN_STATUSES: frozenset[RunStatus] = frozenset({"completed", "failed", "cancelled"})
NodeRunStatus = Literal[
"queued",
"running",
"completed",
"failed",
"skipped",
"cancelled",
]
SkillMode = Literal["agent_skill", "callable_skill"]
MergeStrategy = Literal["all", "first_success", "any_success"]
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
AgentResponseMode = Literal["text", "json"]
AgentThreadMode = Literal["isolated_per_node", "shared_run_thread"]
ExecutionMode = Literal["normal"]
class WorkflowGraphSettings(BaseModel):
"""Per-graph resource limits (still capped by system limits at publish)."""
run_timeout_seconds: int = Field(default=1800, ge=1, alias="runTimeoutSeconds")
node_timeout_seconds: int = Field(default=300, ge=1, alias="nodeTimeoutSeconds")
max_steps: int = Field(default=200, ge=1, alias="maxSteps")
max_loop_iterations: int = Field(default=5, ge=1, alias="maxLoopIterations")
max_parallelism: int = Field(default=4, ge=1, alias="maxParallelism")
model_config = {"extra": "forbid", "populate_by_name": True}
class WorkflowNode(BaseModel):
"""A single node on the internal standard graph."""
id: str = Field(min_length=1)
type: NodeType
name: str = ""
config: dict[str, Any] = Field(default_factory=dict)
model_config = {"extra": "forbid", "populate_by_name": True}
@field_validator("id")
@classmethod
def _id_not_blank(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("node id must be non-empty")
return stripped
class WorkflowEdge(BaseModel):
"""Directed edge. ``source_port`` names a condition / loop outlet when set."""
id: str = Field(min_length=1)
source: str
target: str
source_port: str | None = Field(default=None, alias="sourcePort")
model_config = {"extra": "forbid", "populate_by_name": True}
class WorkflowGraph(BaseModel):
"""Internal standard workflow graph (publish / run snapshot)."""
schema_version: Literal["1.0"] = Field(default=SCHEMA_VERSION, alias="schemaVersion")
id: str = Field(min_length=1)
name: str = ""
description: str = ""
input_schema: dict[str, Any] = Field(default_factory=dict, alias="inputSchema")
output_schema: dict[str, Any] = Field(default_factory=dict, alias="outputSchema")
nodes: list[WorkflowNode] = Field(default_factory=list)
edges: list[WorkflowEdge] = Field(default_factory=list)
settings: WorkflowGraphSettings = Field(default_factory=WorkflowGraphSettings)
model_config = {"extra": "forbid", "populate_by_name": True}
@model_validator(mode="after")
def _unique_ids(self) -> WorkflowGraph:
node_ids = [n.id for n in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("duplicate node id")
edge_ids = [e.id for e in self.edges]
if len(edge_ids) != len(set(edge_ids)):
raise ValueError("duplicate edge id")
known = set(node_ids)
for edge in self.edges:
if edge.source not in known:
raise ValueError(f"edge {edge.id} source not found: {edge.source}")
if edge.target not in known:
raise ValueError(f"edge {edge.id} target not found: {edge.target}")
return self
def node_map(self) -> dict[str, WorkflowNode]:
return {node.id: node for node in self.nodes}
def canonical_json(self) -> str:
"""Deterministic JSON used for ``graph_hash`` (sorted keys, no whitespace)."""
return self.model_dump_json(by_alias=True, exclude_none=True)
def graph_hash(self) -> str:
return sha256(self.canonical_json().encode("utf-8")).hexdigest()
class ArtifactRef(BaseModel):
"""Reference to a large/binary result; full bytes live outside events."""
artifact_id: str = Field(alias="artifactId")
name: str
mime_type: str = Field(default="application/octet-stream", alias="mimeType")
path: str = ""
size_bytes: int | None = Field(default=None, alias="sizeBytes")
preview: str | None = None
model_config = {"extra": "forbid", "populate_by_name": True}
class NodeResult(BaseModel):
"""Uniform result returned by every node executor."""
data: dict[str, Any] = Field(default_factory=dict)
messages: list[dict[str, Any]] = Field(default_factory=list)
artifacts: list[ArtifactRef] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
warnings: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid", "populate_by_name": True}
class StartRunRequest(BaseModel):
"""Body for ``POST /api/workflows/{workflow_id}/runs``."""
version_id: str = Field(alias="versionId")
inputs: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str | None = Field(default=None, alias="idempotencyKey")
execution_mode: ExecutionMode = Field(default="normal", alias="executionMode")
model_config = {"extra": "forbid", "populate_by_name": True}
class ResumeRunRequest(BaseModel):
"""Body for ``POST /api/workflows/runs/{run_id}/resume``."""
resume_token: str = Field(alias="resumeToken")
action: str
payload: dict[str, Any] = Field(default_factory=dict)
model_config = {"extra": "forbid", "populate_by_name": True}
# ── Typed node config helpers (optional; validators may use later) ───────────
class AgentNodeConfig(BaseModel):
agent_id: str = Field(alias="agentId")
prompt_template: str = Field(default="", alias="promptTemplate")
input_bindings: dict[str, Any] = Field(default_factory=dict, alias="inputBindings")
response_mode: AgentResponseMode = Field(default="text", alias="responseMode")
response_schema: dict[str, Any] = Field(default_factory=dict, alias="responseSchema")
thread_mode: AgentThreadMode = Field(default="isolated_per_node", alias="threadMode")
timeout_seconds: int = Field(default=600, ge=1, alias="timeoutSeconds")
skill_names: list[str] = Field(default_factory=list, alias="skillNames")
model_config = {"extra": "forbid", "populate_by_name": True}
class SkillNodeConfig(BaseModel):
mode: SkillMode = "agent_skill"
agent_id: str | None = Field(default=None, alias="agentId")
skill_names: list[str] = Field(default_factory=list, alias="skillNames")
prompt_template: str = Field(default="", alias="promptTemplate")
input_bindings: dict[str, Any] = Field(default_factory=dict, alias="inputBindings")
# callable_skill fields (phase 4+; accepted in schema, not executed in phase 0)
entry: str | None = None
input_schema: dict[str, Any] = Field(default_factory=dict, alias="inputSchema")
output_schema: dict[str, Any] = Field(default_factory=dict, alias="outputSchema")
timeout_seconds: int = Field(default=300, ge=1, alias="timeoutSeconds")
model_config = {"extra": "forbid", "populate_by_name": True}
class HttpNodeConfig(BaseModel):
method: HttpMethod = "GET"
url: str
headers: dict[str, str] = Field(default_factory=dict)
query: dict[str, Any] = Field(default_factory=dict)
body: Any = None
credential_ref: str | None = Field(default=None, alias="credentialRef")
timeout_seconds: int = Field(default=60, ge=1, alias="timeoutSeconds")
max_response_bytes: int = Field(default=1_048_576, ge=1, alias="maxResponseBytes")
allow_redirects: bool = Field(default=False, alias="allowRedirects")
model_config = {"extra": "forbid", "populate_by_name": True}
class SqlReadNodeConfig(BaseModel):
data_source_id: str = Field(alias="dataSourceId")
statement: str
parameters: dict[str, Any] = Field(default_factory=dict)
max_rows: int = Field(default=1000, ge=1, alias="maxRows")
model_config = {"extra": "forbid", "populate_by_name": True}
class CodeNodeConfig(BaseModel):
language: Literal["python"] = "python"
source: str
timeout_seconds: int = Field(default=60, ge=1, alias="timeoutSeconds")
allow_network: bool = Field(default=False, alias="allowNetwork")
model_config = {"extra": "forbid", "populate_by_name": True}
class TransformNodeConfig(BaseModel):
operations: list[dict[str, Any]] = Field(default_factory=list)
template: str | None = None
model_config = {"extra": "forbid", "populate_by_name": True}
class ConditionBranch(BaseModel):
name: str
expression: str
model_config = {"extra": "forbid", "populate_by_name": True}
class ConditionNodeConfig(BaseModel):
branches: list[ConditionBranch] = Field(default_factory=list)
default_branch: str | None = Field(default=None, alias="defaultBranch")
model_config = {"extra": "forbid", "populate_by_name": True}
class MergeNodeConfig(BaseModel):
strategy: MergeStrategy = "all"
sources: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid", "populate_by_name": True}
class HumanInputNodeConfig(BaseModel):
form_schema: dict[str, Any] = Field(default_factory=dict, alias="formSchema")
prompt: str = ""
actions: list[str] = Field(default_factory=lambda: ["submit"])
model_config = {"extra": "forbid", "populate_by_name": True}
class SubworkflowNodeConfig(BaseModel):
workflow_id: str = Field(alias="workflowId")
version_id: str = Field(alias="versionId")
input_mapping: dict[str, str] = Field(default_factory=dict, alias="inputMapping")
model_config = {"extra": "forbid", "populate_by_name": True}
class LoopNodeConfig(BaseModel):
max_iterations: int = Field(default=5, ge=1, alias="maxIterations")
continue_when: str = Field(default="", alias="continueWhen")
body_entry: str = Field(alias="bodyEntry")
model_config = {"extra": "forbid", "populate_by_name": True}
class OutputNodeConfig(BaseModel):
mapping: dict[str, str] = Field(default_factory=dict)
llm_repair: bool = Field(default=False, alias="llmRepair")
model_config = {"extra": "forbid", "populate_by_name": True}
__all__ = [
"SCHEMA_VERSION",
"ALL_NODE_TYPES",
"ALL_RUN_STATUSES",
"TERMINAL_RUN_STATUSES",
"AgentNodeConfig",
"AgentResponseMode",
"AgentThreadMode",
"ArtifactRef",
"CodeNodeConfig",
"ConditionBranch",
"ConditionNodeConfig",
"ExecutionMode",
"HttpMethod",
"HttpNodeConfig",
"HumanInputNodeConfig",
"LoopNodeConfig",
"MergeNodeConfig",
"MergeStrategy",
"NodeResult",
"NodeRunStatus",
"NodeType",
"OutputNodeConfig",
"ResumeRunRequest",
"RunStatus",
"SkillMode",
"SkillNodeConfig",
"SqlReadNodeConfig",
"StartRunRequest",
"SubworkflowNodeConfig",
"TransformNodeConfig",
"WorkflowEdge",
"WorkflowGraph",
"WorkflowGraphSettings",
"WorkflowNode",
]