571 lines
18 KiB
Python
571 lines
18 KiB
Python
"""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,
|
|
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,
|
|
AgentArtifactsCommand,
|
|
AgentArtifactsResult,
|
|
AgentRunCancelCommand,
|
|
AgentRunCommand,
|
|
AgentRunDispatchFailureCommand,
|
|
AgentRunFailureCommand,
|
|
AgentTaskContext,
|
|
DispatchDueSchedulesCommand,
|
|
DispatchDueSchedulesResult,
|
|
EvidenceRecord,
|
|
ExternalFeedbackCommand,
|
|
ExternalFeedbackResult,
|
|
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,
|
|
permissions_for_context(ctx),
|
|
)
|
|
|
|
|
|
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:
|
|
await service.detail(task_id)
|
|
snapshot = await read_task_data(
|
|
context=ctx, query=query, credential=authorization
|
|
)
|
|
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}/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.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)
|