feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Authenticated workbench snapshot and resumable Server-Sent Events."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
from app.api.v1.endpoints.workbench_runtime import workbench_runtime
|
||||
from app.auth.permissions import OPERATIONAL_TASK_VIEW, permissions_for_context, require_permission
|
||||
from app.auth.project_dependencies import (
|
||||
ProjectContext,
|
||||
get_metadata_repository,
|
||||
get_project_business_routing,
|
||||
get_project_pg_connection,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.workbench_events import WorkbenchEvent, WorkbenchSyncSnapshot
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
from app.infra.db.project_routing import ActiveProjectRouting
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskService:
|
||||
return OperationalTaskService(
|
||||
OperationalTaskRepository(conn, ctx.project_id),
|
||||
ctx,
|
||||
permissions_for_context(ctx),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workbench/sync-snapshot", response_model=WorkbenchSyncSnapshot)
|
||||
async def workbench_sync_snapshot(
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
):
|
||||
async with conn.transaction(isolation_level="repeatable read", read_only=True):
|
||||
cursor = await WorkbenchEventRepository(conn, ctx.project_id).current_cursor()
|
||||
runtime = await workbench_runtime(ctx=ctx, conn=conn)
|
||||
tasks, _ = await _service(ctx, conn).list(
|
||||
task_type=None, status=None, limit=50, offset=0
|
||||
)
|
||||
notifications, _ = await _service(ctx, conn).list_notifications(
|
||||
unread_only=False, limit=100, offset=0
|
||||
)
|
||||
return WorkbenchSyncSnapshot(
|
||||
schema="workbench-sync-snapshot@1",
|
||||
event_cursor=cursor,
|
||||
runtime=runtime,
|
||||
tasks=tasks,
|
||||
notifications=notifications,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workbench/events")
|
||||
async def workbench_events(
|
||||
after: int | None = Query(default=None, ge=0),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
|
||||
routing: ActiveProjectRouting = Depends(get_project_business_routing),
|
||||
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||
):
|
||||
if not settings.WORKBENCH_EVENTS_ENABLED:
|
||||
raise HTTPException(status_code=503, detail="Workbench events are disabled")
|
||||
cursor = after
|
||||
if cursor is None and last_event_id:
|
||||
try:
|
||||
cursor = int(last_event_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid Last-Event-ID") from exc
|
||||
cursor = cursor or 0
|
||||
current_identity = _database_identity(routing.business_dsn)
|
||||
try:
|
||||
active_routings = await metadata_repo.list_active_project_db_routings("biz_data")
|
||||
finally:
|
||||
# FastAPI keeps yield dependencies alive for a StreamingResponse. The
|
||||
# stream only needs immutable routing values, so release the shared
|
||||
# metadata checkout before the five-minute SSE body begins.
|
||||
await metadata_repo.session.close()
|
||||
if any(
|
||||
item.project_id != ctx.project_id
|
||||
and _database_identity(item.dsn) == current_identity
|
||||
for item in active_routings
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Workbench events require one dedicated business database per project",
|
||||
)
|
||||
|
||||
async def stream():
|
||||
started = asyncio.get_running_loop().time()
|
||||
last_keepalive = started
|
||||
async with workbench_event_hubs.subscribe(ctx.project_id, routing.business_dsn) as hub:
|
||||
oldest = await hub.oldest_cursor()
|
||||
latest = await hub.current_cursor()
|
||||
if cursor > 0 and (
|
||||
oldest is None or cursor < oldest - 1 or cursor > latest
|
||||
):
|
||||
payload = {"schema": "workbench-event@1", "cursor": cursor}
|
||||
yield f"event: reset_required\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n"
|
||||
return
|
||||
current = cursor
|
||||
wake_generation = hub.generation
|
||||
while asyncio.get_running_loop().time() - started < 300:
|
||||
rows = await hub.read_after(current)
|
||||
for row in rows:
|
||||
event = WorkbenchEvent(
|
||||
schema="workbench-event@1",
|
||||
**row,
|
||||
)
|
||||
current = event.event_seq
|
||||
data = event.model_dump_json(by_alias=True)
|
||||
yield f"id: {current}\nevent: workbench_event\ndata: {data}\n\n"
|
||||
if rows:
|
||||
continue
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now - last_keepalive >= 15:
|
||||
yield f": keepalive {int(now)}\n\n"
|
||||
last_keepalive = now
|
||||
# LISTEN is only a wake-up path. The timeout also reconciles
|
||||
# against the durable cursor if a notification was missed.
|
||||
wake_generation = await hub.wait_for_wake(wake_generation, timeout=1)
|
||||
yield "event: reconnect\ndata: {}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Content-Encoding": "identity",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _database_identity(dsn: str) -> tuple[str, str, str]:
|
||||
values = conninfo_to_dict(dsn)
|
||||
return (
|
||||
values.get("host") or values.get("hostaddr") or "localhost",
|
||||
values.get("port") or "5432",
|
||||
values.get("dbname") or values.get("user") or "",
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from app.api.v1.endpoints import (
|
||||
leakage,
|
||||
meta,
|
||||
model_import,
|
||||
operational_tasks,
|
||||
project,
|
||||
project_data,
|
||||
scada,
|
||||
@@ -18,6 +19,7 @@ from app.api.v1.endpoints import (
|
||||
simulation,
|
||||
web_search,
|
||||
workbench_runtime,
|
||||
workbench_events,
|
||||
)
|
||||
from app.api.v1.endpoints.components import (
|
||||
controls,
|
||||
@@ -91,6 +93,14 @@ api_router.include_router(
|
||||
tags=["Workbench Runtime"],
|
||||
dependencies=[simulation_access],
|
||||
)
|
||||
api_router.include_router(
|
||||
workbench_events.router,
|
||||
tags=["Workbench Events"],
|
||||
)
|
||||
api_router.include_router(
|
||||
operational_tasks.router,
|
||||
tags=["Operational Tasks"],
|
||||
)
|
||||
api_router.include_router(access.router, tags=["Access Control"])
|
||||
api_router.include_router(agent_auth.router, tags=["Agent Auth"])
|
||||
api_router.include_router(
|
||||
|
||||
Reference in New Issue
Block a user