381 lines
12 KiB
Python
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",
|
|
]
|