"""Project-scoped APIs for operational tasks, evidence and approved actions.""" from __future__ import annotations from datetime import datetime from typing import Literal from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from psycopg import AsyncConnection from app.auth.permissions import ( OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_EXTERNAL_SYNC, OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, SCADA_VIEW, SIMULATION_VIEW, permissions_for_context, require_permission, ) from app.auth.project_dependencies import ( ProjectContext, get_project_context, get_project_pg_connection, ) from app.domain.domain_errors import DomainError from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot from app.domain.schemas.operational_task import ( ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand, WorkOrderDraftUpdateCommand, WorkOrderConfirmDispatchCommand, TaskAccessRequest, TaskAccessRequestCreate, TaskAccessRequestDecision, AgentArtifactsCommand, AgentArtifactsResult, AgentRunCancelCommand, AgentRunCommand, AgentRunDispatchFailureCommand, AgentRunFailureCommand, AgentTaskContext, DispatchDueSchedulesCommand, DispatchDueSchedulesResult, EvidenceRecord, ExternalFeedbackCommand, ExternalFeedbackResult, ExternalSourceWithdrawalCommand, HumanApprovalAction, HumanEvidenceCommand, NotificationAcknowledgeCommand, OperationalTaskCreate, OperationalTaskDetail, OperationalTaskNotification, OperationalTaskNotificationPage, OperationalTaskPage, OperationalTaskRetryCommand, OperationalTaskRun, OperationalTaskRunPage, OperationalTaskSchedule, OperationalTaskScheduleCreate, OperationalTaskSchedulePage, OperationalTaskScheduleUpdate, RecoverExpiredLeasesCommand, RecoverExpiredLeasesResult, TaskStatus, TaskDataQueryResult, TaskType, ) from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository from app.infra.integrations.operational_actions import ( OperationalActionDispatcher, get_operational_action_dispatcher, ) from app.infra.integrations.operational_data import TaskDataError, read_task_data from app.services.operational_tasks import OperationalTaskService router = APIRouter() def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService: return OperationalTaskService( OperationalTaskRepository(conn, ctx.project_id), ctx, _task_permissions(ctx), ) def _task_permissions(ctx: ProjectContext) -> set[str]: permissions = set(permissions_for_context(ctx)) if ctx.system_role == "service": permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW}) return permissions def _raise_domain(exc: DomainError): raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc @router.post( "/operational-tasks", response_model=OperationalTaskDetail, status_code=status.HTTP_201_CREATED, ) async def create_operational_task( command: OperationalTaskCreate, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).create(command) except DomainError as exc: _raise_domain(exc) @router.get("/operational-tasks", response_model=OperationalTaskPage) async def list_operational_tasks( task_type: TaskType | None = None, task_status: TaskStatus | None = Query(default=None, alias="status"), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: items, next_offset = await _service(ctx, conn).list( task_type=task_type, status=task_status, limit=limit, offset=offset ) return OperationalTaskPage( items=items, limit=limit, offset=offset, next_offset=next_offset ) except DomainError as exc: _raise_domain(exc) @router.get("/operational-task-runs", response_model=OperationalTaskRunPage) async def list_operational_task_runs( run_status: Literal[ "queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled" ] | None = Query(default=None, alias="status"), limit: int = Query(default=50, ge=1, le=100), cursor: str | None = None, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): cursor_created_at = None cursor_run_id = None if cursor: try: timestamp, run_id = cursor.rsplit("|", 1) cursor_created_at = datetime.fromisoformat(timestamp) cursor_run_id = UUID(run_id) except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail="Invalid run cursor") from exc try: items, next_cursor = await _service(ctx, conn).list_runs( status=run_status, limit=limit, cursor_created_at=cursor_created_at, cursor_run_id=cursor_run_id, ) return OperationalTaskRunPage(items=items, next_cursor=next_cursor) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/schedules", response_model=OperationalTaskSchedule, status_code=status.HTTP_201_CREATED, ) async def create_operational_schedule( command: OperationalTaskScheduleCreate, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).create_schedule(command) except DomainError as exc: _raise_domain(exc) @router.get( "/operational-tasks/schedules", response_model=OperationalTaskSchedulePage ) async def list_operational_schedules( ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return OperationalTaskSchedulePage(items=await _service(ctx, conn).list_schedules()) except DomainError as exc: _raise_domain(exc) @router.patch( "/operational-tasks/schedules/{schedule_id}", response_model=OperationalTaskSchedule, ) async def update_operational_schedule( schedule_id: UUID, command: OperationalTaskScheduleUpdate, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).update_schedule(schedule_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/schedules/dispatch-due", response_model=DispatchDueSchedulesResult, ) async def dispatch_due_operational_schedules( command: DispatchDueSchedulesCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).dispatch_due_schedules(command.limit) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/leases/recover", response_model=RecoverExpiredLeasesResult, ) async def recover_operational_task_leases( command: RecoverExpiredLeasesCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).recover_expired_leases(command.limit) except DomainError as exc: _raise_domain(exc) @router.get( "/operational-task-notifications", response_model=OperationalTaskNotificationPage, ) async def list_operational_task_notifications( unread_only: bool = False, limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: items, next_offset = await _service(ctx, conn).list_notifications( unread_only=unread_only, limit=limit, offset=offset ) return OperationalTaskNotificationPage( items=items, limit=limit, offset=offset, next_offset=next_offset ) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-task-notifications/{notification_id}/acknowledge", response_model=OperationalTaskNotification, ) async def acknowledge_operational_task_notification( notification_id: UUID, command: NotificationAcknowledgeCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).acknowledge_notification(notification_id, command) except DomainError as exc: _raise_domain(exc) @router.get("/operational-tasks/{task_id}", response_model=OperationalTaskDetail) async def get_operational_task( task_id: UUID, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).detail(task_id) except DomainError as exc: _raise_domain(exc) @router.post("/operational-tasks/{task_id}/retry", response_model=OperationalTaskDetail) async def retry_operational_task( task_id: UUID, command: OperationalTaskRetryCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).retry_task(task_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/claim", response_model=OperationalTaskRun, ) async def claim_operational_task_run( task_id: UUID, run_id: UUID, command: AgentRunCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).claim_run(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/heartbeat", response_model=OperationalTaskRun, ) async def heartbeat_operational_task_run( task_id: UUID, run_id: UUID, command: AgentRunCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).heartbeat_run(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/complete", response_model=OperationalTaskDetail, ) async def complete_operational_task_run( task_id: UUID, run_id: UUID, command: AgentRunCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).complete_run(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/fail", response_model=OperationalTaskDetail, ) async def fail_operational_task_run( task_id: UUID, run_id: UUID, command: AgentRunFailureCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).fail_run(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/cancel", response_model=OperationalTaskDetail, ) async def cancel_operational_task_run( task_id: UUID, run_id: UUID, command: AgentRunCancelCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).cancel_run(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/runs/{run_id}/dispatch-failure", response_model=OperationalTaskRun, ) async def record_operational_task_run_dispatch_failure( task_id: UUID, run_id: UUID, command: AgentRunDispatchFailureCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).record_dispatch_failure(task_id, run_id, command) except DomainError as exc: _raise_domain(exc) @router.get( "/operational-tasks/{task_id}/agent-context", response_model=AgentTaskContext ) async def get_operational_task_agent_context( task_id: UUID, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).agent_context(task_id) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/agent-artifacts", response_model=AgentArtifactsResult, ) async def publish_operational_task_artifacts( task_id: UUID, command: AgentArtifactsCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).publish_artifacts(task_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/data-query", response_model=TaskDataQueryResult ) async def query_operational_task_data( task_id: UUID, query: TaskDataQuery, authorization: str | None = Header(default=None, alias="Authorization"), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): service = _service(ctx, conn) try: if not await service.can_read_source(task_id, query.source): raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403) source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW snapshot = await read_task_data( context=ctx, query=query, credential=authorization, delegated=source_permission not in _task_permissions(ctx), ) evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot) return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot) except TaskDataError as exc: raise HTTPException(status_code=exc.status, detail=exc.detail) from exc except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/access-requests", response_model=TaskAccessRequest, status_code=status.HTTP_201_CREATED, ) async def request_operational_task_access( task_id: UUID, command: TaskAccessRequestCreate, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).request_access(task_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/access-requests/{access_request_id}/decision", response_model=TaskAccessRequest, ) async def decide_operational_task_access( task_id: UUID, access_request_id: UUID, command: TaskAccessRequestDecision, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).decide_access(task_id, access_request_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/human-evidence", response_model=OperationalTaskDetail, ) async def add_operational_task_human_evidence( task_id: UUID, command: HumanEvidenceCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).add_human_evidence(task_id, command) except DomainError as exc: _raise_domain(exc) @router.get( "/operational-tasks/{task_id}/evidence/{evidence_id}/snapshot", response_model=TaskDataSnapshot, ) async def get_operational_task_data_snapshot( task_id: UUID, evidence_id: str, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).data_snapshot(task_id, evidence_id) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/actions", response_model=HumanApprovalAction, status_code=status.HTTP_201_CREATED, ) async def propose_operational_task_action( task_id: UUID, command: ActionProposalCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).propose_action(task_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/actions/{action_id}/decision", response_model=HumanApprovalAction, ) async def decide_operational_task_action( task_id: UUID, action_id: UUID, command: ActionDecisionCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).decide_action(task_id, action_id, command) except DomainError as exc: _raise_domain(exc) @router.patch( "/operational-tasks/{task_id}/actions/{action_id}/draft", response_model=HumanApprovalAction, ) async def update_operational_work_order_draft( task_id: UUID, action_id: UUID, command: WorkOrderDraftUpdateCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).update_work_order_draft(task_id, action_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch", response_model=HumanApprovalAction, ) async def confirm_operational_work_order_dispatch( task_id: UUID, action_id: UUID, command: WorkOrderConfirmDispatchCommand, authorization: str | None = Header(default=None, alias="Authorization"), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher), ): try: return await _service(ctx, conn).confirm_work_order_dispatch( task_id, action_id, command, dispatcher, authorization ) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/actions/{action_id}/execute", response_model=HumanApprovalAction, ) async def execute_operational_task_action( task_id: UUID, action_id: UUID, command: ActionExecuteCommand, authorization: str | None = Header(default=None, alias="Authorization"), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher), ): try: return await _service(ctx, conn).submit_action( task_id, action_id, command, dispatcher, authorization ) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/actions/{action_id}/reconcile", response_model=HumanApprovalAction, ) async def reconcile_operational_task_action( task_id: UUID, action_id: UUID, command: ActionReconcileCommand, authorization: str | None = Header(default=None, alias="Authorization"), ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), conn: AsyncConnection = Depends(get_project_pg_connection), dispatcher: OperationalActionDispatcher = Depends(get_operational_action_dispatcher), ): try: return await _service(ctx, conn).reconcile_action( task_id, action_id, command, dispatcher, authorization ) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/external-feedback", response_model=ExternalFeedbackResult, ) async def synchronize_operational_task_feedback( task_id: UUID, command: ExternalFeedbackCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).external_feedback(task_id, command) except DomainError as exc: _raise_domain(exc) @router.post( "/operational-tasks/{task_id}/withdraw-external-source", response_model=OperationalTaskDetail, ) async def withdraw_operational_task_external_source( task_id: UUID, command: ExternalSourceWithdrawalCommand, ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)), conn: AsyncConnection = Depends(get_project_pg_connection), ): try: return await _service(ctx, conn).withdraw_external_source(task_id, command) except DomainError as exc: _raise_domain(exc)