feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+47 -5
View File
@@ -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,
)
+570
View File
@@ -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)
+153
View File
@@ -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 "",
)
+10
View File
@@ -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(
+31
View File
@@ -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)
+2
View File
@@ -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,
)
+15
View File
@@ -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"
+1 -1
View File
@@ -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"]
+44
View File
@@ -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
+567
View File
@@ -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
+49
View File
@@ -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)
+37 -3
View File
@@ -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
+19
View File
@@ -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()]
+2
View File
@@ -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()
+177
View File
@@ -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
View File
@@ -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
+52 -18
View File
@@ -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]:
+3
View File
@@ -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(