712 lines
26 KiB
Python
712 lines
26 KiB
Python
"""Workflow API routes - CRUD, execution, approval, and real-time progress"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, Security
|
|
from fastapi.security import APIKeyHeader, APIKeyQuery
|
|
|
|
from agentkit.orchestrator.workflow_schema import (
|
|
ApproveRequest,
|
|
CreateWorkflowRequest,
|
|
ExecuteWorkflowRequest,
|
|
WorkflowDefinition,
|
|
WorkflowExecution,
|
|
WorkflowStage,
|
|
WorkflowSummary,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(tags=["workflows"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API Key Authentication
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
_api_key_query = APIKeyQuery(name="api_key", auto_error=False)
|
|
|
|
|
|
async def _verify_api_key(
|
|
request: Request,
|
|
api_key_header: str | None = Security(_api_key_header),
|
|
api_key_query: str | None = Security(_api_key_query),
|
|
) -> None:
|
|
"""Verify API key for REST endpoints. Raises HTTPException if invalid."""
|
|
configured_api_key: str | None = None
|
|
if hasattr(request.app.state, "server_config") and request.app.state.server_config:
|
|
configured_api_key = request.app.state.server_config.api_key
|
|
if configured_api_key is None and hasattr(request.app.state, "api_key"):
|
|
configured_api_key = request.app.state.api_key
|
|
|
|
# If no API key is configured, allow all requests (backwards compat)
|
|
if configured_api_key is None:
|
|
return
|
|
|
|
provided = api_key_header or api_key_query
|
|
if provided != configured_api_key:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid or missing API key. Provide via X-API-Key header or api_key query parameter.",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory Workflow Store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class WorkflowStore:
|
|
"""In-memory workflow store."""
|
|
|
|
def __init__(self, max_workflows: int = 500, max_executions: int = 1000):
|
|
self._workflows: dict[str, WorkflowDefinition] = {}
|
|
self._executions: dict[str, WorkflowExecution] = {}
|
|
self._max_workflows = max_workflows
|
|
self._max_executions = max_executions
|
|
self._approval_events: dict[str, asyncio.Event] = {} # key: f"{execution_id}:{stage_name}"
|
|
|
|
def save(self, workflow: WorkflowDefinition) -> WorkflowDefinition:
|
|
workflow.updated_at = datetime.now(timezone.utc).isoformat()
|
|
self._workflows[workflow.workflow_id] = workflow
|
|
# Evict oldest if over limit
|
|
if len(self._workflows) > self._max_workflows:
|
|
oldest_id = min(
|
|
self._workflows, key=lambda k: self._workflows[k].updated_at
|
|
)
|
|
del self._workflows[oldest_id]
|
|
return workflow
|
|
|
|
def get(self, workflow_id: str) -> WorkflowDefinition | None:
|
|
return self._workflows.get(workflow_id)
|
|
|
|
def list(self, limit: int = 50) -> list[WorkflowSummary]:
|
|
sorted_wf = sorted(
|
|
self._workflows.values(),
|
|
key=lambda w: w.updated_at,
|
|
reverse=True,
|
|
)
|
|
summaries = []
|
|
for w in sorted_wf[:limit]:
|
|
summaries.append(
|
|
WorkflowSummary(
|
|
workflow_id=w.workflow_id,
|
|
name=w.name,
|
|
version=w.version,
|
|
stage_count=len(w.stages),
|
|
trigger_count=len(w.triggers),
|
|
created_at=w.created_at,
|
|
updated_at=w.updated_at,
|
|
)
|
|
)
|
|
return summaries
|
|
|
|
def delete(self, workflow_id: str) -> bool:
|
|
if workflow_id in self._workflows:
|
|
del self._workflows[workflow_id]
|
|
return True
|
|
return False
|
|
|
|
def create_execution(self, workflow_id: str) -> WorkflowExecution:
|
|
execution = WorkflowExecution(
|
|
execution_id=str(uuid.uuid4()),
|
|
workflow_id=workflow_id,
|
|
status="pending",
|
|
started_at=datetime.now(timezone.utc).isoformat(),
|
|
)
|
|
self._executions[execution.execution_id] = execution
|
|
# Evict oldest if over limit
|
|
if len(self._executions) > self._max_executions:
|
|
oldest_id = min(
|
|
self._executions,
|
|
key=lambda k: self._executions[k].started_at or "",
|
|
)
|
|
del self._executions[oldest_id]
|
|
return execution
|
|
|
|
def get_execution(self, execution_id: str) -> WorkflowExecution | None:
|
|
return self._executions.get(execution_id)
|
|
|
|
def update_execution(self, execution_id: str, **kwargs: Any) -> WorkflowExecution:
|
|
execution = self._executions.get(execution_id)
|
|
if execution is None:
|
|
raise KeyError(f"Execution '{execution_id}' not found")
|
|
for key, value in kwargs.items():
|
|
if hasattr(execution, key):
|
|
setattr(execution, key, value)
|
|
return execution
|
|
|
|
|
|
# Module-level singleton
|
|
_workflow_store = WorkflowStore()
|
|
|
|
# WebSocket subscribers for real-time execution progress
|
|
_ws_subscribers: list[WebSocket] = []
|
|
|
|
|
|
def _get_store(request: Request) -> WorkflowStore:
|
|
"""Get the workflow store from app state or use the module-level singleton."""
|
|
store = getattr(request.app.state, "workflow_store", None)
|
|
return store if store is not None else _workflow_store
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _validate_workflow_stages(stages: list[WorkflowStage]) -> None:
|
|
"""Validate workflow stages for missing dependencies and circular deps."""
|
|
stage_names = {s.name for s in stages}
|
|
for stage in stages:
|
|
for dep in stage.depends_on:
|
|
if dep not in stage_names:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"阶段 '{stage.name}' 依赖了不存在的阶段 '{dep}'",
|
|
)
|
|
|
|
# Check for circular dependencies
|
|
in_degree: dict[str, int] = {s.name: 0 for s in stages}
|
|
dependents: dict[str, list[str]] = {s.name: [] for s in stages}
|
|
for s in stages:
|
|
for dep in s.depends_on:
|
|
in_degree[s.name] += 1
|
|
dependents[dep].append(s.name)
|
|
|
|
remaining = set(in_degree.keys())
|
|
while remaining:
|
|
current_level = [name for name in remaining if in_degree[name] == 0]
|
|
if not current_level:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="工作流存在循环依赖",
|
|
)
|
|
for name in current_level:
|
|
remaining.remove(name)
|
|
for dep in dependents[name]:
|
|
in_degree[dep] -= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workflow execution engine (simplified)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _execute_workflow(
|
|
workflow: WorkflowDefinition,
|
|
execution: WorkflowExecution,
|
|
variables: dict[str, Any],
|
|
store: WorkflowStore | None = None,
|
|
) -> None:
|
|
"""Execute a workflow by running its stages in topological order."""
|
|
_store = store or _workflow_store
|
|
execution.status = "running"
|
|
_store.update_execution(execution.execution_id, status="running")
|
|
|
|
# Topological sort
|
|
stage_map = {s.name: s for s in workflow.stages}
|
|
in_degree: dict[str, int] = {s.name: 0 for s in workflow.stages}
|
|
dependents: dict[str, list[str]] = {s.name: [] for s in workflow.stages}
|
|
for s in workflow.stages:
|
|
for dep in s.depends_on:
|
|
in_degree[s.name] += 1
|
|
dependents[dep].append(s.name)
|
|
|
|
ordered: list[str] = []
|
|
remaining = set(in_degree.keys())
|
|
while remaining:
|
|
current_level = [name for name in remaining if in_degree[name] == 0]
|
|
if not current_level:
|
|
execution.status = "failed"
|
|
execution.error = "循环依赖"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="failed",
|
|
error="循环依赖",
|
|
completed_at=execution.completed_at,
|
|
)
|
|
return
|
|
for name in sorted(current_level):
|
|
ordered.append(name)
|
|
remaining.remove(name)
|
|
for dep in dependents[name]:
|
|
in_degree[dep] -= 1
|
|
|
|
# Execute stages in order
|
|
for stage_name in ordered:
|
|
stage = stage_map[stage_name]
|
|
execution.current_stage = stage_name
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
current_stage=stage_name,
|
|
)
|
|
|
|
# Notify WebSocket subscribers
|
|
await _broadcast_ws({
|
|
"event": "stage_started",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
})
|
|
|
|
try:
|
|
if stage.type == "approval":
|
|
# Pause execution and wait for approval via asyncio.Event
|
|
event_key = f"{execution.execution_id}:{stage_name}"
|
|
approval_event = asyncio.Event()
|
|
_store._approval_events[event_key] = approval_event
|
|
|
|
execution.status = "paused"
|
|
execution.current_stage = stage_name
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="paused",
|
|
current_stage=stage_name,
|
|
)
|
|
await _broadcast_ws({
|
|
"event": "approval_required",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
})
|
|
|
|
# Wait for approval with timeout
|
|
try:
|
|
approval_timeout = stage.config.get("approval_timeout", 3600)
|
|
await asyncio.wait_for(approval_event.wait(), timeout=approval_timeout)
|
|
# Check if execution was cancelled/rejected while waiting
|
|
if execution.status == "cancelled":
|
|
await _broadcast_ws({
|
|
"event": "stage_failed",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
"error": "Approval rejected",
|
|
})
|
|
return
|
|
# Approval was granted — the /approve endpoint already set stage_results
|
|
# Only update status to running if not already set
|
|
if execution.status != "running":
|
|
execution.status = "running"
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="running",
|
|
)
|
|
except asyncio.TimeoutError:
|
|
execution.stage_results[stage_name] = {
|
|
"status": "timeout",
|
|
"approver": "none",
|
|
"comment": "审批超时",
|
|
}
|
|
execution.status = "failed"
|
|
execution.error = f"Approval timeout for stage {stage_name}"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="failed",
|
|
error=execution.error,
|
|
completed_at=execution.completed_at,
|
|
stage_results=execution.stage_results,
|
|
)
|
|
await _broadcast_ws({
|
|
"event": "stage_failed",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
"error": "Approval timeout",
|
|
})
|
|
return
|
|
finally:
|
|
_store._approval_events.pop(event_key, None)
|
|
elif stage.type == "condition":
|
|
# Evaluate condition expression
|
|
condition_expr = stage.config.get("expression", "")
|
|
result = _evaluate_condition(condition_expr, variables)
|
|
execution.stage_results[stage_name] = {
|
|
"status": "completed",
|
|
"condition_result": result,
|
|
}
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
stage_results=execution.stage_results,
|
|
)
|
|
else:
|
|
# Skill or parallel stage - simulate execution
|
|
execution.stage_results[stage_name] = {
|
|
"status": "completed",
|
|
"output": {"dry_run": True, "action": stage.action},
|
|
}
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
stage_results=execution.stage_results,
|
|
)
|
|
|
|
await _broadcast_ws({
|
|
"event": "stage_completed",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
})
|
|
|
|
except Exception as e:
|
|
execution.stage_results[stage_name] = {
|
|
"status": "failed",
|
|
"error": str(e),
|
|
}
|
|
execution.status = "failed"
|
|
execution.error = f"阶段 '{stage_name}' 执行失败: {e}"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="failed",
|
|
error=execution.error,
|
|
completed_at=execution.completed_at,
|
|
stage_results=execution.stage_results,
|
|
)
|
|
await _broadcast_ws({
|
|
"event": "stage_failed",
|
|
"execution_id": execution.execution_id,
|
|
"stage": stage_name,
|
|
"error": str(e),
|
|
})
|
|
return
|
|
|
|
execution.status = "completed"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
execution.current_stage = None
|
|
_store.update_execution(
|
|
execution.execution_id,
|
|
status="completed",
|
|
completed_at=execution.completed_at,
|
|
current_stage=None,
|
|
)
|
|
await _broadcast_ws({
|
|
"event": "execution_completed",
|
|
"execution_id": execution.execution_id,
|
|
})
|
|
|
|
|
|
_SAFE_VAR_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
|
_SAFE_OPERATORS = {"==", "!=", ">", "<", ">=", "<="}
|
|
|
|
|
|
def _evaluate_condition(expression: str, variables: dict[str, Any]) -> bool:
|
|
"""Evaluate a condition expression safely."""
|
|
expression = expression.strip()
|
|
if not expression:
|
|
return True
|
|
|
|
# Try each operator (longer operators first to avoid partial matches)
|
|
for op in sorted(_SAFE_OPERATORS, key=len, reverse=True):
|
|
if op in expression:
|
|
parts = expression.split(op, 1)
|
|
if len(parts) != 2:
|
|
continue
|
|
left = parts[0].strip()
|
|
right = parts[1].strip()
|
|
|
|
# Validate variable names
|
|
if left and not _SAFE_VAR_PATTERN.match(left):
|
|
raise ValueError(f"Invalid variable name in condition: {left}")
|
|
|
|
if left and left not in variables:
|
|
# Missing variable: treat as None/empty — condition evaluates to False
|
|
left_val = None
|
|
else:
|
|
left_val = variables.get(left, left)
|
|
# Strip quotes from right side if present
|
|
if right.startswith('"') and right.endswith('"'):
|
|
right_val = right[1:-1]
|
|
elif right.startswith("'") and right.endswith("'"):
|
|
right_val = right[1:-1]
|
|
elif right and _SAFE_VAR_PATTERN.match(right):
|
|
right_val = variables.get(right, right)
|
|
else:
|
|
right_val = right
|
|
|
|
# Compare based on operator
|
|
if op == "==":
|
|
return str(left_val) == str(right_val)
|
|
if op == "!=":
|
|
return str(left_val) != str(right_val)
|
|
try:
|
|
left_num = float(left_val)
|
|
right_num = float(right_val)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if op == ">":
|
|
return left_num > right_num
|
|
if op == "<":
|
|
return left_num < right_num
|
|
if op == ">=":
|
|
return left_num >= right_num
|
|
if op == "<=":
|
|
return left_num <= right_num
|
|
|
|
# Boolean check for variable existence
|
|
if _SAFE_VAR_PATTERN.match(expression):
|
|
return bool(variables.get(expression))
|
|
|
|
raise ValueError(f"Invalid condition expression: {expression}")
|
|
|
|
|
|
async def _broadcast_ws(message: dict[str, Any]) -> None:
|
|
"""Broadcast a message to all WebSocket subscribers."""
|
|
disconnected = []
|
|
for ws in _ws_subscribers:
|
|
try:
|
|
await ws.send_json(message)
|
|
except Exception:
|
|
disconnected.append(ws)
|
|
for ws in disconnected:
|
|
_ws_subscribers.remove(ws)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/workflows")
|
|
async def list_workflows(request: Request, limit: int = 50, _auth: None = Depends(_verify_api_key)):
|
|
"""List all workflows."""
|
|
store = _get_store(request)
|
|
summaries = store.list(limit=limit)
|
|
return {"workflows": [s.model_dump() for s in summaries], "total": len(summaries)}
|
|
|
|
|
|
@router.post("/workflows", status_code=201)
|
|
async def create_workflow(request: Request, body: CreateWorkflowRequest, _auth: None = Depends(_verify_api_key)):
|
|
"""Create a new workflow."""
|
|
store = _get_store(request)
|
|
_validate_workflow_stages(body.stages)
|
|
|
|
workflow = WorkflowDefinition(
|
|
workflow_id=str(uuid.uuid4()),
|
|
name=body.name,
|
|
stages=body.stages,
|
|
triggers=body.triggers,
|
|
variables_schema=body.variables_schema,
|
|
output_schema=body.output_schema,
|
|
)
|
|
saved = store.save(workflow)
|
|
return saved.model_dump()
|
|
|
|
|
|
@router.get("/workflows/{workflow_id}")
|
|
async def get_workflow(request: Request, workflow_id: str, _auth: None = Depends(_verify_api_key)):
|
|
"""Get a workflow by ID."""
|
|
store = _get_store(request)
|
|
workflow = store.get(workflow_id)
|
|
if workflow is None:
|
|
raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在")
|
|
return workflow.model_dump()
|
|
|
|
|
|
@router.put("/workflows/{workflow_id}")
|
|
async def update_workflow(
|
|
request: Request, workflow_id: str, body: CreateWorkflowRequest,
|
|
_auth: None = Depends(_verify_api_key),
|
|
):
|
|
"""Update an existing workflow."""
|
|
store = _get_store(request)
|
|
existing = store.get(workflow_id)
|
|
if existing is None:
|
|
raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在")
|
|
|
|
_validate_workflow_stages(body.stages)
|
|
|
|
existing.name = body.name
|
|
existing.stages = body.stages
|
|
existing.triggers = body.triggers
|
|
existing.variables_schema = body.variables_schema
|
|
existing.output_schema = body.output_schema
|
|
existing.version += 1
|
|
saved = store.save(existing)
|
|
return saved.model_dump()
|
|
|
|
|
|
@router.delete("/workflows/{workflow_id}")
|
|
async def delete_workflow(request: Request, workflow_id: str, _auth: None = Depends(_verify_api_key)):
|
|
"""Delete a workflow."""
|
|
store = _get_store(request)
|
|
deleted = store.delete(workflow_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在")
|
|
return {"message": "已删除"}
|
|
|
|
|
|
@router.post("/workflows/{workflow_id}/execute")
|
|
async def execute_workflow(
|
|
request: Request, workflow_id: str, body: ExecuteWorkflowRequest
|
|
):
|
|
"""Execute a workflow."""
|
|
store = _get_store(request)
|
|
workflow = store.get(workflow_id)
|
|
if workflow is None:
|
|
raise HTTPException(status_code=404, detail=f"工作流 '{workflow_id}' 不存在")
|
|
|
|
execution = store.create_execution(workflow_id)
|
|
execution.variables = body.variables
|
|
|
|
# Start execution in background
|
|
task = asyncio.create_task(
|
|
_execute_workflow(workflow, execution, body.variables, store=store)
|
|
)
|
|
store._running_tasks = getattr(store, "_running_tasks", {})
|
|
store._running_tasks[execution.execution_id] = task
|
|
task.add_done_callback(lambda t: store._running_tasks.pop(execution.execution_id, None))
|
|
|
|
return {
|
|
"execution_id": execution.execution_id,
|
|
"workflow_id": workflow_id,
|
|
"status": execution.status,
|
|
}
|
|
|
|
|
|
@router.get("/workflows/executions/{execution_id}")
|
|
async def get_execution(request: Request, execution_id: str):
|
|
"""Get execution status."""
|
|
store = _get_store(request)
|
|
execution = store.get_execution(execution_id)
|
|
if execution is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"执行记录 '{execution_id}' 不存在"
|
|
)
|
|
return execution.model_dump()
|
|
|
|
|
|
@router.post("/workflows/executions/{execution_id}/approve")
|
|
async def approve_execution(
|
|
request: Request, execution_id: str, body: ApproveRequest,
|
|
_auth: None = Depends(_verify_api_key),
|
|
):
|
|
"""Approve a paused approval node."""
|
|
store = _get_store(request)
|
|
execution = store.get_execution(execution_id)
|
|
if execution is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"执行记录 '{execution_id}' 不存在"
|
|
)
|
|
if execution.status != "paused":
|
|
raise HTTPException(
|
|
status_code=400, detail="当前执行状态不是等待审批"
|
|
)
|
|
|
|
if body.approved:
|
|
if execution.current_stage:
|
|
execution.stage_results[execution.current_stage] = {
|
|
"status": "approved",
|
|
"approver": "user",
|
|
"comment": body.comment,
|
|
}
|
|
execution.status = "running"
|
|
store.update_execution(
|
|
execution.execution_id,
|
|
status="running",
|
|
stage_results=execution.stage_results,
|
|
)
|
|
# Resume the waiting execution by setting the approval event
|
|
stage_name = execution.current_stage
|
|
if stage_name:
|
|
event_key = f"{execution_id}:{stage_name}"
|
|
if event_key in store._approval_events:
|
|
store._approval_events[event_key].set()
|
|
else:
|
|
execution.status = "cancelled"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
if execution.current_stage:
|
|
execution.stage_results[execution.current_stage] = {
|
|
"status": "rejected",
|
|
"approver": "user",
|
|
"comment": body.comment,
|
|
}
|
|
store.update_execution(
|
|
execution.execution_id,
|
|
status="cancelled",
|
|
completed_at=execution.completed_at,
|
|
stage_results=execution.stage_results,
|
|
)
|
|
# Set the approval event so the waiting coroutine can observe the cancelled state
|
|
stage_name = execution.current_stage
|
|
if stage_name:
|
|
event_key = f"{execution_id}:{stage_name}"
|
|
if event_key in store._approval_events:
|
|
store._approval_events[event_key].set()
|
|
|
|
return execution.model_dump()
|
|
|
|
|
|
@router.post("/workflows/executions/{execution_id}/cancel")
|
|
async def cancel_execution(request: Request, execution_id: str, _auth: None = Depends(_verify_api_key)):
|
|
"""Cancel a running execution."""
|
|
store = _get_store(request)
|
|
execution = store.get_execution(execution_id)
|
|
if execution is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"执行记录 '{execution_id}' 不存在"
|
|
)
|
|
if execution.status not in ("running", "paused", "pending"):
|
|
raise HTTPException(
|
|
status_code=400, detail="当前执行状态无法取消"
|
|
)
|
|
|
|
execution.status = "cancelled"
|
|
execution.completed_at = datetime.now(timezone.utc).isoformat()
|
|
store.update_execution(
|
|
execution.execution_id,
|
|
status="cancelled",
|
|
completed_at=execution.completed_at,
|
|
)
|
|
# Set any pending approval event so a paused workflow can observe the cancelled state
|
|
if hasattr(execution, "current_stage") and execution.current_stage:
|
|
event_key = f"{execution_id}:{execution.current_stage}"
|
|
if event_key in store._approval_events:
|
|
store._approval_events[event_key].set()
|
|
return execution.model_dump()
|
|
|
|
|
|
@router.websocket("/workflows/ws")
|
|
async def workflow_websocket(websocket: WebSocket):
|
|
"""Real-time workflow execution progress WebSocket."""
|
|
# Authentication
|
|
configured_api_key: str | None = None
|
|
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config:
|
|
configured_api_key = websocket.app.state.server_config.api_key
|
|
if configured_api_key is None and hasattr(websocket.app.state, "api_key"):
|
|
configured_api_key = websocket.app.state.api_key
|
|
|
|
if configured_api_key:
|
|
provided = websocket.query_params.get("api_key")
|
|
if provided != configured_api_key:
|
|
await websocket.accept()
|
|
await websocket.send_json(
|
|
{"event": "error", "data": {"message": "Invalid or missing api_key"}}
|
|
)
|
|
await websocket.close(code=4001, reason="Invalid or missing api_key")
|
|
return
|
|
|
|
await websocket.accept()
|
|
_ws_subscribers.append(websocket)
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
raw = await asyncio.wait_for(websocket.receive_text(), timeout=120.0)
|
|
except asyncio.TimeoutError:
|
|
await websocket.close(code=1000, reason="Heartbeat timeout")
|
|
return
|
|
# Keep connection alive - messages are primarily server-push
|
|
except WebSocketDisconnect:
|
|
logger.debug("Workflow WebSocket disconnected")
|
|
except Exception as e:
|
|
logger.error(f"Workflow WebSocket error: {e}")
|
|
finally:
|
|
if websocket in _ws_subscribers:
|
|
_ws_subscribers.remove(websocket)
|