# 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, )