feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -22,6 +22,10 @@ class ProblemDetails(BaseModel):
|
||||
code: str
|
||||
trace_id: str
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
retryable: bool = False
|
||||
related_refs: list[Any] = Field(default_factory=list)
|
||||
recovery_action: str = "none"
|
||||
retry_after_seconds: int | None = None
|
||||
|
||||
|
||||
def _trace_id(request: Request) -> str:
|
||||
@@ -36,6 +40,10 @@ def _problem_response(
|
||||
detail: str,
|
||||
code: str,
|
||||
errors: list[dict[str, Any]] | None = None,
|
||||
retryable: bool = False,
|
||||
related_refs: list[Any] | None = None,
|
||||
recovery_action: str = "none",
|
||||
retry_after_seconds: int | None = None,
|
||||
) -> JSONResponse:
|
||||
problem = ProblemDetails(
|
||||
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
||||
@@ -46,6 +54,10 @@ def _problem_response(
|
||||
code=code,
|
||||
trace_id=_trace_id(request),
|
||||
errors=errors or [],
|
||||
retryable=retryable,
|
||||
related_refs=related_refs or [],
|
||||
recovery_action=recovery_action,
|
||||
retry_after_seconds=retry_after_seconds,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
@@ -89,7 +101,6 @@ def install_problem_details_handlers(app: FastAPI) -> None:
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
code_by_status = {
|
||||
401: "unauthenticated",
|
||||
403: "forbidden",
|
||||
@@ -98,12 +109,43 @@ def install_problem_details_handlers(app: FastAPI) -> None:
|
||||
422: "validation_error",
|
||||
503: "dependency_unavailable",
|
||||
}
|
||||
fallback_code = code_by_status.get(exc.status_code, "request_error")
|
||||
detail_payload = exc.detail if isinstance(exc.detail, dict) else None
|
||||
code = (
|
||||
detail_payload.get("code")
|
||||
if detail_payload and isinstance(detail_payload.get("code"), str)
|
||||
else fallback_code
|
||||
)
|
||||
detail = exc.detail if isinstance(exc.detail, str) else None
|
||||
if detail_payload:
|
||||
candidate = detail_payload.get("message") or detail_payload.get("detail")
|
||||
if isinstance(candidate, str):
|
||||
detail = candidate
|
||||
if detail is None:
|
||||
detail = code.replace("_", " ").title()
|
||||
related_refs = detail_payload.get("related_refs", []) if detail_payload else []
|
||||
if not isinstance(related_refs, list):
|
||||
related_refs = []
|
||||
recovery_action = (
|
||||
detail_payload.get("recovery_action", "none") if detail_payload else "none"
|
||||
)
|
||||
if not isinstance(recovery_action, str):
|
||||
recovery_action = "none"
|
||||
retry_after_seconds = (
|
||||
detail_payload.get("retry_after_seconds") if detail_payload else None
|
||||
)
|
||||
if not isinstance(retry_after_seconds, int):
|
||||
retry_after_seconds = None
|
||||
return _problem_response(
|
||||
request,
|
||||
status_code=exc.status_code,
|
||||
title=code_by_status.get(exc.status_code, "request_error")
|
||||
.replace("_", " ")
|
||||
.title(),
|
||||
title=fallback_code.replace("_", " ").title(),
|
||||
detail=detail,
|
||||
code=code_by_status.get(exc.status_code, "request_error"),
|
||||
code=code,
|
||||
retryable=(
|
||||
bool(detail_payload.get("retryable", False)) if detail_payload else False
|
||||
),
|
||||
related_refs=related_refs,
|
||||
recovery_action=recovery_action,
|
||||
retry_after_seconds=retry_after_seconds,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,6 +21,14 @@ MODEL_IMPORT = "model.import"
|
||||
AUDIT_VIEW = "audit.view"
|
||||
ENVIRONMENT_MANAGE = "environment.manage"
|
||||
MEMBERSHIP_MANAGE = "membership.manage"
|
||||
OPERATIONAL_TASK_VIEW = "operational_task.view"
|
||||
OPERATIONAL_TASK_WRITE = "operational_task.write"
|
||||
OPERATIONAL_TASK_EXECUTE = "operational_task.execute"
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC = "operational_task.external_sync"
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE = "operational_action.work_order.approve"
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE = "operational_action.work_order.execute"
|
||||
OPERATIONAL_ACTION_COMMAND_APPROVE = "operational_action.command.approve"
|
||||
OPERATIONAL_ACTION_COMMAND_EXECUTE = "operational_action.command.execute"
|
||||
|
||||
PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||
{
|
||||
@@ -36,6 +44,10 @@ PROJECT_MEMBER_PERMISSIONS = frozenset(
|
||||
RISK_RUN,
|
||||
OPTIMIZATION_VIEW,
|
||||
OPTIMIZATION_RUN,
|
||||
OPERATIONAL_TASK_VIEW,
|
||||
OPERATIONAL_TASK_WRITE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -44,6 +56,7 @@ PROJECT_VIEWER_PERMISSIONS = frozenset(
|
||||
WEBGIS_VIEW,
|
||||
SCADA_VIEW,
|
||||
SIMULATION_VIEW,
|
||||
OPERATIONAL_TASK_VIEW,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -53,6 +66,15 @@ SYSTEM_ADMIN_PERMISSIONS = frozenset(
|
||||
AUDIT_VIEW,
|
||||
ENVIRONMENT_MANAGE,
|
||||
MEMBERSHIP_MANAGE,
|
||||
OPERATIONAL_TASK_EXECUTE,
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||
}
|
||||
)
|
||||
|
||||
SYSTEM_SERVICE_PERMISSIONS = frozenset(
|
||||
{
|
||||
OPERATIONAL_TASK_EXECUTE,
|
||||
OPERATIONAL_TASK_EXTERNAL_SYNC,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,6 +93,15 @@ def resolve_permissions(
|
||||
permissions = set(PROJECT_ROLE_PERMISSIONS.get(project_role or "", frozenset()))
|
||||
if is_superuser or system_role == "admin":
|
||||
permissions.update(SYSTEM_ADMIN_PERMISSIONS)
|
||||
if project_role == "member":
|
||||
permissions.update(
|
||||
{
|
||||
OPERATIONAL_ACTION_COMMAND_APPROVE,
|
||||
OPERATIONAL_ACTION_COMMAND_EXECUTE,
|
||||
}
|
||||
)
|
||||
elif system_role == "service" and project_role == "member":
|
||||
permissions.update(SYSTEM_SERVICE_PERMISSIONS)
|
||||
return frozenset(permissions)
|
||||
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ async def resolve_project_business_routing(
|
||||
return ActiveProjectRouting(
|
||||
project_code=ctx.project_code,
|
||||
business_dsn=business.dsn,
|
||||
project_id=ctx.project_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +162,7 @@ async def resolve_project_simulation_routing(
|
||||
project_code=ctx.project_code,
|
||||
business_dsn=business.dsn,
|
||||
timescale_dsn=timescale.dsn,
|
||||
project_id=ctx.project_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ class Settings(BaseSettings):
|
||||
KEYCLOAK_AUDIENCE: str = ""
|
||||
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS: int = 900
|
||||
|
||||
# Authenticated workbench synchronization.
|
||||
WORKBENCH_EVENTS_ENABLED: bool = True
|
||||
WORKBENCH_EVENT_RETENTION_DAYS: int = 7
|
||||
CORS_ALLOWED_ORIGINS: str = (
|
||||
"http://127.0.0.1:5173,http://localhost:5173,"
|
||||
"http://127.0.0.1:15173,http://localhost:15173"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_allowed_origins(self) -> list[str]:
|
||||
origins = [value.strip() for value in self.CORS_ALLOWED_ORIGINS.split(",") if value.strip()]
|
||||
if "*" in origins:
|
||||
raise ValueError("CORS_ALLOWED_ORIGINS must list explicit origins")
|
||||
return origins
|
||||
|
||||
# Bocha Web Search API
|
||||
BOCHA_API_KEY: str = ""
|
||||
BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search"
|
||||
|
||||
@@ -5,7 +5,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
BusinessRole = Literal["admin", "user"]
|
||||
BusinessRole = Literal["admin", "service", "user"]
|
||||
ProjectRole = Literal["member", "viewer"]
|
||||
ProjectStatus = Literal["active", "inactive", "archived"]
|
||||
ProjectDbRole = Literal["biz_data", "iot_data"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Finite read scopes available to operational investigations."""
|
||||
from datetime import timedelta
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
|
||||
|
||||
|
||||
TaskDataSource = Literal[
|
||||
"workbench_runtime", "scada_devices", "scada_readings", "peripheral_points",
|
||||
"peripheral_work_orders", "peripheral_observations", "peripheral_test_data",
|
||||
]
|
||||
|
||||
|
||||
class TaskDataQuery(StrictModel):
|
||||
source: TaskDataSource
|
||||
device_ids: list[NonEmptyStr] = Field(default_factory=list, max_length=20)
|
||||
point_id: NonEmptyStr | None = None
|
||||
start: WorkbenchDateTime | None = None
|
||||
end: WorkbenchDateTime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_window(self):
|
||||
if self.source in {"scada_readings", "peripheral_observations"}:
|
||||
if self.start is None or self.end is None:
|
||||
raise ValueError("time series queries require start and end")
|
||||
if not timedelta(0) < self.end - self.start <= timedelta(days=1):
|
||||
raise ValueError("query window must be positive and at most 24 hours")
|
||||
if self.source == "scada_readings" and not self.device_ids:
|
||||
raise ValueError("SCADA queries require device_ids")
|
||||
if self.source == "peripheral_observations" and not self.point_id:
|
||||
raise ValueError("peripheral queries require point_id")
|
||||
return self
|
||||
|
||||
|
||||
class TaskDataSnapshot(StrictModel):
|
||||
source: TaskDataSource
|
||||
source_ref: NonEmptyStr
|
||||
queried_at: WorkbenchDateTime
|
||||
origin: Literal["system_data", "synthetic"]
|
||||
data: JsonValue
|
||||
truncated: bool
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Contracts for project-scoped operational tasks and human-approved actions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, JsonValue, model_validator
|
||||
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchDateTime,
|
||||
WorkbenchRuntimeWorkOrderSubtask,
|
||||
)
|
||||
|
||||
|
||||
TaskType = Literal["scheduled_flow", "external_work_order", "event_investigation"]
|
||||
TaskStatus = Literal[
|
||||
"queued", "running", "waiting_human", "completed", "failed", "cancelled"
|
||||
]
|
||||
TaskLifecycleStatus = Literal[
|
||||
"open", "waiting_human", "completed", "failed", "cancelled"
|
||||
]
|
||||
OperationalTaskRunStatus = Literal[
|
||||
"queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"
|
||||
]
|
||||
TaskPriority = Literal["normal", "urgent"]
|
||||
TaskSourceKind = Literal["schedule", "external_system", "operator", "agent"]
|
||||
TriggerKind = Literal["schedule", "external_sync", "event", "manual"]
|
||||
EvidenceKind = Literal["system_data", "external_feedback", "human_input", "synthetic"]
|
||||
PlanNodeStatus = Literal[
|
||||
"pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"
|
||||
]
|
||||
ActionKind = Literal["create_work_order", "issue_command"]
|
||||
ApprovalStatus = Literal["pending", "approved", "rejected"]
|
||||
ExecutionStatus = Literal[
|
||||
"not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"
|
||||
]
|
||||
|
||||
|
||||
class TaskSource(StrictModel):
|
||||
kind: TaskSourceKind
|
||||
external_id: NonEmptyStr | None = None
|
||||
external_revision: PositiveInt | None = None
|
||||
event_kind: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class TaskTrigger(StrictModel):
|
||||
kind: TriggerKind
|
||||
occurred_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
|
||||
|
||||
class OperationalTaskCreate(StrictModel):
|
||||
request_id: UUID
|
||||
task_type: TaskType
|
||||
workflow: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
priority: TaskPriority = "normal"
|
||||
source: TaskSource
|
||||
trigger: TaskTrigger
|
||||
scheduled_for: WorkbenchDateTime | None = None
|
||||
agent_session_id: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class ExecutionPlanNode(StrictModel):
|
||||
id: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
owner: Literal["agent", "human"]
|
||||
status: PlanNodeStatus
|
||||
detail: NonEmptyStr
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
action_refs: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExecutionPlanEdge(StrictModel):
|
||||
from_node: NonEmptyStr = Field(alias="from")
|
||||
to_node: NonEmptyStr = Field(alias="to")
|
||||
condition: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class ExecutionPlanInput(StrictModel):
|
||||
schema_name: Literal["execution-plan@1"] = Field(alias="schema")
|
||||
title: NonEmptyStr
|
||||
reason: NonEmptyStr
|
||||
nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)]
|
||||
edges: Annotated[list[ExecutionPlanEdge], Field(max_length=300)] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_graph(self):
|
||||
node_ids = [node.id for node in self.nodes]
|
||||
if len(set(node_ids)) != len(node_ids):
|
||||
raise ValueError("execution plan node ids must be unique")
|
||||
known = set(node_ids)
|
||||
graph = {node_id: [] for node_id in node_ids}
|
||||
indegree = {node_id: 0 for node_id in node_ids}
|
||||
seen_edges: set[tuple[str, str]] = set()
|
||||
for edge in self.edges:
|
||||
pair = (edge.from_node, edge.to_node)
|
||||
if edge.from_node not in known or edge.to_node not in known:
|
||||
raise ValueError("execution plan edges must reference known nodes")
|
||||
if edge.from_node == edge.to_node or pair in seen_edges:
|
||||
raise ValueError("execution plan edges must be unique and non-reflexive")
|
||||
seen_edges.add(pair)
|
||||
graph[edge.from_node].append(edge.to_node)
|
||||
indegree[edge.to_node] += 1
|
||||
pending = [node_id for node_id, value in indegree.items() if value == 0]
|
||||
visited = 0
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
visited += 1
|
||||
for target in graph[current]:
|
||||
indegree[target] -= 1
|
||||
if indegree[target] == 0:
|
||||
pending.append(target)
|
||||
if visited != len(node_ids):
|
||||
raise ValueError("execution plan must be acyclic")
|
||||
return self
|
||||
|
||||
|
||||
class EvidenceInput(StrictModel):
|
||||
evidence_id: NonEmptyStr
|
||||
kind: EvidenceKind
|
||||
summary: NonEmptyStr
|
||||
source_label: NonEmptyStr
|
||||
source_ref: NonEmptyStr | None = None
|
||||
observed_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class EvidenceRecord(EvidenceInput):
|
||||
recorded_at: WorkbenchDateTime
|
||||
recorded_by: NonEmptyStr
|
||||
has_snapshot: bool = False
|
||||
|
||||
|
||||
class StoredEvidenceRecord(EvidenceRecord):
|
||||
data_snapshot: TaskDataSnapshot | None = None
|
||||
|
||||
|
||||
class InvestigationReportInput(StrictModel):
|
||||
title: NonEmptyStr
|
||||
current_conclusion: NonEmptyStr
|
||||
conclusion_boundary: NonEmptyStr
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
recommendations: list[NonEmptyStr] = Field(default_factory=list)
|
||||
pending_items: list[NonEmptyStr] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExecutionPlanSnapshot(StrictModel):
|
||||
revision: PositiveInt
|
||||
execution_generation: PositiveInt | None = None
|
||||
source_session_id: NonEmptyStr
|
||||
published_at: WorkbenchDateTime
|
||||
published_by: NonEmptyStr
|
||||
plan: ExecutionPlanInput
|
||||
|
||||
|
||||
class InvestigationReportRevision(StrictModel):
|
||||
revision: PositiveInt
|
||||
execution_generation: PositiveInt | None = None
|
||||
source_session_id: NonEmptyStr
|
||||
published_at: WorkbenchDateTime
|
||||
published_by: NonEmptyStr
|
||||
report: InvestigationReportInput
|
||||
|
||||
|
||||
class AgentArtifactsCommand(StrictModel):
|
||||
request_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
run_id: UUID | None = None
|
||||
evidence: list[EvidenceInput] = Field(default_factory=list, max_length=100)
|
||||
plan: ExecutionPlanInput | None = None
|
||||
report: InvestigationReportInput | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_artifact(self):
|
||||
if not self.evidence and self.plan is None and self.report is None:
|
||||
raise ValueError("at least one artifact must be supplied")
|
||||
return self
|
||||
|
||||
|
||||
class CreateWorkOrderPayload(StrictModel):
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
work_order_type: NonEmptyStr
|
||||
priority: TaskPriority = "normal"
|
||||
scheduled_at: WorkbenchDateTime | None = None
|
||||
location: NonEmptyStr | None = None
|
||||
assignee: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class CommandTarget(StrictModel):
|
||||
kind: NonEmptyStr
|
||||
id: NonEmptyStr
|
||||
|
||||
|
||||
class IssueCommandPayload(StrictModel):
|
||||
command_type: NonEmptyStr
|
||||
target: CommandTarget
|
||||
parameters: dict[str, JsonValue]
|
||||
impact_summary: NonEmptyStr
|
||||
preconditions: list[NonEmptyStr] = Field(min_length=1)
|
||||
expires_at: WorkbenchDateTime
|
||||
|
||||
|
||||
ActionPayload = CreateWorkOrderPayload | IssueCommandPayload
|
||||
|
||||
|
||||
class ActionProposalCommand(StrictModel):
|
||||
request_id: UUID
|
||||
kind: ActionKind
|
||||
reason: NonEmptyStr
|
||||
payload: ActionPayload
|
||||
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
|
||||
node_id: NonEmptyStr | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload_kind(self):
|
||||
expected = CreateWorkOrderPayload if self.kind == "create_work_order" else IssueCommandPayload
|
||||
if not isinstance(self.payload, expected):
|
||||
raise ValueError("action payload does not match its kind")
|
||||
return self
|
||||
|
||||
|
||||
class ActionDecisionCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
decision: Literal["approved", "rejected"]
|
||||
comment: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class ActionExecuteCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
|
||||
|
||||
class ActionReconcileCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
|
||||
|
||||
class ActionReceipt(StrictModel):
|
||||
adapter: NonEmptyStr
|
||||
receipt_id: NonEmptyStr
|
||||
submitted_at: WorkbenchDateTime
|
||||
completed_at: WorkbenchDateTime | None = None
|
||||
external_status: NonEmptyStr
|
||||
detail: NonEmptyStr
|
||||
|
||||
|
||||
class HumanApprovalAction(StrictModel):
|
||||
action_id: UUID
|
||||
task_id: UUID
|
||||
version: PositiveInt
|
||||
kind: ActionKind
|
||||
reason: NonEmptyStr
|
||||
payload: ActionPayload
|
||||
evidence_refs: list[NonEmptyStr]
|
||||
node_id: NonEmptyStr | None = None
|
||||
required_approval_permission: NonEmptyStr
|
||||
required_execution_permission: NonEmptyStr
|
||||
approval_status: ApprovalStatus
|
||||
execution_status: ExecutionStatus
|
||||
execution_request_id: UUID | None = None
|
||||
proposed_by: NonEmptyStr
|
||||
proposed_at: WorkbenchDateTime
|
||||
decided_by: NonEmptyStr | None = None
|
||||
decided_at: WorkbenchDateTime | None = None
|
||||
decision_comment: NonEmptyStr | None = None
|
||||
executed_by: NonEmptyStr | None = None
|
||||
executed_at: WorkbenchDateTime | None = None
|
||||
receipt: ActionReceipt | None = None
|
||||
execution_detail: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class ExternalWorkOrderSnapshot(StrictModel):
|
||||
work_order_id: UUID
|
||||
revision: PositiveInt
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
status: Literal[
|
||||
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||
]
|
||||
priority: Literal["low", "normal", "high", "urgent"]
|
||||
assignee: NonEmptyStr | None = None
|
||||
scheduled_at: WorkbenchDateTime | None = None
|
||||
work_order_type: Literal["general", "water_quality_complaint", "burst_report"] = "general"
|
||||
location: NonEmptyStr | None = None
|
||||
scenario_code: NonEmptyStr | None = None
|
||||
subtasks: Annotated[
|
||||
list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50)
|
||||
] | None = None
|
||||
origin: Literal["synthetic"]
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_subtasks(self):
|
||||
if self.subtasks is not None and len(
|
||||
{item.id for item in self.subtasks}
|
||||
) != len(self.subtasks):
|
||||
raise ValueError("work order subtask ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class OperationalTaskRun(StrictModel):
|
||||
run_id: UUID
|
||||
task_id: UUID
|
||||
generation: PositiveInt
|
||||
execution_request_id: UUID
|
||||
status: OperationalTaskRunStatus
|
||||
revision: PositiveInt
|
||||
agent_session_id: NonEmptyStr | None = None
|
||||
lease_expires_at: WorkbenchDateTime | None = None
|
||||
heartbeat_at: WorkbenchDateTime | None = None
|
||||
started_at: WorkbenchDateTime | None = None
|
||||
finished_at: WorkbenchDateTime | None = None
|
||||
failure_code: NonEmptyStr | None = None
|
||||
failure_detail: NonEmptyStr | None = None
|
||||
dispatch_attempts: int = Field(default=0, ge=0)
|
||||
next_dispatch_at: WorkbenchDateTime
|
||||
created_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class OperationalTaskSummary(StrictModel):
|
||||
project_id: UUID
|
||||
task_id: UUID
|
||||
task_type: TaskType
|
||||
workflow: NonEmptyStr
|
||||
lifecycle_status: TaskLifecycleStatus = "open"
|
||||
active_run: OperationalTaskRun | None = None
|
||||
# Compatibility fields for the staged 014 -> 015 rollout. New consumers
|
||||
# must use lifecycle_status and active_run.
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
revision: PositiveInt
|
||||
execution_generation: PositiveInt
|
||||
execution_request_id: UUID
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
source: TaskSource
|
||||
trigger: TaskTrigger
|
||||
scheduled_for: WorkbenchDateTime | None
|
||||
agent_session_id: NonEmptyStr | None
|
||||
agent_lease_expires_at: WorkbenchDateTime | None
|
||||
current_plan_revision: PositiveInt | None
|
||||
current_report_revision: PositiveInt | None
|
||||
created_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class OperationalTaskPermissions(StrictModel):
|
||||
can_publish_artifacts: bool
|
||||
can_propose_actions: bool
|
||||
can_sync_external_feedback: bool
|
||||
can_decide_work_orders: bool
|
||||
can_execute_work_orders: bool
|
||||
can_decide_commands: bool
|
||||
can_execute_commands: bool
|
||||
|
||||
|
||||
class OperationalTaskDetail(OperationalTaskSummary):
|
||||
latest_plan: ExecutionPlanSnapshot | None
|
||||
latest_report: InvestigationReportRevision | None
|
||||
evidence: list[EvidenceRecord]
|
||||
actions: list[HumanApprovalAction]
|
||||
permissions: OperationalTaskPermissions
|
||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||
|
||||
|
||||
class OperationalTaskPage(StrictModel):
|
||||
items: list[OperationalTaskSummary]
|
||||
limit: PositiveInt
|
||||
offset: int = Field(ge=0)
|
||||
next_offset: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class OperationalTaskRunPage(StrictModel):
|
||||
items: list[OperationalTaskRun]
|
||||
next_cursor: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class AgentTaskContext(StrictModel):
|
||||
task: OperationalTaskDetail
|
||||
approved_data_scopes: list[NonEmptyStr]
|
||||
|
||||
|
||||
class AgentRunCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_revision: PositiveInt
|
||||
source_session_id: NonEmptyStr
|
||||
|
||||
|
||||
class AgentRunFailureCommand(AgentRunCommand):
|
||||
failure_code: NonEmptyStr
|
||||
failure_detail: NonEmptyStr
|
||||
|
||||
|
||||
class AgentRunCancelCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_revision: PositiveInt
|
||||
reason: NonEmptyStr
|
||||
|
||||
|
||||
class AgentRunDispatchFailureCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_revision: PositiveInt
|
||||
failure_code: NonEmptyStr
|
||||
failure_detail: NonEmptyStr
|
||||
retryable: bool = True
|
||||
|
||||
|
||||
class OperationalTaskRetryCommand(StrictModel):
|
||||
request_id: UUID
|
||||
reason: NonEmptyStr
|
||||
|
||||
|
||||
class RecoverExpiredLeasesCommand(StrictModel):
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class RecoverExpiredLeasesResult(StrictModel):
|
||||
task_ids: list[UUID]
|
||||
recovered_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class AgentArtifactsResult(StrictModel):
|
||||
task: OperationalTaskDetail
|
||||
plan_revision: PositiveInt | None
|
||||
report_revision: PositiveInt | None
|
||||
|
||||
|
||||
class TaskDataQueryResult(StrictModel):
|
||||
evidence: EvidenceRecord
|
||||
snapshot: TaskDataSnapshot
|
||||
|
||||
|
||||
class ExternalFeedbackEvidence(StrictModel):
|
||||
evidence_id: NonEmptyStr
|
||||
kind: Literal["external_feedback", "synthetic"] = "external_feedback"
|
||||
summary: NonEmptyStr
|
||||
source_label: NonEmptyStr
|
||||
source_ref: NonEmptyStr | None = None
|
||||
observed_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class ExternalFeedbackCommand(StrictModel):
|
||||
request_id: UUID
|
||||
source_event_id: NonEmptyStr
|
||||
source_revision: PositiveInt
|
||||
status: Literal[
|
||||
"scheduled", "assigned", "in_progress", "awaiting_feedback",
|
||||
"completed", "cancelled", "failed"
|
||||
]
|
||||
occurred_at: WorkbenchDateTime
|
||||
detail: NonEmptyStr
|
||||
evidence: ExternalFeedbackEvidence | None = None
|
||||
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def snapshot_matches_feedback_revision(self):
|
||||
if self.work_order_snapshot is not None:
|
||||
work_order_id = str(self.work_order_snapshot.work_order_id)
|
||||
if self.source_event_id not in {
|
||||
work_order_id, f"peripheral_sample:{work_order_id}"
|
||||
}:
|
||||
raise ValueError("work_order_snapshot id must match source_event_id")
|
||||
if self.work_order_snapshot.revision != self.source_revision:
|
||||
raise ValueError("work_order_snapshot revision must match source_revision")
|
||||
expected_status = {
|
||||
"draft": "scheduled",
|
||||
"open": "assigned",
|
||||
"in_progress": "in_progress",
|
||||
"awaiting_reply": "awaiting_feedback",
|
||||
"completed": "completed",
|
||||
"cancelled": "cancelled",
|
||||
}[self.work_order_snapshot.status]
|
||||
if self.status != expected_status:
|
||||
raise ValueError("work_order_snapshot status must match feedback status")
|
||||
return self
|
||||
|
||||
|
||||
class ExternalFeedbackResult(StrictModel):
|
||||
task: OperationalTaskDetail
|
||||
source_event_id: NonEmptyStr
|
||||
source_revision: PositiveInt
|
||||
accepted_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class HumanEvidenceCommand(StrictModel):
|
||||
request_id: UUID
|
||||
evidence: EvidenceInput
|
||||
|
||||
|
||||
class OperationalTaskNotification(StrictModel):
|
||||
notification_id: UUID
|
||||
task_id: UUID
|
||||
kind: Literal[
|
||||
"task_created", "action_pending", "report_updated", "task_completed",
|
||||
"task_failed", "run_delivery_degraded"
|
||||
]
|
||||
title: NonEmptyStr
|
||||
description: NonEmptyStr
|
||||
required_permission: NonEmptyStr | None = None
|
||||
status: Literal["unread", "read"]
|
||||
version: PositiveInt
|
||||
created_at: WorkbenchDateTime
|
||||
read_at: WorkbenchDateTime | None = None
|
||||
read_by: NonEmptyStr | None = None
|
||||
|
||||
|
||||
class OperationalTaskNotificationPage(StrictModel):
|
||||
items: list[OperationalTaskNotification]
|
||||
limit: PositiveInt
|
||||
offset: int = Field(ge=0)
|
||||
next_offset: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class NotificationAcknowledgeCommand(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
|
||||
|
||||
class OperationalTaskScheduleCreate(StrictModel):
|
||||
request_id: UUID
|
||||
schedule_key: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
interval_seconds: int = Field(ge=60, le=31_536_000)
|
||||
next_run_at: WorkbenchDateTime
|
||||
priority: TaskPriority = "normal"
|
||||
|
||||
|
||||
class OperationalTaskSchedule(StrictModel):
|
||||
schedule_id: UUID
|
||||
schedule_key: NonEmptyStr
|
||||
title: NonEmptyStr
|
||||
summary: NonEmptyStr
|
||||
interval_seconds: int = Field(ge=60, le=31_536_000)
|
||||
next_run_at: WorkbenchDateTime
|
||||
priority: TaskPriority
|
||||
enabled: bool
|
||||
version: PositiveInt
|
||||
created_at: WorkbenchDateTime
|
||||
updated_at: WorkbenchDateTime
|
||||
|
||||
|
||||
class OperationalTaskSchedulePage(StrictModel):
|
||||
items: list[OperationalTaskSchedule]
|
||||
|
||||
|
||||
class OperationalTaskScheduleUpdate(StrictModel):
|
||||
request_id: UUID
|
||||
expected_version: PositiveInt
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DispatchDueSchedulesCommand(StrictModel):
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class DispatchDueSchedulesResult(StrictModel):
|
||||
task_ids: list[UUID]
|
||||
dispatched_at: WorkbenchDateTime
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Stable workbench event-stream and synchronization contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, JsonValue
|
||||
|
||||
from app.domain.schemas.operational_task import (
|
||||
OperationalTaskNotification,
|
||||
OperationalTaskSummary,
|
||||
)
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictModel
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchDateTime, WorkbenchRuntimeSnapshot
|
||||
|
||||
|
||||
WorkbenchEventTopic = Literal[
|
||||
"operational.task.changed",
|
||||
"operational.run.queued",
|
||||
"operational.run.changed",
|
||||
"operational.report.published",
|
||||
"operational.action.changed",
|
||||
"operational.notification.changed",
|
||||
"workbench.alert.changed",
|
||||
"workbench.work_order.changed",
|
||||
"analysis.run.changed",
|
||||
]
|
||||
|
||||
|
||||
class WorkbenchEvent(StrictModel):
|
||||
schema_name: Literal["workbench-event@1"] = Field(alias="schema")
|
||||
event_seq: PositiveInt
|
||||
event_id: UUID
|
||||
project_id: UUID
|
||||
topic: WorkbenchEventTopic
|
||||
aggregate_type: NonEmptyStr
|
||||
aggregate_id: NonEmptyStr
|
||||
aggregate_revision: PositiveInt
|
||||
occurred_at: WorkbenchDateTime
|
||||
metadata: dict[str, JsonValue]
|
||||
|
||||
|
||||
class WorkbenchSyncSnapshot(StrictModel):
|
||||
schema_name: Literal["workbench-sync-snapshot@1"] = Field(alias="schema")
|
||||
event_cursor: int = Field(ge=0)
|
||||
runtime: WorkbenchRuntimeSnapshot
|
||||
tasks: list[OperationalTaskSummary]
|
||||
notifications: list[OperationalTaskNotification]
|
||||
generated_at: WorkbenchDateTime
|
||||
@@ -525,6 +525,39 @@ class MetadataRepository:
|
||||
pool_max_size=record.pool_max_size,
|
||||
)
|
||||
|
||||
async def list_active_project_db_routings(
|
||||
self, db_role: str
|
||||
) -> List[ProjectDbRouting]:
|
||||
result = await self.session.execute(
|
||||
select(models.ProjectDatabase)
|
||||
.join(models.Project, models.Project.id == models.ProjectDatabase.project_id)
|
||||
.where(
|
||||
models.ProjectDatabase.db_role == db_role,
|
||||
models.Project.status == "active",
|
||||
)
|
||||
.order_by(models.ProjectDatabase.project_id)
|
||||
)
|
||||
if not is_database_encryption_configured():
|
||||
raise ValueError("DATABASE_ENCRYPTION_KEY is not configured")
|
||||
encryptor = get_database_encryptor()
|
||||
try:
|
||||
return [
|
||||
ProjectDbRouting(
|
||||
project_id=record.project_id,
|
||||
db_role=record.db_role,
|
||||
db_type=record.db_type,
|
||||
dsn=_normalize_postgres_dsn(encryptor.decrypt(record.dsn_encrypted)),
|
||||
pool_min_size=record.pool_min_size,
|
||||
pool_max_size=record.pool_max_size,
|
||||
)
|
||||
for record in result.scalars().all()
|
||||
]
|
||||
except InvalidToken as exc:
|
||||
raise ValueError(
|
||||
"Failed to decrypt project DB DSN: DATABASE_ENCRYPTION_KEY mismatch "
|
||||
"or invalid dsn_encrypted value"
|
||||
) from exc
|
||||
|
||||
async def list_projects_for_user(self, user_id: UUID) -> List[ProjectSummary]:
|
||||
stmt = (
|
||||
select(models.Project, models.UserProjectMembership.project_role)
|
||||
|
||||
@@ -53,18 +53,52 @@ class AnalysisRepository:
|
||||
status: str,
|
||||
created_by: str,
|
||||
parameters: dict[str, Any],
|
||||
) -> None:
|
||||
) -> int:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE analysis.runs
|
||||
SET created_by = %s, status = %s, parameters = %s
|
||||
SET created_by = %s, status = %s, parameters = %s,
|
||||
revision = revision + 1
|
||||
WHERE run_id = %s
|
||||
RETURNING revision
|
||||
""",
|
||||
(created_by, status, Jsonb(parameters), run_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise LookupError(f"analysis run {run_id} does not exist")
|
||||
return int(row["revision"])
|
||||
|
||||
@staticmethod
|
||||
def append_run_changed_event_sync(
|
||||
conn: Connection,
|
||||
*,
|
||||
project_id: UUID,
|
||||
run_id: UUID,
|
||||
revision: int,
|
||||
status: str,
|
||||
occurred_at: datetime,
|
||||
results_committed: bool,
|
||||
) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO workbench.domain_event_log
|
||||
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata)
|
||||
VALUES (%s,%s,'analysis.run.changed','analysis_run',%s,%s,%s,%s)
|
||||
RETURNING event_seq""",
|
||||
(
|
||||
project_id, uuid4(), str(run_id), revision, occurred_at,
|
||||
Jsonb({"run_id": str(run_id), "status": status,
|
||||
"results_committed": results_committed}),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.execute(
|
||||
"SELECT pg_notify('workbench_events', %s)",
|
||||
(f'{project_id}:{row["event_seq"]}',),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def insert_result_sync(
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
"""PostgreSQL persistence for durable operational tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.types.json import Jsonb
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
|
||||
class OperationalTaskRepository:
|
||||
def __init__(self, connection: AsyncConnection, project_id):
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
try:
|
||||
async with self.connection.transaction():
|
||||
yield self
|
||||
except UndefinedTable as exc:
|
||||
raise DomainError(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Project operational-task storage is not provisioned",
|
||||
status=503,
|
||||
) from exc
|
||||
|
||||
async def lock(self, key: str) -> None:
|
||||
await self.connection.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
|
||||
(f"tjwater-operational:{self.project_id}:{key}",),
|
||||
)
|
||||
|
||||
async def get_request(self, scope: str, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT request_digest,response
|
||||
FROM workbench.operational_task_requests
|
||||
WHERE project_id=%s AND scope=%s AND request_id=%s""",
|
||||
(self.project_id, scope, request_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def save_request(
|
||||
self, scope: str, request_id: str, request_digest: str, response: dict
|
||||
) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_requests
|
||||
(project_id,scope,request_id,request_digest,response)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,scope,request_id) DO UPDATE SET
|
||||
response=excluded.response
|
||||
WHERE operational_task_requests.request_digest=excluded.request_digest""",
|
||||
(self.project_id, scope, request_id, request_digest, Jsonb(response)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError(
|
||||
"IDEMPOTENCY_CONFLICT",
|
||||
"request_id was used with different content",
|
||||
status=409,
|
||||
)
|
||||
|
||||
async def get_task(self, task_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
WHERE project_id=%s AND task_id=%s""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def get_active_run(self, task_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
AND status IN ('queued','claimed','running')
|
||||
ORDER BY generation DESC LIMIT 1""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def get_run(self, run_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND run_id=%s""",
|
||||
(self.project_id, run_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def insert_run(self, run: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_runs
|
||||
(project_id,run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
self.project_id, run["run_id"], run["task_id"], run["generation"],
|
||||
run["execution_request_id"], run["status"], run["revision"],
|
||||
run.get("agent_session_id"), run.get("lease_expires_at"),
|
||||
run.get("heartbeat_at"), run.get("started_at"), run.get("finished_at"),
|
||||
run.get("failure_code"), run.get("failure_detail"),
|
||||
run.get("dispatch_attempts", 0), run["next_dispatch_at"],
|
||||
run["created_at"], run["updated_at"],
|
||||
),
|
||||
)
|
||||
|
||||
async def update_run(self, run: dict, expected_revision: int) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_runs SET
|
||||
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
|
||||
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
|
||||
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
|
||||
WHERE project_id=%s AND run_id=%s AND revision=%s""",
|
||||
(
|
||||
run["status"], run["revision"], run.get("agent_session_id"),
|
||||
run.get("lease_expires_at"), run.get("heartbeat_at"),
|
||||
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
|
||||
run.get("failure_detail"), run.get("dispatch_attempts", 0),
|
||||
run["next_dispatch_at"], run["updated_at"], self.project_id,
|
||||
run["run_id"], expected_revision,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409)
|
||||
|
||||
async def list_runs(
|
||||
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None
|
||||
) -> list[dict]:
|
||||
result = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s
|
||||
AND (%s::text IS NULL OR status=%s::text)
|
||||
AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid))
|
||||
AND (status<>'queued' OR next_dispatch_at<=now())
|
||||
ORDER BY created_at,run_id LIMIT %s""",
|
||||
(
|
||||
self.project_id, status, status, cursor_created_at,
|
||||
cursor_created_at, cursor_run_id, limit,
|
||||
),
|
||||
)
|
||||
return [dict(row) for row in await result.fetchall()]
|
||||
|
||||
async def claim_expired_runs(self, limit: int) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
|
||||
created_at,updated_at
|
||||
FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND status IN ('claimed','running')
|
||||
AND lease_expires_at IS NOT NULL AND lease_expires_at<=now()
|
||||
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||
(self.project_id, limit),
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
async def consecutive_timed_out_runs(self, task_id: str) -> int:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT status FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND task_id=%s ORDER BY generation DESC""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
count = 0
|
||||
for row in await cursor.fetchall():
|
||||
if row["status"] != "timed_out":
|
||||
break
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def append_event(
|
||||
self, *, topic: str, aggregate_type: str, aggregate_id: str,
|
||||
aggregate_revision: int, occurred_at, metadata: dict | None = None,
|
||||
) -> int:
|
||||
event_id = uuid4()
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.domain_event_log
|
||||
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING event_seq""",
|
||||
(
|
||||
self.project_id, event_id, topic, aggregate_type, aggregate_id,
|
||||
aggregate_revision, occurred_at, Jsonb(metadata or {}),
|
||||
),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
event_seq = int(row["event_seq"])
|
||||
await self.connection.execute(
|
||||
"SELECT pg_notify('workbench_events', %s)",
|
||||
(f'{self.project_id}:{event_seq}',),
|
||||
)
|
||||
return event_seq
|
||||
|
||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
WHERE project_id=%s AND create_request_id=%s""",
|
||||
(self.project_id, request_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def get_task_by_external_source(
|
||||
self, source_kind: str, external_id: str
|
||||
) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
WHERE project_id=%s
|
||||
AND document#>>'{source,kind}'=%s
|
||||
AND document#>>'{source,external_id}'=%s""",
|
||||
(self.project_id, source_kind, external_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
task_type: str | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT task.document,
|
||||
CASE WHEN active_run.run_id IS NULL THEN NULL
|
||||
ELSE to_jsonb(active_run) - 'project_id' END AS active_run
|
||||
FROM workbench.operational_tasks AS task
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
|
||||
AND run.status IN ('queued','claimed','running')
|
||||
ORDER BY run.generation DESC LIMIT 1
|
||||
) AS active_run ON true
|
||||
WHERE task.project_id=%s
|
||||
AND (%s::text IS NULL OR task.task_type=%s::text)
|
||||
AND (%s::text IS NULL OR task.status=%s::text)
|
||||
ORDER BY task.updated_at DESC,task.task_id
|
||||
LIMIT %s OFFSET %s""",
|
||||
(
|
||||
self.project_id,
|
||||
task_type,
|
||||
task_type,
|
||||
status,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
),
|
||||
)
|
||||
return [
|
||||
{**row["document"], "active_run": row["active_run"]}
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
|
||||
async def insert_task(self, task: dict, create_request_id: str) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_tasks
|
||||
(project_id,task_id,create_request_id,task_type,workflow,status,lifecycle_status,priority,
|
||||
revision,execution_generation,execution_request_id,agent_session_id,
|
||||
agent_lease_expires_at,current_plan_revision,current_report_revision,
|
||||
scheduled_for,created_at,updated_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
self._task_params(task, create_request_id),
|
||||
)
|
||||
|
||||
async def update_task(self, task: dict) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_tasks SET
|
||||
status=%s,lifecycle_status=%s,revision=%s,execution_generation=%s,execution_request_id=%s,
|
||||
agent_session_id=%s,agent_lease_expires_at=%s,current_plan_revision=%s,
|
||||
current_report_revision=%s,updated_at=%s,document=%s
|
||||
WHERE project_id=%s AND task_id=%s AND revision=%s""",
|
||||
(
|
||||
task["status"], task["lifecycle_status"], task["revision"], task["execution_generation"],
|
||||
task["execution_request_id"], task.get("agent_session_id"),
|
||||
task.get("agent_lease_expires_at"), task.get("current_plan_revision"),
|
||||
task.get("current_report_revision"), task["updated_at"], Jsonb(task),
|
||||
self.project_id, task["task_id"], task["revision"] - 1,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError("VERSION_CONFLICT", "Task revision changed", status=409)
|
||||
|
||||
def _task_params(self, task: dict, create_request_id: str) -> tuple:
|
||||
return (
|
||||
self.project_id, task["task_id"], create_request_id, task["task_type"],
|
||||
task["workflow"], task["status"], task["lifecycle_status"], task["priority"], task["revision"],
|
||||
task["execution_generation"], task["execution_request_id"],
|
||||
task.get("agent_session_id"), task.get("agent_lease_expires_at"),
|
||||
task.get("current_plan_revision"), task.get("current_report_revision"),
|
||||
task.get("scheduled_for"), task["created_at"], task["updated_at"], Jsonb(task),
|
||||
)
|
||||
|
||||
async def get_evidence(self, task_id: str, evidence_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_evidence
|
||||
WHERE project_id=%s AND task_id=%s AND evidence_id=%s""",
|
||||
(self.project_id, task_id, evidence_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def list_evidence(self, task_id: str) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_evidence
|
||||
WHERE project_id=%s AND task_id=%s ORDER BY created_at,evidence_id""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def insert_evidence(self, task_id: str, evidence: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_evidence
|
||||
(project_id,task_id,evidence_id,document,created_at)
|
||||
VALUES (%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, task_id, evidence["evidence_id"], Jsonb(evidence),
|
||||
evidence["recorded_at"]),
|
||||
)
|
||||
|
||||
async def get_latest_plan(self, task_id: str) -> dict | None:
|
||||
return await self._latest_artifact("operational_task_plans", task_id)
|
||||
|
||||
async def get_latest_report(self, task_id: str) -> dict | None:
|
||||
return await self._latest_artifact("operational_task_reports", task_id)
|
||||
|
||||
async def _latest_artifact(self, table: str, task_id: str) -> dict | None:
|
||||
if table not in {"operational_task_plans", "operational_task_reports"}:
|
||||
raise ValueError("unsupported artifact table")
|
||||
cursor = await self.connection.execute(
|
||||
f"""SELECT document FROM workbench.{table}
|
||||
WHERE project_id=%s AND task_id=%s ORDER BY revision DESC LIMIT 1""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def insert_plan(self, task_id: str, snapshot: dict) -> None:
|
||||
await self._insert_artifact("operational_task_plans", task_id, snapshot)
|
||||
|
||||
async def insert_report(self, task_id: str, snapshot: dict) -> None:
|
||||
await self._insert_artifact("operational_task_reports", task_id, snapshot)
|
||||
|
||||
async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None:
|
||||
if table not in {"operational_task_plans", "operational_task_reports"}:
|
||||
raise ValueError("unsupported artifact table")
|
||||
await self.connection.execute(
|
||||
f"""INSERT INTO workbench.{table}
|
||||
(project_id,task_id,revision,document,created_at)
|
||||
VALUES (%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, task_id, snapshot["revision"], Jsonb(snapshot),
|
||||
snapshot["published_at"]),
|
||||
)
|
||||
|
||||
async def get_action(self, task_id: str, action_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_actions
|
||||
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
|
||||
(self.project_id, task_id, action_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def list_actions(self, task_id: str) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_actions
|
||||
WHERE project_id=%s AND task_id=%s ORDER BY created_at,action_id""",
|
||||
(self.project_id, task_id),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def insert_action(self, task_id: str, action: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_actions
|
||||
(project_id,task_id,action_id,version,approval_status,execution_status,
|
||||
document,created_at,updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, task_id, action["action_id"], action["version"],
|
||||
action["approval_status"], action["execution_status"], Jsonb(action),
|
||||
action["proposed_at"], action["proposed_at"]),
|
||||
)
|
||||
await self._insert_action_revision(task_id, action)
|
||||
|
||||
async def update_action(self, task_id: str, action: dict) -> None:
|
||||
await self._insert_action_revision(task_id, action)
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_actions SET
|
||||
version=%s,approval_status=%s,execution_status=%s,document=%s,updated_at=%s
|
||||
WHERE project_id=%s AND task_id=%s AND action_id=%s""",
|
||||
(action["version"], action["approval_status"], action["execution_status"],
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at") or
|
||||
action["proposed_at"], self.project_id, task_id, action["action_id"]),
|
||||
)
|
||||
|
||||
async def _insert_action_revision(self, task_id: str, action: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_action_revisions
|
||||
(project_id,task_id,action_id,version,document,recorded_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)""",
|
||||
(
|
||||
self.project_id, task_id, action["action_id"], action["version"],
|
||||
Jsonb(action), action.get("executed_at") or action.get("decided_at")
|
||||
or action["proposed_at"],
|
||||
),
|
||||
)
|
||||
|
||||
async def get_feedback(self, task_id: str, source_event_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT source_revision,document,accepted_at
|
||||
FROM workbench.operational_task_external_feedback
|
||||
WHERE project_id=%s AND task_id=%s AND source_event_id=%s""",
|
||||
(self.project_id, task_id, source_event_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def upsert_feedback(self, task_id: str, feedback: dict, accepted_at) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_external_feedback
|
||||
(project_id,task_id,source_event_id,source_revision,document,accepted_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,task_id,source_event_id) DO UPDATE SET
|
||||
source_revision=excluded.source_revision,document=excluded.document,
|
||||
accepted_at=excluded.accepted_at""",
|
||||
(self.project_id, task_id, feedback["source_event_id"],
|
||||
feedback["source_revision"], Jsonb(feedback), accepted_at),
|
||||
)
|
||||
|
||||
async def insert_notification(self, notification: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_notifications
|
||||
(project_id,notification_id,task_id,created_at,document)
|
||||
VALUES (%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, notification["notification_id"], notification["task_id"],
|
||||
notification["created_at"], Jsonb(notification)),
|
||||
)
|
||||
|
||||
async def get_notification(self, notification_id: str, user_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT n.document || jsonb_build_object(
|
||||
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
|
||||
'version',COALESCE(r.version,1),
|
||||
'read_at',r.read_at,'read_by',
|
||||
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
|
||||
) AS document
|
||||
FROM workbench.operational_task_notifications n
|
||||
LEFT JOIN workbench.operational_task_notification_reads r
|
||||
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
|
||||
AND r.user_id=%s
|
||||
WHERE n.project_id=%s AND n.notification_id=%s""",
|
||||
(user_id, self.project_id, notification_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def mark_notification_read(
|
||||
self, notification_id: str, user_id: str, version: int, read_at
|
||||
) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_notification_reads
|
||||
(project_id,notification_id,user_id,version,read_at)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,notification_id,user_id) DO UPDATE SET
|
||||
version=excluded.version,read_at=excluded.read_at""",
|
||||
(self.project_id, notification_id, user_id, version, read_at),
|
||||
)
|
||||
|
||||
async def list_notifications(
|
||||
self, *, user_id: str, allowed_permissions: list[str],
|
||||
unread_only: bool, limit: int, offset: int
|
||||
) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT n.document || jsonb_build_object(
|
||||
'status',CASE WHEN r.read_at IS NULL THEN 'unread' ELSE 'read' END,
|
||||
'version',COALESCE(r.version,1),
|
||||
'read_at',r.read_at,'read_by',
|
||||
CASE WHEN r.user_id IS NULL THEN NULL ELSE 'principal:' || r.user_id::text END
|
||||
) AS document
|
||||
FROM workbench.operational_task_notifications n
|
||||
LEFT JOIN workbench.operational_task_notification_reads r
|
||||
ON r.project_id=n.project_id AND r.notification_id=n.notification_id
|
||||
AND r.user_id=%s
|
||||
WHERE n.project_id=%s
|
||||
AND (%s=false OR r.read_at IS NULL)
|
||||
AND (n.document->>'required_permission' IS NULL
|
||||
OR n.document->>'required_permission'=ANY(%s))
|
||||
ORDER BY n.created_at DESC,n.notification_id LIMIT %s OFFSET %s""",
|
||||
(user_id, self.project_id, unread_only, allowed_permissions, limit, offset),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def get_schedule_by_request(self, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_schedules
|
||||
WHERE project_id=%s AND create_request_id=%s""",
|
||||
(self.project_id, request_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def insert_schedule(self, schedule: dict, request_id: str) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_schedules
|
||||
(project_id,schedule_id,create_request_id,schedule_key,next_run_at,
|
||||
interval_seconds,enabled,version,document,created_at,updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(self.project_id, schedule["schedule_id"], request_id,
|
||||
schedule["schedule_key"], schedule["next_run_at"],
|
||||
schedule["interval_seconds"], schedule["enabled"], schedule["version"],
|
||||
Jsonb(schedule), schedule["created_at"], schedule["updated_at"]),
|
||||
)
|
||||
|
||||
async def list_schedules(self) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_schedules
|
||||
WHERE project_id=%s ORDER BY next_run_at,schedule_id""",
|
||||
(self.project_id,),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def get_schedule(self, schedule_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_schedules
|
||||
WHERE project_id=%s AND schedule_id=%s""",
|
||||
(self.project_id, schedule_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def update_schedule(self, schedule: dict) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_schedules SET
|
||||
enabled=%s,version=%s,updated_at=%s,document=%s
|
||||
WHERE project_id=%s AND schedule_id=%s AND version=%s""",
|
||||
(
|
||||
schedule["enabled"], schedule["version"], schedule["updated_at"],
|
||||
Jsonb(schedule), self.project_id, schedule["schedule_id"],
|
||||
schedule["version"] - 1,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError("VERSION_CONFLICT", "Schedule version changed", status=409)
|
||||
|
||||
async def claim_due_schedules(self, limit: int) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_task_schedules
|
||||
WHERE project_id=%s AND enabled=true AND next_run_at <= now()
|
||||
ORDER BY next_run_at,schedule_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||
(self.project_id, limit),
|
||||
)
|
||||
return [row["document"] for row in await cursor.fetchall()]
|
||||
|
||||
async def advance_schedule(self, schedule: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.operational_task_schedules SET
|
||||
next_run_at=%s,version=%s,updated_at=%s,document=%s
|
||||
WHERE project_id=%s AND schedule_id=%s""",
|
||||
(schedule["next_run_at"], schedule["version"], schedule["updated_at"],
|
||||
Jsonb(schedule), self.project_id, schedule["schedule_id"]),
|
||||
)
|
||||
|
||||
async def insert_occurrence(self, schedule_id: str, due_at, task_id: str, created_at) -> bool:
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_schedule_occurrences
|
||||
(project_id,schedule_id,due_at,task_id,created_at)
|
||||
VALUES (%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,schedule_id,due_at) DO NOTHING
|
||||
RETURNING task_id""",
|
||||
(self.project_id, schedule_id, due_at, task_id, created_at),
|
||||
)
|
||||
return await cursor.fetchone() is not None
|
||||
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.types.json import Jsonb
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
|
||||
@@ -86,3 +87,21 @@ class TaskAlertRepository:
|
||||
Jsonb({key: value for key, value in state.items() if key != "revision"}),
|
||||
),
|
||||
)
|
||||
|
||||
async def append_changed_event(self, state: dict) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.domain_event_log
|
||||
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata)
|
||||
VALUES (%s,%s,'workbench.alert.changed','workbench_alert',%s,%s,%s,%s)
|
||||
RETURNING event_seq""",
|
||||
(
|
||||
self.project_id, uuid4(), state["alert_id"], state["revision"],
|
||||
state["updated_at"], Jsonb({"alert_id": state["alert_id"]}),
|
||||
),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await self.connection.execute(
|
||||
"SELECT pg_notify('workbench_events', %s)",
|
||||
(f'{self.project_id}:{row["event_seq"]}',),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Per-process, project-scoped PostgreSQL LISTEN hubs for workbench SSE."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkbenchEventHub:
|
||||
"""Own one LISTEN connection and fan wake-ups out to SSE subscribers."""
|
||||
|
||||
def __init__(self, project_id: UUID, dsn: str) -> None:
|
||||
self.project_id = project_id
|
||||
self.dsn = dsn
|
||||
self.subscribers = 0
|
||||
self._connection: AsyncConnection | None = None
|
||||
self._pool: AsyncConnectionPool | None = None
|
||||
self._connection_lock = asyncio.Lock()
|
||||
self._condition = asyncio.Condition()
|
||||
self._generation = 0
|
||||
self._ready = asyncio.Event()
|
||||
self._closed = False
|
||||
self._listener_task: asyncio.Task | None = None
|
||||
self._idle_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._listener_task is None or self._listener_task.done():
|
||||
self._closed = False
|
||||
self._ready.clear()
|
||||
self._listener_task = asyncio.create_task(
|
||||
self._listen_loop(), name=f"workbench-events:{self.project_id}"
|
||||
)
|
||||
await asyncio.wait_for(self._ready.wait(), timeout=10)
|
||||
|
||||
async def _connect(self) -> AsyncConnection:
|
||||
if self._pool is None:
|
||||
self._pool = AsyncConnectionPool(
|
||||
conninfo=self.dsn,
|
||||
min_size=0,
|
||||
max_size=1,
|
||||
open=False,
|
||||
kwargs={"autocommit": True, "row_factory": dict_row},
|
||||
)
|
||||
await self._pool.open()
|
||||
connection = await self._pool.getconn()
|
||||
await connection.execute("LISTEN workbench_events")
|
||||
return connection
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
retry_seconds = 1
|
||||
while not self._closed:
|
||||
try:
|
||||
if self._connection is None or self._connection.closed:
|
||||
async with self._connection_lock:
|
||||
if self._connection is None or self._connection.closed:
|
||||
self._connection = await self._connect()
|
||||
self._ready.set()
|
||||
retry_seconds = 1
|
||||
notified = False
|
||||
async with self._connection_lock:
|
||||
connection = self._connection
|
||||
if connection is None:
|
||||
continue
|
||||
async for notification in connection.notifies(timeout=1, stop_after=1):
|
||||
if notification.payload.startswith(f"{self.project_id}:"):
|
||||
notified = True
|
||||
break
|
||||
if notified:
|
||||
await self._wake()
|
||||
await asyncio.sleep(0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Workbench event listener failed for project %s", self.project_id)
|
||||
await self._drop_connection()
|
||||
await self._wake()
|
||||
await asyncio.sleep(retry_seconds)
|
||||
retry_seconds = min(retry_seconds * 2, 30)
|
||||
|
||||
async def _wake(self) -> None:
|
||||
async with self._condition:
|
||||
self._generation += 1
|
||||
self._condition.notify_all()
|
||||
|
||||
async def wait_for_wake(self, generation: int, timeout: float) -> int:
|
||||
async with self._condition:
|
||||
if self._generation != generation:
|
||||
return self._generation
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
self._condition.wait_for(lambda: self._generation != generation),
|
||||
timeout=timeout,
|
||||
)
|
||||
return self._generation
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return self._generation
|
||||
|
||||
async def oldest_cursor(self) -> int | None:
|
||||
return await self._read(lambda repository: repository.oldest_cursor())
|
||||
|
||||
async def current_cursor(self) -> int:
|
||||
return await self._read(lambda repository: repository.current_cursor())
|
||||
|
||||
async def read_after(self, after: int) -> list[dict]:
|
||||
return await self._read(lambda repository: repository.read_after(after))
|
||||
|
||||
async def _read(self, operation):
|
||||
await self.start()
|
||||
async with self._connection_lock:
|
||||
if self._connection is None or self._connection.closed:
|
||||
self._connection = await self._connect()
|
||||
self._ready.set()
|
||||
repository = WorkbenchEventRepository(self._connection, self.project_id)
|
||||
return await operation(repository)
|
||||
|
||||
async def _drop_connection(self) -> None:
|
||||
async with self._connection_lock:
|
||||
connection, self._connection = self._connection, None
|
||||
if connection is not None and self._pool is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._pool.putconn(connection)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed = True
|
||||
if self._idle_task is not None:
|
||||
self._idle_task.cancel()
|
||||
self._idle_task = None
|
||||
if self._listener_task is not None:
|
||||
self._listener_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._listener_task
|
||||
self._listener_task = None
|
||||
await self._drop_connection()
|
||||
if self._pool is not None:
|
||||
await self._pool.close()
|
||||
self._pool = None
|
||||
|
||||
|
||||
class WorkbenchEventHubManager:
|
||||
def __init__(self, idle_seconds: float = 60) -> None:
|
||||
self.idle_seconds = idle_seconds
|
||||
self._hubs: dict[tuple[UUID, str], WorkbenchEventHub] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscribe(self, project_id: UUID, dsn: str):
|
||||
key = (project_id, dsn)
|
||||
async with self._lock:
|
||||
hub = self._hubs.get(key)
|
||||
if hub is None:
|
||||
hub = WorkbenchEventHub(project_id, dsn)
|
||||
self._hubs[key] = hub
|
||||
if hub._idle_task is not None:
|
||||
hub._idle_task.cancel()
|
||||
hub._idle_task = None
|
||||
hub.subscribers += 1
|
||||
try:
|
||||
await hub.start()
|
||||
yield hub
|
||||
finally:
|
||||
async with self._lock:
|
||||
hub.subscribers = max(0, hub.subscribers - 1)
|
||||
if hub.subscribers == 0 and hub._idle_task is None:
|
||||
hub._idle_task = asyncio.create_task(self._close_when_idle(key, hub))
|
||||
|
||||
async def _close_when_idle(
|
||||
self, key: tuple[UUID, str], hub: WorkbenchEventHub
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.sleep(self.idle_seconds)
|
||||
async with self._lock:
|
||||
if hub.subscribers or self._hubs.get(key) is not hub:
|
||||
return
|
||||
self._hubs.pop(key, None)
|
||||
hub._idle_task = None
|
||||
await hub.close()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def close_all(self) -> None:
|
||||
async with self._lock:
|
||||
hubs = list(self._hubs.values())
|
||||
self._hubs.clear()
|
||||
await asyncio.gather(*(hub.close() for hub in hubs), return_exceptions=True)
|
||||
|
||||
|
||||
workbench_event_hubs = WorkbenchEventHubManager()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Cursor reads for the durable project-scoped workbench event log."""
|
||||
from __future__ import annotations
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
class WorkbenchEventRepository:
|
||||
def __init__(self, connection: AsyncConnection, project_id):
|
||||
self.connection = connection
|
||||
self.project_id = str(project_id)
|
||||
|
||||
async def current_cursor(self) -> int:
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT COALESCE(max(event_seq),0) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
|
||||
(self.project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cursor"])
|
||||
|
||||
async def oldest_cursor(self) -> int | None:
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT min(event_seq) AS cursor FROM workbench.domain_event_log WHERE project_id=%s",
|
||||
(self.project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cursor"]) if row and row["cursor"] is not None else None
|
||||
|
||||
async def read_after(self, after: int, limit: int = 200) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT event_seq,event_id,project_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata
|
||||
FROM workbench.domain_event_log
|
||||
WHERE project_id=%s AND event_seq>%s
|
||||
ORDER BY event_seq LIMIT %s""",
|
||||
(self.project_id, after, limit),
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
@@ -4,6 +4,7 @@ from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
||||
|
||||
@@ -15,6 +16,7 @@ class ActiveProjectRouting:
|
||||
project_code: str
|
||||
business_dsn: str
|
||||
timescale_dsn: str | None = None
|
||||
project_id: UUID | None = None
|
||||
|
||||
@property
|
||||
def business_database_name(self) -> str:
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Typed external action adapters. Credentials are request-scoped, never persisted."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionReceipt, CreateWorkOrderPayload, HumanApprovalAction,
|
||||
IssueCommandPayload, OperationalTaskDetail,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionDispatchOutcome:
|
||||
status: Literal["submitted", "succeeded", "failed", "unsupported", "unknown"]
|
||||
detail: str
|
||||
receipt: ActionReceipt | None = None
|
||||
|
||||
|
||||
class OperationalActionDispatcher(Protocol):
|
||||
async def dispatch(
|
||||
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||
action: HumanApprovalAction, credential: str | None,
|
||||
) -> ActionDispatchOutcome: ...
|
||||
|
||||
async def reconcile(
|
||||
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||
action: HumanApprovalAction, credential: str | None,
|
||||
) -> ActionDispatchOutcome: ...
|
||||
|
||||
|
||||
class OperationalActionSettings(BaseSettings):
|
||||
peripheral_url: str = ""
|
||||
command_gateway_url: str = ""
|
||||
timeout_seconds: float = Field(default=10, ge=1, le=60)
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPERATIONAL_ACTION_", extra="ignore",
|
||||
env_file=Path(__file__).resolve().parents[3] / ".env",
|
||||
)
|
||||
|
||||
|
||||
class SampleWorkOrderReceipt(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
work_order_id: UUID
|
||||
project_id: UUID
|
||||
created_at: datetime
|
||||
origin: Literal["synthetic"]
|
||||
status: Literal[
|
||||
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
|
||||
]
|
||||
|
||||
|
||||
class CommandGatewayReceipt(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
request_id: UUID
|
||||
project_id: UUID
|
||||
receipt_id: str = Field(min_length=1)
|
||||
status: Literal["accepted", "running", "completed", "failed"]
|
||||
submitted_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
detail: str = Field(min_length=1)
|
||||
|
||||
|
||||
class HttpOperationalActionDispatcher:
|
||||
def __init__(
|
||||
self, settings: OperationalActionSettings | None = None,
|
||||
*, transport: httpx.AsyncBaseTransport | None = None,
|
||||
):
|
||||
self.settings = settings or OperationalActionSettings()
|
||||
self.transport = transport
|
||||
|
||||
async def dispatch(
|
||||
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||
action: HumanApprovalAction, credential: str | None,
|
||||
) -> ActionDispatchOutcome:
|
||||
base = (self.settings.peripheral_url if action.kind == "create_work_order"
|
||||
else self.settings.command_gateway_url)
|
||||
if not base:
|
||||
return ActionDispatchOutcome("unsupported", "尚未配置该动作的外部执行适配器")
|
||||
if not credential or not credential.lower().startswith("bearer "):
|
||||
return ActionDispatchOutcome("failed", "缺少当前用户的产品身份,未提交外部动作")
|
||||
if action.task_id != task.task_id or task.project_id != context.project_id:
|
||||
return ActionDispatchOutcome("failed", "动作与当前项目任务不匹配,未提交")
|
||||
if action.approval_status != "approved":
|
||||
return ActionDispatchOutcome("failed", "动作尚未通过人工确认,未提交")
|
||||
request_id = action.execution_request_id
|
||||
if request_id is None:
|
||||
return ActionDispatchOutcome("failed", "缺少持久化执行编号,未提交")
|
||||
headers = {"Authorization": credential, "X-Project-Id": str(context.project_id)}
|
||||
try:
|
||||
if action.kind == "create_work_order":
|
||||
payload = CreateWorkOrderPayload.model_validate(action.payload)
|
||||
body = {
|
||||
"request_id": str(request_id), "title": payload.title,
|
||||
"summary": payload.description, "status": "open",
|
||||
"priority": payload.priority, "assignee": payload.assignee,
|
||||
"scheduled_at": payload.scheduled_at.isoformat() if payload.scheduled_at else None,
|
||||
"location": payload.location,
|
||||
# An approved action creates a child execution order, not a
|
||||
# new inbound complaint/burst event for the sync loop.
|
||||
"work_order_type": "general",
|
||||
}
|
||||
path = "/api/sample/v1/work-orders"
|
||||
recovery_path = f"/api/sample/v1/requests/{request_id}"
|
||||
else:
|
||||
payload = IssueCommandPayload.model_validate(action.payload)
|
||||
if payload.expires_at <= datetime.now(timezone.utc):
|
||||
return ActionDispatchOutcome("failed", "命令确认窗口已过期,未提交")
|
||||
body = {
|
||||
"request_id": str(request_id), "project_id": str(context.project_id),
|
||||
"task_id": str(task.task_id), "action_id": str(action.action_id),
|
||||
"action_version": action.version,
|
||||
"approved_by": action.decided_by,
|
||||
"approved_at": action.decided_at.isoformat() if action.decided_at else None,
|
||||
"command": payload.model_dump(mode="json"),
|
||||
}
|
||||
path = "/api/v1/commands"
|
||||
recovery_path = f"/api/v1/commands/requests/{request_id}"
|
||||
except (ValidationError, TypeError, ValueError):
|
||||
return ActionDispatchOutcome("failed", "动作参数不符合执行契约,未提交")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base.rstrip("/"), headers=headers, timeout=self.settings.timeout_seconds,
|
||||
follow_redirects=False, trust_env=False, transport=self.transport,
|
||||
) as client:
|
||||
try:
|
||||
response = await client.post(path, json=body)
|
||||
if response.status_code in {400, 401, 403, 404, 409, 422}:
|
||||
# Sample responses explicitly state whether any write occurred.
|
||||
# An untyped rejection is not proof that no effect took place.
|
||||
error = response.json()
|
||||
if isinstance(error, dict) and error.get("effect") == "none":
|
||||
return ActionDispatchOutcome("failed", "外部系统拒绝了动作,未生效")
|
||||
response.raise_for_status()
|
||||
return self._receipt(action.kind, response.json(), request_id, context.project_id)
|
||||
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||
# A lost response can follow a successful write. Recover using the
|
||||
# same idempotency key, never manufacture success or blindly retry.
|
||||
try:
|
||||
recovered = await client.get(recovery_path)
|
||||
recovered.raise_for_status()
|
||||
value = recovered.json()
|
||||
if action.kind == "create_work_order":
|
||||
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
|
||||
raise ValueError("request receipt mismatch")
|
||||
value = value["result"]
|
||||
return self._receipt(action.kind, value, request_id, context.project_id)
|
||||
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||
return ActionDispatchOutcome("unknown", "外部执行结果待核实,请通过回执核对,勿重复下发")
|
||||
|
||||
async def reconcile(
|
||||
self, *, context: ProjectContext, task: OperationalTaskDetail,
|
||||
action: HumanApprovalAction, credential: str | None,
|
||||
) -> ActionDispatchOutcome:
|
||||
request_id = action.execution_request_id
|
||||
if not credential or request_id is None or action.task_id != task.task_id or task.project_id != context.project_id:
|
||||
return ActionDispatchOutcome("unknown", "无法核实当前动作的身份或执行编号")
|
||||
base = self.settings.peripheral_url if action.kind == "create_work_order" else self.settings.command_gateway_url
|
||||
if not base:
|
||||
return ActionDispatchOutcome("unknown", "回执查询服务尚未配置")
|
||||
path = (f"/api/sample/v1/requests/{request_id}" if action.kind == "create_work_order"
|
||||
else f"/api/v1/commands/requests/{request_id}")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base.rstrip("/"), timeout=self.settings.timeout_seconds,
|
||||
follow_redirects=False, trust_env=False, transport=self.transport,
|
||||
) as client:
|
||||
response = await client.get(path, headers={
|
||||
"Authorization": credential, "X-Project-Id": str(context.project_id),
|
||||
})
|
||||
response.raise_for_status()
|
||||
value = response.json()
|
||||
if action.kind == "create_work_order":
|
||||
if not isinstance(value, dict) or value.get("request_id") != str(request_id):
|
||||
raise ValueError("request receipt mismatch")
|
||||
value = value["result"]
|
||||
return self._receipt(action.kind, value, request_id, context.project_id)
|
||||
except (httpx.HTTPError, ValueError, KeyError, ValidationError):
|
||||
return ActionDispatchOutcome("unknown", "尚未取得可核实的执行回执")
|
||||
|
||||
@staticmethod
|
||||
def _receipt(kind: str, value: object, request_id: UUID, project_id: UUID) -> ActionDispatchOutcome:
|
||||
if kind == "create_work_order":
|
||||
order = SampleWorkOrderReceipt.model_validate(value)
|
||||
if order.project_id != project_id or order.created_at.tzinfo is None:
|
||||
raise ValueError("invalid external project or timestamp")
|
||||
receipt = ActionReceipt(
|
||||
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
|
||||
submitted_at=order.created_at, completed_at=order.created_at,
|
||||
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
|
||||
)
|
||||
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
|
||||
result = CommandGatewayReceipt.model_validate(value)
|
||||
if result.project_id != project_id or result.request_id != request_id:
|
||||
raise ValueError("command receipt does not match submission")
|
||||
if result.submitted_at.tzinfo is None or (result.completed_at and result.completed_at.tzinfo is None):
|
||||
raise ValueError("command receipt timestamps must include timezone")
|
||||
if result.status == "completed" and result.completed_at is None:
|
||||
raise ValueError("command has no completion receipt")
|
||||
receipt = ActionReceipt(
|
||||
adapter="command_gateway", receipt_id=result.receipt_id,
|
||||
submitted_at=result.submitted_at, completed_at=result.completed_at,
|
||||
external_status=result.status, detail=result.detail,
|
||||
)
|
||||
status = "succeeded" if result.status == "completed" else "failed" if result.status == "failed" else "submitted"
|
||||
return ActionDispatchOutcome(status, result.detail, receipt)
|
||||
|
||||
|
||||
def get_operational_action_dispatcher() -> OperationalActionDispatcher:
|
||||
return HttpOperationalActionDispatcher()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Read-only source adapters with bounded queries and explicit provenance."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW, permissions_for_context
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
|
||||
|
||||
|
||||
class TaskDataSettings(BaseSettings):
|
||||
server_url: str = "http://127.0.0.1:8000"
|
||||
peripheral_url: str = ""
|
||||
timeout_seconds: float = Field(default=15, ge=1, le=60)
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPERATIONAL_DATA_", extra="ignore",
|
||||
env_file=Path(__file__).resolve().parents[3] / ".env",
|
||||
)
|
||||
|
||||
|
||||
class TaskDataError(Exception):
|
||||
def __init__(self, status: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _matches_project(value: object, project_id: str) -> bool:
|
||||
if isinstance(value, list):
|
||||
return all(_matches_project(item, project_id) for item in value)
|
||||
if isinstance(value, dict):
|
||||
if "project_id" in value and value["project_id"] != project_id:
|
||||
return False
|
||||
return all(_matches_project(item, project_id) for item in value.values())
|
||||
return True
|
||||
|
||||
|
||||
def _select_demo_observations(
|
||||
payload: object, query: TaskDataQuery, project_id: str,
|
||||
) -> dict[str, object]:
|
||||
"""Project a bounded DEMO-* observation query from the stored demo dataset."""
|
||||
if not isinstance(payload, dict) or not _matches_project(payload, project_id):
|
||||
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||
points = payload.get("points")
|
||||
observations = payload.get("observations")
|
||||
if not isinstance(points, list) or not isinstance(observations, list):
|
||||
raise TaskDataError(502, "外围合成样本格式无效")
|
||||
if not any(isinstance(point, dict) and point.get("point_id") == query.point_id for point in points):
|
||||
raise TaskDataError(404, "合成样本测点不存在")
|
||||
assert query.start is not None and query.end is not None
|
||||
items: list[object] = []
|
||||
try:
|
||||
for item in observations:
|
||||
if not isinstance(item, dict) or item.get("point_id") != query.point_id:
|
||||
continue
|
||||
observed_at = datetime.fromisoformat(str(item.get("observed_at")))
|
||||
if query.start <= observed_at < query.end:
|
||||
items.append(item)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TaskDataError(502, "外围合成样本格式无效") from exc
|
||||
return {
|
||||
"origin": "synthetic",
|
||||
"source_id": payload.get("source_id"),
|
||||
"source_revision": payload.get("source_revision"),
|
||||
"point_id": query.point_id,
|
||||
"window": {
|
||||
"start": query.start.isoformat(),
|
||||
"end": query.end.isoformat(),
|
||||
},
|
||||
"items": items,
|
||||
"truncated": False,
|
||||
}
|
||||
|
||||
|
||||
async def read_task_data(
|
||||
*, context: ProjectContext, query: TaskDataQuery, credential: str | None,
|
||||
settings: TaskDataSettings | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> TaskDataSnapshot:
|
||||
config = settings or TaskDataSettings()
|
||||
permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW
|
||||
if permission not in permissions_for_context(context):
|
||||
raise TaskDataError(403, "当前身份没有该数据源的读取权限")
|
||||
if not credential or not credential.lower().startswith("bearer "):
|
||||
raise TaskDataError(401, "数据查询需要当前产品身份")
|
||||
peripheral = query.source.startswith("peripheral_")
|
||||
base = config.peripheral_url if peripheral else config.server_url
|
||||
if not base:
|
||||
raise TaskDataError(503, "调查数据源尚未配置")
|
||||
paths = {
|
||||
"workbench_runtime": "/api/v1/workbench/runtime",
|
||||
"scada_devices": "/api/v1/scada-devices",
|
||||
"scada_readings": "/api/v1/timeseries/scada-readings",
|
||||
"peripheral_points": "/api/sample/v1/monitoring/points",
|
||||
"peripheral_work_orders": "/api/sample/v1/work-orders",
|
||||
"peripheral_observations": "/api/sample/v1/monitoring/observations",
|
||||
"peripheral_test_data": "/api/sample/v1/test-data",
|
||||
}
|
||||
demo_observations = (
|
||||
query.source == "peripheral_observations"
|
||||
and query.point_id is not None
|
||||
and query.point_id.startswith("DEMO-")
|
||||
)
|
||||
path = "/api/sample/v1/demo-data" if demo_observations else paths[query.source]
|
||||
params: dict[str, str | int] = {"limit": 100}
|
||||
if query.source == "scada_readings":
|
||||
params.update(device_ids=",".join(query.device_ids), start_time=query.start.isoformat(), end_time=query.end.isoformat())
|
||||
elif query.source == "peripheral_observations" and not demo_observations:
|
||||
params = {"point_id": query.point_id, "start": query.start.isoformat(), "end": query.end.isoformat()}
|
||||
elif demo_observations:
|
||||
params = {}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base.rstrip("/"), timeout=config.timeout_seconds, follow_redirects=False,
|
||||
trust_env=False, transport=transport,
|
||||
) as client:
|
||||
async with client.stream("GET", path, params=params, headers={
|
||||
"Authorization": credential, "X-Project-Id": str(context.project_id),
|
||||
}) as response:
|
||||
if response.status_code in {401, 403}:
|
||||
raise TaskDataError(response.status_code, "数据源拒绝当前身份或项目权限")
|
||||
response.raise_for_status()
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
body.extend(chunk)
|
||||
if len(body) > 2_000_000:
|
||||
raise TaskDataError(422, "查询结果过大,请缩短时间范围或减少设备数量")
|
||||
data = json.loads(body)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise TaskDataError(502, "调查数据查询失败,不能据此形成已核实结论") from exc
|
||||
if demo_observations:
|
||||
data = _select_demo_observations(data, query, str(context.project_id))
|
||||
elif not _matches_project(data, str(context.project_id)):
|
||||
raise TaskDataError(502, "数据源返回了其他项目的数据")
|
||||
origin = "synthetic" if peripheral else "system_data"
|
||||
historical_replay = False
|
||||
if query.source in {"peripheral_points", "peripheral_observations"}:
|
||||
source_origin = data.get("origin") if isinstance(data, dict) else None
|
||||
if source_origin not in {"historical_replay", "synthetic"}:
|
||||
raise TaskDataError(502, "监测数据缺少明确来源,不能作为已核实证据")
|
||||
historical_replay = source_origin == "historical_replay"
|
||||
origin = "system_data" if historical_replay else "synthetic"
|
||||
truncated = isinstance(data, dict) and bool(data.get("truncated") or data.get("next_cursor") or data.get("next_offset"))
|
||||
if isinstance(data, list) and len(data) > 1000:
|
||||
data, truncated = data[:1000], True
|
||||
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||||
if len(data["items"]) > 1000:
|
||||
data = {**data, "items": data["items"][:1000]}
|
||||
truncated = True
|
||||
if isinstance(data.get("total"), int) and data["total"] > len(data["items"]):
|
||||
truncated = True
|
||||
# The digest identifies the returned snapshot, not merely a mutable URL.
|
||||
digest = hashlib.sha256(json.dumps(data, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
|
||||
if demo_observations:
|
||||
assert query.start is not None and query.end is not None
|
||||
source_ref = (
|
||||
f"peripheral_demo_data:{query.point_id}:"
|
||||
f"{query.start.isoformat()}:{query.end.isoformat()}:sha256:{digest}"
|
||||
)
|
||||
elif historical_replay:
|
||||
# Use a distinct identity: old snapshots may have incorrectly labeled
|
||||
# this same historical response synthetic. Immutable evidence stays intact.
|
||||
source_ref = f"peripheral_historical_replay:{query.source}:sha256:{digest}"
|
||||
else:
|
||||
source_ref = f"{query.source}:sha256:{digest}"
|
||||
return TaskDataSnapshot(
|
||||
source=query.source, source_ref=source_ref,
|
||||
queried_at=datetime.now(timezone.utc), origin=origin,
|
||||
data=data, truncated=truncated,
|
||||
)
|
||||
+3
-1
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
from app.api.problem_details import install_problem_details_handlers
|
||||
from app.api.v1.rest_router import api_router
|
||||
from app.infra.db.dynamic_manager import project_connection_manager
|
||||
from app.infra.db.postgresql.workbench_event_hub import workbench_event_hubs
|
||||
from app.infra.db.metadb.database import close_metadata_engine
|
||||
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
|
||||
from app.native.wndb.core.connection import close_all_project_pools
|
||||
@@ -31,6 +32,7 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await workbench_event_hubs.close_all()
|
||||
await project_connection_manager.close_all()
|
||||
close_all_timescale_pools()
|
||||
close_all_project_pools()
|
||||
@@ -67,7 +69,7 @@ app.add_middleware(AuditMiddleware)
|
||||
# 确保这是你最后一个添加的 app.add_middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 允许所有来源
|
||||
allow_origins=settings.cors_allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.native.wndb.core.connection import project_connection, project_transaction
|
||||
from app.infra.db.project_routing import get_active_project_routing
|
||||
from app.domain.time import parse_utc_time
|
||||
|
||||
|
||||
@@ -40,16 +41,21 @@ def create_analysis_run(
|
||||
started_at = parse_utc_time(scheme_start_time, field_name="scheme_start_time")
|
||||
run_id = uuid4()
|
||||
with project_connection(name) as conn:
|
||||
AnalysisRepository.create_run_sync(
|
||||
conn,
|
||||
run_id=run_id,
|
||||
name=scheme_name,
|
||||
run_type=scheme_type,
|
||||
created_by=username,
|
||||
started_at=started_at,
|
||||
status=status,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
with conn.transaction():
|
||||
AnalysisRepository.create_run_sync(
|
||||
conn,
|
||||
run_id=run_id,
|
||||
name=scheme_name,
|
||||
run_type=scheme_type,
|
||||
created_by=username,
|
||||
started_at=started_at,
|
||||
status=status,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
_append_run_event(
|
||||
conn, run_id, revision=1, status=status,
|
||||
occurred_at=started_at, results_committed=False,
|
||||
)
|
||||
return run_id
|
||||
|
||||
|
||||
@@ -84,6 +90,10 @@ def store_analysis_run_with_result(
|
||||
result_type=result_type,
|
||||
payload=result_payload,
|
||||
)
|
||||
_append_run_event(
|
||||
conn, run_id, revision=1, status="completed",
|
||||
occurred_at=started_at, results_committed=True,
|
||||
)
|
||||
return run_id
|
||||
|
||||
|
||||
@@ -96,13 +106,37 @@ def update_analysis_run(
|
||||
scheme_detail: dict[str, Any],
|
||||
) -> None:
|
||||
with project_connection(name) as conn:
|
||||
AnalysisRepository.update_run_sync(
|
||||
conn,
|
||||
run_id,
|
||||
status=status,
|
||||
created_by=username,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
with conn.transaction():
|
||||
revision = AnalysisRepository.update_run_sync(
|
||||
conn,
|
||||
run_id,
|
||||
status=status,
|
||||
created_by=username,
|
||||
parameters=scheme_detail,
|
||||
)
|
||||
_append_run_event(
|
||||
conn, run_id, revision=revision, status=status,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
results_committed=status == "completed",
|
||||
)
|
||||
|
||||
|
||||
def _append_run_event(
|
||||
conn, run_id: UUID, *, revision: int, status: str,
|
||||
occurred_at: datetime, results_committed: bool,
|
||||
) -> None:
|
||||
routing = get_active_project_routing()
|
||||
if routing is None or routing.project_id is None:
|
||||
return
|
||||
AnalysisRepository.append_run_changed_event_sync(
|
||||
conn,
|
||||
project_id=routing.project_id,
|
||||
run_id=run_id,
|
||||
revision=revision,
|
||||
status=status,
|
||||
occurred_at=occurred_at,
|
||||
results_committed=results_committed,
|
||||
)
|
||||
|
||||
|
||||
def get_analysis_run(name: str, run_id: UUID) -> dict[str, Any]:
|
||||
|
||||
@@ -67,6 +67,9 @@ class TaskAlertService:
|
||||
state_document = state.model_dump(mode="json")
|
||||
await self.repo.save_event(event, state_document)
|
||||
await self.repo.save_alert(state_document)
|
||||
append_changed_event = getattr(self.repo, "append_changed_event", None)
|
||||
if append_changed_event is not None:
|
||||
await append_changed_event(state_document)
|
||||
return state
|
||||
|
||||
def _transition(
|
||||
|
||||
Reference in New Issue
Block a user