mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 18:13:03 +00:00
734 lines
28 KiB
Python
734 lines
28 KiB
Python
"""Workflow management routes."""
|
|
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from flask import current_app, jsonify, make_response, request
|
|
from flask_restx import Namespace, Resource
|
|
from sqlalchemy import text as sql_text
|
|
|
|
from docsgpt.agents.workflows.cel_evaluator import (
|
|
CelEvaluationError,
|
|
validate_cel_expression,
|
|
)
|
|
from docsgpt.api.user.resource_access import (
|
|
AccessDenied,
|
|
can_use_ref,
|
|
resolve,
|
|
sponsor_details,
|
|
sponsors_after_save,
|
|
)
|
|
from docsgpt.storage.db.base_repository import looks_like_uuid
|
|
from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
|
|
from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
|
|
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
|
|
from docsgpt.storage.db.session import db_readonly, db_session
|
|
from docsgpt.core.json_schema_utils import (
|
|
JsonSchemaValidationError,
|
|
normalize_json_schema_payload,
|
|
)
|
|
from docsgpt.core.model_utils import get_model_capabilities
|
|
from docsgpt.api.user.utils import (
|
|
error_response,
|
|
get_user_id,
|
|
require_auth,
|
|
require_fields,
|
|
success_response,
|
|
)
|
|
|
|
workflows_ns = Namespace("workflows", path="/api")
|
|
|
|
|
|
def _workflow_error_response(message: str, err: Exception):
|
|
current_app.logger.error(f"{message}: {err}", exc_info=True)
|
|
return error_response(message)
|
|
|
|
|
|
def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str):
|
|
"""Resolve a workflow by UUID or legacy Mongo id, scoped to user."""
|
|
if not workflow_id:
|
|
return None
|
|
if looks_like_uuid(workflow_id):
|
|
row = repo.get(workflow_id, user_id)
|
|
if row is not None:
|
|
return row
|
|
return repo.get_by_legacy_id(workflow_id, user_id)
|
|
|
|
|
|
def _workflow_access(conn, workflow_id: str, user_id: str, action: str):
|
|
"""Resolve a workflow the caller may ``action``, and the id to act as.
|
|
|
|
The caller's own workflow is always theirs. Otherwise access comes from
|
|
an agent of the workflow's owner that uses it: ``view`` to read it,
|
|
``edit`` to change it, ``delete`` to remove it (checked on that agent).
|
|
|
|
Args:
|
|
conn: Open database connection.
|
|
workflow_id: Workflow UUID or legacy id.
|
|
user_id: The caller.
|
|
action: Agent action required (``view``, ``edit`` or ``delete``).
|
|
|
|
Returns:
|
|
``(workflow, acting_user_id)``.
|
|
|
|
Raises:
|
|
AccessDenied: 404 when not visible, 403 when the role can't ``action``.
|
|
"""
|
|
repo = WorkflowsRepository(conn)
|
|
own = _resolve_workflow(repo, workflow_id, user_id)
|
|
if own is not None:
|
|
return own, user_id
|
|
if not looks_like_uuid(str(workflow_id)):
|
|
raise AccessDenied(404, "Workflow not found")
|
|
workflow = repo.get_by_id(str(workflow_id))
|
|
if workflow is None:
|
|
raise AccessDenied(404, "Workflow not found")
|
|
agent_ids = conn.execute(
|
|
sql_text(
|
|
"SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"
|
|
),
|
|
{"wid": str(workflow["id"]), "owner": workflow["user_id"]},
|
|
).scalars().all()
|
|
visible = False
|
|
for agent_id in agent_ids:
|
|
ra = resolve(conn, "agent", str(agent_id), user_id)
|
|
if ra is None:
|
|
continue
|
|
visible = True
|
|
if ra.can(action):
|
|
return workflow, ra.owner_id
|
|
if not visible:
|
|
raise AccessDenied(404, "Workflow not found")
|
|
raise AccessDenied(403, "Your access to this item doesn't allow that")
|
|
|
|
|
|
def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
|
|
"""The ``(type, id)`` tools and sources a workflow's agent nodes reference.
|
|
|
|
Args:
|
|
nodes: Nodes as the builder sends them (config under ``data`` or
|
|
``data.config``, like the engine reads it).
|
|
|
|
Returns:
|
|
list: ``("tool", id)`` and ``("source", id)`` pairs.
|
|
"""
|
|
refs: List[Tuple[str, str]] = []
|
|
for node in nodes or []:
|
|
if not isinstance(node, dict) or node.get("type") != "agent":
|
|
continue
|
|
data = node.get("data") or {}
|
|
cfg = data.get("config") if isinstance(data.get("config"), dict) else data
|
|
for resource_type, key in (("tool", "tools"), ("source", "sources")):
|
|
values = cfg.get(key) or []
|
|
if isinstance(values, (str, int)):
|
|
values = [values]
|
|
refs.extend((resource_type, str(v)) for v in values if v)
|
|
return refs
|
|
|
|
|
|
def _new_node_ref_denied(
|
|
conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
|
|
) -> Optional[AccessDenied]:
|
|
"""403 for the first node tool/source ``caller`` newly adds but can't use.
|
|
|
|
A workflow runs as its owner, so an editor saving the owner's graph must
|
|
not reference the owner's private tools or sources: the caller's own
|
|
access counts (``use_in_own`` for a tool, ``use`` for a source), not the
|
|
owner's. Refs already in the stored graph stay, like an agent's.
|
|
|
|
Args:
|
|
conn: Open database connection.
|
|
previous_nodes: The stored graph's nodes, in builder shape.
|
|
new_nodes: The nodes being saved.
|
|
caller: The editor saving.
|
|
|
|
Returns:
|
|
An :class:`AccessDenied` to return, or None when every new ref is fine.
|
|
"""
|
|
from docsgpt.agents.default_tools import is_synthesized_tool_id
|
|
|
|
existing = set(_node_refs(previous_nodes))
|
|
for resource_type, resource_id in _node_refs(new_nodes):
|
|
if (resource_type, resource_id) in existing:
|
|
continue
|
|
if resource_type == "tool" and is_synthesized_tool_id(resource_id):
|
|
continue
|
|
if resource_type == "source" and resource_id == "default":
|
|
continue
|
|
if not can_use_ref(conn, resource_type, resource_id, caller):
|
|
return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
|
|
return None
|
|
|
|
|
|
def _denied(err: AccessDenied):
|
|
"""403/404 in this module's ``error`` shape, plus the shared ``message`` key."""
|
|
return make_response(
|
|
jsonify({"success": False, "error": err.message, "message": err.message}), err.status
|
|
)
|
|
|
|
|
|
def _write_graph(
|
|
conn,
|
|
pg_workflow_id: str,
|
|
graph_version: int,
|
|
nodes_data: List[Dict],
|
|
edges_data: List[Dict],
|
|
) -> List[Dict]:
|
|
"""Bulk-create nodes + edges for one graph version. Uses ON CONFLICT upsert.
|
|
|
|
Edges arrive with source/target as user-provided node-id strings. We
|
|
insert nodes first, capture their ``node_id → UUID`` map, then
|
|
translate edges before insertion. Edges referencing missing nodes are
|
|
dropped with a warning.
|
|
"""
|
|
nodes_repo = WorkflowNodesRepository(conn)
|
|
edges_repo = WorkflowEdgesRepository(conn)
|
|
|
|
if nodes_data:
|
|
created_nodes = nodes_repo.bulk_create(
|
|
pg_workflow_id, graph_version,
|
|
[
|
|
{
|
|
"node_id": n["id"],
|
|
"node_type": n["type"],
|
|
"title": n.get("title", ""),
|
|
"description": n.get("description", ""),
|
|
"position": n.get("position", {"x": 0, "y": 0}),
|
|
"config": n.get("data", {}),
|
|
}
|
|
for n in nodes_data
|
|
],
|
|
)
|
|
node_uuid_by_str = {n["node_id"]: n["id"] for n in created_nodes}
|
|
else:
|
|
created_nodes = []
|
|
node_uuid_by_str = {}
|
|
|
|
if edges_data:
|
|
translated_edges: List[Dict] = []
|
|
for e in edges_data:
|
|
src = e.get("source")
|
|
tgt = e.get("target")
|
|
from_uuid = node_uuid_by_str.get(src)
|
|
to_uuid = node_uuid_by_str.get(tgt)
|
|
if not from_uuid or not to_uuid:
|
|
current_app.logger.warning(
|
|
"Workflow graph write: dropping edge %s; node refs unresolved "
|
|
"(source=%s, target=%s)",
|
|
e.get("id"), src, tgt,
|
|
)
|
|
continue
|
|
translated_edges.append({
|
|
"edge_id": e["id"],
|
|
"from_node_id": from_uuid,
|
|
"to_node_id": to_uuid,
|
|
"source_handle": e.get("sourceHandle"),
|
|
"target_handle": e.get("targetHandle"),
|
|
})
|
|
if translated_edges:
|
|
edges_repo.bulk_create(
|
|
pg_workflow_id, graph_version, translated_edges,
|
|
)
|
|
|
|
return created_nodes
|
|
|
|
|
|
def serialize_workflow(w: Dict) -> Dict:
|
|
"""Serialize workflow row to API response format."""
|
|
created_at = w.get("created_at")
|
|
updated_at = w.get("updated_at")
|
|
return {
|
|
"id": str(w["id"]),
|
|
"name": w.get("name"),
|
|
"description": w.get("description"),
|
|
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at,
|
|
"updated_at": updated_at.isoformat() if hasattr(updated_at, "isoformat") else updated_at,
|
|
}
|
|
|
|
|
|
def serialize_node(n: Dict) -> Dict:
|
|
"""Serialize workflow node row to API response format."""
|
|
return {
|
|
"id": n["node_id"],
|
|
"type": n["node_type"],
|
|
"title": n.get("title"),
|
|
"description": n.get("description"),
|
|
"position": n.get("position"),
|
|
"data": n.get("config", {}) or {},
|
|
}
|
|
|
|
|
|
def serialize_edge(e: Dict) -> Dict:
|
|
"""Serialize workflow edge row to API response format."""
|
|
return {
|
|
"id": e["edge_id"],
|
|
"source": e.get("source_id"),
|
|
"target": e.get("target_id"),
|
|
"sourceHandle": e.get("source_handle"),
|
|
"targetHandle": e.get("target_handle"),
|
|
}
|
|
|
|
|
|
def get_workflow_graph_version(workflow: Dict) -> int:
|
|
"""Get current graph version with fallback."""
|
|
raw_version = workflow.get("current_graph_version", 1)
|
|
try:
|
|
version = int(raw_version)
|
|
return version if version > 0 else 1
|
|
except (ValueError, TypeError):
|
|
return 1
|
|
|
|
|
|
def validate_json_schema_payload(
|
|
json_schema: Any,
|
|
) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
"""Validate and normalize optional JSON schema payload for structured output."""
|
|
if json_schema is None:
|
|
return None, None
|
|
try:
|
|
return normalize_json_schema_payload(json_schema), None
|
|
except JsonSchemaValidationError as exc:
|
|
return None, str(exc)
|
|
|
|
|
|
def normalize_agent_node_json_schemas(nodes: List[Dict]) -> List[Dict]:
|
|
"""Normalize agent-node JSON schema payloads before persistence."""
|
|
normalized_nodes: List[Dict] = []
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
normalized_nodes.append(node)
|
|
continue
|
|
|
|
normalized_node = dict(node)
|
|
if normalized_node.get("type") != "agent":
|
|
normalized_nodes.append(normalized_node)
|
|
continue
|
|
|
|
raw_config = normalized_node.get("data")
|
|
if not isinstance(raw_config, dict) or "json_schema" not in raw_config:
|
|
normalized_nodes.append(normalized_node)
|
|
continue
|
|
|
|
normalized_config = dict(raw_config)
|
|
try:
|
|
normalized_config["json_schema"] = normalize_json_schema_payload(
|
|
raw_config.get("json_schema")
|
|
)
|
|
except JsonSchemaValidationError:
|
|
# Validation runs before normalization; keep original on unexpected shape.
|
|
normalized_config["json_schema"] = raw_config.get("json_schema")
|
|
normalized_node["data"] = normalized_config
|
|
normalized_nodes.append(normalized_node)
|
|
|
|
return normalized_nodes
|
|
|
|
|
|
def validate_workflow_structure(
|
|
nodes: List[Dict], edges: List[Dict], user_id: str | None = None
|
|
) -> List[str]:
|
|
"""Validate workflow graph structure.
|
|
|
|
``user_id`` is required so per-user BYOM custom-model UUIDs resolve
|
|
when checking each agent node's structured-output capability.
|
|
"""
|
|
errors = []
|
|
|
|
if not nodes:
|
|
errors.append("Workflow must have at least one node")
|
|
return errors
|
|
|
|
start_nodes = [n for n in nodes if n.get("type") == "start"]
|
|
if len(start_nodes) != 1:
|
|
errors.append("Workflow must have exactly one start node")
|
|
|
|
end_nodes = [n for n in nodes if n.get("type") == "end"]
|
|
if not end_nodes:
|
|
errors.append("Workflow must have at least one end node")
|
|
|
|
node_ids = {n.get("id") for n in nodes}
|
|
node_map = {n.get("id"): n for n in nodes}
|
|
end_ids = {n.get("id") for n in end_nodes}
|
|
|
|
for edge in edges:
|
|
source_id = edge.get("source")
|
|
target_id = edge.get("target")
|
|
if source_id not in node_ids:
|
|
errors.append(f"Edge references non-existent source: {source_id}")
|
|
if target_id not in node_ids:
|
|
errors.append(f"Edge references non-existent target: {target_id}")
|
|
|
|
if start_nodes:
|
|
start_id = start_nodes[0].get("id")
|
|
if not any(e.get("source") == start_id for e in edges):
|
|
errors.append("Start node must have at least one outgoing edge")
|
|
|
|
condition_nodes = [n for n in nodes if n.get("type") == "condition"]
|
|
for cnode in condition_nodes:
|
|
cnode_id = cnode.get("id")
|
|
cnode_title = cnode.get("title", cnode_id)
|
|
outgoing = [e for e in edges if e.get("source") == cnode_id]
|
|
if len(outgoing) < 2:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' must have at least 2 outgoing edges"
|
|
)
|
|
node_data = cnode.get("data", {}) or {}
|
|
cases = node_data.get("cases", [])
|
|
if not isinstance(cases, list):
|
|
cases = []
|
|
if not cases or not any(
|
|
isinstance(c, dict) and str(c.get("expression", "")).strip() for c in cases
|
|
):
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' must have at least one case with an expression"
|
|
)
|
|
|
|
case_handles: Set[str] = set()
|
|
duplicate_case_handles: Set[str] = set()
|
|
for case in cases:
|
|
if not isinstance(case, dict):
|
|
continue
|
|
raw_handle = case.get("sourceHandle", "")
|
|
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
|
|
if not handle:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' has a case without a branch handle"
|
|
)
|
|
continue
|
|
if handle in case_handles:
|
|
duplicate_case_handles.add(handle)
|
|
case_handles.add(handle)
|
|
|
|
for handle in duplicate_case_handles:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' has duplicate case handle '{handle}'"
|
|
)
|
|
|
|
outgoing_by_handle: Dict[str, List[Dict]] = {}
|
|
for out_edge in outgoing:
|
|
raw_handle = out_edge.get("sourceHandle", "")
|
|
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
|
|
outgoing_by_handle.setdefault(handle, []).append(out_edge)
|
|
|
|
for handle, handle_edges in outgoing_by_handle.items():
|
|
if not handle:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' has an outgoing edge without sourceHandle"
|
|
)
|
|
continue
|
|
if handle != "else" and handle not in case_handles:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' has a connection from unknown branch '{handle}'"
|
|
)
|
|
if len(handle_edges) > 1:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' has multiple outgoing edges from branch '{handle}'"
|
|
)
|
|
|
|
if "else" not in outgoing_by_handle:
|
|
errors.append(f"Condition node '{cnode_title}' must have an 'else' branch")
|
|
|
|
for case in cases:
|
|
if not isinstance(case, dict):
|
|
continue
|
|
raw_handle = case.get("sourceHandle", "")
|
|
handle = raw_handle.strip() if isinstance(raw_handle, str) else ""
|
|
if not handle:
|
|
continue
|
|
|
|
raw_expression = case.get("expression", "")
|
|
has_expression = isinstance(raw_expression, str) and bool(
|
|
raw_expression.strip()
|
|
)
|
|
has_outgoing = bool(outgoing_by_handle.get(handle))
|
|
if has_expression and not has_outgoing:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' case '{handle}' has an expression but no outgoing edge"
|
|
)
|
|
if not has_expression and has_outgoing:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' case '{handle}' has an outgoing edge but no expression"
|
|
)
|
|
if has_expression:
|
|
try:
|
|
validate_cel_expression(raw_expression)
|
|
except CelEvaluationError as exc:
|
|
errors.append(
|
|
f"Condition node '{cnode_title}' case '{handle}' "
|
|
f"has an invalid expression: {exc}"
|
|
)
|
|
|
|
for handle, handle_edges in outgoing_by_handle.items():
|
|
if not handle:
|
|
continue
|
|
for out_edge in handle_edges:
|
|
target = out_edge.get("target")
|
|
if target and not _can_reach_end(target, edges, node_map, end_ids):
|
|
errors.append(
|
|
f"Branch '{handle}' of condition '{cnode_title}' "
|
|
f"must eventually reach an end node"
|
|
)
|
|
|
|
# Set State nodes were validated nowhere. A node whose CEL does not compile
|
|
# saves and publishes clean, then aborts the run on first execution — the
|
|
# user only finds out as a failed answer, with no pointer to the node.
|
|
state_nodes = [n for n in nodes if n.get("type") == "state"]
|
|
for snode in state_nodes:
|
|
snode_title = snode.get("title", snode.get("id"))
|
|
node_data = snode.get("data", {}) or {}
|
|
# The builder writes state config under ``data.config`` but other
|
|
# payloads keep it flat on ``data``; the engine reads
|
|
# ``node.config.get("config", node.config)`` for exactly this reason,
|
|
# so accept both rather than silently validating nothing.
|
|
nested = node_data.get("config")
|
|
source = nested if isinstance(nested, dict) else node_data
|
|
operations = source.get("operations", [])
|
|
if not isinstance(operations, list):
|
|
continue
|
|
for index, operation in enumerate(operations):
|
|
if not isinstance(operation, dict):
|
|
continue
|
|
raw_expression = operation.get("expression", "")
|
|
target_variable = operation.get("target_variable", "")
|
|
has_expression = isinstance(raw_expression, str) and bool(
|
|
raw_expression.strip()
|
|
)
|
|
has_target = isinstance(target_variable, str) and bool(
|
|
target_variable.strip()
|
|
)
|
|
# The engine silently skips an operation missing either half
|
|
# (workflow_engine._execute_state_node), so downstream nodes read a
|
|
# variable that never gets set. Surface it at save time instead.
|
|
if has_expression and not has_target:
|
|
errors.append(
|
|
f"Set State node '{snode_title}' operation {index + 1} "
|
|
f"has an expression but no target variable"
|
|
)
|
|
if has_target and not has_expression:
|
|
errors.append(
|
|
f"Set State node '{snode_title}' operation {index + 1} "
|
|
f"has a target variable but no expression"
|
|
)
|
|
if has_expression:
|
|
try:
|
|
validate_cel_expression(raw_expression)
|
|
except CelEvaluationError as exc:
|
|
errors.append(
|
|
f"Set State node '{snode_title}' operation {index + 1} "
|
|
f"has an invalid expression: {exc}"
|
|
)
|
|
|
|
agent_nodes = [n for n in nodes if n.get("type") == "agent"]
|
|
for agent_node in agent_nodes:
|
|
agent_title = agent_node.get("title", agent_node.get("id", "unknown"))
|
|
raw_config = agent_node.get("data", {}) or {}
|
|
if not isinstance(raw_config, dict):
|
|
errors.append(f"Agent node '{agent_title}' has invalid configuration")
|
|
continue
|
|
normalized_schema, schema_error = validate_json_schema_payload(
|
|
raw_config.get("json_schema")
|
|
)
|
|
has_json_schema = normalized_schema is not None
|
|
|
|
model_id = raw_config.get("model_id")
|
|
if has_json_schema and isinstance(model_id, str) and model_id.strip():
|
|
capabilities = get_model_capabilities(model_id.strip(), user_id=user_id)
|
|
if capabilities and not capabilities.get("supports_structured_output", False):
|
|
errors.append(
|
|
f"Agent node '{agent_title}' selected model does not support structured output"
|
|
)
|
|
if schema_error:
|
|
errors.append(f"Agent node '{agent_title}' JSON schema {schema_error}")
|
|
|
|
code_nodes = [n for n in nodes if n.get("type") == "code"]
|
|
for code_node in code_nodes:
|
|
code_title = code_node.get("title", code_node.get("id", "unknown"))
|
|
raw_config = code_node.get("data", {}) or {}
|
|
if not isinstance(raw_config, dict):
|
|
errors.append(f"Code node '{code_title}' has invalid configuration")
|
|
continue
|
|
if not str(raw_config.get("code", "")).strip():
|
|
errors.append(f"Code node '{code_title}' must have code to execute")
|
|
_, schema_error = validate_json_schema_payload(raw_config.get("json_schema"))
|
|
if schema_error:
|
|
errors.append(f"Code node '{code_title}' JSON schema {schema_error}")
|
|
|
|
for node in nodes:
|
|
if not node.get("id"):
|
|
errors.append("All nodes must have an id")
|
|
if not node.get("type"):
|
|
errors.append(f"Node {node.get('id', 'unknown')} must have a type")
|
|
|
|
return errors
|
|
|
|
|
|
def _can_reach_end(
|
|
node_id: str, edges: List[Dict], node_map: Dict, end_ids: set, visited: set = None
|
|
) -> bool:
|
|
if visited is None:
|
|
visited = set()
|
|
if node_id in end_ids:
|
|
return True
|
|
if node_id in visited or node_id not in node_map:
|
|
return False
|
|
visited.add(node_id)
|
|
outgoing = [e.get("target") for e in edges if e.get("source") == node_id]
|
|
return any(_can_reach_end(t, edges, node_map, end_ids, visited) for t in outgoing if t)
|
|
|
|
|
|
@workflows_ns.route("/workflows")
|
|
class WorkflowList(Resource):
|
|
|
|
@require_auth
|
|
@require_fields(["name"])
|
|
def post(self):
|
|
"""Create a new workflow with nodes and edges."""
|
|
user_id = get_user_id()
|
|
data = request.get_json()
|
|
|
|
name = data.get("name", "").strip()
|
|
description = data.get("description", "")
|
|
nodes_data = data.get("nodes", [])
|
|
edges_data = data.get("edges", [])
|
|
|
|
validation_errors = validate_workflow_structure(
|
|
nodes_data, edges_data, user_id=user_id
|
|
)
|
|
if validation_errors:
|
|
return error_response(
|
|
"Workflow validation failed", errors=validation_errors
|
|
)
|
|
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
|
|
|
try:
|
|
with db_session() as conn:
|
|
repo = WorkflowsRepository(conn)
|
|
workflow = repo.create(user_id, name, description=description)
|
|
pg_workflow_id = str(workflow["id"])
|
|
_write_graph(conn, pg_workflow_id, 1, nodes_data, edges_data)
|
|
except Exception as err:
|
|
return _workflow_error_response("Failed to create workflow", err)
|
|
|
|
return success_response({"id": pg_workflow_id}, 201)
|
|
|
|
|
|
@workflows_ns.route("/workflows/<string:workflow_id>")
|
|
class WorkflowDetail(Resource):
|
|
|
|
@require_auth
|
|
def get(self, workflow_id: str):
|
|
"""Get workflow details with nodes and edges."""
|
|
user_id = get_user_id()
|
|
try:
|
|
with db_readonly() as conn:
|
|
try:
|
|
workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
pg_workflow_id = str(workflow["id"])
|
|
graph_version = get_workflow_graph_version(workflow)
|
|
nodes = WorkflowNodesRepository(conn).find_by_version(
|
|
pg_workflow_id, graph_version,
|
|
)
|
|
edges = WorkflowEdgesRepository(conn).find_by_version(
|
|
pg_workflow_id, graph_version,
|
|
)
|
|
sponsored = sponsor_details(conn, "workflow", workflow)
|
|
except Exception as err:
|
|
return _workflow_error_response("Failed to fetch workflow", err)
|
|
|
|
return success_response(
|
|
{
|
|
"workflow": serialize_workflow(workflow),
|
|
"nodes": [serialize_node(n) for n in nodes],
|
|
"edges": [serialize_edge(e) for e in edges],
|
|
"resource_sponsors": sponsored,
|
|
}
|
|
)
|
|
|
|
@require_auth
|
|
@require_fields(["name"])
|
|
def put(self, workflow_id: str):
|
|
"""Update workflow and replace nodes/edges."""
|
|
user_id = get_user_id()
|
|
data = request.get_json()
|
|
name = data.get("name", "").strip()
|
|
description = data.get("description", "")
|
|
nodes_data = data.get("nodes", [])
|
|
edges_data = data.get("edges", [])
|
|
|
|
try:
|
|
with db_session() as conn:
|
|
repo = WorkflowsRepository(conn)
|
|
try:
|
|
workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
# Validated as the owner: the workflow runs with the owner's
|
|
# models, so their BYOM ids are the ones that must resolve.
|
|
validation_errors = validate_workflow_structure(
|
|
nodes_data, edges_data, user_id=acting
|
|
)
|
|
if validation_errors:
|
|
return error_response(
|
|
"Workflow validation failed", errors=validation_errors
|
|
)
|
|
nodes_data = normalize_agent_node_json_schemas(nodes_data)
|
|
pg_workflow_id = str(workflow["id"])
|
|
current_graph_version = get_workflow_graph_version(workflow)
|
|
if acting != user_id:
|
|
previous_nodes = [
|
|
serialize_node(n)
|
|
for n in WorkflowNodesRepository(conn).find_by_version(
|
|
pg_workflow_id, current_graph_version,
|
|
)
|
|
]
|
|
denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
|
|
if denied is not None:
|
|
return _denied(denied)
|
|
next_graph_version = current_graph_version + 1
|
|
|
|
_write_graph(
|
|
conn, pg_workflow_id, next_graph_version,
|
|
nodes_data, edges_data,
|
|
)
|
|
workflow_fields = {
|
|
"name": name,
|
|
"description": description,
|
|
"current_graph_version": next_graph_version,
|
|
}
|
|
# A node tool/source the owner can't use runs as the editor
|
|
# who attached it (its sponsor); record who that is.
|
|
sponsors = sponsors_after_save(
|
|
conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data)
|
|
)
|
|
if sponsors != (workflow.get("resource_sponsors") or {}):
|
|
workflow_fields["resource_sponsors"] = sponsors
|
|
repo.update(pg_workflow_id, acting, workflow_fields)
|
|
WorkflowNodesRepository(conn).delete_other_versions(
|
|
pg_workflow_id, next_graph_version,
|
|
)
|
|
WorkflowEdgesRepository(conn).delete_other_versions(
|
|
pg_workflow_id, next_graph_version,
|
|
)
|
|
except Exception as err:
|
|
return _workflow_error_response("Failed to update workflow", err)
|
|
|
|
return success_response()
|
|
|
|
@require_auth
|
|
def delete(self, workflow_id: str):
|
|
"""Delete workflow and its graph."""
|
|
user_id = get_user_id()
|
|
try:
|
|
with db_session() as conn:
|
|
repo = WorkflowsRepository(conn)
|
|
try:
|
|
workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
# ON DELETE CASCADE on workflow_nodes/edges cleans children.
|
|
repo.delete(str(workflow["id"]), acting)
|
|
except Exception as err:
|
|
return _workflow_error_response("Failed to delete workflow", err)
|
|
|
|
return success_response()
|