From 9335bdc1ea54ff3ca9aab833740a1352cf4a1ef7 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 24 Sep 2026 04:19:02 +0800 Subject: [PATCH] feat(workbench): persist operational workflows and events --- .env.example | 19 + app/api/problem_details.py | 52 +- app/api/v1/endpoints/operational_tasks.py | 570 ++ app/api/v1/endpoints/workbench_events.py | 153 + app/api/v1/router.py | 10 + app/auth/permissions.py | 31 + app/auth/project_dependencies.py | 2 + app/core/config.py | 15 + app/domain/schemas/admin_metadata.py | 2 +- app/domain/schemas/operational_data.py | 44 + app/domain/schemas/operational_task.py | 567 ++ app/domain/schemas/workbench_events.py | 49 + .../repositories/metadata_repository.py | 33 + app/infra/db/postgresql/analysis.py | 40 +- app/infra/db/postgresql/operational_tasks.py | 589 ++ app/infra/db/postgresql/task_alerts.py | 19 + .../db/postgresql/workbench_event_hub.py | 200 + app/infra/db/postgresql/workbench_events.py | 37 + app/infra/db/project_routing.py | 2 + app/infra/integrations/operational_actions.py | 218 + app/infra/integrations/operational_data.py | 177 + app/main.py | 4 +- app/services/operational_tasks.py | 1605 ++++ app/services/scheme_management.py | 70 +- app/services/task_alerts.py | 3 + contracts/manifest.json | 2 +- contracts/peripheral-sample.openapi.json | 303 + contracts/server-v1.openapi.json | 7055 ++++++++++++++++- docs/integration-ui-keycloak-client.md | 22 + docs/operational-task-workers.md | 108 + docs/operational-tasks.md | 54 + docs/peripheral-sample.md | 6 + docs/workbench-operations-storage.md | 2 +- docs/workbench-runtime.md | 2 +- resources/sql/014_operational_tasks.sql | 202 + .../015_workbench_events_and_task_runs.sql | 155 + .../sql/016_metadata_service_identities.sql | 9 + samples/peripheral/schemas.py | 43 +- samples/peripheral/store.py | 19 +- scripts/cleanup_workbench_events.py | 114 + scripts/import_agent_investigation.py | 130 + scripts/provision_integration_ui_client.py | 169 + .../provision_operational_worker_identity.py | 459 ++ scripts/run_operational_worker.py | 259 + scripts/seed_peripheral_sample.py | 88 +- scripts/seed_workbench_work_orders.py | 504 +- scripts/sync_peripheral_work_orders.py | 170 + tests/api/test_agent_auth_endpoints.py | 5 + tests/api/test_problem_details.py | 42 + tests/auth/test_permissions.py | 43 + tests/auth/test_rbac_migration.py | 8 + .../test_operational_tasks_storage.py | 291 + .../test_peripheral_sample_live.py | 4 + tests/unit/test_import_agent_investigation.py | 60 + .../unit/test_operational_action_adapters.py | 184 + tests/unit/test_operational_data.py | 131 + tests/unit/test_operational_tasks.py | 852 ++ tests/unit/test_operational_worker.py | 141 + .../test_peripheral_managed_projection.py | 147 + .../unit/test_peripheral_managed_resources.py | 28 +- tests/unit/test_peripheral_task_sync.py | 162 + .../unit/test_scheme_management_lifecycle.py | 47 + tests/unit/test_seed_peripheral_sample.py | 91 + tests/unit/test_seed_workbench_work_orders.py | 70 + tests/unit/test_workbench_event_hub.py | 32 + tests/unit/test_workbench_events.py | 94 + 66 files changed, 16580 insertions(+), 238 deletions(-) create mode 100644 app/api/v1/endpoints/operational_tasks.py create mode 100644 app/api/v1/endpoints/workbench_events.py create mode 100644 app/domain/schemas/operational_data.py create mode 100644 app/domain/schemas/operational_task.py create mode 100644 app/domain/schemas/workbench_events.py create mode 100644 app/infra/db/postgresql/operational_tasks.py create mode 100644 app/infra/db/postgresql/workbench_event_hub.py create mode 100644 app/infra/db/postgresql/workbench_events.py create mode 100644 app/infra/integrations/operational_actions.py create mode 100644 app/infra/integrations/operational_data.py create mode 100644 app/services/operational_tasks.py create mode 100644 docs/integration-ui-keycloak-client.md create mode 100644 docs/operational-task-workers.md create mode 100644 docs/operational-tasks.md create mode 100644 resources/sql/014_operational_tasks.sql create mode 100644 resources/sql/015_workbench_events_and_task_runs.sql create mode 100644 resources/sql/016_metadata_service_identities.sql create mode 100644 scripts/cleanup_workbench_events.py create mode 100644 scripts/import_agent_investigation.py create mode 100644 scripts/provision_integration_ui_client.py create mode 100644 scripts/provision_operational_worker_identity.py create mode 100644 scripts/run_operational_worker.py create mode 100644 scripts/sync_peripheral_work_orders.py create mode 100644 tests/api/test_problem_details.py create mode 100644 tests/integration/test_operational_tasks_storage.py create mode 100644 tests/unit/test_import_agent_investigation.py create mode 100644 tests/unit/test_operational_action_adapters.py create mode 100644 tests/unit/test_operational_data.py create mode 100644 tests/unit/test_operational_tasks.py create mode 100644 tests/unit/test_operational_worker.py create mode 100644 tests/unit/test_peripheral_managed_projection.py create mode 100644 tests/unit/test_peripheral_task_sync.py create mode 100644 tests/unit/test_seed_peripheral_sample.py create mode 100644 tests/unit/test_seed_workbench_work_orders.py create mode 100644 tests/unit/test_workbench_event_hub.py create mode 100644 tests/unit/test_workbench_events.py diff --git a/.env.example b/.env.example index 8300125..c89ca22 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,25 @@ KEYCLOAK_ALGORITHM=RS256 KEYCLOAK_AUDIENCE="account" KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900 +# ============================================ +# 工作台事件同步 +# ============================================ +# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。 +CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173" +WORKBENCH_EVENTS_ENABLED=true +WORKBENCH_EVENT_RETENTION_DAYS=7 + +# 每个项目启动一个 worker 实例。长期运行优先使用 OAuth 客户端凭据。 +OPERATIONAL_WORKER_PROJECT_ID="" +OPERATIONAL_WORKER_SERVER_URL="http://127.0.0.1:8000" +OPERATIONAL_WORKER_AGENT_URL="http://127.0.0.1:8787" +OPERATIONAL_WORKER_RECONCILE_SECONDS=30 +OPERATIONAL_WORKER_MAX_CONCURRENCY=4 +OPERATIONAL_WORKER_TOKEN_URL="" +OPERATIONAL_WORKER_CLIENT_ID="" +OPERATIONAL_WORKER_CLIENT_SECRET="" +OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300 + # ============================================ # Bocha Web Search API diff --git a/app/api/problem_details.py b/app/api/problem_details.py index 2fd1fb1..b3b90c3 100644 --- a/app/api/problem_details.py +++ b/app/api/problem_details.py @@ -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, ) diff --git a/app/api/v1/endpoints/operational_tasks.py b/app/api/v1/endpoints/operational_tasks.py new file mode 100644 index 0000000..f17ee2f --- /dev/null +++ b/app/api/v1/endpoints/operational_tasks.py @@ -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) diff --git a/app/api/v1/endpoints/workbench_events.py b/app/api/v1/endpoints/workbench_events.py new file mode 100644 index 0000000..ee4f44b --- /dev/null +++ b/app/api/v1/endpoints/workbench_events.py @@ -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 "", + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index e620ba2..cf4d009 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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( diff --git a/app/auth/permissions.py b/app/auth/permissions.py index 380ea5e..1e118d8 100644 --- a/app/auth/permissions.py +++ b/app/auth/permissions.py @@ -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) diff --git a/app/auth/project_dependencies.py b/app/auth/project_dependencies.py index 23ca5c7..b96710c 100644 --- a/app/auth/project_dependencies.py +++ b/app/auth/project_dependencies.py @@ -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, ) diff --git a/app/core/config.py b/app/core/config.py index 89e3acc..eb2a079 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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" diff --git a/app/domain/schemas/admin_metadata.py b/app/domain/schemas/admin_metadata.py index 584cc4b..1bc9d7d 100644 --- a/app/domain/schemas/admin_metadata.py +++ b/app/domain/schemas/admin_metadata.py @@ -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"] diff --git a/app/domain/schemas/operational_data.py b/app/domain/schemas/operational_data.py new file mode 100644 index 0000000..7987ea0 --- /dev/null +++ b/app/domain/schemas/operational_data.py @@ -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 diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py new file mode 100644 index 0000000..4eae4c0 --- /dev/null +++ b/app/domain/schemas/operational_task.py @@ -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 diff --git a/app/domain/schemas/workbench_events.py b/app/domain/schemas/workbench_events.py new file mode 100644 index 0000000..d894cf9 --- /dev/null +++ b/app/domain/schemas/workbench_events.py @@ -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 diff --git a/app/infra/db/metadb/repositories/metadata_repository.py b/app/infra/db/metadb/repositories/metadata_repository.py index b75240f..a109b63 100644 --- a/app/infra/db/metadb/repositories/metadata_repository.py +++ b/app/infra/db/metadb/repositories/metadata_repository.py @@ -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) diff --git a/app/infra/db/postgresql/analysis.py b/app/infra/db/postgresql/analysis.py index 682805f..99be323 100644 --- a/app/infra/db/postgresql/analysis.py +++ b/app/infra/db/postgresql/analysis.py @@ -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( diff --git a/app/infra/db/postgresql/operational_tasks.py b/app/infra/db/postgresql/operational_tasks.py new file mode 100644 index 0000000..1140341 --- /dev/null +++ b/app/infra/db/postgresql/operational_tasks.py @@ -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 diff --git a/app/infra/db/postgresql/task_alerts.py b/app/infra/db/postgresql/task_alerts.py index 40aa438..90ecbf2 100644 --- a/app/infra/db/postgresql/task_alerts.py +++ b/app/infra/db/postgresql/task_alerts.py @@ -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"]}',), + ) diff --git a/app/infra/db/postgresql/workbench_event_hub.py b/app/infra/db/postgresql/workbench_event_hub.py new file mode 100644 index 0000000..152159b --- /dev/null +++ b/app/infra/db/postgresql/workbench_event_hub.py @@ -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() diff --git a/app/infra/db/postgresql/workbench_events.py b/app/infra/db/postgresql/workbench_events.py new file mode 100644 index 0000000..b7337e6 --- /dev/null +++ b/app/infra/db/postgresql/workbench_events.py @@ -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()] diff --git a/app/infra/db/project_routing.py b/app/infra/db/project_routing.py index 85c99e4..e3979ab 100644 --- a/app/infra/db/project_routing.py +++ b/app/infra/db/project_routing.py @@ -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: diff --git a/app/infra/integrations/operational_actions.py b/app/infra/integrations/operational_actions.py new file mode 100644 index 0000000..c9f1214 --- /dev/null +++ b/app/infra/integrations/operational_actions.py @@ -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() diff --git a/app/infra/integrations/operational_data.py b/app/infra/integrations/operational_data.py new file mode 100644 index 0000000..a9186d4 --- /dev/null +++ b/app/infra/integrations/operational_data.py @@ -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, + ) diff --git a/app/main.py b/app/main.py index 1b31c0b..dfaa603 100644 --- a/app/main.py +++ b/app/main.py @@ -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=["*"], diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py new file mode 100644 index 0000000..d0a76ea --- /dev/null +++ b/app/services/operational_tasks.py @@ -0,0 +1,1605 @@ +"""State machines for durable operational tasks and approved external actions.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Iterable +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 + +from app.auth.permissions import ( + OPERATIONAL_ACTION_COMMAND_APPROVE, + OPERATIONAL_ACTION_COMMAND_EXECUTE, + OPERATIONAL_ACTION_WORK_ORDER_APPROVE, + OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, + OPERATIONAL_TASK_EXECUTE, + OPERATIONAL_TASK_EXTERNAL_SYNC, + OPERATIONAL_TASK_WRITE, + SCADA_VIEW, + SIMULATION_VIEW, +) +from app.domain.domain_errors import DomainError, require +from app.domain.schemas.operational_task import ( + ActionDecisionCommand, + ActionExecuteCommand, + ActionProposalCommand, + ActionReconcileCommand, + AgentArtifactsCommand, + AgentArtifactsResult, + AgentTaskContext, + AgentRunCancelCommand, + AgentRunCommand, + AgentRunDispatchFailureCommand, + AgentRunFailureCommand, + CreateWorkOrderPayload, + DispatchDueSchedulesResult, + EvidenceRecord, + ExecutionPlanSnapshot, + ExternalFeedbackCommand, + ExternalFeedbackResult, + HumanApprovalAction, + HumanEvidenceCommand, + InvestigationReportRevision, + IssueCommandPayload, + NotificationAcknowledgeCommand, + OperationalTaskCreate, + OperationalTaskDetail, + OperationalTaskNotification, + OperationalTaskPermissions, + OperationalTaskSchedule, + OperationalTaskScheduleCreate, + OperationalTaskScheduleUpdate, + OperationalTaskSummary, + OperationalTaskRetryCommand, + OperationalTaskRun, + RecoverExpiredLeasesResult, + StoredEvidenceRecord, +) +from app.domain.schemas.operational_data import TaskDataSnapshot +from app.services.content_digest import digest + + +LEASE_DURATION = timedelta(seconds=60) + + +def _json(model) -> dict: + return model.model_dump(mode="json", by_alias=True) + + +def _task_json(model: OperationalTaskSummary) -> dict: + return model.model_dump(mode="json", by_alias=True, exclude={"active_run"}) + + +class OperationalTaskService: + def __init__(self, repository, context, permissions: Iterable[str], *, clock=None): + self.repo = repository + self.context = context + self.permissions = frozenset(permissions) + self.clock = clock or (lambda: datetime.now(timezone.utc)) + + def _now(self) -> datetime: + now = self.clock() + require(now.tzinfo is not None, "SCHEMA_INVALID", "Clock must be timezone-aware") + return now + + def _actor(self) -> str: + return f"principal:{self.context.user_id}" + + @property + def _supports_runs(self) -> bool: + return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run") + + async def _active_run(self, task: dict) -> dict | None: + if self._supports_runs: + return await self.repo.get_active_run(str(task["task_id"])) + if task.get("status") not in {"queued", "running"}: + return None + now = self._now() + return _json(OperationalTaskRun( + run_id=uuid5(NAMESPACE_URL, f"legacy-run:{task['task_id']}:{task['execution_generation']}"), + task_id=task["task_id"], + generation=task["execution_generation"], + execution_request_id=task["execution_request_id"], + status="running" if task["status"] == "running" else "queued", + revision=task["revision"], + agent_session_id=task.get("agent_session_id"), + lease_expires_at=task.get("agent_lease_expires_at"), + heartbeat_at=task.get("updated_at") if task["status"] == "running" else None, + started_at=task.get("created_at") if task["status"] == "running" else None, + dispatch_attempts=0, + next_dispatch_at=task.get("created_at") or now, + created_at=task.get("created_at") or now, + updated_at=task.get("updated_at") or now, + )) + + async def _append_event(self, **kwargs) -> int | None: + append_event = getattr(self.repo, "append_event", None) + return await append_event(**kwargs) if append_event is not None else None + + def _permission_view(self) -> OperationalTaskPermissions: + permissions = self.permissions + return OperationalTaskPermissions( + can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions, + can_propose_actions=OPERATIONAL_TASK_WRITE in permissions, + can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions, + can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions, + can_execute_work_orders=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in permissions, + can_decide_commands=OPERATIONAL_ACTION_COMMAND_APPROVE in permissions, + can_execute_commands=OPERATIONAL_ACTION_COMMAND_EXECUTE in permissions, + ) + + async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail: + body = _json(command) + request_id = str(command.request_id) + scope = "task:create" + async with self.repo.transaction(): + await self.repo.lock(f"request:{scope}:{request_id}") + prior = await self.repo.get_request(scope, request_id) + if prior: + self._require_same_request(prior, body) + return await self.detail(UUID(prior["response"]["task_id"])) + if command.source.external_id: + await self.repo.lock( + f"external-source:{command.source.kind}:{command.source.external_id}" + ) + existing = await self.repo.get_task_by_external_source( + command.source.kind, command.source.external_id + ) + if existing: + await self.repo.save_request( + scope, request_id, digest(body), {"task_id": existing["task_id"]} + ) + return await self.detail(UUID(existing["task_id"])) + now = self._now() + task_id = uuid4() + task = OperationalTaskSummary( + project_id=self.context.project_id, + task_id=task_id, + task_type=command.task_type, + workflow=command.workflow, + lifecycle_status="open", + status="queued", + priority=command.priority, + revision=1, + execution_generation=1, + execution_request_id=self._execution_request_id(task_id, 1), + title=command.title, + summary=command.summary, + source=command.source, + trigger=command.trigger, + scheduled_for=command.scheduled_for, + agent_session_id=command.agent_session_id, + agent_lease_expires_at=None, + current_plan_revision=None, + current_report_revision=None, + created_at=now, + updated_at=now, + ) + task_document = _task_json(task) + await self.repo.insert_task(task_document, request_id) + if command.task_type != "external_work_order": + run = self._new_run(task_id, 1, now) + await self.repo.insert_run(_json(run)) + await self._run_event(run, now, queued=True) + await self._notify( + task_id, "task_created", command.title, "工况任务已进入待执行队列", now + ) + await self.repo.save_request( + scope, request_id, digest(body), {"task_id": str(task_id)} + ) + await self._task_event(task_document, now) + return await self.detail(task_id) + + async def list( + self, *, task_type: str | None, status: str | None, limit: int, offset: int + ) -> tuple[list[OperationalTaskSummary], int | None]: + rows = await self.repo.list_tasks( + task_type=task_type, status=status, limit=limit + 1, offset=offset + ) + items: list[OperationalTaskSummary] = [] + for row in rows[:limit]: + active_run = row.get("active_run") if "active_run" in row else await self._active_run(row) + items.append(OperationalTaskSummary.model_validate({ + **row, + "active_run": active_run, + })) + return items, offset + limit if len(rows) > limit else None + + async def detail(self, task_id: UUID) -> OperationalTaskDetail: + task = await self.repo.get_task(str(task_id)) + require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404) + plan = await self.repo.get_latest_plan(str(task_id)) + report = await self.repo.get_latest_report(str(task_id)) + evidence = [ + self._public_evidence(document) + for document in await self.repo.list_evidence(str(task_id)) + ] + actions = await self.repo.list_actions(str(task_id)) + active_run = await self._active_run(task) + work_order_snapshot = None + if task["task_type"] == "external_work_order" and task["source"].get("external_id"): + feedback = await self.repo.get_feedback( + str(task_id), task["source"]["external_id"] + ) + if feedback: + work_order_snapshot = feedback["document"].get("work_order_snapshot") + return OperationalTaskDetail( + **{key: value for key, value in task.items() if key != "active_run"}, + active_run=active_run, + latest_plan=plan, + latest_report=report, + evidence=evidence, + actions=actions, + permissions=self._permission_view(), + work_order_snapshot=work_order_snapshot, + ) + + async def agent_context(self, task_id: UUID) -> AgentTaskContext: + scopes: list[str] = [] + if SIMULATION_VIEW in self.permissions: + scopes.extend([ + "workbench_runtime", "peripheral_points", "peripheral_work_orders", + "peripheral_observations", "peripheral_test_data", + ]) + if SCADA_VIEW in self.permissions: + scopes.extend(["scada_devices", "scada_readings"]) + return AgentTaskContext( + task=await self.detail(task_id), + approved_data_scopes=scopes, + ) + + async def list_runs( + self, *, status: str | None, limit: int, + cursor_created_at=None, cursor_run_id=None, + ) -> tuple[list[OperationalTaskRun], str | None]: + rows = await self.repo.list_runs( + status=status, + limit=limit + 1, + cursor_created_at=cursor_created_at, + cursor_run_id=cursor_run_id, + ) + items = [OperationalTaskRun.model_validate(row) for row in rows[:limit]] + next_cursor = None + if len(rows) > limit and items: + last = items[-1] + next_cursor = f"{last.created_at.isoformat()}|{last.run_id}" + return items, next_cursor + + async def claim_run( + self, task_id: UUID, run_id: UUID, command: AgentRunCommand + ) -> OperationalTaskRun: + body = _json(command) + scope = f"run:{run_id}:claim" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return OperationalTaskRun.model_validate(replay["run"]) + await self.repo.lock(f"run:{run_id}") + run = await self._run(task_id, run_id) + require( + str(command.request_id) == str(run["execution_request_id"]), + "EXECUTION_REQUEST_MISMATCH", + "Claim request does not match this run", + status=409, + ) + require(run["revision"] == command.expected_revision, "VERSION_CONFLICT", + "Run revision changed", status=409) + require(run["status"] == "queued", "INVALID_TRANSITION", + "Only queued runs can be claimed", status=409) + now = self._now() + expected = run["revision"] + run.update( + status="running", + revision=expected + 1, + agent_session_id=command.source_session_id, + lease_expires_at=now + LEASE_DURATION, + heartbeat_at=now, + started_at=run.get("started_at") or now, + updated_at=now, + ) + run_model = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(run_model), expected) + task = await self._task(task_id) + task.update( + status="running", + agent_session_id=command.source_session_id, + agent_lease_expires_at=now + LEASE_DURATION, + revision=task["revision"] + 1, + updated_at=now, + ) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._run_event(run_model, now) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"run": _json(run_model)}) + return run_model + + async def heartbeat_run( + self, task_id: UUID, run_id: UUID, command: AgentRunCommand + ) -> OperationalTaskRun: + body = _json(command) + scope = f"run:{run_id}:heartbeat" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return OperationalTaskRun.model_validate(replay["run"]) + await self.repo.lock(f"run:{run_id}") + run = await self._run(task_id, run_id) + require(run["revision"] == command.expected_revision, "VERSION_CONFLICT", + "Run revision changed", status=409) + require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION", + "Only an active run has a lease", status=409) + require(run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Agent session does not own this run", status=409) + now = self._now() + self._require_active_run_lease(run, now) + expected = run["revision"] + run.update( + status="running", + revision=expected + 1, + heartbeat_at=now, + lease_expires_at=now + LEASE_DURATION, + updated_at=now, + ) + run_model = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(run_model), expected) + # Deliberately no task update and no browser event: heartbeats are + # lease maintenance, not business-state changes. + await self._save_request(scope, command.request_id, body, {"run": _json(run_model)}) + return run_model + + async def complete_run( + self, task_id: UUID, run_id: UUID, command: AgentRunCommand + ) -> OperationalTaskDetail: + return await self._finish_run(task_id, run_id, command, outcome="succeeded") + + async def fail_run( + self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand + ) -> OperationalTaskDetail: + return await self._finish_run( + task_id, run_id, command, outcome="failed", + failure_code=command.failure_code, failure_detail=command.failure_detail, + ) + + async def cancel_run( + self, task_id: UUID, run_id: UUID, command: AgentRunCancelCommand + ) -> OperationalTaskDetail: + body = _json(command) + scope = f"run:{run_id}:cancel" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return await self.detail(task_id) + await self.repo.lock(f"run:{run_id}") + run = await self._run(task_id, run_id) + require(run["revision"] == command.expected_revision, "VERSION_CONFLICT", + "Run revision changed", status=409) + require(run["status"] in {"queued", "claimed", "running"}, + "INVALID_TRANSITION", "Run is already terminal", status=409) + now = self._now() + expected = run["revision"] + run.update( + status="cancelled", revision=expected + 1, finished_at=now, + lease_expires_at=None, failure_code="CANCELLED", + failure_detail=command.reason, updated_at=now, + ) + run_model = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(run_model), expected) + task = await self._task(task_id) + task.update( + lifecycle_status="cancelled", status="cancelled", + revision=task["revision"] + 1, agent_session_id=None, + agent_lease_expires_at=None, updated_at=now, + ) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._run_event(run_model, now) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) + return await self.detail(task_id) + + async def retry_task( + self, task_id: UUID, command: OperationalTaskRetryCommand + ) -> OperationalTaskDetail: + body = _json(command) + scope = f"task:{task_id}:retry" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return await self.detail(task_id) + await self.repo.lock(f"task:{task_id}") + task = await self._task(task_id) + require(task["task_type"] != "external_work_order", "INVALID_TRANSITION", + "External work orders do not have Agent runs", status=409) + require(await self.repo.get_active_run(str(task_id)) is None, + "ACTIVE_RUN_EXISTS", "Task already has an active run", status=409) + now = self._now() + generation = task["execution_generation"] + 1 + run = self._new_run(task_id, generation, now) + await self.repo.insert_run(_json(run)) + task.update( + lifecycle_status="open", status="queued", + revision=task["revision"] + 1, + execution_generation=generation, + execution_request_id=str(run.execution_request_id), + agent_session_id=None, agent_lease_expires_at=None, updated_at=now, + ) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._run_event(run, now, queued=True) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) + return await self.detail(task_id) + + async def record_dispatch_failure( + self, task_id: UUID, run_id: UUID, command: AgentRunDispatchFailureCommand + ) -> OperationalTaskRun: + body = _json(command) + scope = f"run:{run_id}:dispatch-failure" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return OperationalTaskRun.model_validate(replay["run"]) + await self.repo.lock(f"run:{run_id}") + run = await self._run(task_id, run_id) + require(run["revision"] == command.expected_revision, "VERSION_CONFLICT", + "Run revision changed", status=409) + require(run["status"] == "queued", "INVALID_TRANSITION", + "Only queued runs accept dispatch failures", status=409) + now = self._now() + expected = run["revision"] + attempts = run["dispatch_attempts"] + 1 + retry_delay = min(300, 5 * (2 ** min(attempts - 1, 6))) + run.update( + revision=expected + 1, + dispatch_attempts=attempts, + failure_code=command.failure_code, + failure_detail=command.failure_detail, + next_dispatch_at=now + timedelta(seconds=retry_delay), + updated_at=now, + ) + if not command.retryable: + run.update(status="failed", finished_at=now, next_dispatch_at=now) + run_model = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(run_model), expected) + await self._run_event(run_model, now, queued=command.retryable) + if attempts == 10: + task = await self._task(task_id) + await self._notify( + task_id, "run_delivery_degraded", task["title"], + "Agent 执行投递连续失败 10 次,请检查服务连接", now, + ) + if not command.retryable: + task = await self._task(task_id) + task.update( + lifecycle_status="failed", status="failed", + revision=task["revision"] + 1, updated_at=now, + ) + await self.repo.update_task( + _task_json(OperationalTaskSummary.model_validate(task)) + ) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"run": _json(run_model)}) + return run_model + + async def recover_expired_leases(self, limit: int) -> RecoverExpiredLeasesResult: + now = self._now() + recovered: list[UUID] = [] + async with self.repo.transaction(): + for run in await self.repo.claim_expired_runs(limit): + task_id = UUID(str(run["task_id"])) + expected = run["revision"] + run.update( + status="timed_out", revision=expected + 1, finished_at=now, + lease_expires_at=None, failure_code="LEASE_TIMEOUT", + failure_detail="Agent run lease expired", updated_at=now, + ) + timed_out = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(timed_out), expected) + await self._run_event(timed_out, now) + task = await self._task(task_id) + timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id)) + task.update( + revision=task["revision"] + 1, agent_session_id=None, + agent_lease_expires_at=None, updated_at=now, + ) + if timeout_count >= 3: + task.update(lifecycle_status="failed", status="failed") + await self._notify( + task_id, "task_failed", task["title"], + "Agent 连续三次执行超时,任务已停止自动重试", now, + ) + else: + generation = task["execution_generation"] + 1 + next_run = self._new_run(task_id, generation, now) + await self.repo.insert_run(_json(next_run)) + task.update( + lifecycle_status="open", status="queued", + execution_generation=generation, + execution_request_id=str(next_run.execution_request_id), + ) + await self._run_event(next_run, now, queued=True) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._task_event(task, now) + recovered.append(task_id) + return RecoverExpiredLeasesResult(task_ids=recovered, recovered_at=now) + + async def publish_artifacts( + self, task_id: UUID, command: AgentArtifactsCommand + ) -> AgentArtifactsResult: + body = _json(command) + scope = f"task:{task_id}:artifacts" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + detail = await self.detail(task_id) + return AgentArtifactsResult(task=detail, **replay) + await self.repo.lock(f"task:{task_id}") + task = await self._task(task_id) + active_run = await self._active_run(task) + require(active_run is not None, "RUN_NOT_FOUND", + "Task has no active Agent run", status=409) + if command.run_id is not None: + require(str(command.run_id) == str(active_run["run_id"]), + "EXECUTION_GENERATION_MISMATCH", + "Artifacts target a stale run", status=409) + require(active_run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Artifacts must come from the claimed Agent session", + status=409) + require(active_run["status"] in {"claimed", "running"}, "INVALID_TRANSITION", + "Only a running run accepts Agent artifacts", status=409) + now = self._now() + self._require_active_run_lease(active_run, now) + known_evidence = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))} + for item in command.evidence: + document = _json(EvidenceRecord( + **_json(item), recorded_at=now, recorded_by=self._actor() + )) + prior = await self.repo.get_evidence(str(task_id), item.evidence_id) + if prior: + require( + digest(self._evidence_facts(prior)) == digest(_json(item)), + "EVIDENCE_CONFLICT", + "Evidence ids are immutable", status=409) + else: + require(item.kind == "synthetic", "EVIDENCE_ORIGIN_INVALID", + "Agent may only add explicitly synthetic evidence; system, external, and human evidence must use their trusted ingestion endpoints", + status=422) + await self.repo.insert_evidence(str(task_id), document) + known_evidence.add(item.evidence_id) + plan_revision = task.get("current_plan_revision") + report_revision = task.get("current_report_revision") + current_plan = None + actions = { + item["action_id"]: HumanApprovalAction.model_validate(item) + for item in await self.repo.list_actions(str(task_id)) + } + if command.plan: + self._require_evidence_refs( + (ref for node in command.plan.nodes for ref in node.evidence_refs), + known_evidence, + ) + self._validate_plan_state(command.plan, known_evidence, actions) + plan_revision = (plan_revision or 0) + 1 + snapshot = ExecutionPlanSnapshot( + revision=plan_revision, + execution_generation=task["execution_generation"], + source_session_id=command.source_session_id, + published_at=now, published_by=self._actor(), plan=command.plan, + ) + await self.repo.insert_plan(str(task_id), _json(snapshot)) + current_plan = snapshot + if command.report: + self._require_evidence_refs(command.report.evidence_refs, known_evidence) + report_revision = (report_revision or 0) + 1 + report = InvestigationReportRevision( + revision=report_revision, + execution_generation=task["execution_generation"], + source_session_id=command.source_session_id, + published_at=now, published_by=self._actor(), report=command.report, + ) + await self.repo.insert_report(str(task_id), _json(report)) + await self._append_event( + topic="operational.report.published", + aggregate_type="operational_report", + aggregate_id=str(task_id), + aggregate_revision=report_revision, + occurred_at=now, + metadata={"task_id": str(task_id), "run_id": str(active_run["run_id"])}, + ) + await self._notify( + task_id, "report_updated", command.report.title, + command.report.current_conclusion, now, + ) + if current_plan is None: + stored_plan = await self.repo.get_latest_plan(str(task_id)) + if stored_plan is not None: + current_plan = ExecutionPlanSnapshot.model_validate(stored_plan) + task.update( + revision=task["revision"] + 1, + current_plan_revision=plan_revision, + current_report_revision=report_revision, + agent_lease_expires_at=now + LEASE_DURATION, + updated_at=now, + ) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + run_expected = active_run["revision"] + active_run.update( + revision=run_expected + 1, + heartbeat_at=now, + lease_expires_at=now + LEASE_DURATION, + updated_at=now, + ) + if self._supports_runs: + await self.repo.update_run( + _json(OperationalTaskRun.model_validate(active_run)), run_expected + ) + else: + # Compatibility for pre-015 repository adapters only. + if ( + command.report and current_plan is not None + and current_plan.execution_generation == task["execution_generation"] + and current_plan.source_session_id == command.source_session_id + ): + task["status"] = self._status_from_plan( + current_plan.plan.nodes, actions.values() + ) + if task["status"] in {"waiting_human", "completed", "failed", "cancelled"}: + task["lifecycle_status"] = task["status"] + task["agent_lease_expires_at"] = ( + now + LEASE_DURATION if task["status"] == "running" else None + ) + await self.repo.update_task( + _task_json(OperationalTaskSummary.model_validate(task)) + ) + await self._task_event(task, now) + response = {"plan_revision": plan_revision, "report_revision": report_revision} + await self._save_request(scope, command.request_id, body, response) + return AgentArtifactsResult(task=await self.detail(task_id), **response) + + async def propose_action( + self, task_id: UUID, command: ActionProposalCommand + ) -> HumanApprovalAction: + body = _json(command) + scope = f"task:{task_id}:action:propose" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + action = await self.repo.get_action(str(task_id), replay["action_id"]) + return HumanApprovalAction.model_validate(action) + await self.repo.lock(f"task:{task_id}") + task = await self._task(task_id) + self._validate_action_payload(command) + if command.node_id: + latest_plan = await self.repo.get_latest_plan(str(task_id)) + require(latest_plan is not None, "PLAN_NODE_NOT_FOUND", + "Action cannot be attached before a plan is published", status=409) + node_ids = {node["id"] for node in latest_plan["plan"]["nodes"]} + require(command.node_id in node_ids, "PLAN_NODE_NOT_FOUND", + "Action references an unknown plan node", refs=[command.node_id], status=409) + known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))} + self._require_evidence_refs(command.evidence_refs, known) + now = self._now() + if task["status"] == "running": + active_run = await self._active_run(task) + require(active_run is not None, "RUN_NOT_FOUND", + "Task has no active Agent run", status=409) + self._require_active_run_lease(active_run, now) + approval_permission, execution_permission = self._action_permissions(command.kind) + action = HumanApprovalAction( + action_id=uuid4(), task_id=task_id, version=1, kind=command.kind, + reason=command.reason, payload=command.payload, + evidence_refs=command.evidence_refs, node_id=command.node_id, + required_approval_permission=approval_permission, + required_execution_permission=execution_permission, + approval_status="pending", execution_status="not_submitted", + execution_request_id=None, + proposed_by=self._actor(), proposed_at=now, + ) + await self.repo.insert_action(str(task_id), _json(action)) + await self._action_event(action, now) + if task["status"] != "running": + task.update( + lifecycle_status="waiting_human", status="waiting_human", + revision=task["revision"] + 1, + agent_lease_expires_at=None, updated_at=now, + ) + await self.repo.update_task( + _task_json(OperationalTaskSummary.model_validate(task)) + ) + await self._task_event(task, now) + await self._notify( + task_id, "action_pending", task["title"], command.reason, now, + required_permission=approval_permission, + ) + await self._save_request( + scope, command.request_id, body, {"action_id": str(action.action_id)} + ) + return action + + async def decide_action( + self, task_id: UUID, action_id: UUID, command: ActionDecisionCommand + ) -> HumanApprovalAction: + body = _json(command) + scope = f"task:{task_id}:action:{action_id}:decision" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return HumanApprovalAction.model_validate(replay["action"]) + await self.repo.lock(f"action:{action_id}") + action = await self._action(task_id, action_id) + require(action.version == command.expected_version, "VERSION_CONFLICT", + "Action version changed", status=409) + require(action.approval_status == "pending", "INVALID_TRANSITION", + "Action has already been decided", status=409) + require(action.required_approval_permission in self.permissions, + "PERMISSION_DENIED", "Current user cannot decide this action", status=403) + now = self._now() + action = action.model_copy(update={ + "version": action.version + 1, "approval_status": command.decision, + "decided_by": self._actor(), "decided_at": now, + "decision_comment": command.comment, + }) + await self.repo.update_action(str(task_id), _json(action)) + await self._action_event(action, now) + if command.decision == "rejected": + task = await self._task(task_id) + now = self._now() + await self._requeue_task(task, now) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"action": _json(action)}) + return action + + async def submit_action( + self, task_id: UUID, action_id: UUID, command: ActionExecuteCommand, + dispatcher, credential: str | None, + ) -> HumanApprovalAction: + body = _json(command) + scope = f"task:{task_id}:action:{action_id}:submit" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return HumanApprovalAction.model_validate(replay["action"]) + await self.repo.lock(f"action:{action_id}") + action = await self._action(task_id, action_id) + require(action.version == command.expected_version, "VERSION_CONFLICT", + "Action version changed", status=409) + require(action.approval_status == "approved", "INVALID_TRANSITION", + "Only an approved action can be submitted", status=409) + require(action.execution_status in {"not_submitted", "failed", "unsupported"}, + "INVALID_TRANSITION", "Action cannot be submitted in its current state", status=409) + require(action.required_execution_permission in self.permissions, + "PERMISSION_DENIED", "Current user cannot submit this action", status=403) + now = self._now() + action = action.model_copy(update={ + "version": action.version + 1, "execution_status": "unknown", + "execution_request_id": uuid5( + NAMESPACE_URL, + f"operational-action:{self.context.project_id}:{task_id}:{action_id}:{action.version}", + ), + "executed_by": self._actor(), "executed_at": now, + "execution_detail": "外部动作已开始提交,结果待回执确认", + }) + await self.repo.update_action(str(task_id), _json(action)) + await self._action_event(action, now) + await self._save_request( + scope, command.request_id, body, {"action": _json(action)} + ) + + task = await self.detail(task_id) + outcome = await dispatcher.dispatch( + context=self.context, task=task, action=action, credential=credential + ) + + async with self.repo.transaction(): + await self.repo.lock(f"request:{scope}:{command.request_id}") + await self.repo.lock(f"action:{action_id}") + current = await self._action(task_id, action_id) + require(current.version == action.version and current.execution_status == "unknown", + "VERSION_CONFLICT", "Action changed while awaiting external receipt", status=409) + self._validate_dispatch_outcome(outcome) + current = current.model_copy(update={ + "version": current.version + 1, + "execution_status": outcome.status, + "receipt": outcome.receipt, + "execution_detail": outcome.detail, + }) + await self.repo.update_action(str(task_id), _json(current)) + await self._action_event(current, self._now()) + if outcome.status in {"succeeded", "failed", "unsupported"}: + task_document = await self._task(task_id) + await self._requeue_task(task_document, self._now()) + await self.repo.update_task( + _task_json(OperationalTaskSummary.model_validate(task_document)) + ) + await self._task_event(task_document, self._now()) + await self._save_request(scope, command.request_id, body, {"action": _json(current)}) + return current + + async def reconcile_action( + self, task_id: UUID, action_id: UUID, command: ActionReconcileCommand, + dispatcher, credential: str | None + ) -> HumanApprovalAction: + body = _json(command) + scope = f"task:{task_id}:action:{action_id}:reconcile" + replay = await self.repo.get_request(scope, str(command.request_id)) + if replay: + self._require_same_request(replay, body) + return HumanApprovalAction.model_validate(replay["response"]["action"]) + action = await self._action(task_id, action_id) + require(action.version == command.expected_version, "VERSION_CONFLICT", + "Action version changed", status=409) + require(action.execution_status in {"submitted", "unknown"}, "INVALID_TRANSITION", + "Only submitted or unknown actions can be reconciled", status=409) + require(action.required_execution_permission in self.permissions, + "PERMISSION_DENIED", "Current user cannot reconcile this action", status=403) + outcome = await dispatcher.reconcile( + context=self.context, task=await self.detail(task_id), action=action, + credential=credential, + ) + self._validate_dispatch_outcome(outcome) + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return HumanApprovalAction.model_validate(replay["action"]) + await self.repo.lock(f"action:{action_id}") + current = await self._action(task_id, action_id) + require(current.version == action.version, "VERSION_CONFLICT", + "Action changed while reconciling", status=409) + current = current.model_copy(update={ + "version": current.version + 1, "execution_status": outcome.status, + "receipt": outcome.receipt, "execution_detail": outcome.detail, + }) + await self.repo.update_action(str(task_id), _json(current)) + await self._action_event(current, self._now()) + if outcome.status in {"succeeded", "failed", "unsupported"}: + task_document = await self._task(task_id) + await self._requeue_task(task_document, self._now()) + await self.repo.update_task( + _task_json(OperationalTaskSummary.model_validate(task_document)) + ) + await self._task_event(task_document, self._now()) + await self._save_request( + scope, command.request_id, body, {"action": _json(current)} + ) + return current + + async def external_feedback( + self, task_id: UUID, command: ExternalFeedbackCommand + ) -> ExternalFeedbackResult: + body = _json(command) + scope = f"task:{task_id}:external-feedback" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return ExternalFeedbackResult(task=await self.detail(task_id), **replay) + await self.repo.lock(f"task:{task_id}") + task = await self._task(task_id) + if task["task_type"] == "external_work_order": + require( + task["source"].get("external_id") == command.source_event_id, + "EXTERNAL_SOURCE_MISMATCH", + "External feedback does not match the task source", + status=409, + ) + if command.work_order_snapshot is not None: + require( + task["task_type"] == "external_work_order", + "INVALID_FEEDBACK_SNAPSHOT", + "Work order snapshots only apply to external work order tasks", + status=422, + ) + existing = await self.repo.get_feedback(str(task_id), command.source_event_id) + current_source_revision = ( + task.get("source", {}).get("external_revision") + if task["task_type"] == "external_work_order" + else None + ) + if current_source_revision is not None: + require( + command.source_revision >= current_source_revision, + "STALE_EXTERNAL_REVISION", + "External feedback revision is stale", + status=409, + ) + if existing: + prior = existing["document"] + if command.source_revision == existing["source_revision"]: + require(digest(prior) == digest(body), "IDEMPOTENCY_CONFLICT", + "External feedback revision was reused with different content", status=409) + response = { + "source_event_id": command.source_event_id, + "source_revision": command.source_revision, + "accepted_at": existing["accepted_at"], + } + await self._save_request(scope, command.request_id, body, response) + return ExternalFeedbackResult(task=await self.detail(task_id), **response) + require(command.source_revision > existing["source_revision"], + "STALE_EXTERNAL_REVISION", "External feedback revision is stale", status=409) + now = self._now() + await self.repo.upsert_feedback(str(task_id), body, now) + if command.evidence: + evidence = EvidenceRecord( + **_json(command.evidence), + recorded_at=now, recorded_by=self._actor(), + ) + prior_evidence = await self.repo.get_evidence( + str(task_id), command.evidence.evidence_id + ) + if prior_evidence: + require( + digest(self._evidence_facts(prior_evidence)) + == digest(self._evidence_facts(_json(evidence))), + "EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409) + else: + await self.repo.insert_evidence(str(task_id), _json(evidence)) + self._apply_feedback_status(task, command.status) + if task["task_type"] == "external_work_order": + task["source"] = { + **task["source"], + "external_revision": command.source_revision, + } + if command.work_order_snapshot is not None: + task.update( + title=command.work_order_snapshot.title, + summary=command.work_order_snapshot.summary, + scheduled_for=command.work_order_snapshot.scheduled_at, + priority=( + "urgent" + if command.work_order_snapshot.priority in {"high", "urgent"} + else "normal" + ), + ) + if task["task_type"] == "event_investigation": + await self._requeue_task(task, now) + else: + task.update(revision=task["revision"] + 1, updated_at=now) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._task_event(task, now) + if task["task_type"] == "external_work_order": + await self._append_event( + topic="workbench.work_order.changed", + aggregate_type="operational_task", + aggregate_id=str(task_id), + aggregate_revision=task["revision"], + occurred_at=now, + metadata={"task_id": str(task_id), "namespace": "operational-task"}, + ) + if task["status"] in {"completed", "failed"}: + await self._notify( + task_id, "task_completed" if task["status"] == "completed" else "task_failed", + task["title"], command.detail, now, + ) + response = { + "source_event_id": command.source_event_id, + "source_revision": command.source_revision, + "accepted_at": now, + } + await self._save_request(scope, command.request_id, body, _json_dict(response)) + return ExternalFeedbackResult(task=await self.detail(task_id), **response) + + async def acknowledge_notification( + self, notification_id: UUID, command: NotificationAcknowledgeCommand + ) -> OperationalTaskNotification: + body = _json(command) + scope = ( + f"notification:{notification_id}:user:{self.context.user_id}:ack" + ) + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return OperationalTaskNotification.model_validate(replay["notification"]) + await self.repo.lock(f"notification:{notification_id}") + value = await self.repo.get_notification( + str(notification_id), str(self.context.user_id) + ) + require(value is not None, "NOTIFICATION_NOT_FOUND", "Notification not found", status=404) + notification = OperationalTaskNotification.model_validate(value) + require( + notification.required_permission is None + or notification.required_permission in self.permissions, + "PERMISSION_DENIED", + "Current user is not a recipient of this notification", + status=403, + ) + require(notification.version == command.expected_version, "VERSION_CONFLICT", + "Notification version changed", status=409) + if notification.status == "unread": + notification = notification.model_copy(update={ + "status": "read", "version": notification.version + 1, + "read_at": self._now(), "read_by": self._actor(), + }) + await self.repo.mark_notification_read( + str(notification_id), str(self.context.user_id), + notification.version, notification.read_at, + ) + await self._append_event( + topic="operational.notification.changed", + aggregate_type="operational_notification", + aggregate_id=str(notification_id), + aggregate_revision=notification.version, + occurred_at=notification.read_at, + metadata={"task_id": str(notification.task_id)}, + ) + await self._save_request( + scope, command.request_id, body, {"notification": _json(notification)} + ) + return notification + + async def record_data_snapshot( + self, task_id: UUID, snapshot: TaskDataSnapshot + ) -> tuple[EvidenceRecord, TaskDataSnapshot]: + """Persist the bounded source response before an Agent may cite it.""" + await self._task(task_id) + now = self._now() + evidence = StoredEvidenceRecord( + evidence_id=snapshot.source_ref, + kind=snapshot.origin, + summary=f"{snapshot.source} 数据快照", + source_label=snapshot.source, + source_ref=snapshot.source_ref, + observed_at=snapshot.queried_at, + recorded_at=now, + recorded_by=self._actor(), + has_snapshot=True, + data_snapshot=snapshot, + ) + async with self.repo.transaction(): + await self.repo.lock(f"task:{task_id}:evidence:{snapshot.source_ref}") + prior = await self.repo.get_evidence(str(task_id), snapshot.source_ref) + if prior: + stored = StoredEvidenceRecord.model_validate(prior) + require( + stored.data_snapshot is not None + and self._snapshot_facts(stored.data_snapshot) + == self._snapshot_facts(snapshot), + "EVIDENCE_CONFLICT", + "Evidence ids are immutable", + status=409, + ) + return self._public_evidence(prior), stored.data_snapshot + await self.repo.insert_evidence(str(task_id), _json(evidence)) + return self._public_evidence(_json(evidence)), snapshot + + async def add_human_evidence( + self, task_id: UUID, command: HumanEvidenceCommand + ) -> OperationalTaskDetail: + body = _json(command) + scope = f"task:{task_id}:human-evidence" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return await self.detail(task_id) + await self.repo.lock(f"task:{task_id}") + task = await self._task(task_id) + require(command.evidence.kind == "human_input", "EVIDENCE_KIND_INVALID", + "This endpoint only accepts human_input evidence", status=422) + now = self._now() + record = EvidenceRecord( + **_json(command.evidence), recorded_at=now, recorded_by=self._actor() + ) + prior = await self.repo.get_evidence(str(task_id), command.evidence.evidence_id) + if prior: + require( + digest(self._evidence_facts(prior)) == digest(_json(command.evidence)), + "EVIDENCE_CONFLICT", "Evidence ids are immutable", status=409, + ) + else: + await self.repo.insert_evidence(str(task_id), _json(record)) + await self._requeue_task(task, now) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._task_event(task, now) + await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) + return await self.detail(task_id) + + async def data_snapshot(self, task_id: UUID, evidence_id: str) -> TaskDataSnapshot: + await self._task(task_id) + value = await self.repo.get_evidence(str(task_id), evidence_id) + require(value is not None, "EVIDENCE_NOT_FOUND", "Evidence not found", status=404) + stored = StoredEvidenceRecord.model_validate(value) + require(stored.data_snapshot is not None, "SNAPSHOT_NOT_FOUND", + "Evidence has no stored data snapshot", status=404) + return stored.data_snapshot + + async def create_schedule( + self, command: OperationalTaskScheduleCreate + ) -> OperationalTaskSchedule: + body = _json(command) + scope = "schedule:create" + async with self.repo.transaction(): + await self.repo.lock(f"schedule-request:{command.request_id}") + request = await self.repo.get_request(scope, str(command.request_id)) + if request: + self._require_same_request(request, body) + prior = await self.repo.get_schedule_by_request(str(command.request_id)) + require(prior is not None, "STATE_CORRUPT", + "Schedule idempotency record has no schedule", status=500) + return OperationalTaskSchedule.model_validate(prior) + prior = await self.repo.get_schedule_by_request(str(command.request_id)) + if prior: + return OperationalTaskSchedule.model_validate(prior) + now = self._now() + schedule = OperationalTaskSchedule( + schedule_id=uuid4(), schedule_key=command.schedule_key, + title=command.title, summary=command.summary, + interval_seconds=command.interval_seconds, + next_run_at=command.next_run_at, priority=command.priority, + enabled=True, version=1, created_at=now, updated_at=now, + ) + await self.repo.insert_schedule(_json(schedule), str(command.request_id)) + await self._save_request( + scope, command.request_id, body, + {"schedule_id": str(schedule.schedule_id)}, + ) + return schedule + + async def list_schedules(self) -> list[OperationalTaskSchedule]: + return [ + OperationalTaskSchedule.model_validate(item) + for item in await self.repo.list_schedules() + ] + + async def update_schedule( + self, schedule_id: UUID, command: OperationalTaskScheduleUpdate + ) -> OperationalTaskSchedule: + body = _json(command) + scope = f"schedule:{schedule_id}:update" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return OperationalTaskSchedule.model_validate(replay["schedule"]) + await self.repo.lock(f"schedule:{schedule_id}") + value = await self.repo.get_schedule(str(schedule_id)) + require(value is not None, "SCHEDULE_NOT_FOUND", "Schedule not found", status=404) + schedule = OperationalTaskSchedule.model_validate(value) + require(schedule.version == command.expected_version, "VERSION_CONFLICT", + "Schedule version changed", status=409) + schedule = schedule.model_copy(update={ + "enabled": command.enabled, + "version": schedule.version + 1, + "updated_at": self._now(), + }) + await self.repo.update_schedule(_json(schedule)) + await self._save_request( + scope, command.request_id, body, {"schedule": _json(schedule)} + ) + return schedule + + async def list_notifications( + self, *, unread_only: bool, limit: int, offset: int + ) -> tuple[list[OperationalTaskNotification], int | None]: + rows = await self.repo.list_notifications( + user_id=str(self.context.user_id), + allowed_permissions=sorted(self.permissions), + unread_only=unread_only, limit=limit + 1, offset=offset + ) + return ( + [OperationalTaskNotification.model_validate(item) for item in rows[:limit]], + offset + limit if len(rows) > limit else None, + ) + + async def dispatch_due_schedules(self, limit: int) -> DispatchDueSchedulesResult: + now = self._now() + task_ids: list[UUID] = [] + async with self.repo.transaction(): + for schedule_document in await self.repo.claim_due_schedules(limit): + schedule = OperationalTaskSchedule.model_validate(schedule_document) + due_at = schedule.next_run_at + task_id = uuid5(schedule.schedule_id, due_at.isoformat()) + request_id = uuid5(NAMESPACE_URL, f"schedule:{schedule.schedule_id}:{due_at.isoformat()}") + task = self._scheduled_task(schedule, task_id, due_at, now) + await self.repo.insert_task(_task_json(task), str(request_id)) + run = self._new_run(task_id, 1, now) + await self.repo.insert_run(_json(run)) + await self.repo.insert_occurrence( + str(schedule.schedule_id), due_at, str(task_id), now + ) + next_schedule = schedule.model_copy(update={ + "next_run_at": due_at + timedelta(seconds=schedule.interval_seconds), + "version": schedule.version + 1, "updated_at": now, + }) + await self.repo.advance_schedule(_json(next_schedule)) + await self._notify( + task_id, "task_created", task.title, "定时流程已进入待执行队列", now + ) + await self._run_event(run, now, queued=True) + await self._task_event(_task_json(task), now) + task_ids.append(task_id) + return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now) + + async def _finish_run( + self, task_id: UUID, run_id: UUID, command: AgentRunCommand, + *, outcome: str, failure_code: str | None = None, + failure_detail: str | None = None, + ) -> OperationalTaskDetail: + body = _json(command) + scope = f"run:{run_id}:{outcome}" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return await self.detail(task_id) + await self.repo.lock(f"run:{run_id}") + run = await self._run(task_id, run_id) + require(run["revision"] == command.expected_revision, "VERSION_CONFLICT", + "Run revision changed", status=409) + require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION", + "Only an active run can finish", status=409) + require(run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Agent session does not own this run", status=409) + now = self._now() + self._require_active_run_lease(run, now) + task = await self._task(task_id) + final_outcome = outcome + if outcome == "succeeded": + plan_document = await self.repo.get_latest_plan(str(task_id)) + report_document = await self.repo.get_latest_report(str(task_id)) + plan = ( + ExecutionPlanSnapshot.model_validate(plan_document) + if plan_document is not None else None + ) + report = ( + InvestigationReportRevision.model_validate(report_document) + if report_document is not None else None + ) + same_generation = ( + plan is not None and report is not None + and plan.execution_generation == run["generation"] + and report.execution_generation == run["generation"] + and plan.source_session_id == command.source_session_id + and report.source_session_id == command.source_session_id + ) + if not same_generation: + final_outcome = "failed" + lifecycle = "failed" + failure_code = "ARTIFACT_INCOMPLETE" + failure_detail = ( + "Run requires a final plan and report from the same generation" + ) + else: + actions = [ + HumanApprovalAction.model_validate(item) + for item in await self.repo.list_actions(str(task_id)) + ] + lifecycle = self._status_from_plan(plan.plan.nodes, actions) + if lifecycle == "running": + final_outcome = "failed" + lifecycle = "failed" + failure_code = "ARTIFACT_INCOMPLETE" + failure_detail = "Final plan still contains running work" + else: + lifecycle = "failed" + expected = run["revision"] + run.update( + status=final_outcome, revision=expected + 1, finished_at=now, + lease_expires_at=None, heartbeat_at=now, + failure_code=failure_code, failure_detail=failure_detail, updated_at=now, + ) + run_model = OperationalTaskRun.model_validate(run) + await self.repo.update_run(_json(run_model), expected) + task.update( + lifecycle_status=lifecycle, + status=lifecycle, + revision=task["revision"] + 1, + agent_session_id=None, + agent_lease_expires_at=None, + updated_at=now, + ) + await self.repo.update_task(_task_json(OperationalTaskSummary.model_validate(task))) + await self._run_event(run_model, now) + await self._task_event(task, now) + if lifecycle in {"completed", "failed"}: + await self._notify( + task_id, + "task_completed" if lifecycle == "completed" else "task_failed", + task["title"], + "工况任务流程状态已更新", + now, + ) + await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) + return await self.detail(task_id) + + async def _run(self, task_id: UUID, run_id: UUID) -> dict: + run = await self.repo.get_run(str(run_id)) + require( + run is not None and str(run["task_id"]) == str(task_id), + "RUN_NOT_FOUND", "Operational task run not found", status=404, + ) + return run + + async def _task(self, task_id: UUID) -> dict: + task = await self.repo.get_task(str(task_id)) + require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404) + return task + + async def _action(self, task_id: UUID, action_id: UUID) -> HumanApprovalAction: + value = await self.repo.get_action(str(task_id), str(action_id)) + require(value is not None, "ACTION_NOT_FOUND", "Action not found", status=404) + return HumanApprovalAction.model_validate(value) + + async def _request_replay(self, scope: str, request_id: UUID, body: dict) -> dict | None: + await self.repo.lock(f"request:{scope}:{request_id}") + prior = await self.repo.get_request(scope, str(request_id)) + if prior: + self._require_same_request(prior, body) + return prior["response"] + return None + + async def _save_request(self, scope: str, request_id: UUID, body: dict, response: dict) -> None: + await self.repo.save_request(scope, str(request_id), digest(body), response) + + @staticmethod + def _require_same_request(prior: dict, body: dict) -> None: + require(prior["request_digest"] == digest(body), "IDEMPOTENCY_CONFLICT", + "request_id was used with different content", status=409) + + @staticmethod + def _execution_request_id(task_id: UUID, generation: int) -> UUID: + return uuid5(NAMESPACE_URL, f"operational-task:{task_id}:generation:{generation}") + + @staticmethod + def _status_from_plan(nodes, actions: Iterable[HumanApprovalAction]) -> str: + statuses = {node.status for node in nodes} + if "running" in statuses: + return "running" + if any( + node.status == "failed" and node.id.startswith("AGENT_RUN_FAILURE") + for node in nodes + ): + return "failed" + if any(action.approval_status == "pending" for action in actions): + return "waiting_human" + if statuses <= {"completed", "skipped", "cancelled"}: + return "completed" + if "failed" in statuses and statuses <= {"completed", "failed", "skipped", "cancelled"}: + return "failed" + if any(node.owner == "human" and node.status in {"pending", "waiting"} for node in nodes): + return "waiting_human" + return "running" + + @staticmethod + def _validate_plan_state(plan, known_evidence: set[str], actions: dict[str, HumanApprovalAction]) -> None: + nodes = {node.id: node for node in plan.nodes} + predecessors: dict[str, set[str]] = {node.id: set() for node in plan.nodes} + for edge in plan.edges: + predecessors[edge.to_node].add(edge.from_node) + for node in plan.nodes: + if node.status in {"running", "completed"}: + incomplete = [ + predecessor for predecessor in predecessors[node.id] + if nodes[predecessor].status not in {"completed", "skipped"} + ] + require(not incomplete, "PLAN_DEPENDENCY_INCOMPLETE", + "A running or completed node has incomplete predecessors", + refs=incomplete, status=409) + if node.status == "completed": + require(bool(node.evidence_refs or node.action_refs), + "PLAN_NODE_UNSUPPORTED", + "Completed nodes require evidence or an executed action receipt", + refs=[node.id], status=409) + for action_id in node.action_refs: + action = actions.get(str(action_id)) + require(action is not None, "ACTION_NOT_FOUND", + "Plan node references an unknown action", refs=[str(action_id)], status=409) + if node.status == "completed": + require(action.execution_status == "succeeded" and action.receipt is not None, + "ACTION_RECEIPT_REQUIRED", + "A completed action node requires a successful external receipt", + refs=[str(action_id)], status=409) + + @staticmethod + def _evidence_facts(document: dict) -> dict: + return { + key: document.get(key) + for key in ( + "evidence_id", "kind", "summary", "source_label", "source_ref", "observed_at" + ) + } + + @staticmethod + def _snapshot_facts(snapshot: TaskDataSnapshot) -> dict: + return snapshot.model_dump( + mode="json", + exclude={"queried_at"}, + ) + + @staticmethod + def _require_active_run_lease(run: dict, now: datetime) -> None: + lease = OperationalTaskRun.model_validate(run).lease_expires_at + require( + lease is not None and lease > now, + "LEASE_EXPIRED", + "Agent run lease has expired", + status=409, + ) + + @staticmethod + def _public_evidence(document: dict) -> EvidenceRecord: + return EvidenceRecord.model_validate( + {key: value for key, value in document.items() if key != "data_snapshot"} + ) + + @staticmethod + def _require_evidence_refs(refs: Iterable[str], known: set[str]) -> None: + missing = sorted(set(refs) - known) + require(not missing, "EVIDENCE_NOT_FOUND", "Artifact references unknown evidence", + refs=missing, status=409) + + @staticmethod + def _validate_action_payload(command: ActionProposalCommand) -> None: + model = CreateWorkOrderPayload if command.kind == "create_work_order" else IssueCommandPayload + try: + model.model_validate(command.payload) + except Exception as exc: + raise DomainError("ACTION_PAYLOAD_INVALID", "Action payload does not match its kind") from exc + + @staticmethod + def _action_permissions(kind: str) -> tuple[str, str]: + if kind == "create_work_order": + return OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE + return OPERATIONAL_ACTION_COMMAND_APPROVE, OPERATIONAL_ACTION_COMMAND_EXECUTE + + @staticmethod + def _validate_dispatch_outcome(outcome) -> None: + if outcome.status in {"submitted", "succeeded"}: + require(outcome.receipt is not None, "RECEIPT_REQUIRED", + "Submitted actions require an external receipt", status=502) + if outcome.status == "succeeded": + require(outcome.receipt.completed_at is not None, "RECEIPT_REQUIRED", + "Successful actions require a completion receipt", status=502) + + @staticmethod + def _apply_feedback_status(task: dict, status: str) -> None: + task["status"] = { + "scheduled": "queued", "assigned": "running", "in_progress": "running", + "awaiting_feedback": "waiting_human", "completed": "completed", + "cancelled": "cancelled", "failed": "failed", + }[status] + task["lifecycle_status"] = { + "scheduled": "open", "assigned": "open", "in_progress": "open", + "awaiting_feedback": "waiting_human", "completed": "completed", + "cancelled": "cancelled", "failed": "failed", + }[status] + task["agent_lease_expires_at"] = None + + async def _requeue_task(self, task: dict, now: datetime) -> None: + if self._supports_runs: + active_run = await self.repo.get_active_run(str(task["task_id"])) + if active_run is not None and active_run["status"] == "queued": + # Multiple inputs can arrive before the worker claims the next + # generation. They are all visible when that queued run starts, + # so coalesce them instead of violating the one-active-run rule. + task.update( + lifecycle_status="open", status="queued", + revision=task["revision"] + 1, + execution_generation=active_run["generation"], + execution_request_id=str(active_run["execution_request_id"]), + agent_session_id=None, agent_lease_expires_at=None, + updated_at=now, + ) + return + if active_run is not None: + expected = active_run["revision"] + active_run.update( + status="cancelled", revision=expected + 1, + lease_expires_at=None, finished_at=now, + failure_code="SUPERSEDED_BY_REQUEUE", + failure_detail="New task input superseded the active run", + updated_at=now, + ) + cancelled = OperationalTaskRun.model_validate(active_run) + await self.repo.update_run(_json(cancelled), expected) + await self._run_event(cancelled, now) + generation = task["execution_generation"] + 1 + task_id = UUID(task["task_id"]) + task.update( + lifecycle_status="open", status="queued", revision=task["revision"] + 1, + execution_generation=generation, + execution_request_id=str(self._execution_request_id(task_id, generation)), + agent_session_id=None, agent_lease_expires_at=None, updated_at=now, + ) + run = self._new_run(task_id, generation, now) + if self._supports_runs: + await self.repo.insert_run(_json(run)) + await self._run_event(run, now, queued=True) + + async def _notify( + self, task_id: UUID, kind: str, title: str, description: str, now: datetime, + *, required_permission: str | None = None, + ) -> None: + notification = OperationalTaskNotification( + notification_id=uuid4(), task_id=task_id, kind=kind, + title=title, description=description, status="unread", version=1, + created_at=now, required_permission=required_permission, + ) + await self.repo.insert_notification(_json(notification)) + await self._append_event( + topic="operational.notification.changed", + aggregate_type="operational_notification", + aggregate_id=str(notification.notification_id), + aggregate_revision=notification.version, + occurred_at=now, + metadata={"task_id": str(task_id), "kind": kind}, + ) + + def _new_run( + self, task_id: UUID, generation: int, now: datetime + ) -> OperationalTaskRun: + return OperationalTaskRun( + run_id=uuid4(), + task_id=task_id, + generation=generation, + execution_request_id=self._execution_request_id(task_id, generation), + status="queued", + revision=1, + dispatch_attempts=0, + next_dispatch_at=now, + created_at=now, + updated_at=now, + ) + + async def _task_event(self, task: dict, now: datetime) -> None: + await self._append_event( + topic="operational.task.changed", + aggregate_type="operational_task", + aggregate_id=str(task["task_id"]), + aggregate_revision=int(task["revision"]), + occurred_at=now, + metadata={ + "task_id": str(task["task_id"]), + "lifecycle_status": task.get("lifecycle_status", "open"), + }, + ) + + async def _run_event( + self, run: OperationalTaskRun, now: datetime, *, queued: bool = False + ) -> None: + await self._append_event( + topic="operational.run.queued" if queued else "operational.run.changed", + aggregate_type="operational_task_run", + aggregate_id=str(run.run_id), + aggregate_revision=run.revision, + occurred_at=now, + metadata={ + "task_id": str(run.task_id), + "run_id": str(run.run_id), + "status": run.status, + }, + ) + + async def _action_event( + self, action: HumanApprovalAction, now: datetime + ) -> None: + await self._append_event( + topic="operational.action.changed", + aggregate_type="operational_action", + aggregate_id=str(action.action_id), + aggregate_revision=action.version, + occurred_at=now, + metadata={"task_id": str(action.task_id)}, + ) + + def _scheduled_task( + self, schedule: OperationalTaskSchedule, task_id: UUID, + due_at: datetime, now: datetime, + ) -> OperationalTaskSummary: + return OperationalTaskSummary( + project_id=self.context.project_id, task_id=task_id, + task_type="scheduled_flow", workflow=schedule.schedule_key, + lifecycle_status="open", status="queued", priority=schedule.priority, revision=1, + execution_generation=1, + execution_request_id=self._execution_request_id(task_id, 1), + title=schedule.title, summary=schedule.summary, + source={ + "kind": "schedule", + "external_id": f"{schedule.schedule_id}:{due_at.isoformat()}", + }, + trigger={"kind": "schedule", "occurred_at": due_at, + "detail": f"定时流程 {schedule.schedule_key} 到期"}, + scheduled_for=due_at, agent_session_id=None, + agent_lease_expires_at=None, current_plan_revision=None, + current_report_revision=None, created_at=now, updated_at=now, + ) + + +def _json_dict(value: dict) -> dict: + """Convert datetime values without allowing arbitrary object serialization.""" + return { + key: item.isoformat().replace("+00:00", "Z") if isinstance(item, datetime) else item + for key, item in value.items() + } diff --git a/app/services/scheme_management.py b/app/services/scheme_management.py index dd956d5..5791e0d 100644 --- a/app/services/scheme_management.py +++ b/app/services/scheme_management.py @@ -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]: diff --git a/app/services/task_alerts.py b/app/services/task_alerts.py index f1ccb27..6ea4864 100644 --- a/app/services/task_alerts.py +++ b/app/services/task_alerts.py @@ -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( diff --git a/contracts/manifest.json b/contracts/manifest.json index b01b367..7812a00 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "de342f10ac2949eb337513b1d1cb6b595083be2fad2860c9ccd92eebe2758e6b" + "sha256": "3669e1b9ba31dad83f9c53028fd974b8bacac0f9d3069f644e49254baf6bb82f" } } } diff --git a/contracts/peripheral-sample.openapi.json b/contracts/peripheral-sample.openapi.json index c424336..10984a6 100644 --- a/contracts/peripheral-sample.openapi.json +++ b/contracts/peripheral-sample.openapi.json @@ -251,6 +251,19 @@ ], "title": "Assignee" }, + "location": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, "priority": { "default": "normal", "enum": [ @@ -267,6 +280,19 @@ "title": "Request Id", "type": "string" }, + "scenario_code": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Code" + }, "scheduled_at": { "anyOf": [ { @@ -285,12 +311,29 @@ "draft", "open", "in_progress", + "awaiting_reply", "completed", "cancelled" ], "title": "Status", "type": "string" }, + "subtasks": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtasks" + }, "summary": { "maxLength": 4000, "minLength": 1, @@ -302,6 +345,16 @@ "minLength": 1, "title": "Title", "type": "string" + }, + "work_order_type": { + "default": "general", + "enum": [ + "general", + "water_quality_complaint", + "burst_report" + ], + "title": "Work Order Type", + "type": "string" } }, "required": [ @@ -1586,16 +1639,44 @@ "title": "Clear Assignee", "type": "boolean" }, + "clear_location": { + "default": false, + "title": "Clear Location", + "type": "boolean" + }, + "clear_scenario_code": { + "default": false, + "title": "Clear Scenario Code", + "type": "boolean" + }, "clear_scheduled_at": { "default": false, "title": "Clear Scheduled At", "type": "boolean" }, + "clear_subtasks": { + "default": false, + "title": "Clear Subtasks", + "type": "boolean" + }, "expected_revision": { "minimum": 1.0, "title": "Expected Revision", "type": "integer" }, + "location": { + "anyOf": [ + { + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, "priority": { "anyOf": [ { @@ -1618,6 +1699,19 @@ "title": "Request Id", "type": "string" }, + "scenario_code": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Code" + }, "scheduled_at": { "anyOf": [ { @@ -1637,6 +1731,7 @@ "draft", "open", "in_progress", + "awaiting_reply", "completed", "cancelled" ], @@ -1648,6 +1743,22 @@ ], "title": "Status" }, + "subtasks": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtasks" + }, "summary": { "anyOf": [ { @@ -1705,6 +1816,17 @@ "title": "Created By", "type": "string" }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, "origin": { "const": "synthetic", "title": "Origin", @@ -1730,6 +1852,17 @@ "title": "Revision", "type": "integer" }, + "scenario_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Code" + }, "scheduled_at": { "anyOf": [ { @@ -1747,12 +1880,29 @@ "draft", "open", "in_progress", + "awaiting_reply", "completed", "cancelled" ], "title": "Status", "type": "string" }, + "subtasks": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtasks" + }, "summary": { "title": "Summary", "type": "string" @@ -1774,6 +1924,16 @@ "format": "uuid", "title": "Work Order Id", "type": "string" + }, + "work_order_type": { + "default": "general", + "enum": [ + "general", + "water_quality_complaint", + "burst_report" + ], + "title": "Work Order Type", + "type": "string" } }, "required": [ @@ -1824,6 +1984,149 @@ ], "title": "WorkOrderPage", "type": "object" + }, + "WorkbenchRuntimeWorkOrderStage": { + "additionalProperties": false, + "properties": { + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "owner": { + "minLength": 1, + "title": "Owner", + "type": "string" + }, + "sequence": { + "minimum": 1.0, + "title": "Sequence", + "type": "integer" + }, + "status": { + "enum": [ + "done", + "current", + "pending", + "blocked", + "skipped" + ], + "title": "Status", + "type": "string" + }, + "time_label": { + "minLength": 1, + "title": "Time Label", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "sequence", + "title", + "status", + "owner", + "time_label", + "description" + ], + "title": "WorkbenchRuntimeWorkOrderStage", + "type": "object" + }, + "WorkbenchRuntimeWorkOrderSubtask": { + "additionalProperties": false, + "properties": { + "assignee": { + "minLength": 1, + "title": "Assignee", + "type": "string" + }, + "duration_minutes": { + "minimum": 1.0, + "title": "Duration Minutes", + "type": "integer" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "location": { + "minLength": 1, + "title": "Location", + "type": "string" + }, + "reply_requirements": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Reply Requirements", + "type": "array" + }, + "reply_window_minutes": { + "minimum": 1.0, + "title": "Reply Window Minutes", + "type": "integer" + }, + "scheduled_at": { + "format": "date-time", + "title": "Scheduled At", + "type": "string" + }, + "stages": { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderStage" + }, + "title": "Stages", + "type": "array" + }, + "status": { + "enum": [ + "pending", + "running", + "blocked", + "completed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "title", + "summary", + "status", + "scheduled_at", + "location", + "assignee", + "duration_minutes", + "reply_window_minutes", + "stages", + "reply_requirements" + ], + "title": "WorkbenchRuntimeWorkOrderSubtask", + "type": "object" } } }, diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index 63c08ed..f6bbb46 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -61,6 +61,203 @@ "title": "AccessContextResponse", "type": "object" }, + "ActionDecisionCommand": { + "additionalProperties": false, + "properties": { + "comment": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "decision": { + "enum": [ + "approved", + "rejected" + ], + "title": "Decision", + "type": "string" + }, + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version", + "decision" + ], + "title": "ActionDecisionCommand", + "type": "object" + }, + "ActionExecuteCommand": { + "additionalProperties": false, + "properties": { + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version" + ], + "title": "ActionExecuteCommand", + "type": "object" + }, + "ActionProposalCommand": { + "additionalProperties": false, + "properties": { + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "kind": { + "enum": [ + "create_work_order", + "issue_command" + ], + "title": "Kind", + "type": "string" + }, + "node_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Node Id" + }, + "payload": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreateWorkOrderPayload" + }, + { + "$ref": "#/components/schemas/IssueCommandPayload" + } + ], + "title": "Payload" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "kind", + "reason", + "payload" + ], + "title": "ActionProposalCommand", + "type": "object" + }, + "ActionReceipt": { + "additionalProperties": false, + "properties": { + "adapter": { + "minLength": 1, + "title": "Adapter", + "type": "string" + }, + "completed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Completed At" + }, + "detail": { + "minLength": 1, + "title": "Detail", + "type": "string" + }, + "external_status": { + "minLength": 1, + "title": "External Status", + "type": "string" + }, + "receipt_id": { + "minLength": 1, + "title": "Receipt Id", + "type": "string" + }, + "submitted_at": { + "format": "date-time", + "title": "Submitted At", + "type": "string" + } + }, + "required": [ + "adapter", + "receipt_id", + "submitted_at", + "external_status", + "detail" + ], + "title": "ActionReceipt", + "type": "object" + }, + "ActionReconcileCommand": { + "additionalProperties": false, + "properties": { + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version" + ], + "title": "ActionReconcileCommand", + "type": "object" + }, "AdminProjectCreateRequest": { "properties": { "code": { @@ -273,6 +470,106 @@ "title": "AdminProjectUpdateRequest", "type": "object" }, + "AgentArtifactsCommand": { + "additionalProperties": false, + "properties": { + "evidence": { + "items": { + "$ref": "#/components/schemas/EvidenceInput" + }, + "maxItems": 100, + "title": "Evidence", + "type": "array" + }, + "plan": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionPlanInput" + }, + { + "type": "null" + } + ] + }, + "report": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationReportInput" + }, + { + "type": "null" + } + ] + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "run_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Run Id" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "request_id", + "source_session_id" + ], + "title": "AgentArtifactsCommand", + "type": "object" + }, + "AgentArtifactsResult": { + "additionalProperties": false, + "properties": { + "plan_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Plan Revision" + }, + "report_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Report Revision" + }, + "task": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + }, + "required": [ + "task", + "plan_revision", + "report_revision" + ], + "title": "AgentArtifactsResult", + "type": "object" + }, "AgentAuthContextResponse": { "properties": { "is_superuser": { @@ -340,6 +637,159 @@ "title": "AgentAuthContextResponse", "type": "object" }, + "AgentRunCancelCommand": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_revision", + "reason" + ], + "title": "AgentRunCancelCommand", + "type": "object" + }, + "AgentRunCommand": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_revision", + "source_session_id" + ], + "title": "AgentRunCommand", + "type": "object" + }, + "AgentRunDispatchFailureCommand": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "failure_code": { + "minLength": 1, + "title": "Failure Code", + "type": "string" + }, + "failure_detail": { + "minLength": 1, + "title": "Failure Detail", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "retryable": { + "default": true, + "title": "Retryable", + "type": "boolean" + } + }, + "required": [ + "request_id", + "expected_revision", + "failure_code", + "failure_detail" + ], + "title": "AgentRunDispatchFailureCommand", + "type": "object" + }, + "AgentRunFailureCommand": { + "additionalProperties": false, + "properties": { + "expected_revision": { + "minimum": 1.0, + "title": "Expected Revision", + "type": "integer" + }, + "failure_code": { + "minLength": 1, + "title": "Failure Code", + "type": "string" + }, + "failure_detail": { + "minLength": 1, + "title": "Failure Detail", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_revision", + "source_session_id", + "failure_code", + "failure_detail" + ], + "title": "AgentRunFailureCommand", + "type": "object" + }, + "AgentTaskContext": { + "additionalProperties": false, + "properties": { + "approved_data_scopes": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Approved Data Scopes", + "type": "array" + }, + "task": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + }, + "required": [ + "task", + "approved_data_scopes" + ], + "title": "AgentTaskContext", + "type": "object" + }, "AuditLogResponse": { "description": "审计日志响应", "properties": { @@ -982,6 +1432,137 @@ "title": "BurstLocationRequestRest", "type": "object" }, + "CommandTarget": { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "kind": { + "minLength": 1, + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "title": "CommandTarget", + "type": "object" + }, + "CreateWorkOrderPayload": { + "additionalProperties": false, + "properties": { + "assignee": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assignee" + }, + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "location": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, + "priority": { + "default": "normal", + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "scheduled_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled At" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "work_order_type": { + "minLength": 1, + "title": "Work Order Type", + "type": "string" + } + }, + "required": [ + "title", + "description", + "work_order_type" + ], + "title": "CreateWorkOrderPayload", + "type": "object" + }, + "DispatchDueSchedulesCommand": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 20, + "maximum": 100.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + } + }, + "title": "DispatchDueSchedulesCommand", + "type": "object" + }, + "DispatchDueSchedulesResult": { + "additionalProperties": false, + "properties": { + "dispatched_at": { + "format": "date-time", + "title": "Dispatched At", + "type": "string" + }, + "task_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Task Ids", + "type": "array" + } + }, + "required": [ + "task_ids", + "dispatched_at" + ], + "title": "DispatchDueSchedulesResult", + "type": "object" + }, "ElementGisProperties": { "additionalProperties": false, "properties": { @@ -1174,6 +1755,772 @@ "title": "ElementHistoryTarget", "type": "object" }, + "EvidenceInput": { + "additionalProperties": false, + "properties": { + "evidence_id": { + "minLength": 1, + "title": "Evidence Id", + "type": "string" + }, + "kind": { + "enum": [ + "system_data", + "external_feedback", + "human_input", + "synthetic" + ], + "title": "Kind", + "type": "string" + }, + "observed_at": { + "format": "date-time", + "title": "Observed At", + "type": "string" + }, + "source_label": { + "minLength": 1, + "title": "Source Label", + "type": "string" + }, + "source_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Ref" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + } + }, + "required": [ + "evidence_id", + "kind", + "summary", + "source_label", + "observed_at" + ], + "title": "EvidenceInput", + "type": "object" + }, + "EvidenceRecord": { + "additionalProperties": false, + "properties": { + "evidence_id": { + "minLength": 1, + "title": "Evidence Id", + "type": "string" + }, + "has_snapshot": { + "default": false, + "title": "Has Snapshot", + "type": "boolean" + }, + "kind": { + "enum": [ + "system_data", + "external_feedback", + "human_input", + "synthetic" + ], + "title": "Kind", + "type": "string" + }, + "observed_at": { + "format": "date-time", + "title": "Observed At", + "type": "string" + }, + "recorded_at": { + "format": "date-time", + "title": "Recorded At", + "type": "string" + }, + "recorded_by": { + "minLength": 1, + "title": "Recorded By", + "type": "string" + }, + "source_label": { + "minLength": 1, + "title": "Source Label", + "type": "string" + }, + "source_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Ref" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + } + }, + "required": [ + "evidence_id", + "kind", + "summary", + "source_label", + "observed_at", + "recorded_at", + "recorded_by" + ], + "title": "EvidenceRecord", + "type": "object" + }, + "ExecutionPlanEdge": { + "additionalProperties": false, + "properties": { + "condition": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Condition" + }, + "from": { + "minLength": 1, + "title": "From", + "type": "string" + }, + "to": { + "minLength": 1, + "title": "To", + "type": "string" + } + }, + "required": [ + "from", + "to" + ], + "title": "ExecutionPlanEdge", + "type": "object" + }, + "ExecutionPlanInput": { + "additionalProperties": false, + "properties": { + "edges": { + "items": { + "$ref": "#/components/schemas/ExecutionPlanEdge" + }, + "maxItems": 300, + "title": "Edges", + "type": "array" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/ExecutionPlanNode" + }, + "maxItems": 100, + "minItems": 1, + "title": "Nodes", + "type": "array" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema": { + "const": "execution-plan@1", + "title": "Schema", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "schema", + "title", + "reason", + "nodes" + ], + "title": "ExecutionPlanInput", + "type": "object" + }, + "ExecutionPlanNode": { + "additionalProperties": false, + "properties": { + "action_refs": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Action Refs", + "type": "array" + }, + "detail": { + "minLength": 1, + "title": "Detail", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "owner": { + "enum": [ + "agent", + "human" + ], + "title": "Owner", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "waiting", + "running", + "completed", + "failed", + "skipped", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "title", + "owner", + "status", + "detail" + ], + "title": "ExecutionPlanNode", + "type": "object" + }, + "ExecutionPlanSnapshot": { + "additionalProperties": false, + "properties": { + "execution_generation": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Execution Generation" + }, + "plan": { + "$ref": "#/components/schemas/ExecutionPlanInput" + }, + "published_at": { + "format": "date-time", + "title": "Published At", + "type": "string" + }, + "published_by": { + "minLength": 1, + "title": "Published By", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "revision", + "source_session_id", + "published_at", + "published_by", + "plan" + ], + "title": "ExecutionPlanSnapshot", + "type": "object" + }, + "ExternalFeedbackCommand": { + "additionalProperties": false, + "properties": { + "detail": { + "minLength": 1, + "title": "Detail", + "type": "string" + }, + "evidence": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalFeedbackEvidence" + }, + { + "type": "null" + } + ] + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source_event_id": { + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "source_revision": { + "minimum": 1.0, + "title": "Source Revision", + "type": "integer" + }, + "status": { + "enum": [ + "scheduled", + "assigned", + "in_progress", + "awaiting_feedback", + "completed", + "cancelled", + "failed" + ], + "title": "Status", + "type": "string" + }, + "work_order_snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Input" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "request_id", + "source_event_id", + "source_revision", + "status", + "occurred_at", + "detail" + ], + "title": "ExternalFeedbackCommand", + "type": "object" + }, + "ExternalFeedbackEvidence": { + "additionalProperties": false, + "properties": { + "evidence_id": { + "minLength": 1, + "title": "Evidence Id", + "type": "string" + }, + "kind": { + "default": "external_feedback", + "enum": [ + "external_feedback", + "synthetic" + ], + "title": "Kind", + "type": "string" + }, + "observed_at": { + "format": "date-time", + "title": "Observed At", + "type": "string" + }, + "source_label": { + "minLength": 1, + "title": "Source Label", + "type": "string" + }, + "source_ref": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Ref" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + } + }, + "required": [ + "evidence_id", + "summary", + "source_label", + "observed_at" + ], + "title": "ExternalFeedbackEvidence", + "type": "object" + }, + "ExternalFeedbackResult": { + "additionalProperties": false, + "properties": { + "accepted_at": { + "format": "date-time", + "title": "Accepted At", + "type": "string" + }, + "source_event_id": { + "minLength": 1, + "title": "Source Event Id", + "type": "string" + }, + "source_revision": { + "minimum": 1.0, + "title": "Source Revision", + "type": "integer" + }, + "task": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + }, + "required": [ + "task", + "source_event_id", + "source_revision", + "accepted_at" + ], + "title": "ExternalFeedbackResult", + "type": "object" + }, + "ExternalWorkOrderSnapshot-Input": { + "additionalProperties": false, + "properties": { + "assignee": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assignee" + }, + "location": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, + "origin": { + "const": "synthetic", + "title": "Origin", + "type": "string" + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "scenario_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Code" + }, + "scheduled_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled At" + }, + "status": { + "enum": [ + "draft", + "open", + "in_progress", + "awaiting_reply", + "completed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "subtasks": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtasks" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "work_order_id": { + "format": "uuid", + "title": "Work Order Id", + "type": "string" + }, + "work_order_type": { + "default": "general", + "enum": [ + "general", + "water_quality_complaint", + "burst_report" + ], + "title": "Work Order Type", + "type": "string" + } + }, + "required": [ + "work_order_id", + "revision", + "title", + "summary", + "status", + "priority", + "origin", + "updated_at" + ], + "title": "ExternalWorkOrderSnapshot", + "type": "object" + }, + "ExternalWorkOrderSnapshot-Output": { + "additionalProperties": false, + "properties": { + "assignee": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Assignee" + }, + "location": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Location" + }, + "origin": { + "const": "synthetic", + "title": "Origin", + "type": "string" + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "scenario_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scenario Code" + }, + "scheduled_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled At" + }, + "status": { + "enum": [ + "draft", + "open", + "in_progress", + "awaiting_reply", + "completed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "subtasks": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/WorkbenchRuntimeWorkOrderSubtask" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtasks" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "work_order_id": { + "format": "uuid", + "title": "Work Order Id", + "type": "string" + }, + "work_order_type": { + "default": "general", + "enum": [ + "general", + "water_quality_complaint", + "burst_report" + ], + "title": "Work Order Type", + "type": "string" + } + }, + "required": [ + "work_order_id", + "revision", + "title", + "summary", + "status", + "priority", + "origin", + "updated_at" + ], + "title": "ExternalWorkOrderSnapshot", + "type": "object" + }, "GisAttributeGroup": { "additionalProperties": false, "properties": { @@ -1211,7 +2558,8 @@ "match_method": { "enum": [ "node_pair", - "geometry" + "geometry", + "source" ], "title": "Match Method", "type": "string" @@ -1299,6 +2647,390 @@ "title": "HistorySource", "type": "string" }, + "HumanApprovalAction": { + "additionalProperties": false, + "properties": { + "action_id": { + "format": "uuid", + "title": "Action Id", + "type": "string" + }, + "approval_status": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "title": "Approval Status", + "type": "string" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Decided At" + }, + "decided_by": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Decided By" + }, + "decision_comment": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Decision Comment" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "executed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Executed At" + }, + "executed_by": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Executed By" + }, + "execution_detail": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Execution Detail" + }, + "execution_request_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Execution Request Id" + }, + "execution_status": { + "enum": [ + "not_submitted", + "submitted", + "succeeded", + "failed", + "unsupported", + "unknown" + ], + "title": "Execution Status", + "type": "string" + }, + "kind": { + "enum": [ + "create_work_order", + "issue_command" + ], + "title": "Kind", + "type": "string" + }, + "node_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Node Id" + }, + "payload": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreateWorkOrderPayload" + }, + { + "$ref": "#/components/schemas/IssueCommandPayload" + } + ], + "title": "Payload" + }, + "proposed_at": { + "format": "date-time", + "title": "Proposed At", + "type": "string" + }, + "proposed_by": { + "minLength": 1, + "title": "Proposed By", + "type": "string" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActionReceipt" + }, + { + "type": "null" + } + ] + }, + "required_approval_permission": { + "minLength": 1, + "title": "Required Approval Permission", + "type": "string" + }, + "required_execution_permission": { + "minLength": 1, + "title": "Required Execution Permission", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "version": { + "minimum": 1.0, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "action_id", + "task_id", + "version", + "kind", + "reason", + "payload", + "evidence_refs", + "required_approval_permission", + "required_execution_permission", + "approval_status", + "execution_status", + "proposed_by", + "proposed_at" + ], + "title": "HumanApprovalAction", + "type": "object" + }, + "HumanEvidenceCommand": { + "additionalProperties": false, + "properties": { + "evidence": { + "$ref": "#/components/schemas/EvidenceInput" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "evidence" + ], + "title": "HumanEvidenceCommand", + "type": "object" + }, + "InvestigationReportInput": { + "additionalProperties": false, + "properties": { + "conclusion_boundary": { + "minLength": 1, + "title": "Conclusion Boundary", + "type": "string" + }, + "current_conclusion": { + "minLength": 1, + "title": "Current Conclusion", + "type": "string" + }, + "evidence_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Evidence Refs", + "type": "array" + }, + "pending_items": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Pending Items", + "type": "array" + }, + "recommendations": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Recommendations", + "type": "array" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "current_conclusion", + "conclusion_boundary" + ], + "title": "InvestigationReportInput", + "type": "object" + }, + "InvestigationReportRevision": { + "additionalProperties": false, + "properties": { + "execution_generation": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Execution Generation" + }, + "published_at": { + "format": "date-time", + "title": "Published At", + "type": "string" + }, + "published_by": { + "minLength": 1, + "title": "Published By", + "type": "string" + }, + "report": { + "$ref": "#/components/schemas/InvestigationReportInput" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "revision", + "source_session_id", + "published_at", + "published_by", + "report" + ], + "title": "InvestigationReportRevision", + "type": "object" + }, + "IssueCommandPayload": { + "additionalProperties": false, + "properties": { + "command_type": { + "minLength": 1, + "title": "Command Type", + "type": "string" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "impact_summary": { + "minLength": 1, + "title": "Impact Summary", + "type": "string" + }, + "parameters": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "title": "Parameters", + "type": "object" + }, + "preconditions": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Preconditions", + "type": "array" + }, + "target": { + "$ref": "#/components/schemas/CommandTarget" + } + }, + "required": [ + "command_type", + "target", + "parameters", + "impact_summary", + "preconditions", + "expires_at" + ], + "title": "IssueCommandPayload", + "type": "object" + }, "JsonValue": {}, "LeakageIdentifyRequestRest": { "properties": { @@ -1551,6 +3283,7 @@ "default": "user", "enum": [ "admin", + "service", "user" ], "title": "Role", @@ -1629,6 +3362,7 @@ { "enum": [ "admin", + "service", "user" ], "type": "string" @@ -1661,6 +3395,1171 @@ "title": "MetadataUsersBatchSyncRequest", "type": "object" }, + "NotificationAcknowledgeCommand": { + "additionalProperties": false, + "properties": { + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version" + ], + "title": "NotificationAcknowledgeCommand", + "type": "object" + }, + "OperationalTaskCreate": { + "additionalProperties": false, + "properties": { + "agent_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Session Id" + }, + "priority": { + "default": "normal", + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "scheduled_for": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled For" + }, + "source": { + "$ref": "#/components/schemas/TaskSource" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "task_type": { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "title": "Task Type", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "trigger": { + "$ref": "#/components/schemas/TaskTrigger" + }, + "workflow": { + "minLength": 1, + "title": "Workflow", + "type": "string" + } + }, + "required": [ + "request_id", + "task_type", + "workflow", + "title", + "summary", + "source", + "trigger" + ], + "title": "OperationalTaskCreate", + "type": "object" + }, + "OperationalTaskDetail": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/HumanApprovalAction" + }, + "title": "Actions", + "type": "array" + }, + "active_run": { + "anyOf": [ + { + "$ref": "#/components/schemas/OperationalTaskRun" + }, + { + "type": "null" + } + ] + }, + "agent_lease_expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Lease Expires At" + }, + "agent_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Session Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "current_plan_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Plan Revision" + }, + "current_report_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Report Revision" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/EvidenceRecord" + }, + "title": "Evidence", + "type": "array" + }, + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "execution_request_id": { + "format": "uuid", + "title": "Execution Request Id", + "type": "string" + }, + "latest_plan": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExecutionPlanSnapshot" + }, + { + "type": "null" + } + ] + }, + "latest_report": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationReportRevision" + }, + { + "type": "null" + } + ] + }, + "lifecycle_status": { + "default": "open", + "enum": [ + "open", + "waiting_human", + "completed", + "failed", + "cancelled" + ], + "title": "Lifecycle Status", + "type": "string" + }, + "permissions": { + "$ref": "#/components/schemas/OperationalTaskPermissions" + }, + "priority": { + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "scheduled_for": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled For" + }, + "source": { + "$ref": "#/components/schemas/TaskSource" + }, + "status": { + "enum": [ + "queued", + "running", + "waiting_human", + "completed", + "failed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "task_type": { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "title": "Task Type", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "trigger": { + "$ref": "#/components/schemas/TaskTrigger" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "work_order_snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalWorkOrderSnapshot-Output" + }, + { + "type": "null" + } + ] + }, + "workflow": { + "minLength": 1, + "title": "Workflow", + "type": "string" + } + }, + "required": [ + "project_id", + "task_id", + "task_type", + "workflow", + "status", + "priority", + "revision", + "execution_generation", + "execution_request_id", + "title", + "summary", + "source", + "trigger", + "scheduled_for", + "agent_session_id", + "agent_lease_expires_at", + "current_plan_revision", + "current_report_revision", + "created_at", + "updated_at", + "latest_plan", + "latest_report", + "evidence", + "actions", + "permissions" + ], + "title": "OperationalTaskDetail", + "type": "object" + }, + "OperationalTaskNotification": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "kind": { + "enum": [ + "task_created", + "action_pending", + "report_updated", + "task_completed", + "task_failed", + "run_delivery_degraded" + ], + "title": "Kind", + "type": "string" + }, + "notification_id": { + "format": "uuid", + "title": "Notification Id", + "type": "string" + }, + "read_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Read At" + }, + "read_by": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Read By" + }, + "required_permission": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Required Permission" + }, + "status": { + "enum": [ + "unread", + "read" + ], + "title": "Status", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "version": { + "minimum": 1.0, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "notification_id", + "task_id", + "kind", + "title", + "description", + "status", + "version", + "created_at" + ], + "title": "OperationalTaskNotification", + "type": "object" + }, + "OperationalTaskNotificationPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OperationalTaskNotification" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "next_offset": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + } + }, + "required": [ + "items", + "limit", + "offset" + ], + "title": "OperationalTaskNotificationPage", + "type": "object" + }, + "OperationalTaskPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OperationalTaskSummary" + }, + "title": "Items", + "type": "array" + }, + "limit": { + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "next_offset": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + } + }, + "required": [ + "items", + "limit", + "offset" + ], + "title": "OperationalTaskPage", + "type": "object" + }, + "OperationalTaskPermissions": { + "additionalProperties": false, + "properties": { + "can_decide_commands": { + "title": "Can Decide Commands", + "type": "boolean" + }, + "can_decide_work_orders": { + "title": "Can Decide Work Orders", + "type": "boolean" + }, + "can_execute_commands": { + "title": "Can Execute Commands", + "type": "boolean" + }, + "can_execute_work_orders": { + "title": "Can Execute Work Orders", + "type": "boolean" + }, + "can_propose_actions": { + "title": "Can Propose Actions", + "type": "boolean" + }, + "can_publish_artifacts": { + "title": "Can Publish Artifacts", + "type": "boolean" + }, + "can_sync_external_feedback": { + "title": "Can Sync External Feedback", + "type": "boolean" + } + }, + "required": [ + "can_publish_artifacts", + "can_propose_actions", + "can_sync_external_feedback", + "can_decide_work_orders", + "can_execute_work_orders", + "can_decide_commands", + "can_execute_commands" + ], + "title": "OperationalTaskPermissions", + "type": "object" + }, + "OperationalTaskRetryCommand": { + "additionalProperties": false, + "properties": { + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "reason" + ], + "title": "OperationalTaskRetryCommand", + "type": "object" + }, + "OperationalTaskRun": { + "additionalProperties": false, + "properties": { + "agent_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Session Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "dispatch_attempts": { + "default": 0, + "minimum": 0.0, + "title": "Dispatch Attempts", + "type": "integer" + }, + "execution_request_id": { + "format": "uuid", + "title": "Execution Request Id", + "type": "string" + }, + "failure_code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Code" + }, + "failure_detail": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Detail" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "generation": { + "minimum": 1.0, + "title": "Generation", + "type": "integer" + }, + "heartbeat_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Heartbeat At" + }, + "lease_expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lease Expires At" + }, + "next_dispatch_at": { + "format": "date-time", + "title": "Next Dispatch At", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "format": "uuid", + "title": "Run Id", + "type": "string" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "status": { + "enum": [ + "queued", + "claimed", + "running", + "succeeded", + "failed", + "timed_out", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "run_id", + "task_id", + "generation", + "execution_request_id", + "status", + "revision", + "next_dispatch_at", + "created_at", + "updated_at" + ], + "title": "OperationalTaskRun", + "type": "object" + }, + "OperationalTaskRunPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OperationalTaskRun" + }, + "title": "Items", + "type": "array" + }, + "next_cursor": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + } + }, + "required": [ + "items" + ], + "title": "OperationalTaskRunPage", + "type": "object" + }, + "OperationalTaskSchedule": { + "additionalProperties": false, + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "interval_seconds": { + "maximum": 31536000.0, + "minimum": 60.0, + "title": "Interval Seconds", + "type": "integer" + }, + "next_run_at": { + "format": "date-time", + "title": "Next Run At", + "type": "string" + }, + "priority": { + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "schedule_id": { + "format": "uuid", + "title": "Schedule Id", + "type": "string" + }, + "schedule_key": { + "minLength": 1, + "title": "Schedule Key", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "version": { + "minimum": 1.0, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "schedule_id", + "schedule_key", + "title", + "summary", + "interval_seconds", + "next_run_at", + "priority", + "enabled", + "version", + "created_at", + "updated_at" + ], + "title": "OperationalTaskSchedule", + "type": "object" + }, + "OperationalTaskScheduleCreate": { + "additionalProperties": false, + "properties": { + "interval_seconds": { + "maximum": 31536000.0, + "minimum": 60.0, + "title": "Interval Seconds", + "type": "integer" + }, + "next_run_at": { + "format": "date-time", + "title": "Next Run At", + "type": "string" + }, + "priority": { + "default": "normal", + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "schedule_key": { + "minLength": 1, + "title": "Schedule Key", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "request_id", + "schedule_key", + "title", + "summary", + "interval_seconds", + "next_run_at" + ], + "title": "OperationalTaskScheduleCreate", + "type": "object" + }, + "OperationalTaskSchedulePage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OperationalTaskSchedule" + }, + "title": "Items", + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "OperationalTaskSchedulePage", + "type": "object" + }, + "OperationalTaskScheduleUpdate": { + "additionalProperties": false, + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version", + "enabled" + ], + "title": "OperationalTaskScheduleUpdate", + "type": "object" + }, + "OperationalTaskSummary": { + "additionalProperties": false, + "properties": { + "active_run": { + "anyOf": [ + { + "$ref": "#/components/schemas/OperationalTaskRun" + }, + { + "type": "null" + } + ] + }, + "agent_lease_expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Lease Expires At" + }, + "agent_session_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Session Id" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "current_plan_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Plan Revision" + }, + "current_report_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Report Revision" + }, + "execution_generation": { + "minimum": 1.0, + "title": "Execution Generation", + "type": "integer" + }, + "execution_request_id": { + "format": "uuid", + "title": "Execution Request Id", + "type": "string" + }, + "lifecycle_status": { + "default": "open", + "enum": [ + "open", + "waiting_human", + "completed", + "failed", + "cancelled" + ], + "title": "Lifecycle Status", + "type": "string" + }, + "priority": { + "enum": [ + "normal", + "urgent" + ], + "title": "Priority", + "type": "string" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "revision": { + "minimum": 1.0, + "title": "Revision", + "type": "integer" + }, + "scheduled_for": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scheduled For" + }, + "source": { + "$ref": "#/components/schemas/TaskSource" + }, + "status": { + "enum": [ + "queued", + "running", + "waiting_human", + "completed", + "failed", + "cancelled" + ], + "title": "Status", + "type": "string" + }, + "summary": { + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "task_type": { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "title": "Task Type", + "type": "string" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "trigger": { + "$ref": "#/components/schemas/TaskTrigger" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "workflow": { + "minLength": 1, + "title": "Workflow", + "type": "string" + } + }, + "required": [ + "project_id", + "task_id", + "task_type", + "workflow", + "status", + "priority", + "revision", + "execution_generation", + "execution_request_id", + "title", + "summary", + "source", + "trigger", + "scheduled_for", + "agent_session_id", + "agent_lease_expires_at", + "current_plan_revision", + "current_report_revision", + "created_at", + "updated_at" + ], + "title": "OperationalTaskSummary", + "type": "object" + }, "Page_AdminProjectResponse_": { "properties": { "items": { @@ -2076,6 +4975,32 @@ "title": "Instance", "type": "string" }, + "recovery_action": { + "default": "none", + "title": "Recovery Action", + "type": "string" + }, + "related_refs": { + "items": {}, + "title": "Related Refs", + "type": "array" + }, + "retry_after_seconds": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Retry After Seconds" + }, + "retryable": { + "default": false, + "title": "Retryable", + "type": "boolean" + }, "status": { "title": "Status", "type": "integer" @@ -2650,6 +5575,44 @@ "title": "RealtimeNodeBatchItem", "type": "object" }, + "RecoverExpiredLeasesCommand": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 20, + "maximum": 100.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + } + }, + "title": "RecoverExpiredLeasesCommand", + "type": "object" + }, + "RecoverExpiredLeasesResult": { + "additionalProperties": false, + "properties": { + "recovered_at": { + "format": "date-time", + "title": "Recovered At", + "type": "string" + }, + "task_ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Task Ids", + "type": "array" + } + }, + "required": [ + "task_ids", + "recovered_at" + ], + "title": "RecoverExpiredLeasesResult", + "type": "object" + }, "RunSimulationManuallyByDateRest": { "properties": { "duration": { @@ -2896,7 +5859,10 @@ "type": "integer" }, "sensor_type": { - "const": "pressure", + "enum": [ + "pressure", + "water_quality" + ], "title": "Sensor Type", "type": "string" } @@ -2926,6 +5892,21 @@ "title": "Created By", "type": "string" }, + "method": { + "anyOf": [ + { + "enum": [ + "sensitivity", + "kmeans" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Method" + }, "min_diameter": { "title": "Min Diameter", "type": "integer" @@ -2957,6 +5938,15 @@ "title": "Sensor Points", "type": "array" }, + "sensor_type": { + "default": "pressure", + "enum": [ + "pressure", + "water_quality" + ], + "title": "Sensor Type", + "type": "string" + }, "status": { "title": "Status", "type": "string" @@ -3082,6 +6072,29 @@ "title": "SessionAuditEventRequest", "type": "object" }, + "SourceGisFeatureProperties": { + "additionalProperties": false, + "properties": { + "group": { + "$ref": "#/components/schemas/GisAttributeGroup" + }, + "layer_key": { + "title": "Layer Key", + "type": "string" + }, + "source_fid": { + "title": "Source Fid", + "type": "integer" + } + }, + "required": [ + "layer_key", + "source_fid", + "group" + ], + "title": "SourceGisFeatureProperties", + "type": "object" + }, "TaskAlertCommand": { "additionalProperties": false, "properties": { @@ -3244,6 +6257,232 @@ "title": "TaskAlertState", "type": "object" }, + "TaskDataQuery": { + "additionalProperties": false, + "properties": { + "device_ids": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Device Ids", + "type": "array" + }, + "end": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End" + }, + "point_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Point Id" + }, + "source": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "title": "Source", + "type": "string" + }, + "start": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start" + } + }, + "required": [ + "source" + ], + "title": "TaskDataQuery", + "type": "object" + }, + "TaskDataQueryResult": { + "additionalProperties": false, + "properties": { + "evidence": { + "$ref": "#/components/schemas/EvidenceRecord" + }, + "snapshot": { + "$ref": "#/components/schemas/TaskDataSnapshot" + } + }, + "required": [ + "evidence", + "snapshot" + ], + "title": "TaskDataQueryResult", + "type": "object" + }, + "TaskDataSnapshot": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/JsonValue" + }, + "origin": { + "enum": [ + "system_data", + "synthetic" + ], + "title": "Origin", + "type": "string" + }, + "queried_at": { + "format": "date-time", + "title": "Queried At", + "type": "string" + }, + "source": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "title": "Source", + "type": "string" + }, + "source_ref": { + "minLength": 1, + "title": "Source Ref", + "type": "string" + }, + "truncated": { + "title": "Truncated", + "type": "boolean" + } + }, + "required": [ + "source", + "source_ref", + "queried_at", + "origin", + "data", + "truncated" + ], + "title": "TaskDataSnapshot", + "type": "object" + }, + "TaskSource": { + "additionalProperties": false, + "properties": { + "event_kind": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Event Kind" + }, + "external_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + }, + "external_revision": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "External Revision" + }, + "kind": { + "enum": [ + "schedule", + "external_system", + "operator", + "agent" + ], + "title": "Kind", + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TaskSource", + "type": "object" + }, + "TaskTrigger": { + "additionalProperties": false, + "properties": { + "detail": { + "minLength": 1, + "title": "Detail", + "type": "string" + }, + "kind": { + "enum": [ + "schedule", + "external_sync", + "event", + "manual" + ], + "title": "Kind", + "type": "string" + }, + "occurred_at": { + "format": "date-time", + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "kind", + "occurred_at", + "detail" + ], + "title": "TaskTrigger", + "type": "object" + }, "TiandituGeocodeRequest": { "properties": { "keyword": { @@ -4237,6 +7476,53 @@ ], "title": "WorkbenchRuntimeWorkOrderSubtask", "type": "object" + }, + "WorkbenchSyncSnapshot": { + "additionalProperties": false, + "properties": { + "event_cursor": { + "minimum": 0.0, + "title": "Event Cursor", + "type": "integer" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + }, + "notifications": { + "items": { + "$ref": "#/components/schemas/OperationalTaskNotification" + }, + "title": "Notifications", + "type": "array" + }, + "runtime": { + "$ref": "#/components/schemas/WorkbenchRuntimeSnapshot" + }, + "schema": { + "const": "workbench-sync-snapshot@1", + "title": "Schema", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/components/schemas/OperationalTaskSummary" + }, + "title": "Tasks", + "type": "array" + } + }, + "required": [ + "schema", + "event_cursor", + "runtime", + "tasks", + "notifications", + "generated_at" + ], + "title": "WorkbenchSyncSnapshot", + "type": "object" } }, "securitySchemes": { @@ -19683,6 +22969,3429 @@ ] } }, + "/api/v1/operational-task-notifications": { + "get": { + "operationId": "get_operational_task_notifications", + "parameters": [ + { + "in": "query", + "name": "unread_only", + "required": false, + "schema": { + "default": false, + "title": "Unread Only", + "type": "boolean" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskNotificationPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Operational Task Notifications", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-task-notifications/{notification_id}/acknowledge": { + "post": { + "operationId": "post_operational_task_notifications_notification_id_acknowledge", + "parameters": [ + { + "in": "path", + "name": "notification_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Notification Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationAcknowledgeCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskNotification" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Acknowledge Operational Task Notification", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-task-runs": { + "get": { + "operationId": "get_operational_task_runs", + "parameters": [ + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "queued", + "claimed", + "running", + "succeeded", + "failed", + "timed_out", + "cancelled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRunPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Operational Task Runs", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks": { + "get": { + "operationId": "get_operational_tasks", + "parameters": [ + { + "in": "query", + "name": "task_type", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "scheduled_flow", + "external_work_order", + "event_investigation" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Type" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "queued", + "running", + "waiting_human", + "completed", + "failed", + "cancelled" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Operational Tasks", + "tags": [ + "Operational Tasks" + ] + }, + "post": { + "operationId": "post_operational_tasks", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Create Operational Task", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/leases/recover": { + "post": { + "operationId": "post_operational_tasks_leases_recover", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverExpiredLeasesCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverExpiredLeasesResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Recover Operational Task Leases", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/schedules": { + "get": { + "operationId": "get_operational_tasks_schedules", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskSchedulePage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "List Operational Schedules", + "tags": [ + "Operational Tasks" + ] + }, + "post": { + "operationId": "post_operational_tasks_schedules", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskScheduleCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskSchedule" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Create Operational Schedule", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/schedules/dispatch-due": { + "post": { + "operationId": "post_operational_tasks_schedules_dispatch_due", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DispatchDueSchedulesCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DispatchDueSchedulesResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Dispatch Due Operational Schedules", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/schedules/{schedule_id}": { + "patch": { + "operationId": "patch_operational_tasks_schedules_schedule_id", + "parameters": [ + { + "in": "path", + "name": "schedule_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Schedule Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskScheduleUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskSchedule" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Update Operational Schedule", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}": { + "get": { + "operationId": "get_operational_tasks_task_id", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Operational Task", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/actions": { + "post": { + "operationId": "post_operational_tasks_task_id_actions", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionProposalCommand" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HumanApprovalAction" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Propose Operational Task Action", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/actions/{action_id}/decision": { + "post": { + "operationId": "post_operational_tasks_task_id_actions_action_id_decision", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Action Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionDecisionCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HumanApprovalAction" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Decide Operational Task Action", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/actions/{action_id}/execute": { + "post": { + "operationId": "post_operational_tasks_task_id_actions_action_id_execute", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Action Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionExecuteCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HumanApprovalAction" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Execute Operational Task Action", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/actions/{action_id}/reconcile": { + "post": { + "operationId": "post_operational_tasks_task_id_actions_action_id_reconcile", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "action_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Action Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionReconcileCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HumanApprovalAction" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Reconcile Operational Task Action", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/agent-artifacts": { + "post": { + "operationId": "post_operational_tasks_task_id_agent_artifacts", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentArtifactsCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentArtifactsResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Publish Operational Task Artifacts", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/agent-context": { + "get": { + "operationId": "get_operational_tasks_task_id_agent_context", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentTaskContext" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Operational Task Agent Context", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/data-query": { + "post": { + "operationId": "post_operational_tasks_task_id_data_query", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDataQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDataQueryResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Query Operational Task Data", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/evidence/{evidence_id}/snapshot": { + "get": { + "operationId": "get_operational_tasks_task_id_evidence_evidence_id_snapshot", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "evidence_id", + "required": true, + "schema": { + "title": "Evidence Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskDataSnapshot" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Get Operational Task Data Snapshot", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/external-feedback": { + "post": { + "operationId": "post_operational_tasks_task_id_external_feedback", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalFeedbackCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalFeedbackResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Synchronize Operational Task Feedback", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/human-evidence": { + "post": { + "operationId": "post_operational_tasks_task_id_human_evidence", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HumanEvidenceCommand" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Add Operational Task Human Evidence", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/retry": { + "post": { + "operationId": "post_operational_tasks_task_id_retry", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRetryCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Retry Operational Task", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/cancel": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_cancel", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunCancelCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Cancel Operational Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/claim": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_claim", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRun" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Claim Operational Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/complete": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_complete", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Complete Operational Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/dispatch-failure": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_dispatch_failure", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunDispatchFailureCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRun" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Record Operational Task Run Dispatch Failure", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/fail": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_fail", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunFailureCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskDetail" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Fail Operational Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/runs/{run_id}/heartbeat": { + "post": { + "operationId": "post_operational_tasks_task_id_runs_run_id_heartbeat", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "run_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Run Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunCommand" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationalTaskRun" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Heartbeat Operational Task Run", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/patterns": { "delete": { "description": "从网络中删除指定的模式", @@ -29493,6 +36202,121 @@ ] } }, + "/api/v1/source-gis/layers/{layer_key}/features/{source_fid}/properties": { + "get": { + "operationId": "get_source_gis_layers_layer_key_features_source_fid_properties", + "parameters": [ + { + "in": "path", + "name": "layer_key", + "required": true, + "schema": { + "title": "Layer Key", + "type": "string" + } + }, + { + "in": "path", + "name": "source_fid", + "required": true, + "schema": { + "title": "Source Fid", + "type": "integer" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceGisFeatureProperties" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "获取源 GIS 要素台账属性", + "tags": [ + "Project Data" + ] + } + }, "/api/v1/sources": { "delete": { "description": "从网络中删除指定节点的水源", @@ -40307,6 +47131,136 @@ ] } }, + "/api/v1/workbench/events": { + "get": { + "operationId": "get_workbench_events", + "parameters": [ + { + "in": "query", + "name": "after", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "After" + } + }, + { + "in": "header", + "name": "Last-Event-ID", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last-Event-Id" + } + }, + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JsonValue" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Workbench Events", + "tags": [ + "Workbench Events" + ] + } + }, "/api/v1/workbench/runtime": { "get": { "operationId": "get_workbench_runtime", @@ -40404,6 +47358,103 @@ ] } }, + "/api/v1/workbench/sync-snapshot": { + "get": { + "operationId": "get_workbench_sync_snapshot", + "parameters": [ + { + "in": "header", + "name": "X-Project-Id", + "required": true, + "schema": { + "title": "X-Project-Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkbenchSyncSnapshot" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authentication required" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Insufficient permission" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource not found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Resource conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Validation error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Dependency unavailable" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "summary": "Workbench Sync Snapshot", + "tags": [ + "Workbench Events" + ] + } + }, "/api/v1/workbench/task-alert-events": { "post": { "operationId": "post_workbench_task_alert_events", diff --git a/docs/integration-ui-keycloak-client.md b/docs/integration-ui-keycloak-client.md new file mode 100644 index 0000000..930706b --- /dev/null +++ b/docs/integration-ui-keycloak-client.md @@ -0,0 +1,22 @@ +# 15173 联调 UI Keycloak 客户端 + +2026-09-23 已在 `tjwater` realm 验证独立 public client +`tjwater-integration-ui`。该客户端只用于本机 15173 联调页面,不替代或修改 +现有 `next-tjwater` 客户端。 + +已验证配置: + +- Standard Authorization Code Flow:开启。 +- PKCE:强制 `S256`。 +- Implicit Flow、Direct Access Grants、Service Accounts:关闭。 +- Public client:开启,不保存客户端密钥。 +- Full Scope Allowed:关闭;未授予 realm-management 或系统管理员角色。 +- Redirect URI:`http://127.0.0.1:15173/*`、`http://localhost:15173/*`。 +- Web Origin:`http://127.0.0.1:15173`、`http://localhost:15173`。 + +可使用以下幂等脚本复核或恢复同一配置;管理员密码仅从隐藏提示或进程环境 +读取,不会写入文件: + +```bash +conda run -n server python scripts/provision_integration_ui_client.py +``` diff --git a/docs/operational-task-workers.md b/docs/operational-task-workers.md new file mode 100644 index 0000000..20e73a1 --- /dev/null +++ b/docs/operational-task-workers.md @@ -0,0 +1,108 @@ +# 工况工作进程与外部动作适配 + +工况执行不依赖浏览器保活。API 保存任务及审批事实,工作进程扫描持久化队列,再向 Agent 提交后端分配的执行请求编号。相同请求编号重试复用同一执行,不能根据轮询时间生成新的编号。 + +## 调查任务队列 + +当前产品分为定时任务、调查任务、预约任务。定时任务保留前端 mock 数据,不调用后端计划创建或到期调度;调查任务读取数据库并进入本工作队列;预约任务读取数据库中的外围工单投影,以外部同步进度为准。已有后端 schedule API 暂不在当前产品主路径使用。 + +从后端仓库运行: + +```bash +conda run -n server python scripts/run_operational_worker.py --once +conda run -n server python scripts/run_operational_worker.py +``` + +部署环境提供以下变量,不将实际值写入仓库。工作进程也读取后端仓库已忽略的 `.env`,已存在的进程环境变量优先: + +| 变量 | 用途 | +| --- | --- | +| `OPERATIONAL_WORKER_PROJECT_ID` | 唯一授权项目 UUID | +| `OPERATIONAL_WORKER_SERVER_URL` | 主后端地址,默认本机 8000 | +| `OPERATIONAL_WORKER_AGENT_URL` | Agent 地址,默认本机 8787 | +| `OPERATIONAL_WORKER_RECONCILE_SECONDS` | SSE 中断或漏通知时的对账间隔,默认 30 秒 | +| `OPERATIONAL_WORKER_MAX_CONCURRENCY` | 同时投递的 run 上限,默认 4,可设置 1–32 | +| `OPERATIONAL_WORKER_BEARER` | 已具有该项目权限的产品身份,仅用于受控开发或短期运行 | +| `OPERATIONAL_WORKER_TOKEN_URL` | 可选 OAuth 客户端凭据取令牌地址 | +| `OPERATIONAL_WORKER_CLIENT_ID`、`OPERATIONAL_WORKER_CLIENT_SECRET` | 长期运行身份;不配置固定 Bearer 时使用,并按有效期刷新令牌 | +| `OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS` | OAuth 缓存上限,默认 300 秒,可设置 30–600 秒;同时受令牌 expires_in 限制,应短于后端允许的最大令牌年龄 | + +工作身份必须能通过现有产品身份与项目成员权限校验。配置令牌获取不等于自动创建产品用户、项目成员或授权。权限错误会保留任务用于后续重试,不会绕过认证。 + +长期 OAuth 身份由身份管理员创建支持 `client_credentials` 的专用机密客户端;服务账号令牌应具有后端配置要求的 audience、UUID `sub` 和 `preferred_username`。再由产品管理员把该 `sub` 绑定到启用的产品用户及目标项目成员关系。只赋予本次调查所需项目权限,不授予系统管理员或设备命令权限。当前项目成员策略同时包含工单审批/执行权限,不能将其误称为已经细分的专用最小权限服务角色。 + +本机联调配置示意(密钥由管理员通过安全配置填入,禁止粘贴到聊天): + +```dotenv +OPERATIONAL_WORKER_PROJECT_ID=324ff7ee-c2d4-5184-a84b-48fb14a2933e +OPERATIONAL_WORKER_SERVER_URL=http://127.0.0.1:8000 +OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:18787 +OPERATIONAL_WORKER_TOKEN_URL=https:///realms//protocol/openid-connect/token +OPERATIONAL_WORKER_CLIENT_ID= +OPERATIONAL_WORKER_CLIENT_SECRET= +OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8011 +OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011 +``` + +选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。 + +工作进程先恢复过期执行租约,再使用 keyset 游标扫描持久化 run 队列;`operational.run.queued` 事件会立即唤醒扫描,连接中断时每 30 秒对账。Agent 正在执行时每 20 秒续租,租约为 60 秒;进程中断后由后端分配新的执行代次,旧 run 和旧会话不能继续覆盖新一轮结果。网络错误及 5xx 从 5 秒指数退避到 5 分钟,确定性 4xx 终止该 run;连续 10 次投递失败产生运维通知。 + +Agent 正常结束必须调用 run `complete`。同一执行代次缺少最终计划或报告时,Server 将 run 以 `ARTIFACT_INCOMPLETE` 明确失败,不依赖租约超时猜测完成状态。心跳只增加 run revision,不改变任务业务 revision。 + +## 事件日志与清理 + +`resources/sql/015_workbench_events_and_task_runs.sql` 在每个项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。 + +事件默认保留 7 天。定时任务先执行 dry-run,再执行清理;脚本从 Metadata DB 解析所有活跃项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行: + +```bash +conda run -n server python scripts/cleanup_workbench_events.py --dry-run +conda run -n server python scripts/cleanup_workbench_events.py +``` + +浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。 + +## 外围样板工单同步 + +```bash +conda run -n server python scripts/sync_peripheral_work_orders.py --once +conda run -n server python scripts/sync_peripheral_work_orders.py +``` + +使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。 + +水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。 + +## 旧调查迁移 + +`scripts/import_agent_investigation.py --dry-run` 只检查旧合成水质调查能否转换。去掉 `--dry-run` 后使用上述产品工作身份导入后端任务、流程、证据及阶段报告;不会修改原 Agent 文件,也不会覆盖已存在的任务计划。应在启用队列工作进程前导入,避免旧调查被当作新的待执行任务领取。 + +## 调查数据读取 + +`OPERATIONAL_DATA_SERVER_URL` 默认本机 8000;外围样板数据读取需配置 `OPERATIONAL_DATA_PERIPHERAL_URL`。查询只允许预定义的数据源、最多 20 个设备和 24 小时时间范围,响应有大小限制。每次查询保存内容摘要对应的数据快照;缺失、拒绝、超限或未配置都返回明确错误,不生成正常数据结论。截断结果必须保留不完整标记。 + +## 人工确认后的动作 + +- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建合成外围工单。成功回执说明工单已创建,不表示现场工作已经完成。 +- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。 +- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。 + +命令网关契约:`POST /api/v1/commands` 接收 `request_id`、`project_id`、`task_id`、`action_id`、`action_version`、`approved_by`、`approved_at` 与类型化 `command`。网关必须按 `request_id` 幂等处理,并在执行前验证目标设备当前状态、前置条件和命令有效期。 + +回执包含 `request_id`、`project_id`、`receipt_id`、`status`(`accepted` / `running` / `completed` / `failed`)、`submitted_at`、可空 `completed_at` 和 `detail`。`completed` 必须有完成时间。网关回执查询路径为 `GET /api/v1/commands/requests/{request_id}`。 + +请求和回执时间必须包含时区。网关地址只来自服务端配置,Agent 或用户动作内容不能指定任意目的地址。产品 Bearer 仅在当前请求内传递,不进入任务、会话、提示词和日志。没有回执的网络故障保留为 `unknown`,不能当作失败后直接重新下发。 + +## 验证边界 + +适配器和工作进程测试使用隔离 HTTP transport 验证权限前提、幂等身份、丢响应恢复、项目隔离、接受与完成的区别。它们不等于生产设备网关或现场执行验收;接入具体网关后还需以其真实回执验证执行链。 + +可从后端仓库复现聚焦验证: + +```bash +conda run -n server python -m pytest tests/unit/test_operational_action_adapters.py tests/unit/test_operational_worker.py tests/unit/test_peripheral_task_sync.py tests/unit/test_operational_data.py tests/unit/test_import_agent_investigation.py -q +OPERATIONAL_TASKS_STORAGE_TEST=1 conda run -n server python -m pytest tests/integration/test_operational_tasks_storage.py -q +``` + +第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。 diff --git a/docs/operational-tasks.md b/docs/operational-tasks.md new file mode 100644 index 0000000..749cec5 --- /dev/null +++ b/docs/operational-tasks.md @@ -0,0 +1,54 @@ +# 工况任务后端契约 + +`resources/sql/014_operational_tasks.sql` 提供项目隔离的统一工况任务存储。所有接口均位于 `/api/v1`,使用产品 Bearer 身份和 `X-Project-Id`;任务正文不会接受客户端提供的项目 ID。 + +## 三类任务 + +- 定时任务:当前产品继续使用前端 mock。`scheduled_flow` 及后端计划接口为已有保留能力,不接当前前端、不由当前 worker 调度或执行。 +- `external_work_order`:外围预约、维修等工单的只读任务投影。`source.kind + source.external_id` 在项目内唯一,进度以外部版本反馈为准。 +- `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。 + +创建请求使用 `request_id` 幂等。任务执行使用独立的 `run_id + execution_request_id`:Agent 必须领取当前 run,并通过 run 级心跳维持 60 秒租约。正常结束必须显式 complete;缺少同代最终计划或报告会以 `ARTIFACT_INCOMPLETE` 失败。`POST /operational-tasks/leases/recover` 会处理过期执行,连续三代超时后停止自动重试。 + +## 主要接口 + +| 方法和路径 | 作用 | +| --- | --- | +| `POST /operational-tasks` | 幂等创建任务 | +| `GET /operational-tasks` | 按类型、状态分页读取任务 | +| `GET /operational-tasks/{task_id}` | 读取当前计划、报告、证据摘要、审批动作和当前用户权限 | +| `GET /operational-tasks/{task_id}/agent-context` | 读取 Agent 任务上下文及当前身份允许的数据源 | +| `POST /operational-tasks/{task_id}/runs/{run_id}/claim` | 使用当前执行请求号领取 queued run | +| `POST /operational-tasks/{task_id}/runs/{run_id}/heartbeat` | 续期当前 Agent run,不递增任务业务 revision | +| `POST /operational-tasks/{task_id}/runs/{run_id}/complete` | 校验同代最终产物并显式结束 run | +| `POST /operational-tasks/{task_id}/runs/{run_id}/fail` | 使用稳定失败码显式终止 run | +| `POST /operational-tasks/{task_id}/data-query` | 查询固定白名单数据源,同时保存有界原始快照并返回证据 ID | +| `POST /operational-tasks/{task_id}/agent-artifacts` | 追加证据、计划快照和报告修订 | +| `POST /operational-tasks/{task_id}/human-evidence` | 保存用户补充信息并重新排队分析 | +| `POST /operational-tasks/{task_id}/external-feedback` | 幂等同步稳定外部事件流的单调版本 | +| `POST /operational-tasks/{task_id}/actions` | 提议需要人工确认的动作 | +| `POST .../actions/{action_id}/decision` | 有对应审批权限的用户确认或拒绝 | +| `POST .../actions/{action_id}/execute` | 重新校验执行权限后通过类型化适配器提交 | +| `POST .../actions/{action_id}/reconcile` | 仅使用固定执行请求号查询不确定或已提交动作的回执 | +| `GET /operational-task-notifications` | 返回当前用户可见、按用户区分已读状态的通知 | + +报告采用不可变修订,正文只保存当前结论、结论边界、建议、证据引用和待确认事项。详细的 Agent 工具轨迹和模型内部推理不进入报告。系统数据只能通过受控 `data-query` 形成证据,外围反馈和人工补充也有各自入口;Agent 不能自行声明这三类证据。合成演练证据始终标记为 `synthetic`。 + +## 人工确认与执行真实性 + +动作分为 `create_work_order` 和 `issue_command`,审批状态与执行状态分别保存。每次状态变化都会写入不可变动作版本;首次提交生成固定 `execution_request_id`,后续回执核对不改变它。 + +工单审批和执行可授予项目成员。设备命令审批、执行只授予具有系统管理身份且同时能进入项目的人员。普通查看者只能看任务及其 `permissions`,不能从前端角色名称推断或绕过服务端权限。 + +`approved` 只表示允许提交。`submitted` 需要外部接收回执,`succeeded` 还需要完成时间和完成回执。未配置适配器返回 `unsupported`,响应丢失返回 `unknown`;这两种情况都不会被记录为执行成功。外部适配器及 worker 的部署方式见 `docs/operational-task-workers.md`。 + +## 数据库部署 + +对每个项目 BizDB 应用: + +```bash +psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/014_operational_tasks.sql +psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/015_workbench_events_and_task_runs.sql +``` + +迁移不会创建或修改外围系统的业务表,也不授予 `PUBLIC` 权限。生产运行前需按部署身份显式授予新表权限。 diff --git a/docs/peripheral-sample.md b/docs/peripheral-sample.md index 08eb49c..fd9473e 100644 --- a/docs/peripheral-sample.md +++ b/docs/peripheral-sample.md @@ -13,3 +13,9 @@ - `/api/sample/v1/demo-data` 演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。 + +## 水质投诉 Agent 演示 + +`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的合成投诉事件,或 `work_order_type=burst_report` 的合成爆管上报。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。 + +样板服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。 diff --git a/docs/workbench-operations-storage.md b/docs/workbench-operations-storage.md index 1172f16..4cf33d8 100644 --- a/docs/workbench-operations-storage.md +++ b/docs/workbench-operations-storage.md @@ -15,6 +15,6 @@ DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST / - 每个子任务包含一个或多个有序 `stages`,`sequence` 从 1 连续递增,阶段编号在子任务内唯一,最多只有一个 `current` 阶段。 - 阶段名称不写死为固定枚举。演示数据使用派发、执行、复令,正式工单适配器可以按业务定义其他阶段。 -演示数据可以先运行 `scripts/seed_peripheral_sample.py`,再运行 `scripts/seed_workbench_work_orders.py` 投影。工作台会显示一张主工单,三项外围核查分别作为子任务展示。 +预约任务演示数据通过 `scripts/seed_workbench_work_orders.py` 直接写入。工作台会显示三张原有 mock 工单,分别处于派发、执行和复令阶段;每张工单都保留自己的子任务与有序阶段。脚本可重复执行,并会先清理旧的外围合成工单投影。外围种子脚本仍会恢复被软删除的固定演示资源。 工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。 diff --git a/docs/workbench-runtime.md b/docs/workbench-runtime.md index 56f6058..96e4751 100644 --- a/docs/workbench-runtime.md +++ b/docs/workbench-runtime.md @@ -7,4 +7,4 @@ - `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。 - 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。 -演示工单由 `scripts/seed_peripheral_sample.py` 写入 `peripheral_sample`,再由 `scripts/seed_workbench_work_orders.py` 将三项外围核查组合成一张主工单和三个子任务,写入 `workbench.work_orders`。当前固定演示日期为 2026-09-17。 +预约任务演示数据由 `scripts/seed_workbench_work_orders.py` 直接写入 `workbench.work_orders`,与外围样板的 synthetic 测试数据相互独立。种子集 `workbench-demo-orders@1` 恢复前端原有的三张 mock 工单,分别覆盖派发、执行和复令状态;脚本会在同一事务中删除旧的“外围合成数据核查”投影并幂等写入新记录。当前固定演示日期为 2026-09-17。外围种子脚本仍可重复执行,并会恢复曾被软删除的固定演示工单和测试数据。 diff --git a/resources/sql/014_operational_tasks.sql b/resources/sql/014_operational_tasks.sql new file mode 100644 index 0000000..c877b49 --- /dev/null +++ b/resources/sql/014_operational_tasks.sql @@ -0,0 +1,202 @@ +-- Durable project-scoped operational tasks, immutable artifacts and approvals. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS workbench; + +CREATE TABLE IF NOT EXISTS workbench.operational_tasks ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + create_request_id uuid NOT NULL, + task_type text NOT NULL CHECK (task_type IN + ('scheduled_flow','external_work_order','event_investigation')), + workflow text NOT NULL CHECK (length(workflow) > 0), + status text NOT NULL CHECK (status IN + ('queued','running','waiting_human','completed','failed','cancelled')), + priority text NOT NULL CHECK (priority IN ('normal','urgent')), + revision bigint NOT NULL CHECK (revision >= 1), + execution_generation bigint NOT NULL CHECK (execution_generation >= 1), + execution_request_id uuid NOT NULL, + agent_session_id text, + agent_lease_expires_at timestamptz, + current_plan_revision bigint, + current_report_revision bigint, + scheduled_for timestamptz, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + document jsonb NOT NULL, + PRIMARY KEY (project_id, task_id), + UNIQUE (project_id, create_request_id), + UNIQUE (project_id, execution_request_id), + CHECK ((jsonb_typeof(document)='object' + AND document->>'task_id'=task_id::text + AND document->>'task_type'=task_type + AND document->>'workflow'=workflow + AND document->>'status'=status + AND (document->>'revision')::bigint=revision) IS TRUE) +); + +CREATE INDEX IF NOT EXISTS operational_tasks_status_idx + ON workbench.operational_tasks(project_id,status,updated_at DESC,task_id); +CREATE INDEX IF NOT EXISTS operational_tasks_lease_idx + ON workbench.operational_tasks(project_id,agent_lease_expires_at) + WHERE status='running' AND agent_lease_expires_at IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS operational_tasks_external_source_idx + ON workbench.operational_tasks( + project_id, + (document#>>'{source,kind}'), + (document#>>'{source,external_id}') + ) WHERE document#>>'{source,external_id}' IS NOT NULL; + +CREATE TABLE IF NOT EXISTS workbench.operational_task_requests ( + project_id uuid NOT NULL, + scope text NOT NULL CHECK (length(scope) > 0), + request_id uuid NOT NULL, + request_digest text NOT NULL CHECK (length(request_digest) > 0), + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id,scope,request_id) +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_evidence ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + evidence_id text NOT NULL CHECK (length(evidence_id) > 0), + document jsonb NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,evidence_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_plans ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + revision bigint NOT NULL CHECK (revision >= 1), + document jsonb NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,revision), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_reports ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + revision bigint NOT NULL CHECK (revision >= 1), + document jsonb NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,revision), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_actions ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + action_id uuid NOT NULL, + version bigint NOT NULL CHECK (version >= 1), + approval_status text NOT NULL CHECK (approval_status IN + ('pending','approved','rejected')), + execution_status text NOT NULL CHECK (execution_status IN + ('not_submitted','submitted','succeeded','failed','unsupported','unknown')), + document jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,action_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE, + CHECK ((document->>'action_id'=action_id::text + AND (document->>'version')::bigint=version + AND document->>'approval_status'=approval_status + AND document->>'execution_status'=execution_status) IS TRUE) +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_action_revisions ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + action_id uuid NOT NULL, + version bigint NOT NULL CHECK (version >= 1), + document jsonb NOT NULL, + recorded_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,action_id,version), + FOREIGN KEY (project_id,task_id,action_id) + REFERENCES workbench.operational_task_actions(project_id,task_id,action_id) + ON DELETE CASCADE, + CHECK ((document->>'action_id'=action_id::text + AND (document->>'version')::bigint=version) IS TRUE) +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_external_feedback ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + source_event_id text NOT NULL CHECK (length(source_event_id) > 0), + source_revision bigint NOT NULL CHECK (source_revision >= 1), + document jsonb NOT NULL, + accepted_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,source_event_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_notifications ( + project_id uuid NOT NULL, + notification_id uuid NOT NULL, + task_id uuid NOT NULL, + created_at timestamptz NOT NULL, + document jsonb NOT NULL, + PRIMARY KEY (project_id,notification_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS operational_task_notifications_unread_idx + ON workbench.operational_task_notifications(project_id,created_at DESC); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_notification_reads ( + project_id uuid NOT NULL, + notification_id uuid NOT NULL, + user_id uuid NOT NULL, + version bigint NOT NULL CHECK (version >= 2), + read_at timestamptz NOT NULL, + PRIMARY KEY (project_id,notification_id,user_id), + FOREIGN KEY (project_id,notification_id) + REFERENCES workbench.operational_task_notifications(project_id,notification_id) + ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_schedules ( + project_id uuid NOT NULL, + schedule_id uuid NOT NULL, + create_request_id uuid NOT NULL, + schedule_key text NOT NULL CHECK (length(schedule_key) > 0), + next_run_at timestamptz NOT NULL, + interval_seconds integer NOT NULL CHECK (interval_seconds >= 60), + enabled boolean NOT NULL, + version bigint NOT NULL CHECK (version >= 1), + document jsonb NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + PRIMARY KEY (project_id,schedule_id), + UNIQUE (project_id,create_request_id), + UNIQUE (project_id,schedule_key), + CHECK ((document->>'schedule_id'=schedule_id::text + AND document->>'schedule_key'=schedule_key + AND (document->>'version')::bigint=version) IS TRUE) +); + +CREATE TABLE IF NOT EXISTS workbench.operational_schedule_occurrences ( + project_id uuid NOT NULL, + schedule_id uuid NOT NULL, + due_at timestamptz NOT NULL, + task_id uuid NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (project_id,schedule_id,due_at), + UNIQUE (project_id,task_id), + FOREIGN KEY (project_id,schedule_id) + REFERENCES workbench.operational_task_schedules(project_id,schedule_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) +); + +COMMIT; diff --git a/resources/sql/015_workbench_events_and_task_runs.sql b/resources/sql/015_workbench_events_and_task_runs.sql new file mode 100644 index 0000000..433c0c1 --- /dev/null +++ b/resources/sql/015_workbench_events_and_task_runs.sql @@ -0,0 +1,155 @@ +-- Durable workbench event log and independent Agent execution runs. + +BEGIN; + +CREATE SCHEMA IF NOT EXISTS workbench; + +CREATE TABLE IF NOT EXISTS workbench.domain_event_log ( + event_seq bigserial PRIMARY KEY, + project_id uuid NOT NULL, + event_id uuid NOT NULL, + topic text NOT NULL CHECK (topic IN ( + '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' + )), + aggregate_type text NOT NULL CHECK (length(aggregate_type) > 0), + aggregate_id text NOT NULL CHECK (length(aggregate_id) > 0), + aggregate_revision bigint NOT NULL CHECK (aggregate_revision >= 1), + schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1), + occurred_at timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (project_id,event_id) +); + +CREATE INDEX IF NOT EXISTS domain_event_log_project_cursor_idx + ON workbench.domain_event_log(project_id,event_seq); +CREATE INDEX IF NOT EXISTS domain_event_log_retention_idx + ON workbench.domain_event_log(occurred_at,event_seq); + +ALTER TABLE IF EXISTS analysis.runs + ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1 + CHECK (revision >= 1); + +CREATE TABLE IF NOT EXISTS workbench.operational_task_runs ( + project_id uuid NOT NULL, + run_id uuid NOT NULL, + task_id uuid NOT NULL, + generation bigint NOT NULL CHECK (generation >= 1), + execution_request_id uuid NOT NULL, + status text NOT NULL CHECK (status IN ( + 'queued','claimed','running','succeeded','failed','timed_out','cancelled' + )), + revision bigint NOT NULL CHECK (revision >= 1), + agent_session_id text, + lease_expires_at timestamptz, + heartbeat_at timestamptz, + started_at timestamptz, + finished_at timestamptz, + failure_code text, + failure_detail text, + dispatch_attempts integer NOT NULL DEFAULT 0 CHECK (dispatch_attempts >= 0), + next_dispatch_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + PRIMARY KEY (project_id,run_id), + UNIQUE (project_id,task_id,generation), + UNIQUE (project_id,execution_request_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS operational_task_runs_one_active_idx + ON workbench.operational_task_runs(project_id,task_id) + WHERE status IN ('queued','claimed','running'); +CREATE INDEX IF NOT EXISTS operational_task_runs_queue_idx + ON workbench.operational_task_runs( + project_id,next_dispatch_at,created_at,run_id + ) WHERE status='queued'; +CREATE INDEX IF NOT EXISTS operational_task_runs_lease_idx + ON workbench.operational_task_runs(project_id,lease_expires_at,run_id) + WHERE status IN ('claimed','running') AND lease_expires_at IS NOT NULL; + +ALTER TABLE workbench.operational_tasks + ADD COLUMN IF NOT EXISTS lifecycle_status text; + +UPDATE workbench.operational_tasks +SET lifecycle_status = CASE status + WHEN 'waiting_human' THEN 'waiting_human' + WHEN 'completed' THEN 'completed' + WHEN 'failed' THEN 'failed' + WHEN 'cancelled' THEN 'cancelled' + ELSE 'open' +END +WHERE lifecycle_status IS NULL; + +UPDATE workbench.operational_tasks +SET document = document || jsonb_build_object('lifecycle_status', lifecycle_status) +WHERE document->>'lifecycle_status' IS DISTINCT FROM lifecycle_status; + +ALTER TABLE workbench.operational_tasks + ALTER COLUMN lifecycle_status SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid='workbench.operational_tasks'::regclass + AND conname='operational_tasks_lifecycle_status_check' + ) THEN + ALTER TABLE workbench.operational_tasks + ADD CONSTRAINT operational_tasks_lifecycle_status_check + CHECK (lifecycle_status IN + ('open','waiting_human','completed','failed','cancelled')); + END IF; +END $$; + +-- Deterministically preserve the currently active or last known execution as +-- generation history. uuid values are derived without requiring an extension. +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 +) +SELECT + project_id, + (substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),1,8) + || '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),9,4) + || '-5' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),14,3) + || '-a' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),18,3) + || '-' || substr(md5(project_id::text || ':' || task_id::text || ':' || execution_generation::text),21,12))::uuid, + task_id, + execution_generation, + execution_request_id, + CASE status + WHEN 'queued' THEN 'queued' + WHEN 'running' THEN 'running' + WHEN 'waiting_human' THEN 'succeeded' + WHEN 'completed' THEN 'succeeded' + WHEN 'failed' THEN 'failed' + WHEN 'cancelled' THEN 'cancelled' + END, + GREATEST(revision,1), + agent_session_id, + agent_lease_expires_at, + CASE WHEN status='running' THEN updated_at ELSE NULL END, + CASE WHEN status IN ('running','waiting_human','completed','failed','cancelled') + THEN created_at ELSE NULL END, + CASE WHEN status IN ('waiting_human','completed','failed','cancelled') + THEN updated_at ELSE NULL END, + CASE WHEN status='failed' THEN 'MIGRATED_FAILURE' ELSE NULL END, + NULL, + 0, + created_at, + created_at, + updated_at +FROM workbench.operational_tasks +ON CONFLICT (project_id,task_id,generation) DO NOTHING; + +COMMIT; diff --git a/resources/sql/016_metadata_service_identities.sql b/resources/sql/016_metadata_service_identities.sql new file mode 100644 index 0000000..eba7164 --- /dev/null +++ b/resources/sql/016_metadata_service_identities.sql @@ -0,0 +1,9 @@ +-- Distinguish non-interactive OAuth identities from human product users. +-- This migration is intentionally re-runnable. + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_role_check; + +ALTER TABLE users + ADD CONSTRAINT users_role_check + CHECK (role IN ('admin', 'service', 'user')); diff --git a/samples/peripheral/schemas.py b/samples/peripheral/schemas.py index a21f4cc..17df8d1 100644 --- a/samples/peripheral/schemas.py +++ b/samples/peripheral/schemas.py @@ -5,6 +5,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, model_validator from app.domain.schemas.shared import ObjectRef, TimeRange, WireDateTime +from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrderSubtask Text = Annotated[str, Field(min_length=1, max_length=4000)] PositiveInt = Annotated[StrictInt, Field(ge=1)] @@ -116,8 +117,21 @@ class TaskPage(Model): next_cursor: UUID | None -WorkOrderStatus = Literal["draft", "open", "in_progress", "completed", "cancelled"] +WorkOrderStatus = Literal[ + "draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled" +] Priority = Literal["low", "normal", "high", "urgent"] +WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"] +WorkOrderSubtasks = Annotated[ + list[WorkbenchRuntimeWorkOrderSubtask], Field(min_length=1, max_length=50) +] + + +def _validate_unique_subtasks( + subtasks: list[WorkbenchRuntimeWorkOrderSubtask] | None, +) -> None: + if subtasks is not None and len({item.id for item in subtasks}) != len(subtasks): + raise ValueError("work order subtask ids must be unique") class CreateWorkOrder(Model): @@ -128,11 +142,16 @@ class CreateWorkOrder(Model): priority: Priority = "normal" assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None scheduled_at: WireDateTime | None = None + work_order_type: WorkOrderType = "general" + location: Annotated[str, Field(min_length=1, max_length=200)] | None = None + scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None + subtasks: WorkOrderSubtasks | None = None @model_validator(mode="after") def aware_schedule(self): if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: raise ValueError("scheduled_at must include timezone") + _validate_unique_subtasks(self.subtasks) return self @@ -147,6 +166,12 @@ class UpdateWorkOrder(Model): clear_assignee: StrictBool = False scheduled_at: WireDateTime | None = None clear_scheduled_at: StrictBool = False + location: Annotated[str, Field(min_length=1, max_length=200)] | None = None + clear_location: StrictBool = False + scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None + clear_scenario_code: StrictBool = False + subtasks: WorkOrderSubtasks | None = None + clear_subtasks: StrictBool = False @model_validator(mode="after") def valid_update(self): @@ -157,8 +182,15 @@ class UpdateWorkOrder(Model): raise ValueError("clear_assignee conflicts with assignee") if self.clear_scheduled_at and self.scheduled_at is not None: raise ValueError("clear_scheduled_at conflicts with scheduled_at") + if self.clear_location and self.location is not None: + raise ValueError("clear_location conflicts with location") + if self.clear_scenario_code and self.scenario_code is not None: + raise ValueError("clear_scenario_code conflicts with scenario_code") + if self.clear_subtasks and self.subtasks is not None: + raise ValueError("clear_subtasks conflicts with subtasks") if self.scheduled_at is not None and self.scheduled_at.tzinfo is None: raise ValueError("scheduled_at must include timezone") + _validate_unique_subtasks(self.subtasks) return self @@ -182,6 +214,15 @@ class WorkOrder(Model): updated_at: WireDateTime created_by: str updated_by: str + work_order_type: WorkOrderType = "general" + location: str | None = None + scenario_code: str | None = None + subtasks: WorkOrderSubtasks | None = None + + @model_validator(mode="after") + def unique_subtasks(self): + _validate_unique_subtasks(self.subtasks) + return self class WorkOrderPage(Model): diff --git a/samples/peripheral/store.py b/samples/peripheral/store.py index fd685f4..d54c7e5 100644 --- a/samples/peripheral/store.py +++ b/samples/peripheral/store.py @@ -263,7 +263,13 @@ class PostgresStore: f"SELECT document FROM peripheral_sample.{table} WHERE project_id=%s AND {id_column}=%s AND deleted_at IS NULL", (who.project_id, resource_id)).fetchone() ensure(row is not None, missing_code, missing_message, 404) - return row["document"] + return self._active_managed_document(row["document"]) + + @staticmethod + def _active_managed_document(document: dict[str, Any]) -> dict[str, Any]: + """Project a current active row without legacy soft-delete metadata.""" + return {key: value for key, value in document.items() + if key not in {"deleted_at", "deleted_by"}} def _list_managed(self, who: Identity, table: str, id_column: str, limit: int, cursor: UUID | None) -> dict[str, Any]: @@ -277,7 +283,7 @@ class PostgresStore: sql += f" AND {id_column}>%s" params.append(cursor) rows = conn.execute(sql + f" ORDER BY {id_column} LIMIT %s", params + [limit + 1]).fetchall() - docs = [row["document"] for row in rows[:limit]] + docs = [self._active_managed_document(row["document"]) for row in rows[:limit]] return {"items": docs, "next_cursor": docs[-1][id_column] if len(rows) > limit else None} def _save_work_order(self, conn, who: Identity, document: dict[str, Any]) -> dict[str, Any]: @@ -319,11 +325,18 @@ class PostgresStore: "WORK_ORDER_NOT_FOUND", "测试工单不存在") ensure(document["revision"] == command.expected_revision, "REVISION_CONFLICT", "工单版本已变化,请重新读取", 409) changes = command.model_dump(mode="json", exclude_unset=True, - exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at"}) + exclude={"request_id", "expected_revision", "clear_assignee", "clear_scheduled_at", + "clear_location", "clear_scenario_code", "clear_subtasks"}) if command.clear_assignee: changes["assignee"] = None if command.clear_scheduled_at: changes["scheduled_at"] = None + if command.clear_location: + changes["location"] = None + if command.clear_scenario_code: + changes["scenario_code"] = None + if command.clear_subtasks: + changes["subtasks"] = None document.update(changes) document.update(revision=document["revision"] + 1, updated_at=datetime.now(timezone.utc).isoformat(), updated_by=who.user_id) diff --git a/scripts/cleanup_workbench_events.py b/scripts/cleanup_workbench_events.py new file mode 100644 index 0000000..cb7fe51 --- /dev/null +++ b/scripts/cleanup_workbench_events.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Delete expired project workbench events in bounded batches.""" +from __future__ import annotations + +import argparse +import asyncio +from collections import defaultdict +from pathlib import Path +import sys +from uuid import UUID + +from psycopg import AsyncConnection +from psycopg.conninfo import conninfo_to_dict + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from app.auth.project_dependencies import DB_ROLE_BIZ_DATA +from app.core.config import settings +from app.infra.db.metadb.database import SessionLocal, close_metadata_engine +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + + +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 "", + ) + + +async def cleanup_project( + project_id: UUID, dsn: str, retention_days: int, batch_size: int, dry_run: bool +) -> int: + async with await AsyncConnection.connect(dsn) as conn: + if dry_run: + cursor = await conn.execute( + """SELECT count(*) FROM workbench.domain_event_log + WHERE project_id=%s + AND occurred_at < now() - make_interval(days => %s)""", + (project_id, retention_days), + ) + return int((await cursor.fetchone())[0]) + deleted = 0 + while True: + cursor = await conn.execute( + """WITH expired AS ( + SELECT event_seq FROM workbench.domain_event_log + WHERE project_id=%s + AND occurred_at < now() - make_interval(days => %s) + ORDER BY event_seq LIMIT %s + ) + DELETE FROM workbench.domain_event_log e + USING expired WHERE e.event_seq=expired.event_seq""", + (project_id, retention_days, batch_size), + ) + await conn.commit() + count = cursor.rowcount + deleted += count + if count < batch_size: + return deleted + + +async def cleanup( + retention_days: int, batch_size: int, project_id: UUID | None, dry_run: bool +) -> dict[UUID, int]: + async with SessionLocal() as session: + repository = MetadataRepository(session) + routings = await repository.list_active_project_db_routings(DB_ROLE_BIZ_DATA) + if project_id is not None: + routings = [routing for routing in routings if routing.project_id == project_id] + if not routings: + raise ValueError("active project business database routing was not found") + by_database = defaultdict(list) + for routing in routings: + by_database[_database_identity(routing.dsn)].append(routing) + shared = [items for items in by_database.values() if len(items) > 1] + if shared: + project_ids = ", ".join(str(item.project_id) for items in shared for item in items) + raise ValueError(f"shared business database routing is unsupported: {project_ids}") + return { + routing.project_id: await cleanup_project( + routing.project_id, routing.dsn, retention_days, batch_size, dry_run + ) + for routing in routings + } + + +async def main(args) -> None: + try: + results = await cleanup( + args.retention_days, args.batch_size, args.project_id, args.dry_run + ) + for project_id, count in results.items(): + action = "would delete" if args.dry_run else "deleted" + print(f"{project_id}: {action} {count} events") + finally: + await close_metadata_engine() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--retention-days", type=int, default=settings.WORKBENCH_EVENT_RETENTION_DAYS + ) + parser.add_argument("--batch-size", type=int, default=5000) + parser.add_argument("--project-id", type=UUID) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + if args.retention_days < 1 or args.batch_size < 1: + parser.error("retention days and batch size must be positive") + asyncio.run(main(args)) diff --git a/scripts/import_agent_investigation.py b/scripts/import_agent_investigation.py new file mode 100644 index 0000000..b7d530e --- /dev/null +++ b/scripts/import_agent_investigation.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Import one historical synthetic investigation without rewriting Agent history.""" +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import re +import sys +from pathlib import Path +from uuid import NAMESPACE_URL, uuid5 + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.domain.schemas.operational_task import AgentArtifactsCommand + +try: + from scripts.run_operational_worker import CredentialProvider, WorkerSettings +except ModuleNotFoundError: + from run_operational_worker import CredentialProvider, WorkerSettings + + +def convert_plan(snapshot: dict) -> tuple[dict, list[dict], dict]: + evidence, nodes = {}, [] + for node in snapshot["nodes"]: + refs = [] + for text in node.get("evidence", []): + evidence_id = "legacy-synthetic:" + hashlib.sha256(text.encode()).hexdigest()[:24] + refs.append(evidence_id) + evidence[evidence_id] = { + "evidence_id": evidence_id, "kind": "synthetic", "summary": text, + "source_label": "历史水质投诉合成调查输入", "source_ref": snapshot["session_id"], + "observed_at": snapshot["updated_at"], + } + nodes.append({"id": node["id"], "title": node["title"], "owner": node["owner"], + "status": node["status"], "detail": node["detail"], "evidence_refs": refs}) + plan = {"schema": "execution-plan@1", "title": snapshot["title"], + "reason": "从原调查会话导入已保存的计划,未重新执行调查", "nodes": nodes, + "edges": [{"from": edge["from"], "to": edge["to"], + **({"condition": edge["label"]} if edge.get("label") else {})} + for edge in snapshot["edges"]]} + judgment = next((node for node in nodes if node["id"] == "STAGE_JUDGMENT"), None) + report = {"title": "历史水质投诉调查报告", "current_conclusion": judgment["detail"] if judgment else "历史调查尚无阶段判断", + "conclusion_boundary": "合成演练的历史结果,未重新查询当前数据;现场工作以实际反馈为准", + "evidence_refs": judgment["evidence_refs"] if judgment else [], + "pending_items": [node["detail"] for node in nodes if node["status"] in {"waiting", "pending"}]} + return plan, list(evidence.values()), report + + +async def run(args): + if not re.fullmatch(r"ses_[A-Za-z0-9_-]+", args.session_id): + raise ValueError("invalid session id") + filename = args.session_id.lower() + ".json" + metadata = json.loads((args.agent_data / "session-metadata" / filename).read_text()) + state = json.loads((args.agent_data / "session-ui-states" / filename).read_text()) + if metadata.get("triggerWorkflow") != "water_quality_complaint" or metadata.get("triggerSource") != "peripheral_sample": + raise ValueError("only explicitly synthetic peripheral water investigations can be imported") + snapshots = state.get("executionPlans", []) + if not snapshots: + raise ValueError("session has no saved plan; no progress will be fabricated") + snapshot = max(snapshots, key=lambda item: item["revision"]) + plan, evidence, report = convert_plan(snapshot) + # Validate the complete payload before reporting readiness or creating a + # backend task. A dry run must catch incompatible historical plan states. + artifacts = AgentArtifactsCommand.model_validate({ + "request_id": str(uuid5(NAMESPACE_URL, f"legacy-artifacts:{metadata.get('projectId')}:{args.session_id}:{snapshot['revision']}")), + "source_session_id": args.session_id, "plan": plan, "evidence": evidence, "report": report, + }).model_dump(mode="json", by_alias=True) + if args.dry_run: + print(f"Ready to import {len(plan['nodes'])} nodes and {len(evidence)} synthetic evidence records; no writes") + return + settings = WorkerSettings.from_environment() + if metadata.get("projectId") != settings.project_id: + raise ValueError("session does not belong to the worker project") + async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: + headers = await CredentialProvider(settings).headers(client) + base = settings.server_url.rstrip("/") + "/api/v1/operational-tasks" + response = await client.post(base, headers=headers, json={ + "request_id": str(uuid5(NAMESPACE_URL, f"legacy-investigation:{settings.project_id}:{args.session_id}")), + "task_type": "event_investigation", "workflow": "water_quality_complaint", + "title": metadata.get("title") or "水质投诉调查", "summary": "从历史合成调查导入,原会话完整保留", + "source": {"kind": "external_system", "external_id": metadata["triggerSubjectId"], "event_kind": "water_quality_complaint"}, + "trigger": {"kind": "event", "occurred_at": metadata["triggerOccurredAt"], "detail": "历史合成外围投诉事件"}, + }) + response.raise_for_status() + task = response.json() + if task["current_plan_revision"] is not None: + print("Task already has a plan; preserved without overwrite") + return + run = task.get("active_run") + if not run: + raise RuntimeError("task has no active Agent run") + if run.get("agent_session_id") != args.session_id or run["status"] != "running": + response = await client.post( + f"{base}/{task['task_id']}/runs/{run['run_id']}/claim", + headers=headers, + json={ + "request_id": run["execution_request_id"], + "expected_revision": run["revision"], + "source_session_id": args.session_id, + }, + ) + response.raise_for_status() + run = response.json() + artifacts["run_id"] = run["run_id"] + response = await client.post(f"{base}/{task['task_id']}/agent-artifacts", headers=headers, json=artifacts) + response.raise_for_status() + run = response.json()["task"]["active_run"] + response = await client.post( + f"{base}/{task['task_id']}/runs/{run['run_id']}/complete", + headers=headers, + json={ + "request_id": str(uuid5(NAMESPACE_URL, f"legacy-complete:{settings.project_id}:{args.session_id}:{snapshot['revision']}")), + "expected_revision": run["revision"], + "source_session_id": args.session_id, + }, + ) + response.raise_for_status() + print(f"Imported task {task['task_id']}; original Agent records unchanged") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("session_id") + parser.add_argument("--agent-data", type=Path, default=Path(__file__).resolve().parents[2] / "next-tjwater-agent/data") + parser.add_argument("--dry-run", action="store_true") + asyncio.run(run(parser.parse_args())) diff --git a/scripts/provision_integration_ui_client.py b/scripts/provision_integration_ui_client.py new file mode 100644 index 0000000..37895b9 --- /dev/null +++ b/scripts/provision_integration_ui_client.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Provision the isolated public Keycloak client used by the 15173 UI.""" +from __future__ import annotations + +import argparse +import getpass +import json +import os +from pathlib import Path +import sys + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from scripts.provision_operational_worker_identity import ( # noqa: E402 + KeycloakProvisioner, + ProvisionError, + _request, +) + + +REDIRECT_URIS = [ + "http://127.0.0.1:15173/*", + "http://localhost:15173/*", +] +WEB_ORIGINS = [ + "http://127.0.0.1:15173", + "http://localhost:15173", +] + + +def ensure_public_client( + keycloak: KeycloakProvisioner, client_id: str +) -> tuple[bool, dict]: + matches = _request( + keycloak.client, + "GET", + f"{keycloak.admin_base}/clients", + params={"clientId": client_id}, + ).json() + exact = [item for item in matches if item.get("clientId") == client_id] + if len(exact) > 1: + raise ProvisionError("multiple Keycloak clients use the integration client id") + created = not exact + if created: + _request( + keycloak.client, + "POST", + f"{keycloak.admin_base}/clients", + json={ + "clientId": client_id, + "name": "TJWater isolated integration UI", + "description": "Local 15173 UI; authorization code flow with PKCE only", + "enabled": True, + "protocol": "openid-connect", + "publicClient": True, + "bearerOnly": False, + "serviceAccountsEnabled": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "fullScopeAllowed": False, + "redirectUris": REDIRECT_URIS, + "webOrigins": WEB_ORIGINS, + "attributes": {"pkce.code.challenge.method": "S256"}, + }, + ) + matches = _request( + keycloak.client, + "GET", + f"{keycloak.admin_base}/clients", + params={"clientId": client_id}, + ).json() + exact = [item for item in matches if item.get("clientId") == client_id] + if len(exact) != 1 or not isinstance(exact[0].get("id"), str): + raise ProvisionError("could not resolve the integration UI client") + internal_id = exact[0]["id"] + representation = _request( + keycloak.client, + "GET", + f"{keycloak.admin_base}/clients/{internal_id}", + ).json() + attributes = dict(representation.get("attributes") or {}) + attributes["pkce.code.challenge.method"] = "S256" + representation.update( + { + "enabled": True, + "publicClient": True, + "bearerOnly": False, + "serviceAccountsEnabled": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "fullScopeAllowed": False, + "redirectUris": REDIRECT_URIS, + "webOrigins": WEB_ORIGINS, + "attributes": attributes, + } + ) + _request( + keycloak.client, + "PUT", + f"{keycloak.admin_base}/clients/{internal_id}", + json=representation, + ) + verified = _request( + keycloak.client, + "GET", + f"{keycloak.admin_base}/clients/{internal_id}", + ).json() + expected = { + "enabled": True, + "publicClient": True, + "bearerOnly": False, + "serviceAccountsEnabled": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "fullScopeAllowed": False, + } + if any(verified.get(key) != value for key, value in expected.items()): + raise ProvisionError("integration UI client flow settings did not persist") + if set(verified.get("redirectUris") or []) != set(REDIRECT_URIS): + raise ProvisionError("integration UI redirect URI allowlist did not persist") + if set(verified.get("webOrigins") or []) != set(WEB_ORIGINS): + raise ProvisionError("integration UI web origin allowlist did not persist") + if (verified.get("attributes") or {}).get("pkce.code.challenge.method") != "S256": + raise ProvisionError("integration UI PKCE policy did not persist") + return created, { + "client_id": client_id, + "public_client": True, + "standard_flow": True, + "pkce": "S256", + "implicit_flow": False, + "direct_grants": False, + "service_accounts": False, + "redirect_uris": REDIRECT_URIS, + "web_origins": WEB_ORIGINS, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088") + parser.add_argument("--realm", default="tjwater") + parser.add_argument("--admin-username", default="admin") + parser.add_argument("--client-id", default="tjwater-integration-ui") + args = parser.parse_args() + password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass( + "Keycloak admin password: " + ) + if not password: + raise SystemExit("administrator password is required") + try: + keycloak = KeycloakProvisioner( + args.keycloak_url, args.realm, args.admin_username, password + ) + try: + created, result = ensure_public_client(keycloak, args.client_id) + finally: + keycloak.close() + except ProvisionError as exc: + raise SystemExit(f"provisioning failed: {exc}") from None + print(json.dumps({"created": created, **result}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/provision_operational_worker_identity.py b/scripts/provision_operational_worker_identity.py new file mode 100644 index 0000000..ac67fd5 --- /dev/null +++ b/scripts/provision_operational_worker_identity.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Provision the least-privileged OAuth identity used by operational workers. + +The Keycloak administrator password is read with a hidden prompt or from the +process-only KEYCLOAK_ADMIN_PASSWORD environment variable. It is never written +to disk. Client secrets are written only to the ignored server .env file. +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import getpass +import json +import os +from pathlib import Path +import sys +from urllib.parse import urlparse +from uuid import UUID + +import httpx +from dotenv import dotenv_values, set_key +from sqlalchemy import select + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +if str(REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(REPOSITORY_ROOT)) + +from app.auth.permissions import permissions_for_context +from app.auth.project_dependencies import ProjectContext +from app.infra.db.metadb import models +from app.infra.db.metadb.database import SessionLocal, close_metadata_engine +from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository + + +class ProvisionError(RuntimeError): + pass + + +def _request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response: + try: + response = client.request(method, url, **kwargs) + except httpx.HTTPError as exc: + raise ProvisionError(f"identity provider request failed: {method} {url}") from exc + if response.status_code >= 400: + raise ProvisionError( + f"identity provider rejected request: {method} {url} status={response.status_code}" + ) + return response + + +def _jwt_payload(token: str) -> dict: + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + value = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, ValueError, json.JSONDecodeError) as exc: + raise ProvisionError("identity provider returned an invalid access token") from exc + if not isinstance(value, dict): + raise ProvisionError("identity provider returned an invalid access token payload") + return value + + +def _audiences(payload: dict) -> set[str]: + audience = payload.get("aud", []) + if isinstance(audience, str): + return {audience} + if isinstance(audience, list): + return {item for item in audience if isinstance(item, str)} + return set() + + +class KeycloakProvisioner: + def __init__(self, base_url: str, realm: str, admin_username: str, admin_password: str): + self.base_url = base_url.rstrip("/") + self.realm = realm + self.client = httpx.Client(timeout=15, follow_redirects=False, trust_env=False) + token_response = _request( + self.client, + "POST", + f"{self.base_url}/realms/master/protocol/openid-connect/token", + data={ + "grant_type": "password", + "client_id": "admin-cli", + "username": admin_username, + "password": admin_password, + }, + ) + token = token_response.json().get("access_token") + if not isinstance(token, str) or not token: + raise ProvisionError("administrator authentication returned no access token") + self.client.headers["Authorization"] = f"Bearer {token}" + self.admin_base = f"{self.base_url}/admin/realms/{realm}" + + def close(self) -> None: + self.client.close() + + def ensure_client(self, client_id: str) -> tuple[str, bool]: + matches = _request( + self.client, + "GET", + f"{self.admin_base}/clients", + params={"clientId": client_id}, + ).json() + exact = [item for item in matches if item.get("clientId") == client_id] + if len(exact) > 1: + raise ProvisionError("multiple Keycloak clients use the requested client id") + created = not exact + if created: + _request( + self.client, + "POST", + f"{self.admin_base}/clients", + json={ + "clientId": client_id, + "name": "TJWater operational worker", + "description": "Dedicated backend worker identity; no interactive login", + "enabled": True, + "protocol": "openid-connect", + "publicClient": False, + "bearerOnly": False, + "serviceAccountsEnabled": True, + "standardFlowEnabled": False, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "fullScopeAllowed": False, + "clientAuthenticatorType": "client-secret", + }, + ) + matches = _request( + self.client, + "GET", + f"{self.admin_base}/clients", + params={"clientId": client_id}, + ).json() + exact = [item for item in matches if item.get("clientId") == client_id] + if len(exact) != 1 or not isinstance(exact[0].get("id"), str): + raise ProvisionError("could not resolve the dedicated Keycloak client") + internal_id = exact[0]["id"] + representation = _request( + self.client, "GET", f"{self.admin_base}/clients/{internal_id}" + ).json() + desired = { + "enabled": True, + "publicClient": False, + "bearerOnly": False, + "serviceAccountsEnabled": True, + "standardFlowEnabled": False, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "fullScopeAllowed": False, + "clientAuthenticatorType": "client-secret", + } + if any(representation.get(key) != value for key, value in desired.items()): + representation.update(desired) + _request( + self.client, + "PUT", + f"{self.admin_base}/clients/{internal_id}", + json=representation, + ) + return internal_id, created + + def client_secret(self, internal_id: str) -> str: + value = _request( + self.client, + "GET", + f"{self.admin_base}/clients/{internal_id}/client-secret", + ).json().get("value") + if not isinstance(value, str) or not value: + raise ProvisionError("dedicated client has no usable secret") + return value + + def service_account_subject(self, internal_id: str) -> UUID: + value = _request( + self.client, + "GET", + f"{self.admin_base}/clients/{internal_id}/service-account-user", + ).json().get("id") + try: + return UUID(str(value)) + except ValueError as exc: + raise ProvisionError("service account subject is not a UUID") from exc + + def ensure_audience_mapper(self, internal_id: str, audience: str) -> bool: + mappers = _request( + self.client, + "GET", + f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models", + ).json() + for mapper in mappers: + config = mapper.get("config") or {} + if ( + mapper.get("protocolMapper") == "oidc-audience-mapper" + and config.get("included.client.audience") == audience + and config.get("access.token.claim") == "true" + ): + return False + _request( + self.client, + "POST", + f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models", + json={ + "name": f"audience-{audience}", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": { + "included.client.audience": audience, + "id.token.claim": "false", + "access.token.claim": "true", + "lightweight.claim": "false", + }, + }, + ) + return True + + def service_token(self, client_id: str, client_secret: str) -> tuple[str, dict]: + response = _request( + self.client, + "POST", + f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + ) + token = response.json().get("access_token") + if not isinstance(token, str) or not token: + raise ProvisionError("service account authentication returned no access token") + return token, _jwt_payload(token) + + +async def _bind_product_identity( + subject: UUID, + *, + username: str, + email: str, + project_id: UUID, + project_code: str, +) -> tuple[UUID, str, bool, bool]: + async with SessionLocal() as session: + repo = MetadataRepository(session) + project = await repo.get_project_by_id(project_id) + if project is None or project.code != project_code or project.status != "active": + raise ProvisionError("target product project is missing, mismatched, or inactive") + user = await repo.get_user_by_keycloak_id(subject) + created_user = user is None + if user is None: + username_conflict = await repo.get_user_by_username(username) + email_result = await session.execute(select(models.User).where(models.User.email == email)) + if username_conflict is not None or email_result.scalar_one_or_none() is not None: + raise ProvisionError("product user name or email is already bound to another identity") + user = await repo.upsert_user_from_keycloak( + keycloak_id=subject, + username=username, + email=email, + role="service", + is_active=True, + ) + elif ( + user.username == username + and user.role == "user" + and user.is_active + and not user.is_superuser + ): + # Forward migration for identities provisioned before the dedicated + # service role existed. + user = await repo.upsert_user_from_keycloak( + keycloak_id=subject, + username=username, + email=email, + role="service", + is_active=True, + ) + elif ( + user.username != username + or user.role != "service" + or not user.is_active + or user.is_superuser + ): + raise ProvisionError("existing product user has incompatible identity or privileges") + membership = await repo.get_project_membership(project_id, user.id) + created_membership = membership is None + if membership is None: + membership = await repo.add_project_member(project_id, user.id, "member") + elif membership.project_role != "member": + raise ProvisionError("existing project membership has an incompatible role") + return user.id, membership.project_role, created_user, created_membership + + +def _update_worker_environment( + env_path: Path, + *, + token_url: str, + client_id: str, + client_secret: str, + project_id: UUID, +) -> None: + current = dotenv_values(env_path) + if current.get("OPERATIONAL_WORKER_BEARER"): + raise ProvisionError("a fixed worker bearer already exists; refusing to overwrite it") + values = { + "OPERATIONAL_WORKER_TOKEN_URL": token_url, + "OPERATIONAL_WORKER_CLIENT_ID": client_id, + "OPERATIONAL_WORKER_CLIENT_SECRET": client_secret, + "OPERATIONAL_WORKER_PROJECT_ID": str(project_id), + "OPERATIONAL_WORKER_SERVER_URL": "http://127.0.0.1:8000", + "OPERATIONAL_WORKER_AGENT_URL": "http://127.0.0.1:18787", + "OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS": "300", + "OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8011", + "OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8011", + "OPERATIONAL_DATA_SERVER_URL": "http://127.0.0.1:8000", + } + for key, value in values.items(): + set_key(str(env_path), key, value, quote_mode="always") + env_path.chmod(0o600) + + +def _verify_product_context(token: str, project_id: UUID) -> dict: + try: + response = httpx.get( + "http://127.0.0.1:8000/api/v1/agent-auth-context", + headers={ + "Authorization": f"Bearer {token}", + "X-Project-Id": str(project_id), + }, + timeout=10, + follow_redirects=False, + trust_env=False, + ) + except httpx.HTTPError as exc: + raise ProvisionError("product identity verification request failed") from exc + if response.status_code != 200: + raise ProvisionError( + f"product identity verification was rejected with status={response.status_code}" + ) + value = response.json() + if not isinstance(value, dict): + raise ProvisionError("product identity verification returned an invalid response") + return value + + +async def provision(args: argparse.Namespace, admin_password: str) -> dict: + env_path = Path(args.env_file).resolve() + env_values = dotenv_values(env_path) + audience = env_values.get("KEYCLOAK_AUDIENCE") or "" + if audience != "account": + raise ProvisionError("backend audience is not the expected account audience") + keycloak = KeycloakProvisioner( + args.keycloak_url, args.realm, args.admin_username, admin_password + ) + try: + internal_id, created_client = keycloak.ensure_client(args.client_id) + secret = keycloak.client_secret(internal_id) + subject = keycloak.service_account_subject(internal_id) + token, payload = keycloak.service_token(args.client_id, secret) + mapper_created = False + if audience not in _audiences(payload): + mapper_created = keycloak.ensure_audience_mapper(internal_id, audience) + token, payload = keycloak.service_token(args.client_id, secret) + if audience not in _audiences(payload): + raise ProvisionError("service token does not contain the backend audience") + if payload.get("sub") != str(subject) or not payload.get("preferred_username"): + raise ProvisionError("service token identity claims do not match the service account") + realm_management = (payload.get("resource_access") or {}).get("realm-management", {}) + if realm_management.get("roles"): + raise ProvisionError("service account unexpectedly has realm-management roles") + product_user_id, project_role, created_user, created_membership = ( + await _bind_product_identity( + subject, + username=f"service-account-{args.client_id}", + email=f"{args.client_id}@service.local", + project_id=UUID(args.project_id), + project_code=args.project_code, + ) + ) + context = ProjectContext( + project_id=UUID(args.project_id), + project_code=args.project_code, + user_id=product_user_id, + project_role=project_role, + system_role="service", + is_superuser=False, + ) + expected_permissions = sorted(permissions_for_context(context)) + _update_worker_environment( + env_path, + token_url=( + f"{args.keycloak_url.rstrip('/')}/realms/{args.realm}" + "/protocol/openid-connect/token" + ), + client_id=args.client_id, + client_secret=secret, + project_id=UUID(args.project_id), + ) + verified = _verify_product_context(token, UUID(args.project_id)) + if ( + verified.get("project_id") != args.project_id + or verified.get("network") != args.project_code + or verified.get("project_role") != "member" + or verified.get("role") != "service" + or verified.get("is_superuser") is not False + or verified.get("permissions") != expected_permissions + ): + raise ProvisionError("product identity verification returned unexpected authorization") + return { + "client_created": created_client, + "audience_mapper_created": mapper_created, + "product_user_created": created_user, + "membership_created": created_membership, + "project_code": verified["network"], + "project_role": verified["project_role"], + "system_role": verified["role"], + "permissions": verified["permissions"], + } + finally: + keycloak.close() + await close_metadata_engine() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088") + parser.add_argument( + "--allow-insecure-http", + action="store_true", + help="allow cleartext Keycloak credentials for an explicitly trusted development network", + ) + parser.add_argument("--realm", default="tjwater") + parser.add_argument("--admin-username", default="admin") + parser.add_argument("--client-id", default="tjwater-operational-worker") + parser.add_argument("--project-id", required=True) + parser.add_argument("--project-code", default="tjwater_v2") + parser.add_argument( + "--env-file", default=str(Path(__file__).resolve().parents[1] / ".env") + ) + args = parser.parse_args() + keycloak_url = urlparse(args.keycloak_url) + if keycloak_url.scheme != "https" and not args.allow_insecure_http: + raise SystemExit( + "Refusing to send administrator and client credentials over cleartext HTTP; " + "use HTTPS or explicitly pass --allow-insecure-http for development" + ) + password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass( + "Keycloak admin password: " + ) + if not password: + raise SystemExit("administrator password is required") + try: + result = asyncio.run(provision(args, password)) + except ProvisionError as exc: + raise SystemExit(f"provisioning failed: {exc}") from None + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_operational_worker.py b/scripts/run_operational_worker.py new file mode 100644 index 0000000..310aa42 --- /dev/null +++ b/scripts/run_operational_worker.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Hand project-scoped queued investigations to the Agent. + +Credentials are deployment environment values, never task/session payloads. +Run with --once for an operator-triggered tick or without it for polling. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import time +from dataclasses import dataclass +from pathlib import Path +from uuid import UUID, uuid4 + +import httpx +from dotenv import load_dotenv + + +logger = logging.getLogger("operational-worker") + + +@dataclass(frozen=True) +class WorkerSettings: + server_url: str + agent_url: str + project_id: str + interval_seconds: float = 30 + max_concurrency: int = 4 + bearer: str = "" + token_url: str = "" + client_id: str = "" + client_secret: str = "" + token_refresh_seconds: float = 300 + + @classmethod + def from_environment(cls) -> "WorkerSettings": + load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False) + project_id = str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"])) + interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30")) + if interval < 1: + raise ValueError("worker interval must be at least one second") + settings = cls( + server_url=os.environ.get("OPERATIONAL_WORKER_SERVER_URL", "http://127.0.0.1:8000"), + agent_url=os.environ.get("OPERATIONAL_WORKER_AGENT_URL", "http://127.0.0.1:8787"), + project_id=project_id, + interval_seconds=interval, + max_concurrency=int(os.environ.get("OPERATIONAL_WORKER_MAX_CONCURRENCY", "4")), + bearer=os.environ.get("OPERATIONAL_WORKER_BEARER", ""), + token_url=os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", ""), + client_id=os.environ.get("OPERATIONAL_WORKER_CLIENT_ID", ""), + client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""), + token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")), + ) + if not 30 <= settings.token_refresh_seconds <= 600: + raise ValueError("service token refresh interval must be between 30 and 600 seconds") + if not 1 <= settings.max_concurrency <= 32: + raise ValueError("worker concurrency must be between 1 and 32") + if not settings.bearer and not all((settings.token_url, settings.client_id, settings.client_secret)): + raise ValueError("configure a product service credential for the operational worker") + return settings + + +class CredentialProvider: + def __init__(self, settings: WorkerSettings): + self.settings = settings + self._token = "" + self._expires = 0.0 + + async def headers(self, client: httpx.AsyncClient) -> dict[str, str]: + if self.settings.bearer: + token = self.settings.bearer.removeprefix("Bearer ") + else: + if time.monotonic() >= self._expires: + response = await client.post(self.settings.token_url, data={ + "grant_type": "client_credentials", "client_id": self.settings.client_id, + "client_secret": self.settings.client_secret, + }) + response.raise_for_status() + payload = response.json() + self._token = payload["access_token"] + if not isinstance(self._token, str) or not self._token: + raise ValueError("identity provider returned no access token") + # Product auth also restricts token age, independently of the + # provider's exp. Do not cache a long-lived OAuth token for hours. + lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds) + self._expires = time.monotonic() + max(1, lifetime - 15) + token = self._token + return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id} + + +class OperationalWorker: + def __init__(self, settings: WorkerSettings, client: httpx.AsyncClient): + self.settings = settings + self.client = client + self.credentials = CredentialProvider(settings) + + async def tick(self) -> int: + headers = await self.credentials.headers(self.client) + server = self.settings.server_url.rstrip("/") + recovery = await self.client.post( + f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, json={"limit": 100}, + ) + recovery.raise_for_status() + active_run_ids: set[str] = set() + for status_value in ("claimed", "running"): + response = await self.client.get( + f"{server}/api/v1/operational-task-runs", headers=headers, + params={"status": status_value, "limit": self.settings.max_concurrency}, + ) + response.raise_for_status() + active_run_ids.update(str(item["run_id"]) for item in response.json()["items"]) + available = max(0, self.settings.max_concurrency - len(active_run_ids)) + if available == 0: + return 0 + response = await self.client.get( + f"{server}/api/v1/operational-task-runs", headers=headers, + params={"status": "queued", "limit": available}, + ) + response.raise_for_status() + queued = response.json()["items"] + + async def dispatch(run: dict) -> bool: + if run.get("status") != "queued": + return False + task_id = str(UUID(run["task_id"])) + run_id = str(UUID(run["run_id"])) + request_id = str(UUID(run["execution_request_id"])) + try: + result = await self.client.post( + f"{self.settings.agent_url.rstrip('/')}/api/v1/agent/chat/task-executions", + headers=headers, + json={ + "task_id": task_id, + "run_id": run_id, + "execution_request_id": request_id, + }, + ) + if result.is_success: + payload = result.json() + if payload.get("task_id") != task_id or payload.get("run_id") != run_id: + raise ValueError("Agent returned a different task run") + return True + await self._record_dispatch_failure( + headers, run, retryable=result.status_code >= 500, + code=f"AGENT_HTTP_{result.status_code}", + ) + return False + except (httpx.HTTPError, ValueError) as exc: + await self._record_dispatch_failure( + headers, run, retryable=True, + code="AGENT_UNAVAILABLE", detail=type(exc).__name__, + ) + return False + + results = await asyncio.gather(*(dispatch(run) for run in queued)) + return sum(results) + + async def _record_dispatch_failure( + self, headers: dict[str, str], run: dict, *, retryable: bool, + code: str, detail: str = "Agent rejected task run dispatch", + ) -> None: + response = await self.client.post( + f"{self.settings.server_url.rstrip('/')}/api/v1/operational-tasks/" + f"{run['task_id']}/runs/{run['run_id']}/dispatch-failure", + headers=headers, + json={ + "request_id": str(uuid4()), + "expected_revision": run["revision"], + "failure_code": code, + "failure_detail": detail, + "retryable": retryable, + }, + ) + response.raise_for_status() + + async def listen(self, wake: asyncio.Event) -> None: + cursor = 0 + while True: + try: + headers = await self.credentials.headers(self.client) + async with self.client.stream( + "GET", + f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/events", + headers={**headers, "Accept": "text/event-stream"}, + params={"after": cursor}, + timeout=None, + ) as response: + response.raise_for_status() + event_name = "" + reset_required = False + async for line in response.aiter_lines(): + if line.startswith("id:"): + cursor = int(line[3:].strip()) + elif line.startswith("event:"): + event_name = line[6:].strip() + elif line.startswith("data:") and event_name == "reset_required": + snapshot = await self.client.get( + f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/sync-snapshot", + headers=headers, + ) + snapshot.raise_for_status() + cursor = int(snapshot.json()["event_cursor"]) + reset_required = True + break + elif line.startswith("data:") and event_name == "workbench_event": + payload = json.loads(line[5:].strip()) + if payload.get("topic") == "operational.run.queued": + wake.set() + elif line == "": + event_name = "" + if not reset_required: + await asyncio.sleep(1) + except (httpx.HTTPError, ValueError, KeyError): + logger.warning("Workbench event stream disconnected; reconciliation remains active") + await asyncio.sleep(5) + + +async def run(once: bool) -> None: + settings = WorkerSettings.from_environment() + async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: + worker = OperationalWorker(settings, client) + wake = asyncio.Event() + listener = None if once else asyncio.create_task(worker.listen(wake)) + try: + while True: + try: + accepted = await worker.tick() + logger.info("Task dispatch tick completed: %d accepted", accepted) + except (httpx.HTTPError, ValueError, KeyError): + # Do not log request headers, response bodies or provider secrets. + logger.error("Task dispatch tick failed; persisted tasks remain available for retry") + if once: + raise SystemExit(1) from None + if once: + return + try: + await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds) + except TimeoutError: + pass + wake.clear() + finally: + if listener is not None: + listener.cancel() + await asyncio.gather(listener, return_exceptions=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO) + try: + asyncio.run(run(args.once)) + except KeyboardInterrupt: + pass diff --git a/scripts/seed_peripheral_sample.py b/scripts/seed_peripheral_sample.py index 808c2fc..6fa71df 100644 --- a/scripts/seed_peripheral_sample.py +++ b/scripts/seed_peripheral_sample.py @@ -75,6 +75,56 @@ TEST_DATA = ( ) +def previous_seed_result( + store: PostgresStore, + project_id: str, + *, + seed_request_id: UUID, + command_key: str, +) -> dict | None: + """Read this script's earlier receipt without weakening API idempotency checks.""" + with store.connection(readonly=True) as connection: + row = connection.execute( + """SELECT command_key,result + FROM peripheral_sample.requests + WHERE project_id=%s AND actor_id=%s AND request_id=%s""", + (project_id, str(ACTOR_ID), seed_request_id), + ).fetchone() + if row is None: + return None + if row["command_key"] != command_key: + raise RuntimeError("seed request id is already used by another command") + return row["result"] + + +def restore_seeded_resources( + store: PostgresStore, + project_id: str, + *, + table: str, + id_column: str, + resource_ids: list[str], +) -> None: + """Restore deterministic seed rows that were soft-deleted after an earlier run.""" + allowed_resources = { + ("work_orders", "work_order_id"), + ("test_data_records", "record_id"), + } + if (table, id_column) not in allowed_resources: + raise ValueError("unsupported seeded resource") + if not resource_ids: + return + + with store.connection() as connection: + connection.execute( + f"""UPDATE peripheral_sample.{table} + SET document=document - 'deleted_at' - 'deleted_by', + deleted_at=NULL, deleted_by=NULL + WHERE project_id=%s AND {id_column}=ANY(%s::uuid[])""", + (project_id, resource_ids), + ) + + def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str: content = build_demo_content() content_digest = digest(content) @@ -112,8 +162,9 @@ def main() -> int: dataset_digest = ensure_demo_dataset(store, project_id) order_ids = [] for item in WORK_ORDERS: + seed_request_id = request_id(item["key"]) command = CreateWorkOrder( - request_id=request_id(item["key"]), + request_id=seed_request_id, title=item["title"], summary=item["summary"], status=item["status"], @@ -121,12 +172,27 @@ def main() -> int: assignee=item["assignee"], scheduled_at=item["scheduled_at"], ) - order_ids.append(store.create_work_order(who, command)["work_order_id"]) + previous = previous_seed_result( + store, + project_id, + seed_request_id=seed_request_id, + command_key="create_work_order", + ) + result = previous or store.create_work_order(who, command) + order_ids.append(result["work_order_id"]) + restore_seeded_resources( + store, + project_id, + table="work_orders", + id_column="work_order_id", + resource_ids=order_ids, + ) record_ids = [] for point_id, label, observed_at, value, unit, quality in TEST_DATA: + seed_request_id = request_id(f"test-data/{point_id}/{observed_at}") command = CreateTestDataRecord( - request_id=request_id(f"test-data/{point_id}/{observed_at}"), + request_id=seed_request_id, label=label, point_id=point_id, observed_at=observed_at, @@ -135,7 +201,21 @@ def main() -> int: quality=quality, notes="外围样板固定合成数据,不是现场监测记录。", ) - record_ids.append(store.create_test_data(who, command)["record_id"]) + previous = previous_seed_result( + store, + project_id, + seed_request_id=seed_request_id, + command_key="create_test_data", + ) + result = previous or store.create_test_data(who, command) + record_ids.append(result["record_id"]) + restore_seeded_resources( + store, + project_id, + table="test_data_records", + id_column="record_id", + resource_ids=record_ids, + ) result = { "project_id": project_id, diff --git a/scripts/seed_workbench_work_orders.py b/scripts/seed_workbench_work_orders.py index 97cfb9f..8e7fce0 100644 --- a/scripts/seed_workbench_work_orders.py +++ b/scripts/seed_workbench_work_orders.py @@ -1,4 +1,4 @@ -"""Project peripheral demo orders into the backend workbench order store.""" +"""Idempotently seed the workbench's original demo reservation tasks.""" from __future__ import annotations @@ -7,7 +7,7 @@ import json import sys from datetime import datetime, timedelta from pathlib import Path -from uuid import UUID +from uuid import UUID, uuid4 from zoneinfo import ZoneInfo from psycopg.types.json import Jsonb @@ -21,158 +21,311 @@ from app.native.wndb.core.connection import project_connection SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql" SHANGHAI = ZoneInfo("Asia/Shanghai") -PROJECTION_SOURCE = "合成数据预约工单" -DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection" +DATASET_ID = "workbench-demo-orders@1" +DEMO_DATE = datetime(2026, 9, 17, tzinfo=SHANGHAI) +SEEDED_WORK_ORDER_PREFIX = "mock-work-order-" +LEGACY_WORK_ORDER_SOURCES = ( + "合成数据预约工单", + "外围样板预约工单", +) -ORDER_DETAILS = { - "合成通信核查": { - "location": "DEMO-P001", - "duration_minutes": 30, - "reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"], +WORK_ORDER_TEMPLATES = ( + { + "id": "incident-valve-dispatch", + "code": "DEMO-WO-20260917-001", + "title": "演示工单:华山路阀门边界确认", + "summary": "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。", + "detail": "该条目为工作台演示数据,用于展示人工确认界面,不代表真实提交或岗位通知。", + "recommendation": "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。", + "source": "异常事件分析", + "location": "华山路沿线阀门组 VG-11", + "dispatcher": "调度中心值班长", + "assignee": "调度执行岗", + "priority": "urgent", + "risk_level": "attention", + "phase": "dispatch", + "start_hour": 10, + "start_minute": 30, + "duration_minutes": 40, + "dispatch_lead_minutes": 30, + "reply_window_minutes": 20, + "reply_requirements": [ + "确认阀门当前开度", + "记录影响分区和用户范围", + "回传派发确认截图", + ], }, - "合成压力偏差复核": { - "location": "DEMO-P001 / DEMO-P002", + { + "id": "valve-operation-execution", + "code": "DEMO-WO-20260917-002", + "title": "演示工单:北辰分区阀门开度调整", + "summary": "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。", + "detail": "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。", + "recommendation": "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。", + "source": "调度方案确认", + "location": "北辰分区边界阀门 BV-07", + "dispatcher": "管网调度岗", + "assignee": "北辰抢修二组", + "priority": "urgent", + "risk_level": "attention", + "phase": "execution", + "start_hour": 11, + "start_minute": 0, + "duration_minutes": 55, + "dispatch_lead_minutes": 25, + "reply_window_minutes": 20, + "reply_requirements": [ + "上传阀门操作前后照片", + "记录最终开度", + "复核北辰末端压力不低于 0.22MPa", + ], + }, + { + "id": "pump-boundary-reply", + "code": "DEMO-WO-20260917-003", + "title": "演示工单:泵站边界复核复令", + "summary": "泵站出水边界复核已完成,等待班组复令和调度确认闭环。", + "detail": "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。", + "recommendation": "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。", + "source": "模型复核建议", + "location": "北辰二级泵站 P-02", + "dispatcher": "模型复核岗", + "assignee": "泵站运行班组", + "priority": "normal", + "risk_level": "normal", + "phase": "reply", + "start_hour": 13, + "start_minute": 0, "duration_minutes": 45, - "reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"], + "dispatch_lead_minutes": 20, + "reply_window_minutes": 35, + "reply_requirements": [ + "上传泵站运行截图", + "填写出水压力复核结果", + "确认模型边界已同步更新", + ], }, - "合成零流量样本确认": { - "location": "DEMO-F001", - "duration_minutes": 20, - "reply_requirements": ["确认零值显示", "确认缺测值显示"], - }, -} +) -def _parse_timestamp(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - - -def _time_label(value: datetime) -> str: +def _clock(value: datetime) -> str: return value.astimezone(SHANGHAI).strftime("%H:%M") -def _time_range(start: datetime, end: datetime) -> str: - return f"{_time_label(start)}-{_time_label(end)}" +def _clock_range(start: datetime, duration_minutes: int) -> str: + return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}" -def _subtask_status(status: str) -> str: - mapped = { - "open": "pending", - "in_progress": "running", - "completed": "completed", - }.get(status) - if mapped is None: - raise ValueError(f"unsupported peripheral work-order status: {status}") - return mapped - - -def _stage_statuses(status: str) -> tuple[str, str, str]: - if status == "completed": +def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]: + if position == "before": return "done", "done", "done" - if status == "running": + if position == "after": + return "pending", "pending", "pending" + if phase == "dispatch": + return "current", "pending", "pending" + if phase == "execution": return "done", "current", "pending" - return "current", "pending", "pending" + return "done", "done", "current" -def project_peripheral_subtask(document: dict) -> dict: - status = _subtask_status(document.get("status")) - scheduled_at = _parse_timestamp(document["scheduled_at"]) - details = ORDER_DETAILS.get(document["title"], {}) - duration_minutes = int(details.get("duration_minutes", 30)) - execution_end = scheduled_at + timedelta(minutes=duration_minutes) - reply_end = execution_end + timedelta(minutes=15) - dispatch_status, execution_status, reply_status = _stage_statuses(status) - assignee = document.get("assignee") or "待分派班组" +def _subtask_status(phase: str, position: str) -> str: + if position == "before": + return "completed" + if position == "after" or phase == "dispatch": + return "pending" + return "running" - return { - "id": document["work_order_id"], - "title": document["title"], - "summary": document["summary"], - "status": status, - "scheduled_at": document["scheduled_at"], - "location": details.get("location", "演示点位"), - "assignee": assignee, - "duration_minutes": duration_minutes, - "reply_window_minutes": 15, - "stages": [ + +def _build_subtasks(template: dict, scheduled_at: datetime) -> list[dict]: + requirements = template["reply_requirements"] + phase = template["phase"] + active_index = { + "dispatch": 0, + "execution": min(1, len(requirements) - 1), + "reply": len(requirements) - 1, + }[phase] + subtask_duration = max( + 5, + (template["duration_minutes"] + len(requirements) - 1) // len(requirements), + ) + subtasks = [] + + for index, requirement in enumerate(requirements): + position = ( + "before" + if index < active_index + else "after" + if index > active_index + else "active" + ) + subtask_start = scheduled_at + timedelta(minutes=index * subtask_duration) + execution_end = subtask_start + timedelta(minutes=subtask_duration) + reply_end = execution_end + timedelta(minutes=template["reply_window_minutes"]) + dispatch_status, execution_status, reply_status = _stage_statuses( + phase, position + ) + subtasks.append( { - "id": "dispatch", - "sequence": 1, - "title": "派发", - "status": dispatch_status, - "owner": "演示调度", - "time_label": _time_label(scheduled_at - timedelta(minutes=15)), - "description": "预约工单子任务已进入演示调度流程。", - }, - { - "id": "execution", - "sequence": 2, - "title": "执行", - "status": execution_status, - "owner": assignee, - "time_label": _time_range(scheduled_at, execution_end), - "description": "按外围合成场景执行本项核查。", - }, - { - "id": "reply", - "sequence": 3, - "title": "复令", - "status": reply_status, - "owner": assignee, - "time_label": _time_range(execution_end, reply_end), - "description": "回传本项核查结果并完成子任务闭环。", - }, - ], - "reply_requirements": details.get( - "reply_requirements", ["回传演示核查结果"] + "id": f"{template['id']}-subtask-{index + 1}", + "title": requirement, + "summary": f"完成“{requirement}”并保留可复核的阶段记录。", + "status": _subtask_status(phase, position), + "scheduled_at": subtask_start, + "location": template["location"], + "assignee": template["assignee"], + "duration_minutes": subtask_duration, + "reply_window_minutes": template["reply_window_minutes"], + "stages": [ + { + "id": "dispatch", + "sequence": 1, + "title": "派发", + "status": dispatch_status, + "owner": template["dispatcher"], + "time_label": _clock( + subtask_start + - timedelta(minutes=template["dispatch_lead_minutes"]) + ), + "description": ( + f"确认{template['location']}任务范围、优先级和到场窗口," + f"派发给{template['assignee']}。" + ), + }, + { + "id": "execution", + "sequence": 2, + "title": "执行中", + "status": execution_status, + "owner": template["assignee"], + "time_label": _clock_range(subtask_start, subtask_duration), + "description": template["summary"], + }, + { + "id": "reply", + "sequence": 3, + "title": "待复令", + "status": reply_status, + "owner": f"{template['assignee']} → 调度中心", + "time_label": _clock_range( + execution_end, + template["reply_window_minutes"], + ), + "description": ( + "提交处置结果、现场照片和影响复核," + f"最迟 {_clock(reply_end)} 前完成复令。" + ), + }, + ], + "reply_requirements": [f"回传“{requirement}”的执行结果"], + } + ) + + return subtasks + + +def build_demo_work_orders() -> list[dict]: + """Build the three reservation tasks previously provided by the frontend mock.""" + documents = [] + for template in WORK_ORDER_TEMPLATES: + scheduled_at = DEMO_DATE.replace( + hour=template["start_hour"], + minute=template["start_minute"], + ) + status = { + "dispatch": "pending", + "execution": "running", + "reply": "awaiting_reply", + }[template["phase"]] + updated_at = { + "dispatch": scheduled_at - timedelta(minutes=5), + "execution": scheduled_at + timedelta(minutes=20), + "reply": scheduled_at + timedelta(minutes=template["duration_minutes"]), + }[template["phase"]] + projected = { + "id": f"{SEEDED_WORK_ORDER_PREFIX}{template['id']}", + "code": template["code"], + "scheduled_at": scheduled_at, + "title": template["title"], + "summary": template["summary"], + "status": status, + "risk_level": template["risk_level"], + "updated_at": updated_at, + "detail": template["detail"], + "recommendation": template["recommendation"], + "duration_minutes": template["duration_minutes"], + "source": template["source"], + "dispatcher": template["dispatcher"], + "priority": template["priority"], + "subtasks": _build_subtasks(template, scheduled_at), + } + documents.append( + WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json") + ) + return documents + + +def replace_demo_work_orders(conn, project_id: UUID, documents: list[dict]) -> None: + """Remove the obsolete synthetic projection and replace this seed dataset.""" + conn.execute( + """ + DELETE FROM workbench.work_orders + WHERE project_id = %s + AND ( + work_order_id LIKE 'demo-work-order-%%' + OR work_order_id LIKE %s + OR document->>'source' = ANY(%s) + ) + """, + ( + project_id, + f"{SEEDED_WORK_ORDER_PREFIX}%", + list(LEGACY_WORK_ORDER_SOURCES), ), - } + ) + for document in documents: + conn.execute( + """ + INSERT INTO workbench.work_orders + (project_id, work_order_id, scheduled_at, updated_at, document) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (project_id, work_order_id) DO UPDATE + SET scheduled_at = EXCLUDED.scheduled_at, + updated_at = EXCLUDED.updated_at, + document = EXCLUDED.document + """, + ( + project_id, + document["id"], + document["scheduled_at"], + document["updated_at"], + Jsonb(document), + ), + ) -def project_peripheral_work_order(source_documents: list[dict]) -> dict: - if not source_documents: - raise ValueError("source documents must not be empty") - - subtasks = [project_peripheral_subtask(document) for document in source_documents] - scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents] - updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents] - end_values = [ - scheduled + timedelta(minutes=subtask["duration_minutes"]) - for scheduled, subtask in zip(scheduled_values, subtasks, strict=True) - ] - statuses = {subtask["status"] for subtask in subtasks} - if statuses == {"completed"}: - status = "completed" - elif statuses == {"pending"}: - status = "pending" - else: - status = "running" - priority = "urgent" if any( - document.get("priority") in {"high", "urgent"} - for document in source_documents - ) else "normal" - - projected = { - "id": DEMO_WORK_ORDER_ID, - "code": "DEMO-WO-001", - "scheduled_at": min(scheduled_values), - "title": "外围合成数据核查", - "summary": "依次核查合成通信、压力偏差和零流量样本。", - "status": status, - "risk_level": "attention" if priority == "urgent" else "normal", - "updated_at": max(updated_values), - "detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。", - "recommendation": "按子任务逐项核查并分别完成阶段回传。", - "duration_minutes": max( - 1, - int((max(end_values) - min(scheduled_values)).total_seconds() // 60), +def publish_demo_work_order_batch_event(conn, project_id: UUID, count: int) -> None: + """Invalidate the demo projection once, never once per seeded row.""" + cursor = conn.execute( + """INSERT INTO workbench.domain_event_log + (project_id,event_id,topic,aggregate_type,aggregate_id, + aggregate_revision,occurred_at,metadata) + SELECT %s,%s,'workbench.work_order.changed','demo_work_order_dataset',%s, + COALESCE(max(aggregate_revision),0)+1,now(),%s + FROM workbench.domain_event_log + WHERE project_id=%s AND aggregate_type='demo_work_order_dataset' + AND aggregate_id=%s + RETURNING event_seq""", + ( + project_id, uuid4(), DATASET_ID, + Jsonb({"namespace": "demo-work-order", "count": count}), + project_id, DATASET_ID, ), - "source": PROJECTION_SOURCE, - "dispatcher": "演示调度", - "priority": priority, - "subtasks": subtasks, - } - return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json") + ) + row = cursor.fetchone() + conn.execute( + "SELECT pg_notify('workbench_events', %s)", + (f'{project_id}:{row["event_seq"]}',), + ) def main() -> int: @@ -180,73 +333,34 @@ def main() -> int: parser.add_argument("--database-name", default="tjwater_v2") parser.add_argument("--project-id", required=True, type=UUID) args = parser.parse_args() + documents = build_demo_work_orders() with project_connection(args.database_name) as conn, conn.transaction(): conn.execute(SCHEMA_SQL.read_text(encoding="utf-8")) - source_rows = conn.execute( - """ - SELECT document - FROM peripheral_sample.work_orders - WHERE project_id = %s - AND deleted_at IS NULL - AND document->>'origin' = 'synthetic' - AND document->>'scheduled_at' IS NOT NULL - AND document->>'status' IN ('open', 'in_progress', 'completed') - ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id - """, - (args.project_id,), - ).fetchall() - source_documents = [row["document"] for row in source_rows] - if not source_documents: - raise RuntimeError("no eligible peripheral demo work orders found") - documents = [project_peripheral_work_order(source_documents)] + replace_demo_work_orders(conn, args.project_id, documents) + publish_demo_work_order_batch_event(conn, args.project_id, len(documents)) - conn.execute( - """ - DELETE FROM workbench.work_orders - WHERE project_id = %s - AND ( - work_order_id LIKE 'demo-work-order-%%' - OR document->>'source' = %s - OR document->>'source' = '外围样板预约工单' - ) - """, - (args.project_id, PROJECTION_SOURCE), - ) - for document in documents: - conn.execute( - """ - INSERT INTO workbench.work_orders - (project_id, work_order_id, scheduled_at, updated_at, document) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (project_id, work_order_id) DO UPDATE - SET scheduled_at = EXCLUDED.scheduled_at, - updated_at = EXCLUDED.updated_at, - document = EXCLUDED.document - """, - ( - args.project_id, - document["id"], - document["scheduled_at"], - document["updated_at"], - Jsonb(document), - ), - ) - - print(json.dumps({ - "database": args.database_name, - "project_id": str(args.project_id), - "source": PROJECTION_SOURCE, - "work_orders": [ + print( + json.dumps( { - "id": document["id"], - "code": document["code"], - "title": document["title"], - "subtask_count": len(document["subtasks"]), - } - for document in documents - ], - }, ensure_ascii=False, indent=2)) + "database": args.database_name, + "project_id": str(args.project_id), + "dataset_id": DATASET_ID, + "work_orders": [ + { + "id": document["id"], + "code": document["code"], + "title": document["title"], + "status": document["status"], + "subtask_count": len(document["subtasks"]), + } + for document in documents + ], + }, + ensure_ascii=False, + indent=2, + ) + ) return 0 diff --git a/scripts/sync_peripheral_work_orders.py b/scripts/sync_peripheral_work_orders.py new file mode 100644 index 0000000..76533db --- /dev/null +++ b/scripts/sync_peripheral_work_orders.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Synchronize the explicitly synthetic peripheral work orders into task projections.""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +from uuid import NAMESPACE_URL, uuid5 + +import httpx + +try: + from scripts.run_operational_worker import CredentialProvider, WorkerSettings +except ModuleNotFoundError: + from run_operational_worker import CredentialProvider, WorkerSettings + + +logger = logging.getLogger("peripheral-task-sync") +STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress", + "awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"} + + +def work_order_snapshot(order: dict) -> dict: + """Preserve external process facts without deriving stages from total status.""" + required = ( + "work_order_id", "revision", "title", "summary", "status", "priority", + "origin", "updated_at", + ) + snapshot = {key: order[key] for key in required} + snapshot.update({key: order.get(key) for key in ( + "assignee", "scheduled_at", "location", "scenario_code", "subtasks", + )}) + snapshot["work_order_type"] = order.get("work_order_type", "general") + return snapshot + + +async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, peripheral_url: str, + credentials: CredentialProvider) -> int: + headers = await credentials.headers(client) + tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks" + existing, event_tasks, investigations, offset = {}, {}, [], 0 + while True: + response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset}) + response.raise_for_status() + page = response.json() + for task in page["items"]: + if task["task_type"] == "external_work_order": + existing[task["source"].get("external_id")] = task + elif task["task_type"] == "event_investigation": + event_tasks[task.get("source", {}).get("external_id")] = task + if task["status"] in {"queued", "waiting_human", "running"}: + investigations.append(task) + if page.get("next_offset") is None: + break + offset = page["next_offset"] + + linked: dict[str, list[str]] = {} + for task in investigations: + response = await client.get(f"{tasks_url}/{task['task_id']}", headers=headers) + response.raise_for_status() + for action in response.json().get("actions", []): + receipt = action.get("receipt") + if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample": + linked.setdefault(receipt["receipt_id"], []).append(task["task_id"]) + + cursor, synchronized = None, 0 + while True: + params = {"limit": 100} + if cursor: + params["cursor"] = cursor + response = await client.get(f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders", + headers=headers, params=params) + response.raise_for_status() + page = response.json() + for order in page["items"]: + if order["project_id"] != settings.project_id or order["origin"] != "synthetic": + raise ValueError("peripheral work order scope or origin mismatch") + external_id = f"peripheral_sample:{order['work_order_id']}" + # Complaint intake is an event even if no browser was open when the + # external system created it. Use the same source key as the UI. + event_kind = order.get("work_order_type") + if ( + event_kind in {"water_quality_complaint", "burst_report"} + and order["work_order_id"] not in event_tasks + and order["work_order_id"] not in linked + ): + event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation")) + event = await client.post(tasks_url, headers=headers, json={ + "request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind, + "title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}", + "priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal", + "source": {"kind": "external_system", "external_id": order["work_order_id"], + "external_revision": order["revision"], "event_kind": event_kind}, + "trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"], + "detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"}, + }) + event.raise_for_status() + event_tasks[order["work_order_id"]] = event.json() + task = existing.get(external_id) + if task is None: + request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create")) + result = await client.post(tasks_url, headers=headers, json={ + "request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order", + "title": order["title"], "summary": f"合成外围工单:{order['summary']}", + "priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal", + "source": {"kind": "external_system", "external_id": external_id, + "event_kind": order.get("work_order_type", "general")}, + "trigger": {"kind": "external_sync", "occurred_at": order["created_at"], + "detail": "外围样板工单同步,进度以外部系统反馈为准"}, + "scheduled_for": order["scheduled_at"], + }) + result.raise_for_status() + task = result.json() + existing[external_id] = task + event_id = f"{external_id}:{order['revision']}" + feedback = { + "request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id, + "source_revision": order["revision"], "status": STATUS[order["status"]], + "occurred_at": order["updated_at"], + "detail": f"合成外围工单 {order['work_order_id']}:{order['status']}", + "evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"], + "source_label": "外围样板合成工单", "source_ref": external_id, + "observed_at": order["updated_at"]}, + } + targets = set(linked.get(order["work_order_id"], [])) + event_task = event_tasks.get(order["work_order_id"]) + if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0): + targets.add(event_task["task_id"]) + if (task["source"].get("external_revision") or 0) < order["revision"]: + targets.add(task["task_id"]) + for target in targets: + payload = dict(feedback) + if target == task["task_id"]: + payload["work_order_snapshot"] = work_order_snapshot(order) + result = await client.post(f"{tasks_url}/{target}/external-feedback", headers=headers, json=payload) + result.raise_for_status() + synchronized += 1 + cursor = page.get("next_cursor") + if not cursor: + return synchronized + + +async def run(once: bool): + settings = WorkerSettings.from_environment() + peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"] + credentials = CredentialProvider(settings) + async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client: + while True: + try: + count = await sync_once(client, settings, peripheral_url, credentials) + logger.info("Peripheral progress synchronization completed: %d revisions", count) + except (httpx.HTTPError, ValueError, KeyError): + logger.error("Peripheral synchronization failed; next tick will reconcile persisted revisions") + if once: + raise SystemExit(1) from None + if once: + return + await asyncio.sleep(settings.interval_seconds) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO) + try: + asyncio.run(run(args.once)) + except KeyboardInterrupt: + pass diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py index 088e263..5852cc4 100644 --- a/tests/api/test_agent_auth_endpoints.py +++ b/tests/api/test_agent_auth_endpoints.py @@ -56,6 +56,11 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): "permissions": [ "burst.run", "burst.view", + "operational_action.work_order.approve", + "operational_action.work_order.execute", + "operational_task.external_sync", + "operational_task.view", + "operational_task.write", "optimization.run", "optimization.view", "risk.run", diff --git a/tests/api/test_problem_details.py b/tests/api/test_problem_details.py new file mode 100644 index 0000000..6e0337a --- /dev/null +++ b/tests/api/test_problem_details.py @@ -0,0 +1,42 @@ +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.api.problem_details import install_problem_details_handlers + + +def test_structured_http_error_preserves_domain_code_and_recovery_fields(): + app = FastAPI() + install_problem_details_handlers(app) + + @app.get("/lease") + async def expired_lease(): + raise HTTPException( + status_code=409, + detail={ + "code": "LEASE_EXPIRED", + "message": "Agent lease has expired", + "retryable": False, + "related_refs": ["task-1"], + "recovery_action": "claim_new_generation", + "retry_after_seconds": None, + }, + ) + + response = TestClient(app).get("/lease") + + assert response.status_code == 409 + assert response.headers["content-type"].startswith("application/problem+json") + assert response.json() == { + "type": "https://tjwater.example/problems/LEASE-EXPIRED", + "title": "Conflict", + "status": 409, + "detail": "Agent lease has expired", + "instance": "/lease", + "code": "LEASE_EXPIRED", + "trace_id": response.json()["trace_id"], + "errors": [], + "retryable": False, + "related_refs": ["task-1"], + "recovery_action": "claim_new_generation", + "retry_after_seconds": None, + } diff --git a/tests/auth/test_permissions.py b/tests/auth/test_permissions.py index 13d96f0..d9a03c8 100644 --- a/tests/auth/test_permissions.py +++ b/tests/auth/test_permissions.py @@ -8,6 +8,14 @@ from app.auth.permissions import ( ENVIRONMENT_MANAGE, MODEL_IMPORT, OPTIMIZATION_RUN, + OPERATIONAL_ACTION_COMMAND_APPROVE, + OPERATIONAL_ACTION_COMMAND_EXECUTE, + OPERATIONAL_ACTION_WORK_ORDER_APPROVE, + OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, + OPERATIONAL_TASK_EXECUTE, + OPERATIONAL_TASK_EXTERNAL_SYNC, + OPERATIONAL_TASK_VIEW, + OPERATIONAL_TASK_WRITE, SCADA_CLEAN, SIMULATION_RUN, SIMULATION_VIEW, @@ -52,11 +60,36 @@ def test_project_role_permission_matrix(): assert SIMULATION_RUN in member assert OPTIMIZATION_RUN in member assert MODEL_IMPORT not in member + assert OPERATIONAL_TASK_VIEW in member + assert OPERATIONAL_TASK_WRITE in member + assert OPERATIONAL_TASK_EXECUTE not in member + assert OPERATIONAL_TASK_EXTERNAL_SYNC not in member + assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE in member + assert OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in member + assert OPERATIONAL_ACTION_COMMAND_APPROVE not in member + assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in member assert WEBGIS_VIEW in viewer assert SIMULATION_VIEW in viewer assert WEBGIS_EDIT not in viewer assert SCADA_CLEAN not in viewer assert SIMULATION_RUN not in viewer + assert OPERATIONAL_TASK_VIEW in viewer + assert OPERATIONAL_TASK_WRITE not in viewer + assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE not in viewer + + +def test_service_identity_gets_only_project_scoped_control_permissions(): + service = resolve_permissions( + project_role="member", + system_role="service", + is_superuser=False, + ) + + assert OPERATIONAL_TASK_VIEW in service + assert OPERATIONAL_TASK_EXECUTE in service + assert OPERATIONAL_TASK_EXTERNAL_SYNC in service + assert ENVIRONMENT_MANAGE not in service + assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in service def test_system_admin_permissions_do_not_grant_project_business_access(): @@ -70,6 +103,16 @@ def test_system_admin_permissions_do_not_grant_project_business_access(): assert AUDIT_VIEW in permissions assert MODEL_IMPORT in permissions assert WEBGIS_VIEW not in permissions + assert OPERATIONAL_ACTION_COMMAND_APPROVE not in permissions + assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in permissions + + project_operator = resolve_permissions( + project_role="member", + system_role="admin", + is_superuser=False, + ) + assert OPERATIONAL_ACTION_COMMAND_APPROVE in project_operator + assert OPERATIONAL_ACTION_COMMAND_EXECUTE in project_operator @pytest.mark.anyio diff --git a/tests/auth/test_rbac_migration.py b/tests/auth/test_rbac_migration.py index a8cd3ac..4adcf43 100644 --- a/tests/auth/test_rbac_migration.py +++ b/tests/auth/test_rbac_migration.py @@ -41,3 +41,11 @@ def test_rbac_migration_normalizes_legacy_roles(): assert "'dispatcher'" in sql assert "THEN 'member'" in sql assert "ELSE 'viewer'" in sql + + +def test_service_identity_migration_extends_business_roles(): + sql = Path("resources/sql/016_metadata_service_identities.sql").read_text( + encoding="utf-8" + ) + + assert "role IN ('admin', 'service', 'user')" in sql diff --git a/tests/integration/test_operational_tasks_storage.py b/tests/integration/test_operational_tasks_storage.py new file mode 100644 index 0000000..d334a95 --- /dev/null +++ b/tests/integration/test_operational_tasks_storage.py @@ -0,0 +1,291 @@ +"""Opt-in PostgreSQL lifecycle check; all schema/data changes roll back.""" +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from uuid import uuid4 + +import httpx +import psycopg +import pytest +from psycopg.rows import dict_row + +from app.auth.permissions import permissions_for_context +from app.auth.project_dependencies import ProjectContext +from app.core.config import get_pgconn_string +from app.domain.domain_errors import DomainError +from app.domain.schemas.operational_task import ( + ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand, + AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand, + NotificationAcknowledgeCommand, OperationalTaskCreate, +) +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.infra.integrations.operational_actions import ( + ActionDispatchOutcome, HttpOperationalActionDispatcher, OperationalActionSettings, +) +from app.services.operational_tasks import OperationalTaskService + + +pytestmark = pytest.mark.skipif( + os.environ.get("OPERATIONAL_TASKS_STORAGE_TEST") != "1", reason="explicit PostgreSQL test opt-in required", +) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(): + migration = "\n".join( + (Path(__file__).parents[2] / f"resources/sql/{name}").read_text() + for name in ("014_operational_tasks.sql", "015_workbench_events_and_task_runs.sql") + ) + # The migration is normally atomic itself. This test owns an outer rollback + # transaction, so it executes its statements without transaction delimiters. + migration = migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", "") + context = ProjectContext(project_id=uuid4(), project_code="isolated-storage-test", + user_id=uuid4(), project_role="member") + conn = await psycopg.AsyncConnection.connect(get_pgconn_string(), row_factory=dict_row, connect_timeout=3) + try: + async with conn.transaction(force_rollback=True): + await conn.execute(migration) + repository = OperationalTaskRepository(conn, context.project_id) + service = OperationalTaskService(repository, context, permissions_for_context(context)) + now = datetime.now(timezone.utc) + command = OperationalTaskCreate( + request_id=uuid4(), task_type="event_investigation", workflow="water_quality_complaint", + title="AUTOTEST 合成调查", summary="隔离事务验证,不是生产投诉", + source={"kind": "operator", "event_kind": "water_quality_complaint"}, + trigger={"kind": "manual", "occurred_at": now, "detail": "transactional integration test"}, + ) + task = await service.create(command) + assert (await service.create(command)).task_id == task.task_id + assert task.status == "queued" + queued_rows = await repository.list_tasks( + task_type=None, status="queued", limit=100, offset=0 + ) + assert [row["task_id"] for row in queued_rows] == [str(task.task_id)] + all_rows = await repository.list_tasks( + task_type=None, status=None, limit=100, offset=0 + ) + assert [row["task_id"] for row in all_rows] == [str(task.task_id)] + claimed_run = await service.claim_run(task.task_id, task.active_run.run_id, AgentRunCommand( + request_id=task.active_run.execution_request_id, + expected_revision=task.active_run.revision, + source_session_id="test-investigation-session", + )) + task = await service.detail(task.task_id) + assert task.active_run == claimed_run + artifact = AgentArtifactsCommand.model_validate({ + "request_id": str(uuid4()), "source_session_id": "test-investigation-session", + "run_id": str(task.active_run.run_id), + "evidence": [{"evidence_id": "synthetic-1", "kind": "synthetic", "summary": "合成采样输入", + "source_label": "事务集成测试", "observed_at": now.isoformat()}], + "plan": {"schema": "execution-plan@1", "title": "调查", "reason": "等待采样", + "nodes": [{"id": "review", "title": "核对输入", "owner": "agent", "status": "completed", + "detail": "已读取合成输入", "evidence_refs": ["synthetic-1"]}, + {"id": "sample", "title": "现场采样", "owner": "human", "status": "waiting", + "detail": "等待外部反馈", "evidence_refs": []}], + "edges": [{"from": "review", "to": "sample"}]}, + "report": {"title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入", + "evidence_refs": ["synthetic-1"], "pending_items": ["现场采样"]}, + }) + published = await service.publish_artifacts(task.task_id, artifact) + assert published.task.status == "running" + assert published.task.latest_report.report.evidence_refs == ["synthetic-1"] + assert (await service.publish_artifacts(task.task_id, artifact)).report_revision == 1 + task = await service.complete_run( + task.task_id, + published.task.active_run.run_id, + AgentRunCommand( + request_id=uuid4(), + expected_revision=published.task.active_run.revision, + source_session_id="test-investigation-session", + ), + ) + assert task.status == "waiting_human" + action = await service.propose_action(task.task_id, ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要现场采样", evidence_refs=["synthetic-1"], + payload={"title": "采样", "description": "合成核验", "work_order_type": "sampling"}, + )) + member_notifications, member_next = await service.list_notifications( + unread_only=False, limit=100, offset=0 + ) + assert member_next is None + assert member_notifications + assert all(item.task_id == task.task_id for item in member_notifications) + action_pending = next( + item for item in member_notifications if item.kind == "action_pending" + ) + task_created = next( + item for item in member_notifications if item.kind == "task_created" + ) + assert action_pending.required_permission == action.required_approval_permission + + viewer_context = ProjectContext( + project_id=context.project_id, + project_code=context.project_code, + user_id=uuid4(), + project_role="viewer", + ) + viewer_service = OperationalTaskService( + OperationalTaskRepository(conn, viewer_context.project_id), + viewer_context, + permissions_for_context(viewer_context), + ) + viewer_notifications, _ = await viewer_service.list_notifications( + unread_only=True, limit=100, offset=0 + ) + assert task_created.notification_id in { + item.notification_id for item in viewer_notifications + } + assert action_pending.notification_id not in { + item.notification_id for item in viewer_notifications + } + + acknowledged = await service.acknowledge_notification( + task_created.notification_id, + NotificationAcknowledgeCommand( + request_id=uuid4(), expected_version=task_created.version + ), + ) + assert acknowledged.status == "read" + assert acknowledged.task_id == task.task_id + + persisted_service = OperationalTaskService( + OperationalTaskRepository(conn, context.project_id), + context, + permissions_for_context(context), + ) + persisted_notifications, _ = await persisted_service.list_notifications( + unread_only=False, limit=100, offset=0 + ) + persisted_created = next( + item for item in persisted_notifications + if item.notification_id == task_created.notification_id + ) + assert persisted_created.status == "read" + assert persisted_created.version == task_created.version + 1 + assert persisted_created.read_by == f"principal:{context.user_id}" + unread_notifications, _ = await persisted_service.list_notifications( + unread_only=True, limit=100, offset=0 + ) + assert task_created.notification_id not in { + item.notification_id for item in unread_notifications + } + viewer_after_member_ack, _ = await viewer_service.list_notifications( + unread_only=True, limit=100, offset=0 + ) + assert task_created.notification_id in { + item.notification_id for item in viewer_after_member_ack + } + action = await service.decide_action(task.task_id, action.action_id, ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version, decision="approved", + )) + assert action.execution_status == "not_submitted" + class NoAdapter: + async def dispatch(self, **kwargs): + return ActionDispatchOutcome("unsupported", "测试不执行外部动作") + action = await service.submit_action(task.task_id, action.action_id, ActionExecuteCommand( + request_id=uuid4(), expected_version=action.version, + ), NoAdapter(), None) + assert action.execution_status == "unsupported" + fresh_service = OperationalTaskService(OperationalTaskRepository(conn, context.project_id), + context, permissions_for_context(context)) + assert (await fresh_service.detail(task.task_id)).actions[0].action_id == action.action_id + before_feedback = await fresh_service.detail(task.task_id) + feedback = ExternalFeedbackCommand( + request_id=uuid4(), source_event_id="synthetic-sampling-order", source_revision=2, + status="completed", occurred_at=now, detail="合成采样工单完成,调查仍需分析", + evidence={"evidence_id": "synthetic-sampling-order:2", "kind": "synthetic", + "summary": "合成采样结果已回传", "source_label": "事务集成外围工单", + "observed_at": now}, + ) + resumed = (await fresh_service.external_feedback(task.task_id, feedback)).task + assert resumed.status == "queued" + assert resumed.execution_generation == before_feedback.execution_generation + assert resumed.execution_request_id == before_feedback.execution_request_id + assert resumed.agent_session_id is None + assert len(resumed.evidence) == 2 + replayed = (await fresh_service.external_feedback(task.task_id, feedback)).task + assert replayed.execution_generation == resumed.execution_generation + assert replayed.revision == resumed.revision + claimed_run = await fresh_service.claim_run( + task.task_id, + resumed.active_run.run_id, + AgentRunCommand( + request_id=resumed.active_run.execution_request_id, + expected_revision=resumed.active_run.revision, + source_session_id="test-investigation-followup-session", + ), + ) + resumed = await fresh_service.detail(task.task_id) + assert resumed.active_run == claimed_run + with pytest.raises(DomainError) as stale_session: + await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={ + "request_id": uuid4(), "run_id": resumed.active_run.run_id, + })) + assert stale_session.value.code == "SESSION_MISMATCH" + assert (await fresh_service.detail(task.task_id)).agent_session_id == resumed.agent_session_id + contract_output = os.environ.get("OPERATIONAL_TASK_CONTRACT_OUTPUT") + if contract_output: + # Optional generated test artifact for cross-repository consumers. + Path(contract_output).write_text( + (await fresh_service.detail(task.task_id)).model_dump_json(indent=2, by_alias=True), encoding="utf-8" + ) + outsider = OperationalTaskService(OperationalTaskRepository(conn, uuid4()), + context, permissions_for_context(context)) + with pytest.raises(DomainError): + await outsider.detail(task.task_id) + sampling = await fresh_service.propose_action(task.task_id, ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要补充采样", + evidence_refs=["synthetic-1"], + payload={"title": "补充采样", "description": "隔离事务测试", "work_order_type": "sampling"}, + )) + sampling = await fresh_service.decide_action(task.task_id, sampling.action_id, ActionDecisionCommand( + request_id=uuid4(), expected_version=sampling.version, decision="approved", + )) + calls, execution_ids = [], [] + receipt_ready = False + order_id = str(uuid4()) + + def sample_transport(request): + calls.append(request.method) + assert request.headers["X-Project-Id"] == str(context.project_id) + if request.method == "POST": + execution_ids.append(json.loads(request.content)["request_id"]) + raise httpx.ReadTimeout("simulated lost response", request=request) + if not receipt_ready: + return httpx.Response(404, json={"effect": "none"}) + assert request.url.path.endswith(execution_ids[0]) + return httpx.Response(200, json={"request_id": execution_ids[0], "result": { + "work_order_id": order_id, "project_id": str(context.project_id), + "created_at": now.isoformat(), "origin": "synthetic", "status": "open", + }}) + + dispatcher = HttpOperationalActionDispatcher( + OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test"), + transport=httpx.MockTransport(sample_transport), + ) + submission = ActionExecuteCommand(request_id=uuid4(), expected_version=sampling.version) + unknown = await fresh_service.submit_action(task.task_id, sampling.action_id, submission, + dispatcher, "Bearer TEST_ONLY") + assert unknown.execution_status == "unknown" + assert calls == ["POST", "GET"] + receipt_ready = True + confirmed = await fresh_service.reconcile_action(task.task_id, sampling.action_id, + ActionReconcileCommand(request_id=uuid4(), expected_version=unknown.version), + dispatcher, "Bearer TEST_ONLY") + assert confirmed.execution_status == "succeeded" + assert confirmed.receipt.receipt_id == order_id + assert confirmed.receipt.external_status == "open" + assert confirmed.execution_request_id == unknown.execution_request_id + assert calls == ["POST", "GET", "GET"] + await fresh_service.submit_action(task.task_id, sampling.action_id, submission, + dispatcher, "Bearer TEST_ONLY") + assert calls == ["POST", "GET", "GET"] + assert (await fresh_service.detail(task.task_id)).status == "queued" + finally: + await conn.close() diff --git a/tests/integration/test_peripheral_sample_live.py b/tests/integration/test_peripheral_sample_live.py index 0dd1472..d8a4876 100644 --- a/tests/integration/test_peripheral_sample_live.py +++ b/tests/integration/test_peripheral_sample_live.py @@ -208,9 +208,13 @@ def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample priority="high", assignee="AUTOTEST", scheduled_at="2026-09-18T01:00:00Z", + work_order_type="water_quality_complaint", + location="AUTOTEST 演示小区", + scenario_code="WQ-SECONDARY-SUPPLY-001", ) order = store.create_work_order(who, order_command) WorkOrder.model_validate(order) + assert order["work_order_type"] == "water_quality_complaint" assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order assert store.create_work_order(who, order_command) == order order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder( diff --git a/tests/unit/test_import_agent_investigation.py b/tests/unit/test_import_agent_investigation.py new file mode 100644 index 0000000..d96bcc2 --- /dev/null +++ b/tests/unit/test_import_agent_investigation.py @@ -0,0 +1,60 @@ +import argparse +import json + +import pytest +from pydantic import ValidationError + +from scripts.import_agent_investigation import convert_plan, run + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def snapshot(): + return {"session_id": "ses_test", "revision": 1, "updated_at": "2026-09-23T00:00:00Z", + "title": "合成调查", "nodes": [ + {"id": "STAGE_JUDGMENT", "title": "阶段判断", "owner": "agent", + "status": "completed", "detail": "合成证据不足以确定原因", "evidence": ["合成输入"]}, + {"id": "SAMPLE", "title": "采样", "owner": "human", "status": "waiting", + "detail": "等待现场采样反馈", "evidence": []}, + ], "edges": [{"from": "STAGE_JUDGMENT", "to": "SAMPLE"}]} + + +def test_legacy_conversion_preserves_waiting_and_explicit_synthetic_boundary(): + plan, evidence, report = convert_plan(snapshot()) + assert plan["nodes"][1]["status"] == "waiting" + assert all(item["kind"] == "synthetic" for item in evidence) + assert report["pending_items"] == ["等待现场采样反馈"] + assert report["current_conclusion"] == "合成证据不足以确定原因" + assert "未重新查询当前数据" in report["conclusion_boundary"] + assert report["evidence_refs"] == [evidence[0]["evidence_id"]] + + +@pytest.mark.anyio +async def test_dry_run_validates_historical_schema_before_any_network_access(tmp_path, monkeypatch, capsys): + metadata_dir = tmp_path / "session-metadata" + states_dir = tmp_path / "session-ui-states" + metadata_dir.mkdir() + states_dir.mkdir() + (metadata_dir / "ses_test.json").write_text(json.dumps({ + "triggerWorkflow": "water_quality_complaint", "triggerSource": "peripheral_sample", + "projectId": "10000000-0000-4000-8000-000000000001", + })) + state = snapshot() + state_path = states_dir / "ses_test.json" + state_path.write_text(json.dumps({"executionPlans": [state]})) + + def no_network(*args, **kwargs): + raise AssertionError("dry run must not open a network client") + + monkeypatch.setattr("scripts.import_agent_investigation.httpx.AsyncClient", no_network) + args = argparse.Namespace(session_id="ses_test", agent_data=tmp_path, dry_run=True) + await run(args) + assert "2 nodes and 1 synthetic evidence records; no writes" in capsys.readouterr().out + state["nodes"][1]["status"] = "invented_status" + state_path.write_text(json.dumps({"executionPlans": [state]})) + with pytest.raises(ValidationError): + await run(args) + assert "Ready" not in capsys.readouterr().out diff --git a/tests/unit/test_operational_action_adapters.py b/tests/unit/test_operational_action_adapters.py new file mode 100644 index 0000000..8df8908 --- /dev/null +++ b/tests/unit/test_operational_action_adapters.py @@ -0,0 +1,184 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest + +from app.infra.integrations.operational_actions import ( + HttpOperationalActionDispatcher, OperationalActionSettings, +) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def inputs(kind="create_work_order"): + project_id, task_id = uuid4(), uuid4() + now = datetime.now(timezone.utc) + payload = {"title": "现场采样", "description": "核验投诉水质", "work_order_type": "sampling"} + if kind == "issue_command": + payload = {"command_type": "set_valve", "target": {"kind": "valve", "id": "V-01"}, + "parameters": {"opening": 20}, "impact_summary": "影响V-01阀门控制范围", + "preconditions": ["现场人员确认"], + "expires_at": (now + timedelta(minutes=10)).isoformat()} + return { + "context": SimpleNamespace(project_id=project_id), + "task": SimpleNamespace(task_id=task_id, project_id=project_id), + "action": SimpleNamespace(task_id=task_id, action_id=uuid4(), version=1, kind=kind, + execution_request_id=uuid4(), + approval_status="approved", payload=payload, + decided_by="operator", decided_at=now), + "credential": "Bearer TEST_ONLY", + } + + +def settings(**kwargs): + return OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test", + command_gateway_url="http://commands.test", **kwargs) + + +@pytest.mark.anyio +async def test_create_work_order_confirms_creation_not_field_completion(): + data = inputs() + calls = [] + def handler(request): + calls.append(request) + return httpx.Response(201, json={"work_order_id": str(uuid4()), + "project_id": str(data["context"].project_id), "created_at": "2026-09-23T08:00:00Z", + "origin": "synthetic", "status": "open"}) + adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)) + result = await adapter.dispatch(**data) + assert result.status == "succeeded" + assert result.receipt.external_status == "open" + assert "现场工作进度" in result.detail + assert calls[0].url.path == "/api/sample/v1/work-orders" + assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id) + + +@pytest.mark.anyio +async def test_work_order_receipt_accepts_awaiting_reply_status(): + data = inputs() + def handler(request): + return httpx.Response(201, json={ + "work_order_id": str(uuid4()), + "project_id": str(data["context"].project_id), + "created_at": "2026-09-23T08:00:00Z", + "origin": "synthetic", + "status": "awaiting_reply", + }) + adapter = HttpOperationalActionDispatcher( + settings(), transport=httpx.MockTransport(handler) + ) + + result = await adapter.dispatch(**data) + + assert result.status == "succeeded" + assert result.receipt.external_status == "awaiting_reply" + + +@pytest.mark.anyio +async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event(): + data = inputs() + data["action"].payload["work_order_type"] = "water_quality_complaint" + submitted = {} + def handler(request): + submitted.update(__import__("json").loads(request.content)) + return httpx.Response(201, json={ + "work_order_id": str(uuid4()), + "project_id": str(data["context"].project_id), + "created_at": "2026-09-23T08:00:00Z", + "origin": "synthetic", + "status": "open", + }) + + result = await HttpOperationalActionDispatcher( + settings(), transport=httpx.MockTransport(handler) + ).dispatch(**data) + + assert result.status == "succeeded" + assert submitted["work_order_type"] == "general" + + +@pytest.mark.anyio +async def test_lost_create_response_recovers_same_request_without_second_write(): + data, methods = inputs(), [] + def handler(request): + methods.append(request.method) + if request.method == "POST": + raise httpx.ReadTimeout("lost", request=request) + return httpx.Response(200, json={"request_id": request.url.path.split("/")[-1], + "result": {"work_order_id": str(uuid4()), "project_id": str(data["context"].project_id), + "created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}}) + result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data) + assert result.status == "succeeded" + assert methods == ["POST", "GET"] + + +@pytest.mark.anyio +async def test_unconfirmed_receipt_remains_unknown_not_failed_or_succeeded(): + data = inputs() + def handler(request): + return httpx.Response(200, json={"work_order_id": str(uuid4()), "project_id": str(uuid4()), + "created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}) + result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data) + assert result.status == "unknown" + assert result.receipt is None + + +@pytest.mark.anyio +async def test_unapproved_and_expired_actions_never_reach_external_system(): + def handler(request): + raise AssertionError("no external call expected") + adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)) + data = inputs() + data["action"].approval_status = "pending" + assert (await adapter.dispatch(**data)).status == "failed" + data = inputs("issue_command") + data["action"].payload["expires_at"] = "2020-01-01T00:00:00Z" + assert (await adapter.dispatch(**data)).status == "failed" + + +@pytest.mark.anyio +async def test_command_acceptance_is_not_execution_completion(): + data = inputs("issue_command") + def handler(request): + import json + body = json.loads(request.content) + assert body["command"]["target"]["id"] == "V-01" + return httpx.Response(202, json={"request_id": body["request_id"], + "project_id": body["project_id"], "receipt_id": "gateway-1", "status": "accepted", + "submitted_at": "2026-09-23T08:00:00Z", "completed_at": None, "detail": "已接收"}) + result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data) + assert result.status == "submitted" + assert result.receipt.completed_at is None + + +@pytest.mark.anyio +async def test_missing_configuration_does_not_invent_receipt(): + adapter = HttpOperationalActionDispatcher(OperationalActionSettings( + _env_file=None, peripheral_url="", command_gateway_url="")) + result = await adapter.dispatch(**inputs()) + assert result.status == "unsupported" + assert result.receipt is None + + +@pytest.mark.anyio +async def test_reconciliation_is_read_only_and_keeps_execution_identity_across_versions(): + data, paths = inputs("issue_command"), [] + def handler(request): + assert request.method == "GET" + paths.append(request.url.path) + return httpx.Response(200, json={ + "request_id": str(data["action"].execution_request_id), + "project_id": str(data["context"].project_id), "receipt_id": "confirmed-command", + "status": "completed", "submitted_at": "2026-09-23T08:00:00Z", + "completed_at": "2026-09-23T08:01:00Z", "detail": "执行回执已确认", + }) + adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)) + assert (await adapter.reconcile(**data)).status == "succeeded" + data["action"].version += 1 + assert (await adapter.reconcile(**data)).status == "succeeded" + assert paths[0] == paths[1] diff --git a/tests/unit/test_operational_data.py b/tests/unit/test_operational_data.py new file mode 100644 index 0000000..59a2c58 --- /dev/null +++ b/tests/unit/test_operational_data.py @@ -0,0 +1,131 @@ +from uuid import uuid4 + +import httpx +import pytest +from pydantic import ValidationError + +from app.auth.project_dependencies import ProjectContext +from app.domain.schemas.operational_data import TaskDataQuery +from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def context(): + return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", project_role="member") + + +def settings(): + return TaskDataSettings(_env_file=None, server_url="http://server.test", peripheral_url="http://sample.test") + + +@pytest.mark.anyio +async def test_actual_read_has_scoped_headers_and_synthetic_provenance(): + ctx = context() + def handler(request): + assert request.method == "GET" + assert request.url.path == "/api/sample/v1/demo-data" + assert not request.url.params + assert request.headers["X-Project-Id"] == str(ctx.project_id) + return httpx.Response(200, json={ + "project_id": str(ctx.project_id), + "source_id": "tjwater_v2:peripheral_sample.demo_datasets:water-demo-v1", + "source_revision": "sha256:test", + "points": [{"point_id": "DEMO-P001"}], + "observations": [ + {"point_id": "DEMO-P001", "observed_at": "2026-09-23T00:30:00Z", "value": 0.22}, + {"point_id": "DEMO-P002", "observed_at": "2026-09-23T00:30:00Z", "value": 1.0}, + {"point_id": "DEMO-P001", "observed_at": "2026-09-24T00:00:00Z", "value": 2.0}, + ], + }) + result = await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="peripheral_observations", point_id="DEMO-P001", + start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"), + transport=httpx.MockTransport(handler)) + assert result.origin == "synthetic" + assert result.data["items"][0]["value"] == 0.22 + assert len(result.data["items"]) == 1 + assert result.data["point_id"] == "DEMO-P001" + assert result.data["window"] == { + "start": "2026-09-23T00:00:00+00:00", "end": "2026-09-23T01:00:00+00:00", + } + assert result.source_ref.startswith( + "peripheral_demo_data:DEMO-P001:2026-09-23T00:00:00+00:00:" + "2026-09-23T01:00:00+00:00:sha256:" + ) + assert not result.truncated + + +@pytest.mark.anyio +async def test_historical_observations_keep_monitoring_endpoint(): + def handler(request): + assert request.url.path == "/api/sample/v1/monitoring/observations" + assert request.url.params["point_id"] == "100026" + return httpx.Response(200, json={"items": [], "truncated": False, "origin": "historical_replay"}) + result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="peripheral_observations", point_id="100026", + start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"), + transport=httpx.MockTransport(handler)) + assert result.origin == "system_data" + assert result.data["origin"] == "historical_replay" + assert result.source_ref.startswith("peripheral_historical_replay:peripheral_observations:sha256:") + + +@pytest.mark.anyio +@pytest.mark.parametrize("origin,expected", [("historical_replay", "system_data"), ("synthetic", "synthetic")]) +async def test_monitoring_point_provenance_follows_source_not_transport(origin, expected): + result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="peripheral_points"), + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"origin": origin, "items": []}))) + assert result.origin == expected + assert result.data["origin"] == origin + assert result.source_ref.startswith( + "peripheral_historical_replay:" if origin == "historical_replay" else "peripheral_points:" + ) + + +@pytest.mark.anyio +async def test_monitoring_data_without_known_provenance_is_not_saved_as_fact(): + with pytest.raises(TaskDataError, match="来源"): + await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="peripheral_points"), + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"items": []}))) + + +@pytest.mark.anyio +async def test_paged_data_is_marked_incomplete_and_receipt_is_content_bound(): + values = [{"items": [1], "total": 2}, {"items": [2], "total": 2}] + def handler(request): + return httpx.Response(200, json=values.pop(0)) + kwargs = dict(context=context(), credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="scada_devices"), transport=httpx.MockTransport(handler)) + first, second = await read_task_data(**kwargs), await read_task_data(**kwargs) + assert first.truncated and second.truncated + assert first.source_ref != second.source_ref + + +@pytest.mark.anyio +@pytest.mark.parametrize("shape", ["root", "items", "list"]) +async def test_cross_project_or_failed_query_cannot_become_evidence(shape): + ctx = context() + foreign = {"project_id": str(uuid4()), "value": 0.22} + payload = {"root": foreign, "items": {"project_id": str(ctx.project_id), "items": [foreign]}, + "list": [foreign]}[shape] + def handler(request): + return httpx.Response(200, json=payload) + with pytest.raises(TaskDataError, match="其他项目"): + await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(), + query=TaskDataQuery(source="workbench_runtime"), transport=httpx.MockTransport(handler)) + + +def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count(): + with pytest.raises(ValidationError): + TaskDataQuery(source="http://arbitrary.test/delete") + with pytest.raises(ValidationError): + TaskDataQuery(source="scada_readings", device_ids=["p1"], + start="2026-09-01T00:00:00Z", end="2026-09-23T00:00:00Z") + with pytest.raises(ValidationError): + TaskDataQuery(source="peripheral_observations") diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py new file mode 100644 index 0000000..a792bb8 --- /dev/null +++ b/tests/unit/test_operational_tasks.py @@ -0,0 +1,852 @@ +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.auth.permissions import ( + OPERATIONAL_ACTION_WORK_ORDER_APPROVE, + OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, + OPERATIONAL_TASK_VIEW, +) +from app.auth.project_dependencies import ProjectContext +from app.domain.domain_errors import DomainError +from app.domain.schemas.operational_task import ( + ActionDecisionCommand, + ActionExecuteCommand, + ActionProposalCommand, + AgentArtifactsCommand, + AgentRunCommand, + ExternalFeedbackCommand, + HumanApprovalAction, + NotificationAcknowledgeCommand, + OperationalTaskNotification, + OperationalTaskRun, + OperationalTaskSummary, +) +from app.domain.schemas.operational_data import TaskDataSnapshot +from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.services.operational_tasks import OperationalTaskService + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class NotificationRepository: + def __init__(self, notification): + self.notification = notification + self.reads = {} + self.requests = {} + + @asynccontextmanager + async def transaction(self): + yield self + + async def lock(self, _key): + return None + + async def get_request(self, scope, request_id): + return self.requests.get((scope, request_id)) + + async def save_request(self, scope, request_id, request_digest, response): + self.requests[(scope, request_id)] = { + "request_digest": request_digest, + "response": response, + } + + async def get_notification(self, notification_id, user_id): + if notification_id != str(self.notification.notification_id): + return None + read = self.reads.get(user_id) + return self.notification.model_copy(update={ + "status": "read" if read else "unread", + "version": 2 if read else 1, + "read_at": read, + "read_by": f"principal:{user_id}" if read else None, + }).model_dump(mode="json") + + async def mark_notification_read(self, notification_id, user_id, version, read_at): + assert version == 2 + self.reads[user_id] = read_at + + +def _context(user_id=None, *, system_role="user"): + return ProjectContext( + project_id=uuid4(), project_code="test", user_id=user_id or uuid4(), + project_role="member", system_role=system_role, + ) + + +@pytest.mark.anyio +async def test_notification_read_state_is_isolated_per_user(): + now = datetime(2026, 9, 23, tzinfo=timezone.utc) + notification = OperationalTaskNotification( + notification_id=uuid4(), task_id=uuid4(), kind="task_created", + title="任务", description="已创建", status="unread", version=1, + created_at=now, + ) + repository = NotificationRepository(notification) + first = _context() + second = _context() + first_service = OperationalTaskService( + repository, first, {OPERATIONAL_TASK_VIEW}, clock=lambda: now + ) + second_service = OperationalTaskService( + repository, second, {OPERATIONAL_TASK_VIEW}, clock=lambda: now + ) + + request_id = uuid4() + read = await first_service.acknowledge_notification( + notification.notification_id, + NotificationAcknowledgeCommand(request_id=request_id, expected_version=1), + ) + + assert read.status == "read" + assert (await repository.get_notification( + str(notification.notification_id), str(second.user_id) + ))["status"] == "unread" + + second_read = await second_service.acknowledge_notification( + notification.notification_id, + NotificationAcknowledgeCommand(request_id=request_id, expected_version=1), + ) + assert second_read.status == "read" + assert second_read.read_by == f"principal:{second.user_id}" + + +def test_running_plan_state_wins_over_future_human_waiting_node(): + nodes = [ + type("Node", (), {"status": "running", "owner": "agent"})(), + type("Node", (), {"status": "waiting", "owner": "human"})(), + ] + + assert OperationalTaskService._status_from_plan(nodes, []) == "running" + + +def test_pending_approval_prevents_plan_from_marking_task_completed(): + action = HumanApprovalAction( + action_id=uuid4(), task_id=uuid4(), version=1, + kind="create_work_order", reason="现场采样", + payload={"title": "采样", "description": "核验", "work_order_type": "sampling"}, + evidence_refs=[], required_approval_permission="work-order.approve", + required_execution_permission="work-order.execute", + approval_status="pending", execution_status="not_submitted", + proposed_by="principal:test", proposed_at=datetime.now(timezone.utc), + ) + nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()] + + assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human" + + +def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work(): + nodes = [ + type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(), + type("Node", (), {"id": "AGENT_RUN_FAILURE", "status": "failed", "owner": "agent"})(), + ] + + assert OperationalTaskService._status_from_plan(nodes, []) == "failed" + + +@pytest.mark.anyio +async def test_targeted_notification_cannot_be_acknowledged_without_permission(): + now = datetime(2026, 9, 23, tzinfo=timezone.utc) + notification = OperationalTaskNotification( + notification_id=uuid4(), task_id=uuid4(), kind="action_pending", + title="待确认", description="命令", required_permission="command.approve", + status="unread", version=1, created_at=now, + ) + service = OperationalTaskService( + NotificationRepository(notification), _context(), {OPERATIONAL_TASK_VIEW}, + clock=lambda: now, + ) + + with pytest.raises(DomainError) as exc: + await service.acknowledge_notification( + notification.notification_id, + NotificationAcknowledgeCommand(request_id=uuid4(), expected_version=1), + ) + + assert exc.value.code == "PERMISSION_DENIED" + + +def _task_document(context, now, *, lease=None): + task_id = uuid4() + return OperationalTaskSummary( + project_id=context.project_id, + task_id=task_id, + task_type="event_investigation", + workflow="water_quality_complaint", + status="running", + priority="normal", + revision=2, + execution_generation=1, + execution_request_id=uuid4(), + title="调查", + summary="验证状态机", + source={"kind": "operator", "event_kind": "water_quality_complaint"}, + trigger={"kind": "manual", "occurred_at": now, "detail": "test"}, + scheduled_for=None, + agent_session_id="session-1", + agent_lease_expires_at=lease, + current_plan_revision=None, + current_report_revision=None, + created_at=now, + updated_at=now, + ).model_dump(mode="json") + + +class TaskRepository: + def __init__(self, task, *, action=None): + self.task = task + self.action = action + self.requests = {} + self.evidence = {} + self.feedback = {} + self.plans = [] + self.reports = [] + self.notifications = [] + self.locks = [] + + @asynccontextmanager + async def transaction(self): + yield self + + async def lock(self, key): + self.locks.append(key) + + async def get_request(self, scope, request_id): + return self.requests.get((scope, request_id)) + + async def save_request(self, scope, request_id, request_digest, response): + self.requests[(scope, request_id)] = { + "request_digest": request_digest, + "response": response, + } + + async def get_task(self, task_id): + return self.task if task_id == self.task["task_id"] else None + + async def update_task(self, task): + self.task = task + + async def get_latest_plan(self, _task_id): + return self.plans[-1] if self.plans else None + + async def get_latest_report(self, _task_id): + return self.reports[-1] if self.reports else None + + async def insert_plan(self, _task_id, snapshot): + self.plans.append(snapshot) + + async def insert_report(self, _task_id, snapshot): + self.reports.append(snapshot) + + async def list_evidence(self, _task_id): + return list(self.evidence.values()) + + async def get_evidence(self, _task_id, evidence_id): + return self.evidence.get(evidence_id) + + async def insert_evidence(self, _task_id, evidence): + self.evidence[evidence["evidence_id"]] = evidence + + async def list_actions(self, _task_id): + return [self.action] if self.action else [] + + async def get_action(self, _task_id, action_id): + if self.action and self.action["action_id"] == action_id: + return self.action + return None + + async def update_action(self, _task_id, action): + self.action = action + + async def insert_action(self, _task_id, action): + self.action = action + + async def insert_notification(self, notification): + self.notifications.append(notification) + + async def get_feedback(self, task_id, source_event_id): + return self.feedback.get((task_id, source_event_id)) + + async def upsert_feedback(self, task_id, feedback, accepted_at): + self.feedback[(task_id, feedback["source_event_id"])] = { + "source_revision": feedback["source_revision"], + "document": feedback, + "accepted_at": accepted_at, + } + + +class RunTaskRepository(TaskRepository): + def __init__(self, task, run): + super().__init__(task) + self.run = run + self.events = [] + + async def get_active_run(self, task_id): + if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}: + return self.run + return None + + async def get_run(self, run_id): + return self.run if run_id == self.run["run_id"] else None + + async def update_run(self, run, expected_revision): + assert run["revision"] == expected_revision + 1 + self.run = run + + async def insert_run(self, run): + self.run = run + + async def append_event(self, **event): + self.events.append(event) + return len(self.events) + + +def _active_run(task, now): + return OperationalTaskRun( + run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"], + execution_request_id=task["execution_request_id"], status="running", revision=2, + agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1), + heartbeat_at=now, started_at=now, dispatch_attempts=0, + next_dispatch_at=now, created_at=now, updated_at=now, + ).model_dump(mode="json") + + +@pytest.mark.anyio +async def test_run_heartbeat_does_not_change_task_business_revision(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + updated = await service.heartbeat_run( + task["task_id"], repository.run["run_id"], + AgentRunCommand( + request_id=uuid4(), expected_revision=2, source_session_id="session-1" + ), + ) + + assert updated.revision == 3 + assert repository.task["revision"] == 2 + assert repository.events == [] + + +@pytest.mark.anyio +async def test_complete_without_same_generation_artifacts_fails_run_explicitly(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + completed = await service.complete_run( + task["task_id"], repository.run["run_id"], + AgentRunCommand( + request_id=uuid4(), expected_revision=2, source_session_id="session-1" + ), + ) + + assert repository.run["status"] == "failed" + assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE" + assert completed.lifecycle_status == "failed" + assert completed.active_run is None + + +@pytest.mark.anyio +async def test_external_feedback_coalesces_into_an_already_queued_run(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=None) + task.update( + status="queued", lifecycle_status="open", execution_generation=2, + execution_request_id=str(uuid4()), agent_session_id=None, + ) + run = _active_run(task, now) + run.update( + generation=2, execution_request_id=task["execution_request_id"], + status="queued", revision=1, agent_session_id=None, + lease_expires_at=None, heartbeat_at=None, started_at=None, + ) + repository = RunTaskRepository(task, run) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + result = await service.external_feedback( + task["task_id"], + ExternalFeedbackCommand( + request_id=uuid4(), source_event_id="sample-result", source_revision=1, + status="completed", occurred_at=now, detail="新的现场结果", + ), + ) + + assert result.task.execution_generation == 2 + assert str(result.task.execution_request_id) == task["execution_request_id"] + assert result.task.active_run is not None + assert str(result.task.active_run.run_id) == run["run_id"] + + +def _waiting_plan(action_id=None): + node = { + "id": "field-work", + "title": "现场处置", + "owner": "human", + "status": "waiting", + "detail": "等待审批与执行", + "evidence_refs": [], + "action_refs": [str(action_id)] if action_id else [], + } + return { + "schema": "execution-plan@1", + "title": "调查计划", + "reason": "等待现场处置", + "nodes": [node], + "edges": [], + } + + +def _current_report(): + return { + "title": "本代调查报告", + "current_conclusion": "等待现场处置", + "conclusion_boundary": "尚无现场回执", + "evidence_refs": [], + "recommendations": ["完成现场处置"], + "pending_items": ["现场处置"], + } + + +@pytest.mark.anyio +async def test_plan_only_keeps_lease_until_same_generation_report_closes_run(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(), + )) + assert planned.task.status == "running" + assert planned.task.agent_lease_expires_at == now + timedelta(minutes=1) + assert planned.task.latest_plan.execution_generation == 1 + + reported = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=_current_report(), + )) + assert reported.task.status == "running" + assert reported.task.latest_report.execution_generation == 1 + + completed = await service.complete_run( + task["task_id"], repository.run["run_id"], + AgentRunCommand( + request_id=uuid4(), expected_revision=repository.run["revision"], + source_session_id="session-1", + ), + ) + assert completed.status == "waiting_human" + assert completed.agent_lease_expires_at is None + + +@pytest.mark.anyio +async def test_report_does_not_close_from_legacy_or_prior_generation_plan(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task["execution_generation"] = 2 + repository = TaskRepository(task) + repository.plans.append({ + "revision": 1, + "execution_generation": None, + "source_session_id": "session-1", + "published_at": now.isoformat(), + "published_by": "principal:test", + "plan": _waiting_plan(), + }) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", report=_current_report(), + )) + + assert result.task.status == "running" + assert result.task.latest_report.execution_generation == 2 + + +@pytest.mark.anyio +async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_run(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(), + )) + revision_before_proposal = repository.run["revision"] + lease_before_proposal = repository.run["lease_expires_at"] + + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要现场核验", + node_id="field-work", evidence_refs=[], + payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"}, + )) + assert repository.task["status"] == "running" + assert repository.run["revision"] == revision_before_proposal + assert repository.run["lease_expires_at"] == lease_before_proposal + heartbeat = await service.heartbeat_run( + task["task_id"], repository.run["run_id"], AgentRunCommand( + request_id=uuid4(), expected_revision=revision_before_proposal, + source_session_id="session-1", + ), + ) + assert heartbeat.status == "running" + + published = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", + run_id=repository.run["run_id"], + plan=_waiting_plan(action.action_id), report=_current_report(), + )) + closed = await service.complete_run( + task["task_id"], repository.run["run_id"], + AgentRunCommand( + request_id=uuid4(), expected_revision=published.task.active_run.revision, + source_session_id="session-1", + ), + ) + assert closed.status == "waiting_human" + assert closed.actions[0].action_id == action.action_id + + +@pytest.mark.anyio +async def test_rejected_action_requeues_task_in_a_new_execution_generation(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now).copy()) + service = OperationalTaskService( + repository, + context, + {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, + clock=lambda: now, + ) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要现场核验", + evidence_refs=[], + payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"}, + )) + original_request_id = repository.task["execution_request_id"] + + rejected = await service.decide_action( + task["task_id"], + action.action_id, + ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version, + decision="rejected", comment="当前方案不执行", + ), + ) + + assert rejected.approval_status == "rejected" + assert rejected.version == action.version + 1 + assert repository.task["status"] == "queued" + assert repository.task["execution_generation"] == 2 + assert repository.task["execution_request_id"] != original_request_id + assert repository.task["agent_session_id"] is None + assert repository.task["agent_lease_expires_at"] is None + + +@pytest.mark.anyio +async def test_action_decision_rejects_a_stale_expected_version(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + service = OperationalTaskService( + repository, + context, + {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, + clock=lambda: now, + ) + action = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要现场核验", + evidence_refs=[], + payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"}, + )) + + with pytest.raises(DomainError) as exc: + await service.decide_action( + task["task_id"], + action.action_id, + ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version + 1, + decision="approved", + ), + ) + + assert exc.value.code == "VERSION_CONFLICT" + assert repository.action["approval_status"] == "pending" + assert repository.action["version"] == action.version + + +@pytest.mark.anyio +async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = TaskRepository(task) + service = OperationalTaskService( + repository, + context, + {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, + clock=lambda: now, + ) + original = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="需要首次核验", + evidence_refs=[], + payload={"title": "首次核验", "description": "原始内容", "work_order_type": "sampling"}, + )) + approved = await service.decide_action( + task["task_id"], + original.action_id, + ActionDecisionCommand( + request_id=uuid4(), expected_version=original.version, decision="approved", + ), + ) + + revised = await service.propose_action(task["task_id"], ActionProposalCommand( + request_id=uuid4(), kind="create_work_order", reason="内容已修改", + evidence_refs=[], + payload={"title": "修改后核验", "description": "新的内容", "work_order_type": "sampling"}, + )) + + assert approved.approval_status == "approved" + assert revised.action_id != approved.action_id + assert revised.payload != approved.payload + assert revised.approval_status == "pending" + assert revised.execution_status == "not_submitted" + assert revised.version == 1 + assert revised.decided_by is None + assert revised.decided_at is None + + +def _work_order_snapshot(work_order_id, now): + return { + "work_order_id": str(work_order_id), + "revision": 2, + "title": "复测预约", + "summary": "等待现场复测回令", + "status": "awaiting_reply", + "priority": "high", + "assignee": "水质班组", + "scheduled_at": (now + timedelta(hours=1)).isoformat(), + "work_order_type": "water_quality_complaint", + "location": "联调小区", + "scenario_code": "WQ-SECONDARY-SUPPLY-001", + "subtasks": [{ + "id": "sampling", + "title": "现场采样", + "summary": "采集并复测", + "status": "running", + "scheduled_at": (now + timedelta(hours=1)).isoformat(), + "location": "联调小区", + "assignee": "水质班组", + "duration_minutes": 60, + "reply_window_minutes": 120, + "stages": [{ + "id": "reply", + "sequence": 1, + "title": "等待回令", + "status": "current", + "owner": "水质班组", + "time_label": "待反馈", + "description": "上传复测结果", + }], + "reply_requirements": ["上传检测结果"], + }], + "origin": "synthetic", + "updated_at": now.isoformat(), + } + + +@pytest.mark.anyio +async def test_external_feedback_exposes_validated_work_order_snapshot_and_refreshes_summary(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + work_order_id = uuid4() + task = _task_document(context, now, lease=None) + task.update( + task_type="external_work_order", + workflow="water_quality_complaint", + source={ + "kind": "external_system", + "external_id": f"peripheral_sample:{work_order_id}", + "external_revision": 1, + "event_kind": "water_quality_complaint", + }, + ) + repository = TaskRepository(task) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + result = await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), + source_event_id=f"peripheral_sample:{work_order_id}", + source_revision=2, + status="awaiting_feedback", + occurred_at=now, + detail="等待现场复测回令", + work_order_snapshot=_work_order_snapshot(work_order_id, now), + )) + + assert result.task.status == "waiting_human" + assert result.task.title == "复测预约" + assert result.task.summary == "等待现场复测回令" + assert result.task.scheduled_for == now + timedelta(hours=1) + assert result.task.priority == "urgent" + assert result.task.work_order_snapshot is not None + assert result.task.work_order_snapshot.subtasks[0].stages[0].status == "current" + + +@pytest.mark.anyio +async def test_event_investigation_rejects_work_order_snapshot(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = TaskRepository(task) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + work_order_id = uuid4() + + with pytest.raises(DomainError) as exc: + await service.external_feedback(task["task_id"], ExternalFeedbackCommand( + request_id=uuid4(), source_event_id=str(work_order_id), source_revision=2, + status="awaiting_feedback", occurred_at=now, detail="不应接受", + work_order_snapshot=_work_order_snapshot(work_order_id, now), + )) + + assert exc.value.code == "INVALID_FEEDBACK_SNAPSHOT" + + +@pytest.mark.anyio +async def test_expired_owned_lease_is_rejected_with_stable_code(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now - timedelta(seconds=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + repository.run["lease_expires_at"] = now - timedelta(seconds=1) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + with pytest.raises(DomainError) as exc: + await service.heartbeat_run( + task["task_id"], repository.run["run_id"], + AgentRunCommand( + request_id=uuid4(), expected_revision=2, source_session_id="session-1" + ), + ) + + assert exc.value.code == "LEASE_EXPIRED" + assert repository.run["revision"] == 2 + + +@pytest.mark.anyio +async def test_interrupted_submit_replays_unknown_without_dispatching_twice(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task.update(status="waiting_human", agent_lease_expires_at=None) + action = HumanApprovalAction( + action_id=uuid4(), + task_id=task["task_id"], + version=2, + kind="create_work_order", + reason="现场核验", + payload={"title": "采样", "description": "核验", "work_order_type": "sampling"}, + evidence_refs=[], + required_approval_permission="work-order.approve", + required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, + approval_status="approved", + execution_status="not_submitted", + proposed_by="principal:agent", + proposed_at=now, + decided_by="principal:approver", + decided_at=now, + ).model_dump(mode="json") + repository = TaskRepository(task, action=action) + service = OperationalTaskService( + repository, + context, + {OPERATIONAL_ACTION_WORK_ORDER_EXECUTE}, + clock=lambda: now, + ) + command = ActionExecuteCommand(request_id=uuid4(), expected_version=2) + + class LostResponseDispatcher: + def __init__(self): + self.calls = 0 + + async def dispatch(self, **_kwargs): + self.calls += 1 + raise RuntimeError("connection lost after submission") + + dispatcher = LostResponseDispatcher() + with pytest.raises(RuntimeError): + await service.submit_action( + task["task_id"], action["action_id"], command, dispatcher, None + ) + + replay = await service.submit_action( + task["task_id"], action["action_id"], command, dispatcher, None + ) + + assert dispatcher.calls == 1 + assert replay.execution_status == "unknown" + assert replay.execution_request_id is not None + + +@pytest.mark.anyio +async def test_snapshot_reuse_returns_the_original_provenance_and_rejects_collision(): + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = TaskRepository(task) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + original = TaskDataSnapshot( + source="peripheral_points", + source_ref="peripheral_points:sha256:same", + queried_at=now, + origin="synthetic", + data={"items": [{"id": "p-1"}]}, + truncated=False, + ) + evidence, stored = await service.record_data_snapshot(task["task_id"], original) + + replayed = original.model_copy(update={"queried_at": now + timedelta(minutes=1)}) + replay_evidence, replay_snapshot = await service.record_data_snapshot( + task["task_id"], replayed + ) + + assert replay_evidence == evidence + assert replay_snapshot == stored == original + + collision = original.model_copy(update={"data": {"items": [{"id": "p-2"}]}}) + with pytest.raises(DomainError) as exc: + await service.record_data_snapshot(task["task_id"], collision) + assert exc.value.code == "EVIDENCE_CONFLICT" + + +@pytest.mark.anyio +async def test_repository_task_update_rejects_a_stale_revision(): + cursor = SimpleNamespace(rowcount=0) + connection = SimpleNamespace(execute=AsyncMock(return_value=cursor)) + repository = OperationalTaskRepository(connection, uuid4()) + now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + task["revision"] = 3 + + with pytest.raises(DomainError) as exc: + await repository.update_task(task) + + assert exc.value.code == "VERSION_CONFLICT" diff --git a/tests/unit/test_operational_worker.py b/tests/unit/test_operational_worker.py new file mode 100644 index 0000000..9b9397a --- /dev/null +++ b/tests/unit/test_operational_worker.py @@ -0,0 +1,141 @@ +import json +from uuid import uuid4 + +import httpx +import pytest + +from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_persisted_queue_retries_with_backend_execution_identity(): + task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4()) + submitted = [] + def handler(request): + assert request.headers["Authorization"] == "Bearer TEST_ONLY" + assert not request.url.path.endswith("schedules/dispatch-due") + if request.url.path.endswith("leases/recover"): + return httpx.Response(200, json={"items": []}) + if request.method == "GET": + if request.url.params.get("status") != "queued": + return httpx.Response(200, json={"items": []}) + return httpx.Response(200, json={"items": [{ + "task_id": task_id, "run_id": run_id, "execution_request_id": execution_id, + "status": "queued", "revision": 1, + }]}) + body = json.loads(request.content) + submitted.append(body) + return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"}) + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + worker = OperationalWorker(settings, client) + assert await worker.tick() == 1 + assert await worker.tick() == 1 + assert submitted[0] == submitted[1] + assert submitted[0] == { + "task_id": task_id, "run_id": run_id, "execution_request_id": execution_id, + } + assert "credential" not in submitted[0] + + +@pytest.mark.anyio +@pytest.mark.parametrize("run_status", ["claimed", "running", "failed"]) +async def test_only_queued_runs_are_submitted(run_status): + def handler(request): + assert not request.url.path.endswith("schedules/dispatch-due") + if request.method == "GET": + requested = request.url.params.get("status") + if requested == run_status and run_status in {"claimed", "running"}: + return httpx.Response(200, json={"items": [{"run_id": str(uuid4()), "status": run_status}]}) + return httpx.Response(200, json={"items": []}) + assert request.url.host == "server.test" + return httpx.Response(200, json={"items": []}) + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await OperationalWorker(settings, client).tick() == 0 + + +@pytest.mark.anyio +async def test_bounded_queue_continues_after_one_agent_handoff_fails(): + runs = [{"task_id": str(uuid4()), "run_id": str(uuid4()), + "execution_request_id": str(uuid4()), "status": "queued", + "revision": 1} for _ in range(2)] + limits, submitted, failures = [], [], [] + + def handler(request): + if request.method == "GET": + if request.url.params.get("status") != "queued": + return httpx.Response(200, json={"items": []}) + limits.append(int(request.url.params["limit"])) + return httpx.Response(200, json={"items": runs}) + if request.url.host == "server.test": + if request.url.path.endswith("leases/recover"): + return httpx.Response(200, json={"items": []}) + failures.append(json.loads(request.content)) + return httpx.Response(200, json={}) + body = json.loads(request.content) + submitted.append(body) + if body["task_id"] == runs[0]["task_id"]: + return httpx.Response(503, json={"detail": "temporary failure"}) + return httpx.Response(202, json={"task_id": body["task_id"], "run_id": body["run_id"]}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await OperationalWorker(settings, client).tick() == 1 + assert limits == [settings.max_concurrency] + assert [body["task_id"] for body in submitted] == [run["task_id"] for run in runs] + assert [body["execution_request_id"] for body in submitted] == [run["execution_request_id"] for run in runs] + assert failures[0]["retryable"] is True + + +@pytest.mark.anyio +async def test_service_credentials_are_cached_and_refreshed_without_entering_task_payload(monkeypatch): + clock = [100.0] + monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0]) + requests = [] + + def handler(request): + requests.append(request) + assert request.url == "https://identity.test/token" + assert "authorization" not in request.headers + return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), + token_url="https://identity.test/token", client_id="test-service", + client_secret="TEST_ONLY_SECRET") + provider = CredentialProvider(settings) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + first = await provider.headers(client) + clock[0] = 140.0 + assert await provider.headers(client) == first + assert len(requests) == 1 + clock[0] = 146.0 + refreshed = await provider.headers(client) + assert len(requests) == 2 + assert first["Authorization"] == "Bearer TEST_ONLY_1" + assert refreshed == {"Authorization": "Bearer TEST_ONLY_2", "X-Project-Id": settings.project_id} + + +@pytest.mark.anyio +async def test_long_lived_oauth_token_is_refreshed_before_product_token_age_limit(monkeypatch): + clock, requests = [0.0], [] + monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0]) + + def handler(request): + requests.append(request) + return httpx.Response(200, json={"access_token": "TEST_ONLY", "expires_in": 3600}) + + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), + token_url="https://identity.test/token", client_id="test-service", + client_secret="TEST_ONLY_SECRET") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + provider = CredentialProvider(settings) + await provider.headers(client) + clock[0] = 301.0 + await provider.headers(client) + assert len(requests) == 2 diff --git a/tests/unit/test_peripheral_managed_projection.py b/tests/unit/test_peripheral_managed_projection.py new file mode 100644 index 0000000..5502990 --- /dev/null +++ b/tests/unit/test_peripheral_managed_projection.py @@ -0,0 +1,147 @@ +from contextlib import contextmanager +from copy import deepcopy +from uuid import uuid4 + +import pytest + +from samples.peripheral.auth import Identity +from samples.peripheral.config import SampleSettings +from samples.peripheral.schemas import TestDataRecord as DataRecordSchema +from samples.peripheral.schemas import WorkOrder +from samples.peripheral.store import PostgresStore + + +class FakeResult: + def __init__(self, *, row=None, rows=None): + self.row = row + self.rows = rows or [] + + def fetchone(self): + return self.row + + def fetchall(self): + return self.rows + + +class ManagedProjectionConnection: + def __init__(self, table: str, document: dict): + self.table = table + self.document = document + + def execute(self, statement, parameters): + normalized = " ".join(statement.split()) + if "FROM peripheral_sample.scenarios" in normalized: + return FakeResult(row=None) + if f"FROM peripheral_sample.{self.table}" in normalized: + row = {"document": self.document} + return FakeResult(row=row, rows=[row]) + raise AssertionError(f"unexpected query: {normalized}") + + +class ManagedProjectionStore(PostgresStore): + def __init__(self, table: str, document: dict): + super().__init__(SampleSettings()) + self.fake_connection = ManagedProjectionConnection(table, document) + + @contextmanager + def connection(self, *, readonly=False, timeseries=False): + assert readonly is True + assert timeseries is False + yield self.fake_connection + + +def work_order_document() -> dict: + now = "2026-09-18T00:00:00Z" + return { + "work_order_id": str(uuid4()), + "project_id": str(uuid4()), + "revision": 2, + "title": "恢复后的演示工单", + "summary": "当前行已恢复,但旧实现留下 tombstone 字段。", + "status": "open", + "priority": "normal", + "assignee": None, + "scheduled_at": None, + "origin": "synthetic", + "created_at": now, + "updated_at": now, + "created_by": str(uuid4()), + "updated_by": str(uuid4()), + "work_order_type": "general", + "location": None, + "scenario_code": None, + "deleted_at": now, + "deleted_by": str(uuid4()), + } + + +def test_active_work_order_projection_removes_only_tombstone_fields(): + document = work_order_document() + stored_document = deepcopy(document) + store = ManagedProjectionStore("work_orders", stored_document) + who = Identity(document["project_id"], str(uuid4()), False) + + result = store.get_work_order(who, document["work_order_id"]) + + assert "deleted_at" not in result + assert "deleted_by" not in result + assert stored_document == document + WorkOrder.model_validate(result) + + +@pytest.mark.parametrize( + ("table", "id_column", "document", "model"), + [ + ("work_orders", "work_order_id", work_order_document(), WorkOrder), + ( + "test_data_records", + "record_id", + { + "record_id": str(uuid4()), + "project_id": str(uuid4()), + "revision": 2, + "label": "恢复后的演示数据", + "point_id": "DEMO-P001", + "observed_at": "2026-09-18T00:00:00Z", + "value": 1.0, + "unit": "m", + "quality": "valid", + "notes": "", + "origin": "synthetic", + "created_at": "2026-09-18T00:00:00Z", + "updated_at": "2026-09-18T00:00:00Z", + "created_by": str(uuid4()), + "updated_by": str(uuid4()), + "deleted_at": "2026-09-18T00:00:00Z", + "deleted_by": str(uuid4()), + }, + DataRecordSchema, + ), + ], +) +def test_active_managed_list_projection_removes_tombstone_fields( + table, id_column, document, model +): + store = ManagedProjectionStore(table, document) + who = Identity(document["project_id"], str(uuid4()), False) + + page = store._list_managed(who, table, id_column, limit=10, cursor=None) + + assert len(page["items"]) == 1 + assert "deleted_at" not in page["items"][0] + assert "deleted_by" not in page["items"][0] + model.model_validate(page["items"][0]) + + +def test_active_projection_does_not_hide_unrelated_fields(): + document = { + "deleted_at": "2026-09-18T00:00:00Z", + "deleted_by": str(uuid4()), + "unexpected_field": "must remain visible to schema validation", + } + + result = PostgresStore._active_managed_document(document) + + assert result == {"unexpected_field": "must remain visible to schema validation"} + assert "deleted_at" in document + assert "deleted_by" in document diff --git a/tests/unit/test_peripheral_managed_resources.py b/tests/unit/test_peripheral_managed_resources.py index d926ecd..277d662 100644 --- a/tests/unit/test_peripheral_managed_resources.py +++ b/tests/unit/test_peripheral_managed_resources.py @@ -33,7 +33,9 @@ class ManagedStore: return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: { "title": command.title or "updated", "summary": command.summary or "summary", "status": command.status or "open", "priority": command.priority or "normal", - "assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1, + "assignee": None, "scheduled_at": None, "work_order_type": "general", + "location": command.location, "scenario_code": command.scenario_code, + "subtasks": command.subtasks}})()) | {"revision": command.expected_revision + 1, "work_order_id": str(work_order_id)} def delete_work_order(self, who, work_order_id, command): @@ -82,14 +84,34 @@ def test_managed_work_order_and_data_routes_are_typed_crud(): created = client.post("/api/sample/v1/work-orders", json={ "request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调", "status": "open", "priority": "high", "assignee": None, "scheduled_at": None, + "work_order_type": "water_quality_complaint", "location": "演示小区", + "scenario_code": "WQ-SECONDARY-SUPPLY-001", + "subtasks": [{ + "id": "sampling", "title": "现场采样", "summary": "采集二次供水样本", + "status": "running", "scheduled_at": "2026-09-18T01:00:00Z", + "location": "演示小区", "assignee": "水质班组", "duration_minutes": 60, + "reply_window_minutes": 120, "reply_requirements": ["上传检测结果"], + "stages": [{"id": "dispatch", "sequence": 1, "title": "派发", + "status": "current", "owner": "调度员", "time_label": "待执行", + "description": "确认采样人员"}], + }], }) assert created.status_code == 201 and created.json()["origin"] == "synthetic" + assert created.json()["work_order_type"] == "water_quality_complaint" + assert created.json()["location"] == "演示小区" + assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001" + assert created.json()["subtasks"][0]["stages"][0]["status"] == "current" work_order_id = created.json()["work_order_id"] changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={ - "request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress", + "request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply", + "location": "复测点", "clear_scenario_code": True, "clear_subtasks": True, "clear_assignee": False, "clear_scheduled_at": False, }) assert changed.status_code == 200 and changed.json()["revision"] == 2 + assert changed.json()["status"] == "awaiting_reply" + update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order") + assert update_command.location == "复测点" + assert update_command.clear_scenario_code and update_command.clear_subtasks deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={ "request_id": str(uuid4()), "expected_revision": 2, }) @@ -120,6 +142,8 @@ def test_managed_update_models_reject_empty_or_conflicting_changes(): (UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}), (UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1, "value": 1.0, "clear_value": True}), + (UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1, + "location": "新位置", "clear_location": True}), ): try: model.model_validate(payload) diff --git a/tests/unit/test_peripheral_task_sync.py b/tests/unit/test_peripheral_task_sync.py new file mode 100644 index 0000000..2b7e41a --- /dev/null +++ b/tests/unit/test_peripheral_task_sync.py @@ -0,0 +1,162 @@ +import json +from uuid import uuid4 + +import httpx +import pytest + +from scripts.run_operational_worker import CredentialProvider, WorkerSettings +from scripts.sync_peripheral_work_orders import sync_once, work_order_snapshot +from app.domain.schemas.operational_task import ExternalFeedbackCommand + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_sync_creates_one_task_and_only_applies_new_external_revisions(): + project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4())) + tasks, writes = [], [] + order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", + "revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal", + "status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z", + "scheduled_at": None, "work_order_type": "general"} + def handler(request): + if request.url.host == "sample.test": + return httpx.Response(200, json={"items": [order], "next_cursor": None}) + if request.method == "GET": + return httpx.Response(200, json={"items": tasks, "next_offset": None}) + body = json.loads(request.content) + writes.append((request.url.path, body)) + if request.url.path.endswith("external-feedback"): + tasks[0]["source"]["external_revision"] = body["source_revision"] + tasks[0]["status"] = body["status"] + return httpx.Response(200, json={"task": tasks[0]}) + task = {**body, "task_id": task_id} + tasks.append(task) + return httpx.Response(201, json=task) + settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + credentials = CredentialProvider(settings) + assert await sync_once(client, settings, "http://sample.test", credentials) == 1 + assert await sync_once(client, settings, "http://sample.test", credentials) == 0 + order.update(revision=2, status="awaiting_reply", updated_at="2026-09-23T09:00:00Z", + title="水表维修复核", assignee="测试班组", location="测试位置", + scheduled_at="2026-09-24T08:00:00Z") + assert await sync_once(client, settings, "http://sample.test", credentials) == 1 + assert len(tasks) == 1 + assert len(writes) == 3 + assert writes[-1][1]["status"] == "awaiting_feedback" + assert writes[-1][1]["source_revision"] == 2 + snapshot = writes[-1][1]["work_order_snapshot"] + assert snapshot["status"] == "awaiting_reply" + assert snapshot["title"] == "水表维修复核" + assert snapshot["assignee"] == "测试班组" + assert snapshot["location"] == "测试位置" + assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z" + assert snapshot["subtasks"] is None + # Exercise the real API schema, including the prefixed external identity. + accepted = ExternalFeedbackCommand.model_validate(writes[-1][1]) + assert str(accepted.work_order_snapshot.work_order_id) == order_id + + +@pytest.mark.anyio +async def test_sample_sync_rejects_cross_project_data_before_writing(): + def handler(request): + assert request.method == "GET" + return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}] + if request.url.host == "sample.test" else []), "next_offset": None}) + settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(ValueError, match="scope"): + await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) + + +@pytest.mark.anyio +@pytest.mark.parametrize("investigation_status", ["waiting_human", "queued"]) +async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status): + project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) + received = [] + def handler(request): + if request.url.host == "sample.test": + return httpx.Response(200, json={"items": [{ + "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", + "revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z", + "title": "合成采样工单", "priority": "normal", + "work_order_type": "water_quality_complaint", "created_at": "2026-09-23T08:00:00Z", + "scheduled_at": None, + }], "next_cursor": None}) + if request.method == "POST": + assert request.url.path.endswith("external-feedback"), "Linked child orders must not start another investigation" + received.append(request.url.path) + payload = json.loads(request.content) + assert payload["source_event_id"] == f"peripheral_sample:{order_id}" + assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback") + return httpx.Response(200, json={}) + if request.url.path.endswith(investigation_id): + return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { + "adapter": "peripheral_sample", "receipt_id": order_id, + }}]}) + return httpx.Response(200, json={"items": [ + {"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status}, + {"task_id": projection_id, "task_type": "external_work_order", "source": { + "external_id": f"peripheral_sample:{order_id}", "external_revision": 1, + }}, + ], "next_offset": None}) + settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + assert await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) == 2 + assert any(investigation_id in path for path in received) + assert any(projection_id in path for path in received) + + +@pytest.mark.anyio +@pytest.mark.parametrize("event_kind", ["water_quality_complaint", "burst_report"]) +async def test_external_events_start_investigations_without_browser(event_kind): + project_id, order_id = str(uuid4()), str(uuid4()) + created = [] + def handler(request): + if request.method == "GET" and request.url.host == "server.test": + return httpx.Response(200, json={"items": [], "next_offset": None}) + if request.url.host == "sample.test": + return httpx.Response(200, json={"items": [{ + "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1, + "title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open", + "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z", + "scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置", + }], "next_cursor": None}) + payload = json.loads(request.content) + if request.url.path.endswith("external-feedback"): + return httpx.Response(200, json={}) + task = {**payload, "task_id": str(uuid4())} + created.append(task) + return httpx.Response(201, json=task) + settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) + assert {task["task_type"] for task in created} == {"event_investigation", "external_work_order"} + investigation = next(task for task in created if task["task_type"] == "event_investigation") + assert investigation["workflow"] == event_kind + assert investigation["source"]["external_id"] == order_id + assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z" + + +def test_snapshot_preserves_nested_process_without_inventing_completed_stages(): + order = { + "work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步", + "status": "in_progress", "priority": "normal", "origin": "synthetic", + "updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general", + "subtasks": [{ + "id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行", + "status": "pending", "scheduled_at": "2026-09-24T08:00:00Z", + "location": "测试位置", "assignee": "测试班组", "duration_minutes": 30, + "reply_window_minutes": 15, "reply_requirements": ["检查记录"], + "stages": [{"id": "dispatch", "sequence": 1, "title": "派发", "status": "pending", + "owner": "测试调度", "time_label": "待安排", "description": "尚未派发"}], + }], + } + snapshot = work_order_snapshot(order) + assert snapshot["subtasks"] == order["subtasks"] + assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending" + assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None diff --git a/tests/unit/test_scheme_management_lifecycle.py b/tests/unit/test_scheme_management_lifecycle.py index b37a4d7..b9dfbb8 100644 --- a/tests/unit/test_scheme_management_lifecycle.py +++ b/tests/unit/test_scheme_management_lifecycle.py @@ -1,6 +1,8 @@ from unittest.mock import MagicMock +from uuid import uuid4 from app.services import scheme_management +from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing def _mock_connection(monkeypatch): @@ -97,3 +99,48 @@ def test_run_and_business_result_share_one_transaction(monkeypatch) -> None: assert created_ids == [(connection, run_id)] assert result_ids == [(connection, run_id)] + + +def test_completed_business_result_emits_one_event_after_result(monkeypatch) -> None: + connection = object() + context = MagicMock() + context.__enter__.return_value = connection + monkeypatch.setattr(scheme_management, "project_transaction", lambda _name: context) + calls = [] + monkeypatch.setattr( + scheme_management.AnalysisRepository, + "create_run_sync", + lambda _conn, **_kwargs: calls.append("run"), + ) + monkeypatch.setattr( + scheme_management.AnalysisRepository, + "insert_result_sync", + lambda _conn, _run_id, **_kwargs: calls.append("result"), + ) + monkeypatch.setattr( + scheme_management.AnalysisRepository, + "append_run_changed_event_sync", + lambda _conn, **event: calls.append(("event", event)), + ) + project_id = uuid4() + routing = ActiveProjectRouting( + project_code="test", business_dsn="postgresql://test/db", project_id=project_id + ) + + with activate_project_routing(routing): + run_id = scheme_management.store_analysis_run_with_result( + name="test", scheme_name="analysis", scheme_type="dma", + username="alice", scheme_start_time="2026-09-23T00:00:00Z", + scheme_detail={}, result_type="summary", result_payload={"rows": []}, + ) + + assert calls[:2] == ["run", "result"] + assert calls[2][0] == "event" + assert calls[2][1] == { + "project_id": project_id, + "run_id": run_id, + "revision": 1, + "status": "completed", + "occurred_at": calls[2][1]["occurred_at"], + "results_committed": True, + } diff --git a/tests/unit/test_seed_peripheral_sample.py b/tests/unit/test_seed_peripheral_sample.py new file mode 100644 index 0000000..04d3e23 --- /dev/null +++ b/tests/unit/test_seed_peripheral_sample.py @@ -0,0 +1,91 @@ +from contextlib import contextmanager +from uuid import uuid4 + +import pytest + +from scripts.seed_peripheral_sample import ( + ACTOR_ID, + previous_seed_result, + restore_seeded_resources, +) + + +class FakeResult: + def __init__(self, row=None): + self.row = row + + def fetchone(self): + return self.row + + +class FakeConnection: + def __init__(self, row=None): + self.row = row + self.calls = [] + + def execute(self, statement, parameters): + self.calls.append((" ".join(statement.split()), parameters)) + return FakeResult(self.row) + + +class FakeStore: + def __init__(self, row=None): + self.connection_value = FakeConnection(row) + self.readonly = None + + @contextmanager + def connection(self, readonly=False): + self.readonly = readonly + yield self.connection_value + + +def test_previous_seed_result_reuses_legacy_receipt(): + resource_id = str(uuid4()) + store = FakeStore({ + "command_key": "create_work_order", + "result": {"work_order_id": resource_id}, + }) + seed_request_id = uuid4() + + result = previous_seed_result( + store, + "project-1", + seed_request_id=seed_request_id, + command_key="create_work_order", + ) + + assert result == {"work_order_id": resource_id} + assert store.readonly is True + assert store.connection_value.calls[0][1] == ( + "project-1", + str(ACTOR_ID), + seed_request_id, + ) + + +def test_restore_seeded_resources_only_restores_allowed_tables(): + store = FakeStore() + resource_ids = [str(uuid4()), str(uuid4())] + + restore_seeded_resources( + store, + "project-1", + table="work_orders", + id_column="work_order_id", + resource_ids=resource_ids, + ) + + statement, parameters = store.connection_value.calls[0] + assert "UPDATE peripheral_sample.work_orders" in statement + assert "document=document - 'deleted_at' - 'deleted_by'" in statement + assert "deleted_at=NULL, deleted_by=NULL" in statement + assert parameters == ("project-1", resource_ids) + + with pytest.raises(ValueError, match="unsupported seeded resource"): + restore_seeded_resources( + store, + "project-1", + table="requests", + id_column="request_id", + resource_ids=resource_ids, + ) diff --git a/tests/unit/test_seed_workbench_work_orders.py b/tests/unit/test_seed_workbench_work_orders.py new file mode 100644 index 0000000..14e2567 --- /dev/null +++ b/tests/unit/test_seed_workbench_work_orders.py @@ -0,0 +1,70 @@ +from scripts.seed_workbench_work_orders import ( + DATASET_ID, + LEGACY_WORK_ORDER_SOURCES, + build_demo_work_orders, + replace_demo_work_orders, +) + + +class FakeConnection: + def __init__(self): + self.calls = [] + + def execute(self, statement, parameters=None): + self.calls.append((" ".join(statement.split()), parameters)) + + +def test_build_demo_work_orders_restores_original_mock_tasks(): + documents = build_demo_work_orders() + + assert DATASET_ID == "workbench-demo-orders@1" + assert [document["title"] for document in documents] == [ + "演示工单:华山路阀门边界确认", + "演示工单:北辰分区阀门开度调整", + "演示工单:泵站边界复核复令", + ] + assert [document["status"] for document in documents] == [ + "pending", + "running", + "awaiting_reply", + ] + assert all(len(document["subtasks"]) == 3 for document in documents) + assert all("合成" not in str(document) for document in documents) + + +def test_demo_work_orders_keep_one_current_stage_per_active_task(): + documents = build_demo_work_orders() + + for document in documents: + current_stages = [ + stage + for subtask in document["subtasks"] + for stage in subtask["stages"] + if stage["status"] == "current" + ] + assert len(current_stages) == 1 + + assert documents[0]["subtasks"][0]["stages"][0]["status"] == "current" + assert documents[1]["subtasks"][1]["stages"][1]["status"] == "current" + assert documents[2]["subtasks"][2]["stages"][2]["status"] == "current" + + +def test_replace_removes_legacy_projection_before_inserting_demo_tasks(): + connection = FakeConnection() + documents = build_demo_work_orders() + + replace_demo_work_orders(connection, "project-1", documents) + + delete_statement, delete_parameters = connection.calls[0] + assert delete_statement.startswith("DELETE FROM workbench.work_orders") + assert "demo-work-order-%" in delete_statement + assert delete_parameters == ( + "project-1", + "mock-work-order-%", + list(LEGACY_WORK_ORDER_SOURCES), + ) + assert len(connection.calls) == 4 + assert all( + call[0].startswith("INSERT INTO workbench.work_orders") + for call in connection.calls[1:] + ) diff --git a/tests/unit/test_workbench_event_hub.py b/tests/unit/test_workbench_event_hub.py new file mode 100644 index 0000000..486d6ec --- /dev/null +++ b/tests/unit/test_workbench_event_hub.py @@ -0,0 +1,32 @@ +import asyncio +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.infra.db.postgresql.workbench_event_hub import WorkbenchEventHub, WorkbenchEventHubManager + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +async def test_project_subscribers_share_one_hub_and_idle_close(monkeypatch): + start = AsyncMock() + close = AsyncMock() + monkeypatch.setattr(WorkbenchEventHub, "start", start) + monkeypatch.setattr(WorkbenchEventHub, "close", close) + manager = WorkbenchEventHubManager(idle_seconds=0.01) + project_id = uuid4() + + async with manager.subscribe(project_id, "postgresql://test/db") as first: + async with manager.subscribe(project_id, "postgresql://test/db") as second: + assert first is second + assert first.subscribers == 2 + assert first.subscribers == 1 + + await asyncio.sleep(0.03) + assert len(manager._hubs) == 0 + close.assert_awaited_once() diff --git a/tests/unit/test_workbench_events.py b/tests/unit/test_workbench_events.py new file mode 100644 index 0000000..a7da870 --- /dev/null +++ b/tests/unit/test_workbench_events.py @@ -0,0 +1,94 @@ +from contextlib import asynccontextmanager +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import workbench_events as endpoint +from app.auth.project_dependencies import ProjectContext +from app.infra.db.project_routing import ActiveProjectRouting + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class FakeHub: + generation = 0 + + async def oldest_cursor(self): + return 5 + + async def current_cursor(self): + return 8 + + +class FakeHubManager: + @asynccontextmanager + async def subscribe(self, _project_id, _dsn): + yield FakeHub() + + +class FakeMetadataRepository: + def __init__(self, routings): + self.routings = routings + self.session = SimpleNamespace(closed=False, close=self.close) + + async def close(self): + self.session.closed = True + + async def list_active_project_db_routings(self, _role): + return self.routings + + +@pytest.mark.anyio +async def test_expired_event_cursor_requires_snapshot_reset(monkeypatch): + project_id = uuid4() + dsn = "postgresql://test@db.example:5432/project_a" + routing = ActiveProjectRouting("project-a", dsn, project_id=project_id) + metadata = FakeMetadataRepository([ + SimpleNamespace(project_id=project_id, dsn=dsn) + ]) + monkeypatch.setattr(endpoint, "workbench_event_hubs", FakeHubManager()) + + response = await endpoint.workbench_events( + after=1, + last_event_id=None, + ctx=ProjectContext( + project_id=project_id, project_code="project-a", user_id=uuid4(), + project_role="member", + ), + routing=routing, + metadata_repo=metadata, + ) + first = await anext(response.body_iterator) + + assert metadata.session.closed is True + assert "event: reset_required" in first + assert '"cursor":1' in first + + +@pytest.mark.anyio +async def test_shared_business_database_disables_event_stream(): + project_id = uuid4() + dsn = "postgresql://user:secret@db.example:5432/shared" + metadata = FakeMetadataRepository([ + SimpleNamespace(project_id=project_id, dsn=dsn), + SimpleNamespace(project_id=uuid4(), dsn="postgresql://other:other@db.example/shared"), + ]) + + with pytest.raises(HTTPException) as exc: + await endpoint.workbench_events( + after=0, + last_event_id=None, + ctx=ProjectContext( + project_id=project_id, project_code="project-a", user_id=uuid4(), + project_role="member", + ), + routing=ActiveProjectRouting("project-a", dsn, project_id=project_id), + metadata_repo=metadata, + ) + + assert exc.value.status_code == 503