368 lines
14 KiB
Python
368 lines
14 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Automation2 routes - node-types, execute, workflows, runs, tasks.
|
|
"""
|
|
|
|
import logging
|
|
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
|
|
from modules.auth import limiter, getRequestContext, RequestContext
|
|
|
|
from modules.features.automation2.mainAutomation2 import getAutomation2Services
|
|
from modules.features.automation2.nodeRegistry import getNodeTypesForApi
|
|
from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface
|
|
from modules.workflows.automation2.executionEngine import executeGraph
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/automation2",
|
|
tags=["Automation2"],
|
|
responses={404: {"description": "Not found"}, 403: {"description": "Forbidden"}},
|
|
)
|
|
|
|
|
|
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|
"""Validate user has access to the automation2 feature instance. Returns mandateId."""
|
|
from fastapi import HTTPException
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
|
|
rootInterface = getRootInterface()
|
|
instance = rootInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found")
|
|
featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId)
|
|
if not featureAccess or not featureAccess.enabled:
|
|
raise HTTPException(status_code=403, detail="Access denied to this feature instance")
|
|
return str(instance.mandateId) if instance.mandateId else ""
|
|
|
|
|
|
@router.get("/{instanceId}/info")
|
|
@limiter.limit("60/minute")
|
|
def get_automation2_info(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Minimal info endpoint - proves the feature works."""
|
|
_validateInstanceAccess(instanceId, context)
|
|
return {
|
|
"featureCode": "automation2",
|
|
"instanceId": instanceId,
|
|
"status": "ok",
|
|
"message": "Automation2 feature ready. Build from here.",
|
|
}
|
|
|
|
|
|
@router.get("/{instanceId}/node-types")
|
|
@limiter.limit("60/minute")
|
|
def get_node_types(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
language: str = Query("en", description="Localization (en, de, fr)"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Return node types for the flow builder: static + I/O from methodDiscovery."""
|
|
logger.info("automation2 node-types request: instanceId=%s language=%s", instanceId, language)
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
services = getAutomation2Services(
|
|
context.user,
|
|
mandateId=mandateId,
|
|
featureInstanceId=instanceId,
|
|
)
|
|
result = getNodeTypesForApi(services, language=language)
|
|
logger.info(
|
|
"automation2 node-types response: %d nodeTypes %d categories",
|
|
len(result.get("nodeTypes", [])),
|
|
len(result.get("categories", [])),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/{instanceId}/execute")
|
|
@limiter.limit("30/minute")
|
|
async def post_execute(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
body: dict = Body(..., description="{ workflowId?, graph: { nodes, connections } }"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Execute automation2 graph. Body: { workflowId?, graph: { nodes, connections } }."""
|
|
userId = str(context.user.id) if context.user else None
|
|
logger.info(
|
|
"automation2 execute request: instanceId=%s userId=%s body_keys=%s",
|
|
instanceId,
|
|
userId,
|
|
list(body.keys()),
|
|
)
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
services = getAutomation2Services(
|
|
context.user,
|
|
mandateId=mandateId,
|
|
featureInstanceId=instanceId,
|
|
)
|
|
graph = body.get("graph") or body
|
|
workflowId = body.get("workflowId")
|
|
if workflowId:
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
wf = a2.getWorkflow(workflowId)
|
|
if wf and wf.get("graph"):
|
|
graph = wf["graph"]
|
|
logger.info("automation2 execute: loaded graph from workflow %s", workflowId)
|
|
nodes_count = len(graph.get("nodes") or [])
|
|
connections_count = len(graph.get("connections") or [])
|
|
logger.info(
|
|
"automation2 execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s",
|
|
nodes_count,
|
|
connections_count,
|
|
workflowId,
|
|
mandateId,
|
|
)
|
|
a2_interface = getAutomation2Interface(context.user, mandateId, instanceId) if workflowId else None
|
|
result = await executeGraph(
|
|
graph=graph,
|
|
services=services,
|
|
workflowId=workflowId,
|
|
instanceId=instanceId,
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
automation2_interface=a2_interface,
|
|
)
|
|
logger.info(
|
|
"automation2 execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s",
|
|
result.get("success"),
|
|
result.get("error"),
|
|
list(result.get("nodeOutputs", {}).keys()) if result.get("nodeOutputs") else [],
|
|
result.get("failedNode"),
|
|
result.get("paused"),
|
|
)
|
|
return result
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Workflow CRUD
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{instanceId}/workflows")
|
|
@limiter.limit("60/minute")
|
|
def get_workflows(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""List all workflows for this feature instance."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
items = a2.getWorkflows()
|
|
return {"workflows": items}
|
|
|
|
|
|
@router.get("/{instanceId}/workflows/{workflowId}")
|
|
@limiter.limit("60/minute")
|
|
def get_workflow(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
workflowId: str = Path(..., description="Workflow ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Get a single workflow by ID."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
wf = a2.getWorkflow(workflowId)
|
|
if not wf:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
return wf
|
|
|
|
|
|
@router.post("/{instanceId}/workflows")
|
|
@limiter.limit("30/minute")
|
|
def create_workflow(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
body: dict = Body(..., description="{ label, graph }"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Create a new workflow."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
created = a2.createWorkflow(body)
|
|
return created
|
|
|
|
|
|
@router.put("/{instanceId}/workflows/{workflowId}")
|
|
@limiter.limit("30/minute")
|
|
def update_workflow(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
workflowId: str = Path(..., description="Workflow ID"),
|
|
body: dict = Body(..., description="{ label?, graph? }"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Update a workflow."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
updated = a2.updateWorkflow(workflowId, body)
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
return updated
|
|
|
|
|
|
@router.delete("/{instanceId}/workflows/{workflowId}")
|
|
@limiter.limit("30/minute")
|
|
def delete_workflow(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
workflowId: str = Path(..., description="Workflow ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Delete a workflow."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
if not a2.deleteWorkflow(workflowId):
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
return {"success": True}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Runs and Resume
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{instanceId}/workflows/{workflowId}/runs")
|
|
@limiter.limit("60/minute")
|
|
def get_workflow_runs(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
workflowId: str = Path(..., description="Workflow ID"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Get runs for a workflow."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
if not a2.getWorkflow(workflowId):
|
|
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
runs = a2.getRunsByWorkflow(workflowId)
|
|
return {"runs": runs}
|
|
|
|
|
|
@router.post("/{instanceId}/runs/{runId}/resume")
|
|
@limiter.limit("30/minute")
|
|
async def resume_run(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
runId: str = Path(..., description="Run ID"),
|
|
body: dict = Body(..., description="{ taskId, result }"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Resume a paused run after task completion."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
run = a2.getRun(runId)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
taskId = body.get("taskId")
|
|
result = body.get("result")
|
|
if not taskId or result is None:
|
|
raise HTTPException(status_code=400, detail="taskId and result required")
|
|
task = a2.getTask(taskId)
|
|
if not task or task.get("runId") != runId:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
if task.get("status") != "pending":
|
|
raise HTTPException(status_code=400, detail="Task already completed")
|
|
a2.updateTask(taskId, status="completed", result=result)
|
|
nodeId = task.get("nodeId")
|
|
nodeOutputs = dict(run.get("nodeOutputs") or {})
|
|
nodeOutputs[nodeId] = result
|
|
runContext = run.get("context") or {}
|
|
connectionMap = runContext.get("connectionMap", {})
|
|
inputSources = runContext.get("inputSources", {})
|
|
workflowId = run.get("workflowId")
|
|
wf = a2.getWorkflow(workflowId) if workflowId else None
|
|
if not wf or not wf.get("graph"):
|
|
raise HTTPException(status_code=400, detail="Workflow graph not found")
|
|
graph = wf["graph"]
|
|
services = getAutomation2Services(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
resume_result = await executeGraph(
|
|
graph=graph,
|
|
services=services,
|
|
workflowId=workflowId,
|
|
instanceId=instanceId,
|
|
userId=str(context.user.id) if context.user else None,
|
|
mandateId=mandateId,
|
|
automation2_interface=a2,
|
|
initialNodeOutputs=nodeOutputs,
|
|
startAfterNodeId=nodeId,
|
|
runId=runId,
|
|
)
|
|
return resume_result
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Tasks
|
|
# -------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/{instanceId}/tasks")
|
|
@limiter.limit("60/minute")
|
|
def get_tasks(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
workflowId: str = Query(None, description="Filter by workflow ID"),
|
|
status: str = Query(None, description="Filter: pending, completed, rejected"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Get tasks - by default those assigned to current user, or all if no assignee filter."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
assigneeId = str(context.user.id) if context.user else None
|
|
items = a2.getTasks(workflowId=workflowId, status=status, assigneeId=assigneeId)
|
|
return {"tasks": items}
|
|
|
|
|
|
@router.post("/{instanceId}/tasks/{taskId}/complete")
|
|
@limiter.limit("30/minute")
|
|
async def complete_task(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature instance ID"),
|
|
taskId: str = Path(..., description="Task ID"),
|
|
body: dict = Body(..., description="{ result }"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> dict:
|
|
"""Complete a task and resume the run."""
|
|
mandateId = _validateInstanceAccess(instanceId, context)
|
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
|
task = a2.getTask(taskId)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
runId = task.get("runId")
|
|
result = body.get("result")
|
|
if result is None:
|
|
raise HTTPException(status_code=400, detail="result required")
|
|
run = a2.getRun(runId)
|
|
if not run:
|
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
if task.get("status") != "pending":
|
|
raise HTTPException(status_code=400, detail="Task already completed")
|
|
a2.updateTask(taskId, status="completed", result=result)
|
|
nodeId = task.get("nodeId")
|
|
nodeOutputs = dict(run.get("nodeOutputs") or {})
|
|
nodeOutputs[nodeId] = result
|
|
workflowId = run.get("workflowId")
|
|
wf = a2.getWorkflow(workflowId) if workflowId else None
|
|
if not wf or not wf.get("graph"):
|
|
raise HTTPException(status_code=400, detail="Workflow graph not found")
|
|
graph = wf["graph"]
|
|
services = getAutomation2Services(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return await executeGraph(
|
|
graph=graph,
|
|
services=services,
|
|
workflowId=workflowId,
|
|
instanceId=instanceId,
|
|
userId=str(context.user.id) if context.user else None,
|
|
mandateId=mandateId,
|
|
automation2_interface=a2,
|
|
initialNodeOutputs=nodeOutputs,
|
|
startAfterNodeId=nodeId,
|
|
runId=runId,
|
|
)
|