From ad813a7c1624a88eb84c4e1964e5beed5cffe0c3 Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 24 Sep 2026 16:36:12 +0800 Subject: [PATCH] feat(workbench): add dynamic task approval lifecycle --- .env.example | 1 + Dockerfile | 2 + README.md | 2 +- app/api/v1/endpoints/operational_tasks.py | 112 +- app/api/v1/endpoints/workbench_runtime.py | 25 - app/auth/permissions.py | 2 + app/domain/schemas/operational_task.py | 111 +- app/infra/db/postgresql/operational_tasks.py | 90 +- .../db/postgresql/workbench_operations.py | 25 +- app/infra/integrations/operational_data.py | 10 +- app/services/operational_tasks.py | 452 ++++++- app/services/workbench_runtime.py | 23 +- contracts/manifest.json | 2 +- contracts/server-v1.openapi.json | 1114 ++++++++++++++++- docs/dynamic-investigation-plan.md | 53 + docs/operational-notification-routing.md | 17 +- docs/operational-task-workers.md | 6 +- docs/operational-tasks.md | 23 +- docs/workbench-database-rollout.md | 10 +- docs/workbench-operations-storage.md | 16 +- docs/workbench-runtime.md | 6 +- resources/sql/011_workbench_work_orders.sql | 3 +- .../019_operational_task_access_requests.sql | 56 + .../sql/021_retire_workbench_work_orders.sql | 55 + ...022_operational_notification_lifecycle.sql | 225 ++++ scripts/seed_workbench_work_orders.py | 270 ---- scripts/sync_peripheral_work_orders.py | 50 +- tests/api/test_agent_auth_endpoints.py | 2 +- .../api/test_operational_task_dynamic_flow.py | 69 + tests/api/test_workbench_runtime_endpoints.py | 88 +- tests/unit/test_operational_data.py | 27 + tests/unit/test_operational_tasks.py | 381 ++++++ tests/unit/test_peripheral_task_sync.py | 98 ++ tests/unit/test_seed_workbench_work_orders.py | 63 - tests/unit/test_workbench_runtime.py | 31 +- 35 files changed, 2956 insertions(+), 564 deletions(-) create mode 100644 docs/dynamic-investigation-plan.md create mode 100644 resources/sql/019_operational_task_access_requests.sql create mode 100644 resources/sql/021_retire_workbench_work_orders.sql create mode 100644 resources/sql/022_operational_notification_lifecycle.sql delete mode 100644 scripts/seed_workbench_work_orders.py create mode 100644 tests/api/test_operational_task_dynamic_flow.py delete mode 100644 tests/unit/test_seed_workbench_work_orders.py diff --git a/.env.example b/.env.example index c89ca22..8ca1641 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,7 @@ OPERATIONAL_WORKER_TOKEN_URL="" OPERATIONAL_WORKER_CLIENT_ID="" OPERATIONAL_WORKER_CLIENT_SECRET="" OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS=300 +OPERATIONAL_DATA_DELEGATION_BEARER="" # ============================================ diff --git a/Dockerfile b/Dockerfile index 3717fdc..ceb265f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,8 @@ COPY app ./app COPY contracts ./contracts COPY infra ./infra COPY resources ./resources +COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/ +COPY samples/peripheral/seed_identity.py ./samples/peripheral/ RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \ rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip RUN mkdir -p db_inp temp data inp diff --git a/README.md b/README.md index 4d24012..4fcc705 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ conda activate server ## 常用命令 外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。 -外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD,并将演示预约工单投影到 `workbench.work_orders`。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。 +外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。 ```bash conda run -n server python -m pytest tests -q diff --git a/app/api/v1/endpoints/operational_tasks.py b/app/api/v1/endpoints/operational_tasks.py index f17ee2f..68cdabb 100644 --- a/app/api/v1/endpoints/operational_tasks.py +++ b/app/api/v1/endpoints/operational_tasks.py @@ -13,6 +13,8 @@ from app.auth.permissions import ( OPERATIONAL_TASK_EXTERNAL_SYNC, OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, + SCADA_VIEW, + SIMULATION_VIEW, permissions_for_context, require_permission, ) @@ -28,6 +30,11 @@ from app.domain.schemas.operational_task import ( ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand, + WorkOrderDraftUpdateCommand, + WorkOrderConfirmDispatchCommand, + TaskAccessRequest, + TaskAccessRequestCreate, + TaskAccessRequestDecision, AgentArtifactsCommand, AgentArtifactsResult, AgentRunCancelCommand, @@ -40,6 +47,7 @@ from app.domain.schemas.operational_task import ( EvidenceRecord, ExternalFeedbackCommand, ExternalFeedbackResult, + ExternalSourceWithdrawalCommand, HumanApprovalAction, HumanEvidenceCommand, NotificationAcknowledgeCommand, @@ -77,10 +85,17 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi return OperationalTaskService( OperationalTaskRepository(conn, ctx.project_id), ctx, - permissions_for_context(ctx), + _task_permissions(ctx), ) +def _task_permissions(ctx: ProjectContext) -> set[str]: + permissions = set(permissions_for_context(ctx)) + if ctx.system_role == "service": + permissions.difference_update({SCADA_VIEW, SIMULATION_VIEW}) + return permissions + + def _raise_domain(exc: DomainError): raise HTTPException(status_code=exc.status, detail=exc.payload()) from exc @@ -434,9 +449,12 @@ async def query_operational_task_data( ): service = _service(ctx, conn) try: - await service.detail(task_id) + if not await service.can_read_source(task_id, query.source): + raise DomainError("PERMISSION_DENIED", "Task has no approved access to this data source", status=403) + source_permission = SCADA_VIEW if query.source.startswith("scada_") else SIMULATION_VIEW snapshot = await read_task_data( - context=ctx, query=query, credential=authorization + context=ctx, query=query, credential=authorization, + delegated=source_permission not in _task_permissions(ctx), ) evidence, stored_snapshot = await service.record_data_snapshot(task_id, snapshot) return TaskDataQueryResult(evidence=evidence, snapshot=stored_snapshot) @@ -446,6 +464,40 @@ async def query_operational_task_data( _raise_domain(exc) +@router.post( + "/operational-tasks/{task_id}/access-requests", + response_model=TaskAccessRequest, + status_code=status.HTTP_201_CREATED, +) +async def request_operational_task_access( + task_id: UUID, + command: TaskAccessRequestCreate, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).request_access(task_id, command) + except DomainError as exc: + _raise_domain(exc) + + +@router.post( + "/operational-tasks/{task_id}/access-requests/{access_request_id}/decision", + response_model=TaskAccessRequest, +) +async def decide_operational_task_access( + task_id: UUID, + access_request_id: UUID, + command: TaskAccessRequestDecision, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).decide_access(task_id, access_request_id, command) + except DomainError as exc: + _raise_domain(exc) + + @router.post( "/operational-tasks/{task_id}/human-evidence", response_model=OperationalTaskDetail, @@ -512,6 +564,44 @@ async def decide_operational_task_action( _raise_domain(exc) +@router.patch( + "/operational-tasks/{task_id}/actions/{action_id}/draft", + response_model=HumanApprovalAction, +) +async def update_operational_work_order_draft( + task_id: UUID, + action_id: UUID, + command: WorkOrderDraftUpdateCommand, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).update_work_order_draft(task_id, action_id, command) + except DomainError as exc: + _raise_domain(exc) + + +@router.post( + "/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch", + response_model=HumanApprovalAction, +) +async def confirm_operational_work_order_dispatch( + task_id: UUID, + action_id: UUID, + command: WorkOrderConfirmDispatchCommand, + 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).confirm_work_order_dispatch( + task_id, action_id, command, dispatcher, authorization + ) + except DomainError as exc: + _raise_domain(exc) + + @router.post( "/operational-tasks/{task_id}/actions/{action_id}/execute", response_model=HumanApprovalAction, @@ -568,3 +658,19 @@ async def synchronize_operational_task_feedback( return await _service(ctx, conn).external_feedback(task_id, command) except DomainError as exc: _raise_domain(exc) + + +@router.post( + "/operational-tasks/{task_id}/withdraw-external-source", + response_model=OperationalTaskDetail, +) +async def withdraw_operational_task_external_source( + task_id: UUID, + command: ExternalSourceWithdrawalCommand, + ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXTERNAL_SYNC)), + conn: AsyncConnection = Depends(get_project_pg_connection), +): + try: + return await _service(ctx, conn).withdraw_external_source(task_id, command) + except DomainError as exc: + _raise_domain(exc) diff --git a/app/api/v1/endpoints/workbench_runtime.py b/app/api/v1/endpoints/workbench_runtime.py index 0055812..ce82524 100644 --- a/app/api/v1/endpoints/workbench_runtime.py +++ b/app/api/v1/endpoints/workbench_runtime.py @@ -18,7 +18,6 @@ from app.domain.schemas.workbench_runtime import ( WorkbenchPersistedAlert, WorkbenchRuntimeLatestAnalysis, WorkbenchRuntimeSnapshot, - WorkbenchRuntimeWorkOrder, ) from app.domain.schemas.task_alert import TaskAlertCommand, TaskAlertEvent, TaskAlertState from app.infra.db.postgresql.analysis import AnalysisRepository @@ -134,12 +133,9 @@ async def workbench_runtime( latest_results: list[dict] | None = None latest_analysis_map_payload: dict | None = None analysis_availability = "not_configured" - work_order_storage_configured = False - work_orders: list[dict] | None = None alert_storage_configured = False alerts: list[dict] | None = None schedule_availability = "not_configured" - work_order_availability = "not_configured" alert_availability = "not_configured" try: async with _projection_transaction(conn): @@ -168,29 +164,11 @@ async def workbench_runtime( try: async with _projection_transaction(conn): operations_availability = await WorkbenchOperationsRepository.availability(conn) - work_order_storage_configured = operations_availability.work_orders alert_storage_configured = operations_availability.alerts except Exception: logger.warning("Workbench operations storage probe is unavailable", exc_info=True) schedule_availability = "degraded" - work_order_availability = "degraded" alert_availability = "degraded" - if work_order_storage_configured: - try: - async with _projection_transaction(conn): - work_order_documents = await WorkbenchOperationsRepository.list_work_orders( - conn, str(ctx.project_id) - ) - work_orders, invalid_work_orders = _validate_documents( - work_order_documents, WorkbenchRuntimeWorkOrder, "work order" - ) - work_order_availability = ( - "degraded" if invalid_work_orders else "ready" - ) - except Exception: - work_orders = None - work_order_availability = "degraded" - logger.warning("Workbench work-order projection is unavailable", exc_info=True) if alert_storage_configured: try: async with _projection_transaction(conn): @@ -209,14 +187,11 @@ async def workbench_runtime( analysis_runs=analysis_runs, latest_analysis_results=latest_results, latest_analysis_map_payload=latest_analysis_map_payload, - work_order_storage_configured=work_order_storage_configured, - work_orders=work_orders, alert_storage_configured=alert_storage_configured, persisted_alerts=alerts, scenario_availability=analysis_availability, artifact_availability=analysis_availability, schedule_availability=schedule_availability, - work_order_availability=work_order_availability, alert_availability=alert_availability, projection_degraded=False, ) diff --git a/app/auth/permissions.py b/app/auth/permissions.py index 1e118d8..f68e1fd 100644 --- a/app/auth/permissions.py +++ b/app/auth/permissions.py @@ -25,6 +25,7 @@ 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_TASK_ACCESS_APPROVE = "operational_task.access.approve" 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" @@ -46,6 +47,7 @@ PROJECT_MEMBER_PERMISSIONS = frozenset( OPTIMIZATION_RUN, OPERATIONAL_TASK_VIEW, OPERATIONAL_TASK_WRITE, + OPERATIONAL_TASK_ACCESS_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, } diff --git a/app/domain/schemas/operational_task.py b/app/domain/schemas/operational_task.py index 859b68e..8499412 100644 --- a/app/domain/schemas/operational_task.py +++ b/app/domain/schemas/operational_task.py @@ -7,7 +7,7 @@ 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.operational_data import TaskDataSnapshot, TaskDataSource from app.domain.schemas.workbench_runtime import ( WorkbenchDateTime, WorkOrderActivity, @@ -43,6 +43,7 @@ class TaskSource(StrictModel): external_id: NonEmptyStr | None = None external_revision: PositiveInt | None = None event_kind: NonEmptyStr | None = None + deleted_at: WorkbenchDateTime | None = None class TaskTrigger(StrictModel): @@ -72,16 +73,21 @@ class ExecutionPlanNode(StrictModel): detail: NonEmptyStr evidence_refs: list[NonEmptyStr] = Field(default_factory=list) action_refs: list[UUID] = Field(default_factory=list) + kind: Literal["activity", "decision", "parallel_split", "parallel_join", "gate"] | None = None + selected_edge_id: NonEmptyStr | None = None + gate_ref: UUID | None = None + revisits_node_id: NonEmptyStr | None = None class ExecutionPlanEdge(StrictModel): + id: NonEmptyStr | None = None 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") + schema_name: Literal["execution-plan@1", "execution-plan@2"] = Field(alias="schema") title: NonEmptyStr reason: NonEmptyStr nodes: Annotated[list[ExecutionPlanNode], Field(min_length=1, max_length=100)] @@ -98,6 +104,7 @@ class ExecutionPlanInput(StrictModel): 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() + edge_ids: set[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: @@ -105,6 +112,10 @@ class ExecutionPlanInput(StrictModel): 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) + if self.schema_name == "execution-plan@2": + if edge.id is None or edge.id in edge_ids: + raise ValueError("v2 execution plan edge ids must be unique") + edge_ids.add(edge.id) 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] @@ -118,6 +129,45 @@ class ExecutionPlanInput(StrictModel): pending.append(target) if visited != len(node_ids): raise ValueError("execution plan must be acyclic") + if self.schema_name == "execution-plan@2" and sum( + not any(edge.to_node == node_id for edge in self.edges) for node_id in node_ids + ) != 1: + raise ValueError("v2 execution plan requires one start node") + if self.schema_name == "execution-plan@2": + by_id = {node.id: node for node in self.nodes} + for node in self.nodes: + if node.kind is None: + raise ValueError("v2 execution plan nodes require kind") + outgoing = [edge for edge in self.edges if edge.from_node == node.id] + incoming = [edge for edge in self.edges if edge.to_node == node.id] + if node.kind == "decision": + if len(outgoing) < 2 or any(not edge.condition for edge in outgoing): + raise ValueError("decision requires at least two labeled routes") + if node.selected_edge_id is not None and node.selected_edge_id not in {edge.id for edge in outgoing}: + raise ValueError("decision selected_edge_id must reference an outgoing route") + if node.status == "completed" and node.selected_edge_id is None: + raise ValueError("completed decision requires a selected route") + if node.selected_edge_id is not None: + for edge in outgoing: + target_status = by_id[edge.to_node].status + if edge.id == node.selected_edge_id and target_status in {"skipped", "cancelled"}: + raise ValueError("selected route cannot be skipped") + if edge.id != node.selected_edge_id and target_status in {"running", "completed", "waiting"}: + raise ValueError("unchosen route cannot be active") + elif node.selected_edge_id is not None: + raise ValueError("only decision nodes may select routes") + if node.kind not in {"decision", "parallel_split"} and len(outgoing) > 1: + raise ValueError("only decision or parallel split may branch") + if node.kind == "parallel_split" and len(outgoing) < 2: + raise ValueError("parallel split requires at least two routes") + if node.kind == "parallel_join" and len(incoming) < 2: + raise ValueError("parallel join requires at least two inputs") + if node.kind == "gate" and node.gate_ref is None: + raise ValueError("gate requires a persisted reference") + if node.kind != "gate" and node.gate_ref is not None: + raise ValueError("only gate nodes may have gate_ref") + if node.revisits_node_id is not None and node.revisits_node_id not in by_id: + raise ValueError("revisited node must exist in the plan") return self @@ -182,6 +232,38 @@ class AgentArtifactsCommand(StrictModel): return self +class TaskAccessRequestCreate(StrictModel): + request_id: UUID + source_session_id: NonEmptyStr + node_id: NonEmptyStr + scopes: Annotated[list[TaskDataSource], Field(min_length=1, max_length=7)] + reason: NonEmptyStr + + +class TaskAccessRequestDecision(StrictModel): + request_id: UUID + expected_version: PositiveInt + decision: Literal["approved", "rejected"] + approved_scopes: list[TaskDataSource] = Field(default_factory=list) + comment: NonEmptyStr | None = None + + +class TaskAccessRequest(StrictModel): + access_request_id: UUID + task_id: UUID + node_id: NonEmptyStr + scopes: list[TaskDataSource] + reason: NonEmptyStr + status: Literal["pending", "approved", "rejected"] + approved_scopes: list[TaskDataSource] + version: PositiveInt + requested_by: NonEmptyStr + requested_at: WorkbenchDateTime + decided_by: NonEmptyStr | None = None + decided_at: WorkbenchDateTime | None = None + comment: NonEmptyStr | None = None + + class CreateWorkOrderPayload(StrictModel): title: NonEmptyStr description: NonEmptyStr @@ -232,6 +314,17 @@ class ActionDecisionCommand(StrictModel): comment: NonEmptyStr | None = None +class WorkOrderDraftUpdateCommand(StrictModel): + request_id: UUID + expected_version: PositiveInt + payload: CreateWorkOrderPayload + + +class WorkOrderConfirmDispatchCommand(StrictModel): + request_id: UUID + expected_version: PositiveInt + + class ActionExecuteCommand(StrictModel): request_id: UUID expected_version: PositiveInt @@ -267,6 +360,8 @@ class HumanApprovalAction(StrictModel): execution_request_id: UUID | None = None proposed_by: NonEmptyStr proposed_at: WorkbenchDateTime + edited_by: NonEmptyStr | None = None + edited_at: WorkbenchDateTime | None = None decided_by: NonEmptyStr | None = None decided_at: WorkbenchDateTime | None = None decision_comment: NonEmptyStr | None = None @@ -358,6 +453,7 @@ class OperationalTaskPermissions(StrictModel): can_execute_work_orders: bool can_decide_commands: bool can_execute_commands: bool + can_decide_access: bool = False class OperationalTaskDetail(OperationalTaskSummary): @@ -365,6 +461,7 @@ class OperationalTaskDetail(OperationalTaskSummary): latest_report: InvestigationReportRevision | None evidence: list[EvidenceRecord] actions: list[HumanApprovalAction] + access_requests: list[TaskAccessRequest] = Field(default_factory=list) permissions: OperationalTaskPermissions work_order_snapshot: ExternalWorkOrderSnapshot | None = None @@ -488,6 +585,11 @@ class ExternalFeedbackResult(StrictModel): accepted_at: WorkbenchDateTime +class ExternalSourceWithdrawalCommand(StrictModel): + request_id: UUID + source_event_id: NonEmptyStr + + class HumanEvidenceCommand(StrictModel): request_id: UUID evidence: EvidenceInput @@ -505,6 +607,11 @@ class OperationalTaskNotification(StrictModel): title: NonEmptyStr description: NonEmptyStr required_permission: NonEmptyStr | None = None + lifecycle_status: Literal["active", "resolved"] = "active" + subject_type: Literal["task", "run", "access_request", "action"] = "task" + subject_id: UUID | None = None + resolved_at: WorkbenchDateTime | None = None + resolution_reason: NonEmptyStr | None = None status: Literal["unread", "read"] version: PositiveInt created_at: WorkbenchDateTime diff --git a/app/infra/db/postgresql/operational_tasks.py b/app/infra/db/postgresql/operational_tasks.py index 1140341..d2d0548 100644 --- a/app/infra/db/postgresql/operational_tasks.py +++ b/app/infra/db/postgresql/operational_tasks.py @@ -252,6 +252,7 @@ class OperationalTaskRepository: ORDER BY run.generation DESC LIMIT 1 ) AS active_run ON true WHERE task.project_id=%s + AND (task.document#>>'{source,deleted_at}') IS NULL 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 @@ -336,6 +337,55 @@ class OperationalTaskRepository: evidence["recorded_at"]), ) + async def access_requests_available(self) -> bool: + # Older project databases can serve existing tasks before migration 019. + # Keep approval writes fail-closed until the table is provisioned. + availability = await self.connection.execute( + "SELECT to_regclass('workbench.operational_task_access_requests') AS relation" + ) + return (await availability.fetchone())["relation"] is not None + + async def list_access_requests(self, task_id: str) -> list[dict]: + if not await self.access_requests_available(): + return [] + cursor = await self.connection.execute( + """SELECT document FROM workbench.operational_task_access_requests + WHERE project_id=%s AND task_id=%s + ORDER BY requested_at,access_request_id""", + (self.project_id, task_id), + ) + return [row["document"] for row in await cursor.fetchall()] + + async def get_access_request(self, task_id: str, access_request_id: str) -> dict | None: + cursor = await self.connection.execute( + """SELECT document FROM workbench.operational_task_access_requests + WHERE project_id=%s AND task_id=%s AND access_request_id=%s""", + (self.project_id, task_id, access_request_id), + ) + row = await cursor.fetchone() + return row["document"] if row else None + + async def insert_access_request(self, task_id: str, document: dict) -> None: + await self.connection.execute( + """INSERT INTO workbench.operational_task_access_requests + (project_id,task_id,access_request_id,node_id,status,version,document,requested_at,updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + (self.project_id, task_id, document["access_request_id"], document["node_id"], + document["status"], document["version"], Jsonb(document), + document["requested_at"], document["requested_at"]), + ) + + async def update_access_request(self, task_id: str, document: dict, expected_version: int) -> None: + cursor = await self.connection.execute( + """UPDATE workbench.operational_task_access_requests + SET status=%s,version=%s,document=%s,updated_at=%s + WHERE project_id=%s AND task_id=%s AND access_request_id=%s AND version=%s""", + (document["status"], document["version"], Jsonb(document), document["decided_at"], + self.project_id, task_id, document["access_request_id"], expected_version), + ) + if cursor.rowcount != 1: + raise DomainError("VERSION_CONFLICT", "Access request version changed", status=409) + async def get_latest_plan(self, task_id: str) -> dict | None: return await self._latest_artifact("operational_task_plans", task_id) @@ -406,7 +456,7 @@ class OperationalTaskRepository: 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 + Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at") or action["proposed_at"], self.project_id, task_id, action["action_id"]), ) @@ -417,7 +467,7 @@ class OperationalTaskRepository: 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") + Jsonb(action), action.get("executed_at") or action.get("decided_at") or action.get("edited_at") or action["proposed_at"], ), ) @@ -453,6 +503,41 @@ class OperationalTaskRepository: notification["created_at"], Jsonb(notification)), ) + async def resolve_notifications( + self, + task_id: str, + *, + kinds: list[str], + subject_type: str, + subject_id: str, + resolved_at, + resolution_reason: str, + ) -> list[dict]: + cursor = await self.connection.execute( + """UPDATE workbench.operational_task_notifications + SET document=document || jsonb_build_object( + 'lifecycle_status','resolved', + 'resolved_at',to_jsonb(%s::timestamptz), + 'resolution_reason',%s + ) + WHERE project_id=%s AND task_id=%s + AND COALESCE(document->>'lifecycle_status','active')='active' + AND document->>'kind'=ANY(%s) + AND document->>'subject_type'=%s + AND document->>'subject_id'=%s + RETURNING document""", + ( + resolved_at, + resolution_reason, + self.project_id, + task_id, + kinds, + subject_type, + subject_id, + ), + ) + return [row["document"] for row in await cursor.fetchall()] + async def get_notification(self, notification_id: str, user_id: str) -> dict | None: cursor = await self.connection.execute( """SELECT n.document || jsonb_build_object( @@ -499,6 +584,7 @@ class OperationalTaskRepository: 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 COALESCE(n.document->>'lifecycle_status','active')='active' AND (%s=false OR r.read_at IS NULL) AND (n.document->>'required_permission' IS NULL OR n.document->>'required_permission'=ANY(%s)) diff --git a/app/infra/db/postgresql/workbench_operations.py b/app/infra/db/postgresql/workbench_operations.py index aeb1ac2..4cd7a86 100644 --- a/app/infra/db/postgresql/workbench_operations.py +++ b/app/infra/db/postgresql/workbench_operations.py @@ -1,4 +1,4 @@ -"""Optional project-local schedule, work-order and alert projections.""" +"""Optional project-local alert projection.""" from dataclasses import dataclass from typing import Any @@ -8,7 +8,6 @@ from psycopg import AsyncConnection @dataclass(frozen=True) class WorkbenchOperationsAvailability: - work_orders: bool alerts: bool schedules: bool = False @@ -23,34 +22,14 @@ class WorkbenchOperationsRepository: async with conn.cursor() as cur: await cur.execute( """ - SELECT - to_regclass('workbench.work_orders') IS NOT NULL AS work_orders, - to_regclass('workbench.alerts') IS NOT NULL AS alerts + SELECT to_regclass('workbench.alerts') IS NOT NULL AS alerts """ ) row = await cur.fetchone() return WorkbenchOperationsAvailability( - work_orders=bool(row and row["work_orders"]), alerts=bool(row and row["alerts"]), ) - @staticmethod - async def list_work_orders( - conn: AsyncConnection, project_id: str, *, limit: int = 100 - ) -> list[dict[str, Any]]: - async with conn.cursor() as cur: - await cur.execute( - """ - SELECT document - FROM workbench.work_orders - WHERE project_id = %s - ORDER BY updated_at DESC, work_order_id - LIMIT %s - """, - (project_id, limit), - ) - return [row["document"] for row in await cur.fetchall()] - @staticmethod async def list_alerts( conn: AsyncConnection, project_id: str, *, limit: int = 100 diff --git a/app/infra/integrations/operational_data.py b/app/infra/integrations/operational_data.py index a9186d4..c2a3dbe 100644 --- a/app/infra/integrations/operational_data.py +++ b/app/infra/integrations/operational_data.py @@ -19,6 +19,7 @@ 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) + delegation_bearer: str = "" model_config = SettingsConfigDict( env_prefix="OPERATIONAL_DATA_", extra="ignore", env_file=Path(__file__).resolve().parents[3] / ".env", @@ -81,13 +82,20 @@ def _select_demo_observations( async def read_task_data( *, context: ProjectContext, query: TaskDataQuery, credential: str | None, + delegated: bool = False, 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): + if permission not in permissions_for_context(context) and not delegated: raise TaskDataError(403, "当前身份没有该数据源的读取权限") + if delegated: + credential = config.delegation_bearer + if not credential: + raise TaskDataError(503, "任务授权已批准,但数据源委托身份尚未配置") + if not credential.lower().startswith("bearer "): + credential = f"Bearer {credential}" if not credential or not credential.lower().startswith("bearer "): raise TaskDataError(401, "数据查询需要当前产品身份") peripheral = query.source.startswith("peripheral_") diff --git a/app/services/operational_tasks.py b/app/services/operational_tasks.py index c781780..7db4692 100644 --- a/app/services/operational_tasks.py +++ b/app/services/operational_tasks.py @@ -11,6 +11,7 @@ from app.auth.permissions import ( OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, OPERATIONAL_TASK_EXECUTE, + OPERATIONAL_TASK_ACCESS_APPROVE, OPERATIONAL_TASK_EXTERNAL_SYNC, OPERATIONAL_TASK_WRITE, SCADA_VIEW, @@ -22,6 +23,11 @@ from app.domain.schemas.operational_task import ( ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand, + WorkOrderDraftUpdateCommand, + WorkOrderConfirmDispatchCommand, + TaskAccessRequest, + TaskAccessRequestCreate, + TaskAccessRequestDecision, AgentArtifactsCommand, AgentArtifactsResult, AgentTaskContext, @@ -35,6 +41,7 @@ from app.domain.schemas.operational_task import ( ExecutionPlanSnapshot, ExternalFeedbackCommand, ExternalFeedbackResult, + ExternalSourceWithdrawalCommand, HumanApprovalAction, HumanEvidenceCommand, InvestigationReportRevision, @@ -54,6 +61,7 @@ from app.domain.schemas.operational_task import ( StoredEvidenceRecord, ) from app.domain.schemas.operational_data import TaskDataSnapshot +from app.domain.schemas.operational_data import TaskDataSource from app.services.content_digest import digest @@ -124,6 +132,7 @@ class OperationalTaskService: 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, + can_decide_access=OPERATIONAL_TASK_ACCESS_APPROVE in permissions, ) async def create(self, command: OperationalTaskCreate) -> OperationalTaskDetail: @@ -210,6 +219,7 @@ class OperationalTaskService: for document in await self.repo.list_evidence(str(task_id)) ] actions = await self.repo.list_actions(str(task_id)) + access_requests = await self.repo.list_access_requests(str(task_id)) if hasattr(self.repo, "list_access_requests") else [] active_run = await self._active_run(task) work_order_snapshot = None if task["task_type"] == "external_work_order" and task["source"].get("external_id"): @@ -225,6 +235,7 @@ class OperationalTaskService: latest_report=report, evidence=evidence, actions=actions, + access_requests=access_requests, permissions=self._permission_view(), work_order_snapshot=work_order_snapshot, ) @@ -238,11 +249,130 @@ class OperationalTaskService: ]) if SCADA_VIEW in self.permissions: scopes.extend(["scada_devices", "scada_readings"]) + if hasattr(self.repo, "list_access_requests"): + for request in await self.repo.list_access_requests(str(task_id)): + if request["status"] == "approved": + scopes.extend(request["approved_scopes"]) return AgentTaskContext( task=await self.detail(task_id), - approved_data_scopes=scopes, + approved_data_scopes=list(dict.fromkeys(scopes)), ) + async def can_read_source(self, task_id: UUID, source: TaskDataSource) -> bool: + task = await self._task(task_id) + permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW + if permission in self.permissions: + return True + if not hasattr(self.repo, "list_access_requests"): + return False + active_run = await self._active_run(task) + if active_run is None or active_run.get("status") not in {"claimed", "running"}: + return False + if not active_run.get("agent_session_id"): + return False + self._require_active_run_lease(active_run, self._now()) + return any( + row["status"] == "approved" and source in row["approved_scopes"] + for row in await self.repo.list_access_requests(str(task_id)) + ) + + async def request_access(self, task_id: UUID, command: TaskAccessRequestCreate) -> TaskAccessRequest: + body = _json(command) + scope = f"task:{task_id}:access:request" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return TaskAccessRequest.model_validate(replay["access_request"]) + availability = getattr(self.repo, "access_requests_available", None) + require( + availability is None or await availability(), + "DEPENDENCY_UNAVAILABLE", + "Task data access requests are not enabled for this project database", + status=503, + ) + 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 and active_run.get("agent_session_id") == command.source_session_id, + "SESSION_MISMATCH", "Current Agent session must own the task run", status=409) + self._require_active_run_lease(active_run, self._now()) + pending = [ + item for item in await self.repo.list_access_requests(str(task_id)) + if item["status"] == "pending" + ] + require( + not pending, + "ACCESS_REQUEST_PENDING", + "Resolve the current task data access request before creating another", + refs=[item["access_request_id"] for item in pending], + status=409, + ) + now = self._now() + request = TaskAccessRequest( + access_request_id=uuid4(), task_id=task_id, node_id=command.node_id, + scopes=list(dict.fromkeys(command.scopes)), reason=command.reason, + status="pending", approved_scopes=[], version=1, + requested_by=self._actor(), requested_at=now, + ) + await self.repo.insert_access_request(str(task_id), _json(request)) + 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) + await self._notify(task_id, "access_approval_pending", task["title"], command.reason, now, + required_permission=OPERATIONAL_TASK_ACCESS_APPROVE, + subject_type="access_request", subject_id=request.access_request_id) + await self._save_request(scope, command.request_id, body, {"access_request": _json(request)}) + return request + + async def decide_access(self, task_id: UUID, access_request_id: UUID, + command: TaskAccessRequestDecision) -> TaskAccessRequest: + body = _json(command) + scope = f"task:{task_id}:access:{access_request_id}:decision" + async with self.repo.transaction(): + replay = await self._request_replay(scope, command.request_id, body) + if replay: + return TaskAccessRequest.model_validate(replay["access_request"]) + require(OPERATIONAL_TASK_ACCESS_APPROVE in self.permissions, "PERMISSION_DENIED", + "Current user cannot approve task data access", status=403) + await self.repo.lock(f"access:{access_request_id}") + raw = await self.repo.get_access_request(str(task_id), str(access_request_id)) + require(raw is not None, "ACCESS_REQUEST_NOT_FOUND", "Access request not found", status=404) + request = TaskAccessRequest.model_validate(raw) + require(request.version == command.expected_version, "VERSION_CONFLICT", + "Access request version changed", status=409) + require(request.status == "pending", "INVALID_TRANSITION", + "Access request has already been decided", status=409) + approved = set(command.approved_scopes) + require(approved <= set(request.scopes), "ACCESS_SCOPE_INVALID", + "Approved scopes must be requested", status=422) + require((command.decision == "approved") == bool(approved), "ACCESS_SCOPE_INVALID", + "Approval needs at least one scope; rejection grants none", status=422) + for source in approved: + permission = SCADA_VIEW if source.startswith("scada_") else SIMULATION_VIEW + require(permission in self.permissions, "PERMISSION_DENIED", + "Approver cannot read a requested source", status=403) + now = self._now() + updated = request.model_copy(update={ + "version": request.version + 1, "status": command.decision, + "approved_scopes": sorted(approved), "decided_by": self._actor(), + "decided_at": now, "comment": command.comment, + }) + await self.repo.update_access_request(str(task_id), _json(updated), request.version) + await self._resolve_notifications( + task_id, + kinds=["access_approval_pending"], + subject_type="access_request", + subject_id=access_request_id, + now=now, + reason=f"access_request_{command.decision}", + ) + task = await self._task(task_id) + 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, {"access_request": _json(updated)}) + return updated + async def list_runs( self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None, @@ -305,6 +435,14 @@ class OperationalTaskService: 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._resolve_notifications( + task_id, + kinds=["run_delivery_degraded"], + subject_type="task", + subject_id=task_id, + now=now, + reason="run_claimed", + ) await self._save_request(scope, command.request_id, body, {"run": _json(run_model)}) return run_model @@ -420,6 +558,14 @@ class OperationalTaskService: 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._resolve_notifications( + task_id, + kinds=["task_failed", "run_delivery_degraded"], + subject_type="task", + subject_id=task_id, + now=now, + reason="task_retried", + ) await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) return await self.detail(task_id) @@ -572,6 +718,35 @@ class OperationalTaskService: known_evidence, ) self._validate_plan_state(command.plan, known_evidence, actions) + if command.plan.schema_name == "execution-plan@2": + requests = { + row["access_request_id"]: TaskAccessRequest.model_validate(row) + for row in await self.repo.list_access_requests(str(task_id)) + } + for node in command.plan.nodes: + if node.kind == "gate": + gate_ref = str(node.gate_ref) + access_request = requests.get(gate_ref) + action = actions.get(gate_ref) + require( + access_request is not None or action is not None, + "GATE_REFERENCE_NOT_FOUND", + "Gate must reference a persisted request or action", + refs=[node.id], status=409, + ) + if node.status == "completed": + require( + ( + access_request is not None + and access_request.status != "pending" + ) or ( + action is not None + and action.approval_status != "pending" + ), + "GATE_NOT_RESOLVED", + "A completed gate requires a completed human decision", + refs=[node.id], status=409, + ) plan_revision = (plan_revision or 0) + 1 snapshot = ExecutionPlanSnapshot( revision=plan_revision, @@ -657,6 +832,7 @@ class OperationalTaskService: return HumanApprovalAction.model_validate(action) await self.repo.lock(f"task:{task_id}") task = await self._task(task_id) + self._require_actionable_task(task) self._validate_action_payload(command) if command.node_id: latest_plan = await self.repo.get_latest_plan(str(task_id)) @@ -699,12 +875,63 @@ class OperationalTaskService: await self._notify( task_id, "action_approval_pending", task["title"], command.reason, now, required_permission=approval_permission, + subject_type="action", subject_id=action.action_id, ) await self._save_request( scope, command.request_id, body, {"action_id": str(action.action_id)} ) return action + async def update_work_order_draft( + self, task_id: UUID, action_id: UUID, command: WorkOrderDraftUpdateCommand + ) -> HumanApprovalAction: + body = _json(command) + scope = f"task:{task_id}:action:{action_id}:draft" + 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"task:{task_id}") + await self.repo.lock(f"action:{action_id}") + self._require_actionable_task(await self._task(task_id)) + action = await self._action(task_id, action_id) + require(action.kind == "create_work_order", "INVALID_ACTION_KIND", + "Only work-order drafts can be edited", status=422) + require(action.version == command.expected_version, "VERSION_CONFLICT", + "Action version changed", status=409) + require(action.approval_status == "pending" and action.execution_status == "not_submitted", + "INVALID_TRANSITION", "Submitted actions cannot be edited", status=409) + require(OPERATIONAL_ACTION_WORK_ORDER_APPROVE in self.permissions, + "PERMISSION_DENIED", "Current user cannot edit this work-order draft", status=403) + now = self._now() + updated = action.model_copy(update={ + "version": action.version + 1, "payload": command.payload, + "edited_by": self._actor(), "edited_at": now, + }) + await self.repo.update_action(str(task_id), _json(updated)) + await self._action_event(updated, now) + await self._save_request(scope, command.request_id, body, {"action": _json(updated)}) + return updated + + async def confirm_work_order_dispatch( + self, task_id: UUID, action_id: UUID, command: WorkOrderConfirmDispatchCommand, + dispatcher, credential: str | None, + ) -> HumanApprovalAction: + require(OPERATIONAL_ACTION_WORK_ORDER_APPROVE in self.permissions + and OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in self.permissions, + "PERMISSION_DENIED", "Current user cannot confirm and dispatch work orders", status=403) + current = await self._action(task_id, action_id) + require(current.kind == "create_work_order", "INVALID_ACTION_KIND", + "Only work orders support one-step confirmation", status=422) + approval = await self.decide_action(task_id, action_id, ActionDecisionCommand( + request_id=uuid5(NAMESPACE_URL, f"confirm:{command.request_id}:approve"), + expected_version=command.expected_version, decision="approved", + )) + return await self.submit_action(task_id, action_id, ActionExecuteCommand( + request_id=uuid5(NAMESPACE_URL, f"confirm:{command.request_id}:dispatch"), + expected_version=approval.version, + ), dispatcher, credential) + async def decide_action( self, task_id: UUID, action_id: UUID, command: ActionDecisionCommand ) -> HumanApprovalAction: @@ -714,7 +941,9 @@ class OperationalTaskService: replay = await self._request_replay(scope, command.request_id, body) if replay: return HumanApprovalAction.model_validate(replay["action"]) + await self.repo.lock(f"task:{task_id}") await self.repo.lock(f"action:{action_id}") + self._require_actionable_task(await self._task(task_id)) action = await self._action(task_id, action_id) require(action.version == command.expected_version, "VERSION_CONFLICT", "Action version changed", status=409) @@ -730,6 +959,14 @@ class OperationalTaskService: }) await self.repo.update_action(str(task_id), _json(action)) await self._action_event(action, now) + await self._resolve_notifications( + task_id, + kinds=["action_pending", "action_approval_pending"], + subject_type="action", + subject_id=action_id, + now=now, + reason=f"action_{command.decision}", + ) if command.decision == "rejected": task = await self._task(task_id) now = self._now() @@ -749,7 +986,9 @@ class OperationalTaskService: replay = await self._request_replay(scope, command.request_id, body) if replay: return HumanApprovalAction.model_validate(replay["action"]) + await self.repo.lock(f"task:{task_id}") await self.repo.lock(f"action:{action_id}") + self._require_actionable_task(await self._task(task_id)) action = await self._action(task_id, action_id) require(action.version == command.expected_version, "VERSION_CONFLICT", "Action version changed", status=409) @@ -771,6 +1010,14 @@ class OperationalTaskService: }) await self.repo.update_action(str(task_id), _json(action)) await self._action_event(action, now) + await self._resolve_notifications( + task_id, + kinds=["external_coordination_failed"], + subject_type="action", + subject_id=action_id, + now=now, + reason="external_coordination_retried", + ) await self._save_request( scope, command.request_id, body, {"action": _json(action)} ) @@ -795,6 +1042,7 @@ class OperationalTaskService: "外部系统提交中断,执行结果未知,请核对回执", self._now(), required_permission=action.required_execution_permission, + subject_type="action", subject_id=action_id, ) raise @@ -822,6 +1070,16 @@ class OperationalTaskService: outcome.detail, outcome_recorded_at, required_permission=action.required_execution_permission, + subject_type="action", subject_id=action_id, + ) + elif outcome.status == "succeeded": + await self._resolve_notifications( + task_id, + kinds=["external_coordination_failed"], + subject_type="action", + subject_id=action_id, + now=outcome_recorded_at, + reason="external_coordination_succeeded", ) if outcome.status in {"succeeded", "failed", "unsupported"}: task_document = await self._task(task_id) @@ -883,6 +1141,16 @@ class OperationalTaskService: outcome.detail, outcome_recorded_at, required_permission=action.required_execution_permission, + subject_type="action", subject_id=action_id, + ) + elif outcome.status == "succeeded": + await self._resolve_notifications( + task_id, + kinds=["external_coordination_failed"], + subject_type="action", + subject_id=action_id, + now=outcome_recorded_at, + reason="external_coordination_succeeded", ) if outcome.status in {"succeeded", "failed", "unsupported"}: task_document = await self._task(task_id) @@ -988,6 +1256,24 @@ class OperationalTaskService: 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["status"] != "failed": + await self._resolve_notifications( + task_id, + kinds=["task_failed"], + subject_type="task", + subject_id=task_id, + now=now, + reason="external_status_recovered", + ) + if task["status"] != "waiting_human": + await self._resolve_notifications( + task_id, + kinds=["human_input_required"], + subject_type="task", + subject_id=task_id, + now=now, + reason="external_status_progressed", + ) if task["task_type"] == "external_work_order": await self._append_event( topic="workbench.work_order.changed", @@ -1010,6 +1296,88 @@ class OperationalTaskService: await self._save_request(scope, command.request_id, body, _json_dict(response)) return ExternalFeedbackResult(task=await self.detail(task_id), **response) + async def withdraw_external_source( + self, task_id: UUID, command: ExternalSourceWithdrawalCommand + ) -> OperationalTaskDetail: + body = _json(command) + scope = f"task:{task_id}:withdraw-external-source" + 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) + source = task["source"] + require( + task["task_type"] == "external_work_order" + and source.get("kind") == "external_system" + and source.get("external_id") == command.source_event_id + and command.source_event_id.startswith("peripheral_sample:"), + "EXTERNAL_SOURCE_MISMATCH", + "Only matching peripheral work orders can be withdrawn", + status=409, + ) + if source.get("deleted_at") is None: + now = self._now() + task["source"] = {**source, "deleted_at": now} + task.update( + status="cancelled", + lifecycle_status="cancelled", + 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._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"}, + ) + for action in await self.repo.list_actions(str(task_id)): + action_id = UUID(str(action["action_id"])) + await self._resolve_notifications( + task_id, + kinds=["action_pending", "action_approval_pending"], + subject_type="action", + subject_id=action_id, + now=now, + reason="external_source_withdrawn", + ) + if action["execution_status"] == "not_submitted": + await self._resolve_notifications( + task_id, + kinds=["external_coordination_failed"], + subject_type="action", + subject_id=action_id, + now=now, + reason="external_source_withdrawn", + ) + if hasattr(self.repo, "list_access_requests"): + for access_request in await self.repo.list_access_requests(str(task_id)): + await self._resolve_notifications( + task_id, + kinds=["access_approval_pending"], + subject_type="access_request", + subject_id=UUID(str(access_request["access_request_id"])), + now=now, + reason="external_source_withdrawn", + ) + await self._resolve_notifications( + task_id, + kinds=["human_input_required", "run_delivery_degraded", "task_failed"], + subject_type="task", + subject_id=task_id, + now=now, + reason="external_source_withdrawn", + ) + await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) + return await self.detail(task_id) + async def acknowledge_notification( self, notification_id: UUID, command: NotificationAcknowledgeCommand ) -> OperationalTaskNotification: @@ -1047,8 +1415,8 @@ class OperationalTaskService: ) await self._append_event( topic="operational.notification.changed", - aggregate_type="operational_notification", - aggregate_id=str(notification_id), + aggregate_type="operational_notification_read", + aggregate_id=f"{notification_id}:{self.context.user_id}", aggregate_revision=notification.version, occurred_at=notification.read_at, metadata={"task_id": str(notification.task_id)}, @@ -1121,6 +1489,14 @@ class OperationalTaskService: 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._resolve_notifications( + task_id, + kinds=["human_input_required"], + subject_type="task", + subject_id=task_id, + now=now, + reason="human_evidence_added", + ) await self._save_request(scope, command.request_id, body, {"task_id": str(task_id)}) return await self.detail(task_id) @@ -1289,7 +1665,13 @@ class OperationalTaskService: HumanApprovalAction.model_validate(item) for item in await self.repo.list_actions(str(task_id)) ] - lifecycle = self._status_from_plan(plan.plan.nodes, actions) + access_requests = [ + TaskAccessRequest.model_validate(item) + for item in await self.repo.list_access_requests(str(task_id)) + ] + lifecycle = self._status_from_plan( + plan.plan.nodes, actions, access_requests + ) if lifecycle == "running": final_outcome = "failed" lifecycle = "failed" @@ -1359,6 +1741,16 @@ class OperationalTaskService: require(task is not None, "TASK_NOT_FOUND", "Operational task not found", status=404) return task + @staticmethod + def _require_actionable_task(task: dict) -> None: + source = task.get("source") or {} + require( + task.get("lifecycle_status") != "cancelled" and source.get("deleted_at") is None, + "TASK_NOT_ACTIONABLE", + "Cancelled or withdrawn tasks do not accept action changes", + status=409, + ) + 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) @@ -1385,7 +1777,11 @@ class OperationalTaskService: return uuid5(NAMESPACE_URL, f"operational-task:{task_id}:generation:{generation}") @staticmethod - def _status_from_plan(nodes, actions: Iterable[HumanApprovalAction]) -> str: + def _status_from_plan( + nodes, + actions: Iterable[HumanApprovalAction], + access_requests: Iterable[TaskAccessRequest] = (), + ) -> str: statuses = {node.status for node in nodes} if "running" in statuses: return "running" @@ -1396,6 +1792,8 @@ class OperationalTaskService: return "failed" if any(action.approval_status == "pending" for action in actions): return "waiting_human" + if any(request.status == "pending" for request in access_requests): + return "waiting_human" if statuses <= {"completed", "skipped", "cancelled"}: return "completed" if "failed" in statuses and statuses <= {"completed", "failed", "skipped", "cancelled"}: @@ -1420,7 +1818,10 @@ class OperationalTaskService: "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), + structural = plan.schema_name == "execution-plan@2" and node.kind in { + "parallel_split", "parallel_join", "gate" + } + require(structural or 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) @@ -1553,11 +1954,14 @@ class OperationalTaskService: async def _notify( self, task_id: UUID, kind: str, title: str, description: str, now: datetime, *, required_permission: str | None = None, + subject_type: str = "task", subject_id: UUID | 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, + lifecycle_status="active", subject_type=subject_type, + subject_id=subject_id or task_id, ) await self.repo.insert_notification(_json(notification)) await self._append_event( @@ -1569,6 +1973,42 @@ class OperationalTaskService: metadata={"task_id": str(task_id), "kind": kind}, ) + async def _resolve_notifications( + self, + task_id: UUID, + *, + kinds: list[str], + subject_type: str, + subject_id: UUID, + now: datetime, + reason: str, + ) -> None: + resolve = getattr(self.repo, "resolve_notifications", None) + if resolve is None: + return + notifications = await resolve( + str(task_id), + kinds=kinds, + subject_type=subject_type, + subject_id=str(subject_id), + resolved_at=now, + resolution_reason=reason, + ) + for notification in notifications: + await self._append_event( + topic="operational.notification.changed", + aggregate_type="operational_notification", + aggregate_id=notification["notification_id"], + aggregate_revision=2, + occurred_at=now, + metadata={ + "task_id": str(task_id), + "kind": notification["kind"], + "change": "resolved", + "reason": reason, + }, + ) + def _new_run( self, task_id: UUID, generation: int, now: datetime ) -> OperationalTaskRun: diff --git a/app/services/workbench_runtime.py b/app/services/workbench_runtime.py index 0b166a6..b3f4d21 100644 --- a/app/services/workbench_runtime.py +++ b/app/services/workbench_runtime.py @@ -5,10 +5,7 @@ from datetime import datetime, timezone from math import isfinite from typing import Any -from app.domain.schemas.workbench_runtime import ( - WorkbenchPersistedAlert, - WorkbenchRuntimeWorkOrder, -) +from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert _ANALYSIS_TYPE_LABELS = { "burst_analysis": "爆管分析", @@ -39,14 +36,11 @@ async def build_workbench_runtime_snapshot( analysis_runs: list[dict[str, Any]] | None = None, latest_analysis_results: list[dict[str, Any]] | None = None, latest_analysis_map_payload: dict[str, Any] | None = None, - work_order_storage_configured: bool = False, - work_orders: list[dict[str, Any]] | None = None, alert_storage_configured: bool = False, persisted_alerts: list[dict[str, Any]] | None = None, scenario_availability: str | None = None, artifact_availability: str | None = None, schedule_availability: str | None = None, - work_order_availability: str | None = None, alert_availability: str | None = None, projection_degraded: bool = False, clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), @@ -62,14 +56,6 @@ async def build_workbench_runtime_snapshot( if persisted_analysis_runs else None ) - persisted_work_orders = [ - WorkbenchRuntimeWorkOrder.model_validate(item).model_dump() - for item in ( - (work_orders or [])[:100] - if work_order_storage_configured - else [] - ) - ] stored_alerts = [ WorkbenchPersistedAlert.model_validate(item) for item in ( @@ -104,9 +90,7 @@ async def build_workbench_runtime_snapshot( "ready" if latest_analysis else "not_configured" ) resolved_schedule_availability = schedule_availability or "not_configured" - resolved_work_order_availability = work_order_availability or ( - "ready" if work_order_storage_configured else "not_configured" - ) + resolved_work_order_availability = "not_configured" resolved_alert_availability = alert_availability or ( "ready" if alert_storage_configured else "not_configured" ) @@ -125,7 +109,6 @@ async def build_workbench_runtime_snapshot( "ready" if ( source != "none" - or work_order_storage_configured or alert_storage_configured ) else "not_configured" @@ -141,7 +124,7 @@ async def build_workbench_runtime_snapshot( "alerts": projected_alerts, "scenarios": scenarios, "latest_analysis": latest_analysis, - "work_orders": persisted_work_orders, + "work_orders": [], } diff --git a/contracts/manifest.json b/contracts/manifest.json index 392fe6a..948ca65 100644 --- a/contracts/manifest.json +++ b/contracts/manifest.json @@ -3,7 +3,7 @@ "contracts": { "server": { "file": "server-v1.openapi.json", - "sha256": "4a7ef16dc9ebf9731359c314ab600730dc9b06dd4b0396c0e4e847d57063ee12" + "sha256": "f684880cf2c17f9643899bbf52af31e877e127cd9a35f73bfcd00a2263bec835" } } } diff --git a/contracts/server-v1.openapi.json b/contracts/server-v1.openapi.json index c5dc38c..83e2a80 100644 --- a/contracts/server-v1.openapi.json +++ b/contracts/server-v1.openapi.json @@ -1904,6 +1904,18 @@ "title": "From", "type": "string" }, + "id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id" + }, "to": { "minLength": 1, "title": "To", @@ -1943,7 +1955,10 @@ "type": "string" }, "schema": { - "const": "execution-plan@1", + "enum": [ + "execution-plan@1", + "execution-plan@2" + ], "title": "Schema", "type": "string" }, @@ -1986,11 +2001,41 @@ "title": "Evidence Refs", "type": "array" }, + "gate_ref": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gate Ref" + }, "id": { "minLength": 1, "title": "Id", "type": "string" }, + "kind": { + "anyOf": [ + { + "enum": [ + "activity", + "decision", + "parallel_split", + "parallel_join", + "gate" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kind" + }, "owner": { "enum": [ "agent", @@ -1999,6 +2044,30 @@ "title": "Owner", "type": "string" }, + "revisits_node_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revisits Node Id" + }, + "selected_edge_id": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Selected Edge Id" + }, "status": { "enum": [ "pending", @@ -2235,6 +2304,27 @@ "title": "ExternalFeedbackResult", "type": "object" }, + "ExternalSourceWithdrawalCommand": { + "additionalProperties": false, + "properties": { + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source_event_id": { + "minLength": 1, + "title": "Source Event Id", + "type": "string" + } + }, + "required": [ + "request_id", + "source_event_id" + ], + "title": "ExternalSourceWithdrawalCommand", + "type": "object" + }, "ExternalWorkOrderSnapshot": { "additionalProperties": false, "properties": { @@ -2556,6 +2646,30 @@ ], "title": "Decision Comment" }, + "edited_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Edited At" + }, + "edited_by": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Edited By" + }, "evidence_refs": { "items": { "minLength": 1, @@ -3359,6 +3473,13 @@ "OperationalTaskDetail": { "additionalProperties": false, "properties": { + "access_requests": { + "items": { + "$ref": "#/components/schemas/TaskAccessRequest" + }, + "title": "Access Requests", + "type": "array" + }, "actions": { "items": { "$ref": "#/components/schemas/HumanApprovalAction" @@ -3633,6 +3754,15 @@ "title": "Kind", "type": "string" }, + "lifecycle_status": { + "default": "active", + "enum": [ + "active", + "resolved" + ], + "title": "Lifecycle Status", + "type": "string" + }, "notification_id": { "format": "uuid", "title": "Notification Id", @@ -3674,6 +3804,30 @@ ], "title": "Required Permission" }, + "resolution_reason": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resolution Reason" + }, + "resolved_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Resolved At" + }, "status": { "enum": [ "unread", @@ -3682,6 +3836,29 @@ "title": "Status", "type": "string" }, + "subject_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subject Id" + }, + "subject_type": { + "default": "task", + "enum": [ + "task", + "run", + "access_request", + "action" + ], + "title": "Subject Type", + "type": "string" + }, "task_id": { "format": "uuid", "title": "Task Id", @@ -3796,6 +3973,11 @@ "OperationalTaskPermissions": { "additionalProperties": false, "properties": { + "can_decide_access": { + "default": false, + "title": "Can Decide Access", + "type": "boolean" + }, "can_decide_commands": { "title": "Can Decide Commands", "type": "boolean" @@ -5955,6 +6137,247 @@ "title": "SourceGisFeatureProperties", "type": "object" }, + "TaskAccessRequest": { + "additionalProperties": false, + "properties": { + "access_request_id": { + "format": "uuid", + "title": "Access Request Id", + "type": "string" + }, + "approved_scopes": { + "items": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "type": "string" + }, + "title": "Approved Scopes", + "type": "array" + }, + "comment": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Comment" + }, + "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" + }, + "node_id": { + "minLength": 1, + "title": "Node Id", + "type": "string" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "requested_at": { + "format": "date-time", + "title": "Requested At", + "type": "string" + }, + "requested_by": { + "minLength": 1, + "title": "Requested By", + "type": "string" + }, + "scopes": { + "items": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "type": "string" + }, + "title": "Scopes", + "type": "array" + }, + "status": { + "enum": [ + "pending", + "approved", + "rejected" + ], + "title": "Status", + "type": "string" + }, + "task_id": { + "format": "uuid", + "title": "Task Id", + "type": "string" + }, + "version": { + "minimum": 1.0, + "title": "Version", + "type": "integer" + } + }, + "required": [ + "access_request_id", + "task_id", + "node_id", + "scopes", + "reason", + "status", + "approved_scopes", + "version", + "requested_by", + "requested_at" + ], + "title": "TaskAccessRequest", + "type": "object" + }, + "TaskAccessRequestCreate": { + "additionalProperties": false, + "properties": { + "node_id": { + "minLength": 1, + "title": "Node Id", + "type": "string" + }, + "reason": { + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "scopes": { + "items": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "type": "string" + }, + "maxItems": 7, + "minItems": 1, + "title": "Scopes", + "type": "array" + }, + "source_session_id": { + "minLength": 1, + "title": "Source Session Id", + "type": "string" + } + }, + "required": [ + "request_id", + "source_session_id", + "node_id", + "scopes", + "reason" + ], + "title": "TaskAccessRequestCreate", + "type": "object" + }, + "TaskAccessRequestDecision": { + "additionalProperties": false, + "properties": { + "approved_scopes": { + "items": { + "enum": [ + "workbench_runtime", + "scada_devices", + "scada_readings", + "peripheral_points", + "peripheral_work_orders", + "peripheral_observations", + "peripheral_test_data" + ], + "type": "string" + }, + "title": "Approved Scopes", + "type": "array" + }, + "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": "TaskAccessRequestDecision", + "type": "object" + }, "TaskAlertCommand": { "additionalProperties": false, "properties": { @@ -6258,6 +6681,18 @@ "TaskSource": { "additionalProperties": false, "properties": { + "deleted_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deleted At" + }, "event_kind": { "anyOf": [ { @@ -6486,6 +6921,52 @@ "title": "WorkOrderActivity", "type": "object" }, + "WorkOrderConfirmDispatchCommand": { + "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": "WorkOrderConfirmDispatchCommand", + "type": "object" + }, + "WorkOrderDraftUpdateCommand": { + "additionalProperties": false, + "properties": { + "expected_version": { + "minimum": 1.0, + "title": "Expected Version", + "type": "integer" + }, + "payload": { + "$ref": "#/components/schemas/CreateWorkOrderPayload" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + } + }, + "required": [ + "request_id", + "expected_version", + "payload" + ], + "title": "WorkOrderDraftUpdateCommand", + "type": "object" + }, "WorkbenchAlertSource": { "additionalProperties": false, "properties": { @@ -24073,6 +24554,250 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/access-requests": { + "post": { + "operationId": "post_operational_tasks_task_id_access_requests", + "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/TaskAccessRequestCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskAccessRequest" + } + } + }, + "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": "Request Operational Task Access", + "tags": [ + "Operational Tasks" + ] + } + }, + "/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision": { + "post": { + "operationId": "post_operational_tasks_task_id_access_requests_access_request_id_decision", + "parameters": [ + { + "in": "path", + "name": "task_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Task Id", + "type": "string" + } + }, + { + "in": "path", + "name": "access_request_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Access Request 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/TaskAccessRequestDecision" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskAccessRequest" + } + } + }, + "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 Access", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/actions": { "post": { "operationId": "post_operational_tasks_task_id_actions", @@ -24190,6 +24915,149 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch": { + "post": { + "operationId": "post_operational_tasks_task_id_actions_action_id_confirm_dispatch", + "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/WorkOrderConfirmDispatchCommand" + } + } + }, + "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": "Confirm Operational Work Order Dispatch", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/actions/{action_id}/decision": { "post": { "operationId": "post_operational_tasks_task_id_actions_action_id_decision", @@ -24317,6 +25185,133 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft": { + "patch": { + "operationId": "patch_operational_tasks_task_id_actions_action_id_draft", + "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/WorkOrderDraftUpdateCommand" + } + } + }, + "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": "Update Operational Work Order Draft", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/operational-tasks/{task_id}/actions/{action_id}/execute": { "post": { "operationId": "post_operational_tasks_task_id_actions_action_id_execute", @@ -26189,6 +27184,123 @@ ] } }, + "/api/v1/operational-tasks/{task_id}/withdraw-external-source": { + "post": { + "operationId": "post_operational_tasks_task_id_withdraw_external_source", + "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/ExternalSourceWithdrawalCommand" + } + } + }, + "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": "Withdraw Operational Task External Source", + "tags": [ + "Operational Tasks" + ] + } + }, "/api/v1/patterns": { "delete": { "description": "从网络中删除指定的模式", diff --git a/docs/dynamic-investigation-plan.md b/docs/dynamic-investigation-plan.md new file mode 100644 index 0000000..1e5ece5 --- /dev/null +++ b/docs/dynamic-investigation-plan.md @@ -0,0 +1,53 @@ +# 动态调查计划数据契约 + +调查任务从工单投诉内容或人工请求创建。Agent 在本次执行中生成 `execution-plan@2`,根据新证据追加修订;数据库保存不可变计划快照的 JSONB 文档,并不保存固定流程模板。任务、Run、证据、访问申请、审核动作和报告各有独立生命周期。计划中的 `gate_ref` 只引用已经持久化的访问申请或动作,不能把审批结果写成模型自行判断的文本。 + +## `execution-plan@2` + +| 字段 | 含义 | +| --- | --- | +| `schema` | 固定为 `execution-plan@2`;旧快照继续按 `execution-plan@1` 读取 | +| `title`、`reason` | 本次计划名称和制定或修订原因 | +| `nodes[]` | 具有稳定 `id`、`title`、`owner`、`status`、`detail`、`kind`、`evidence_refs[]`、`action_refs[]` 的步骤 | +| `nodes[].kind` | `activity` 单项执行;`decision` 条件判断;`parallel_split` 并行分叉;`parallel_join` 并行汇合;`gate` 人工审核或授权等待点 | +| `nodes[].selected_edge_id` | 仅判断节点使用。判断完成时指向实际选择的出边;未选择时为 `null` | +| `nodes[].gate_ref` | 仅审核节点使用。UUID 指向任务内已有的访问申请或待确认动作 | +| `nodes[].revisits_node_id` | 再次核验时,新节点引用先前节点 ID;不创建回环连线 | +| `edges[]` | 每条有稳定 `id`、`from`、`to` 和可空 `condition`;判断路线必须有条件文字 | + +图必须无环且只有一个起点。只有 `decision` 和 `parallel_split` 可以有多条出边;`parallel_join` 至少有两条入边。选中路线的直接目标不能标为跳过;未选路线的直接目标不能标为执行中、待人工或已完成。执行中和已完成的步骤,前置步骤必须完成或跳过。已完成的业务判断与活动必须引用真实证据或动作;纯分叉、汇合和审核节点不要求伪造证据。服务端在发布时再次核验引用、当前 Run 和会话租约。 + +下面是最小示例;`gate_ref` 所指申请先通过独立接口创建,再发布包含审核节点的计划。审批后,Agent 从当前任务上下文读取结果并发布下一版计划。 + +```json +{ + "schema": "execution-plan@2", + "title": "水质投诉调查", + "reason": "核对工单位置和当前风险", + "nodes": [ + {"id": "risk", "title": "判断紧急风险", "owner": "agent", "status": "completed", "detail": "根据投诉记录判断", "kind": "decision", "selected_edge_id": "urgent", "evidence_refs": ["complaint-1"], "action_refs": []}, + {"id": "field", "title": "安排现场采样", "owner": "human", "status": "pending", "detail": "待安排现场人员", "kind": "activity", "evidence_refs": [], "action_refs": []}, + {"id": "routine", "title": "常规核查", "owner": "human", "status": "skipped", "detail": "本次选择紧急路线", "kind": "activity", "evidence_refs": [], "action_refs": []}, + {"id": "split", "title": "并行核对", "owner": "agent", "status": "pending", "detail": "授权后同时核对现场和数据", "kind": "parallel_split", "evidence_refs": [], "action_refs": []}, + {"id": "upstream", "title": "上游监测", "owner": "agent", "status": "pending", "detail": "待批准读取", "kind": "activity", "evidence_refs": [], "action_refs": []}, + {"id": "network", "title": "管网运行", "owner": "agent", "status": "pending", "detail": "待核对运行事件", "kind": "activity", "evidence_refs": [], "action_refs": []}, + {"id": "join", "title": "汇总调查证据", "owner": "agent", "status": "waiting", "detail": "等待现场与数据结果", "kind": "parallel_join", "evidence_refs": [], "action_refs": []}, + {"id": "access", "title": "审核数据范围", "owner": "human", "status": "waiting", "detail": "等待项目成员审核", "kind": "gate", "gate_ref": "84000000-0000-4000-8000-000000000001", "evidence_refs": [], "action_refs": []} + ], + "edges": [ + {"id": "urgent", "from": "risk", "to": "access", "condition": "有紧急风险"}, + {"id": "normal", "from": "risk", "to": "routine", "condition": "无紧急风险"}, + {"id": "access-split", "from": "access", "to": "split", "condition": null}, + {"id": "field-route", "from": "split", "to": "field", "condition": "现场"}, + {"id": "upstream-route", "from": "split", "to": "upstream", "condition": "上游"}, + {"id": "network-route", "from": "split", "to": "network", "condition": "管网"}, + {"id": "field-join", "from": "field", "to": "join", "condition": null}, + {"id": "upstream-join", "from": "upstream", "to": "join", "condition": null}, + {"id": "network-join", "from": "network", "to": "join", "condition": null} + ] +} +``` + +实际调查可以把现场路径与 Agent 路径放在同一个并行分叉下,并在下一次修订中补充更多判断或并行组。已发布计划保留原样;拒绝授权、人工补充、复测或回访触发新 Run 时,Agent 读取当前任务事实后继续规划。旧 `execution-plan@1` 只有依赖关系,前端按原始前置条件展示,不推断它代表条件选择还是并行执行。 + +访问申请独立存于 `workbench.operational_task_access_requests`,包括请求范围、理由、申请节点、审核人、批准范围和版本;工单草稿及其审批、回执仍存于动作表。前端默认呈现选中路线,并在明确的分叉与汇合处组成并行组;未选路线折叠保留供核对。 diff --git a/docs/operational-notification-routing.md b/docs/operational-notification-routing.md index 4f92b5d..3bf2925 100644 --- a/docs/operational-notification-routing.md +++ b/docs/operational-notification-routing.md @@ -1,6 +1,15 @@ # 异常处置通知路由 -“异常处置”只展示需要当前用户判断、授权或处置的活动事项。通知表仍是用户收件箱,不替代 `domain_event_log`;列表按当前用户权限和已读状态过滤。 +“异常处置”只展示需要当前用户判断、授权或处置的活动事项。通知表仍是用户收件箱,不替代 `domain_event_log`;列表同时按通知生命周期、当前用户权限和已读状态过滤。 + +通知存在两个互不替代的状态维度: + +- `lifecycle_status=active|resolved` 保存在通知文档中,表示对应业务事项是否仍需处理,是项目共享状态。 +- `status=unread|read` 由 `operational_task_notification_reads` 按用户计算,只表示某个用户是否看过。 + +新通知保存 `subject_type + subject_id`,明确关联任务、Run、访问申请或动作。访问申请审核、动作审批、外部协同恢复、任务重试和人工材料补充,会在更新权威业务状态的同一事务中把对应通知改为 `resolved`,并追加 `operational.notification.changed`。Header 只接收 `active` 通知;已解决通知不会因为某个用户尚未阅读而继续计数。 + +共享生命周期事件使用 `operational_notification` 聚合;用户点击已读使用 `operational_notification_read` 聚合,聚合 ID 为 `notification_id:user_id`。这样项目共享的解决状态与每个用户的已读修订不会占用同一聚合版本。外围来源撤销时,尚未提交的动作审批、访问审批和任务级待办一并解决;已经提交或结果未知的外部动作仍保留只读回执核对入口。 ## 进入异常处置的类型 @@ -25,6 +34,10 @@ ## 数据库应用范围 -2026-09-24 本轮只允许在 `tjwater_v2` 执行 `020`。执行前必须确认 `current_database()`、完成备份,并在事务后核对:普通生命周期通知为零,审批、任务异常和外部协同异常仍保留。其他项目数据库待 `tjwater_v2` 验证通过后再单独评估,不批量应用。 +2026-09-24 本轮只允许在 `tjwater_v2` 执行 `020` 和 `022`。执行前必须确认 `current_database()`、完成备份,并在事务后核对:普通生命周期通知为零,仍待处理的审批保持 `active`,已经审批或恢复的事项为 `resolved`。`022` 只回填通知 JSON 文档及表注释,不新增表或物理列。其他项目数据库待 `tjwater_v2` 验证通过后再单独评估,不批量应用。 本次实际执行结果:删除 16 条普通生命周期通知,保留 2 条旧 `action_pending` 审批通知,活动业务告警仍为 0 条,并写入 `event_seq=39` 的批量失效事件。通知表和用户已读表的迁移前逻辑备份位于 `/home/zhifu/TJWaterBackups/notification-routing-20260924-gZvjsn/tjwater_v2-notifications-before-020.json`,SHA-256 为 `046d6c3a0888b981791f3f39ee1972f304aab18a7e6af035da742c069b97a369`。系统缺少 `pg_dump` 客户端,因此本轮没有生成新的整库 custom dump;上一轮整库备份未被覆盖。 + +`022_operational_notification_lifecycle.sql` 带有 `current_database()='tjwater_v2'` 硬门禁。实际执行后,2 条旧审批通知均已关联到动作:仍为 `pending/not_submitted` 的 1 条保持 `active`,已经 `approved/succeeded` 的 1 条变为 `resolved`;活动未读查询只返回前者。迁移写入批量失效事件 `event_seq=79`,并更新通知文档列注释。迁移前逻辑备份位于 `/home/zhifu/TJWaterBackups/notification-lifecycle-20260924-tLsVeI/tjwater_v2-notifications-before-022.json`,SHA-256 为 `09df5efd2c748536752eaf0df94ad455d88458bb99866ed2163d8a0db56979ea`。其他 BizDB 未执行;扩展仍需维护者明确确认。 + +历史通知本身没有业务对象 ID。回填只在任务内恰有一个候选动作或访问申请时建立关联;存在多个候选时保持 `active` 并关联任务,不按时间邻近关系猜测,也不会自动隐藏。后续由人工核对或专门迁移处理这类歧义数据。新写入始终携带精确 `subject_id`,不受此限制。 diff --git a/docs/operational-task-workers.md b/docs/operational-task-workers.md index 96b2912..4976fc3 100644 --- a/docs/operational-task-workers.md +++ b/docs/operational-task-workers.md @@ -81,7 +81,9 @@ conda run -n server python scripts/sync_peripheral_work_orders.py 使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器读取分页工单,将外围版本投影到 `external_work_order` 任务。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。 -通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务,也不影响 `workbench.work_orders` 中保留的三条演示工单。 +工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单仍由独立 worker 使用服务身份周期同步,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与 worker 固定项目不一致。 + +通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务。三张独立演示工单已迁入外围样板,由同一同步器维护。 水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。 @@ -91,7 +93,7 @@ conda run -n server python scripts/sync_peripheral_work_orders.py ## 调查数据读取 -`OPERATIONAL_DATA_SERVER_URL` 默认本机 8000;外围样板数据读取需配置 `OPERATIONAL_DATA_PERIPHERAL_URL`。查询只允许预定义的数据源、最多 20 个设备和 24 小时时间范围,响应有大小限制。每次查询保存内容摘要对应的数据快照;缺失、拒绝、超限或未配置都返回明确错误,不生成正常数据结论。截断结果必须保留不完整标记。 +`OPERATIONAL_DATA_SERVER_URL` 默认本机 8000;外围样板数据读取需配置 `OPERATIONAL_DATA_PERIPHERAL_URL`。人工批准任务级读取范围后,Server 使用仅保存在自身运行环境中的 `OPERATIONAL_DATA_DELEGATION_BEARER` 查询来源;Agent 服务不接收此凭证。该凭证必须拥有目标项目和来源的读取权限,缺失时已批准的查询返回 503。查询只允许预定义的数据源、最多 20 个设备和 24 小时时间范围,响应有大小限制。每次查询保存内容摘要对应的数据快照;缺失、拒绝、超限或未配置都返回明确错误,不生成正常数据结论。截断结果必须保留不完整标记。 ## 人工确认后的动作 diff --git a/docs/operational-tasks.md b/docs/operational-tasks.md index 7f250ac..0051c87 100644 --- a/docs/operational-tasks.md +++ b/docs/operational-tasks.md @@ -10,6 +10,12 @@ 预约任务采用单层结构:一张外围工单对应一条任务,工单快照直接提供状态、位置、负责人、预约时间、回执要求和按发生时间记录的 `activity`。活动仅由工单创建、修改或显式现场反馈产生;不能按当前状态补造派发、执行、复令三个阶段。旧快照中的 `subtasks` 仅在读取时兼容并丢弃,不回写外围工单或历史版本;后续外围修订同步为新结构。 - `event_investigation`:投诉、爆管上报或人工发起的调查。Agent 查询受控数据,发布执行图、证据引用和报告;现场反馈会创建新的执行 generation,让 Agent 重新分析。 +调查计划由 Agent 按本次工单或人工请求动态生成。新计划使用 `execution-plan@2`,节点声明执行步骤、条件判断、并行分叉/汇合和审核点;判断节点记录所选路线,审核点引用持久化申请或动作。重新分析追加计划修订和新节点,不使用环形连线。旧 `execution-plan@1` 快照保留原样读取。字段、约束与示例见 [`dynamic-investigation-plan.md`](dynamic-investigation-plan.md)。 + +数据读取申请通过 `POST /operational-tasks/{task_id}/access-requests` 创建,`POST .../access-requests/{access_request_id}/decision` 由具备项目审核权限及对应数据源读取权限的用户决定。申请、批准范围和审核身份保存在任务库中,批准或拒绝后重新排队分析。调查接口不会将服务身份继承的项目数据读取权限直接授予 Agent;批准的范围也只在该任务的有效执行期可查询。需配置 `OPERATIONAL_DATA_DELEGATION_BEARER` 为具备对应来源读取权限及该项目成员身份的专用产品凭证。未配置时返回 503,不以普通 Agent 会话的工具许可代替任务授权。 + +工单草稿可通过 `PATCH .../actions/{action_id}/draft` 修改并增加审计版本;`POST .../actions/{action_id}/confirm-dispatch` 使用一次人工操作批准当前版本并幂等提交,结果不明时继续使用回执核对。其他动作类型维持既有审批接口,不在调查页伪装为已支持的工单编辑动作。 + 创建请求使用 `request_id` 幂等。任务执行使用独立的 `run_id + execution_request_id`:Agent 必须领取当前 run,并通过 run 级心跳维持 60 秒租约。正常结束必须显式 complete;缺少同代最终计划或报告会以 `ARTIFACT_INCOMPLETE` 失败。`POST /operational-tasks/leases/recover` 会处理过期执行,连续三代超时后停止自动重试。 ## 主要接口 @@ -25,11 +31,15 @@ | `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}/access-requests` | Agent 创建任务级数据访问申请 | +| `POST .../access-requests/{access_request_id}/decision` | 人工批准部分或全部申请范围,或拒绝申请 | | `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` | 有对应审批权限的用户确认或拒绝 | +| `PATCH .../actions/{action_id}/draft` | 人工修改工单草稿并产生新审核版本 | +| `POST .../actions/{action_id}/confirm-dispatch` | 一次确认当前草稿并幂等下发工单 | | `POST .../actions/{action_id}/execute` | 重新校验执行权限后通过类型化适配器提交 | | `POST .../actions/{action_id}/reconcile` | 仅使用固定执行请求号查询不确定或已提交动作的回执 | | `GET /operational-task-notifications` | 返回当前用户可见、按用户区分已读状态的通知 | @@ -54,15 +64,12 @@ test "$(psql "$PROJECT_BIZ_DSN" -XAtc 'select current_database()')" = "tjwater_v2" ``` -完成备份和回滚演练后,再按依赖顺序应用。`017` 是工作台范围的注释迁移,要求告警、工单、任务和 Run 表均已存在: +`008`、`011`、`014`、`015` 和 `017` 属于历史基线;不要在旧工单表退役后重新运行 `011`,否则会重建它。`019` 和 `022` 都带有 `tjwater_v2` 物理数据库名硬门禁,其他 BizDB 尚未应用。完成备份和回滚演练后,仅在已核对的 `tjwater_v2` 执行迁移: ```bash -psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/008_workbench_alert_events.sql -psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/011_workbench_work_orders.sql -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 -psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/017_workbench_schema_comments.sql -psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/018_remove_synthetic_check_task_projections.sql +psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/019_operational_task_access_requests.sql +psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/021_retire_workbench_work_orders.sql +psql "$PROJECT_BIZ_DSN" -v ON_ERROR_STOP=1 -f resources/sql/022_operational_notification_lifecycle.sql ``` -`017_workbench_schema_comments.sql` 只增加数据库可见的表、列和 schema 注释,不改变数据、约束或索引。`018_remove_synthetic_check_task_projections.sql` 读取 `peripheral_sample.work_orders` 的不可编辑创建者标识,删除错误进入预约任务的旧种子检查投影,不修改外围表,也不删除演示工单、事件工单或调查动作创建的工单。迁移不授予 `PUBLIC` 权限。生产运行前需按部署身份显式授予新表权限。SQL 文件包含自己的事务边界,不能把外层 `ROLLBACK` 当作演练;具体要求见数据库变更门禁。 +`017_workbench_schema_comments.sql` 只增加历史基线的数据库可见注释。`018_remove_synthetic_check_task_projections.sql` 只清理错误进入预约任务的旧种子检查投影。`020_prune_non_actionable_notifications.sql` 删除历史任务创建、报告更新和正常完成通知,保留审批与异常通知。`021` 只有在三条演示工单均已迁入外围样板并形成有效工况任务时才删除旧表,不修改外围源及历史修订。`022_operational_notification_lifecycle.sql` 仅限 `tjwater_v2`,为保留通知回填 `active|resolved` 生命周期和业务对象引用,不新增表。通知路由和状态流见 [`operational-notification-routing.md`](operational-notification-routing.md)。SQL 文件包含自己的事务边界,不能把外层 `ROLLBACK` 当作演练;具体要求见数据库变更门禁。 diff --git a/docs/workbench-database-rollout.md b/docs/workbench-database-rollout.md index 67252bf..90724a8 100644 --- a/docs/workbench-database-rollout.md +++ b/docs/workbench-database-rollout.md @@ -8,12 +8,18 @@ `017_workbench_schema_comments.sql` 只为 `workbench` schema 及其 17 张表和关键列增加数据库可见注释。它先在 `tjwater_v2` 应用;其他项目数据库保持原状,直到 `tjwater_v2` 验证通过并获得明确扩展确认。 -`018_remove_synthetic_check_task_projections.sql` 清理曾被错误同步为预约任务的通用合成检查投影。它按完整 `peripheral_sample:` 标识关联外围源,只匹配 `general` 类型、没有调查动作回执关联且由专用 seed actor 创建的旧检查任务;不使用用户可编辑的标题或工单状态作为删除依据。`workbench.work_orders` 中三条演示工单、投诉与爆管事件以及调查产生的现场采样工单均不在清理范围。同步器使用同一专用 seed actor 阻止旧种子检查再次生成。 +`019_operational_task_access_requests.sql` 已只在 `tjwater_v2` 应用,新增带表级和列级注释的任务访问申请表;初始记录数为 0。旧 `workbench.work_orders` 已退役,因此应用后 `workbench` schema 仍为 17 张表,而不是继续增加重复投影。其他项目数据库尚未应用 `019`。 -预约工单结构调整无需 DDL。`seed_workbench_work_orders.py --project-id <已核对的项目 UUID>` 只重建该项目的三条演示预约投影,保留工单 ID 和标题,改为单层字段与显式活动记录。执行前必须核对脚本删除谓词实际命中的 ID;它不修改 `peripheral_sample.work_orders`、历史修订或调查任务。其他项目不得随 `tjwater_v2` 自动重建。 +未应用 `019` 的项目仍可读取既有工况任务,但创建访问申请会明确返回 `503 DEPENDENCY_UNAVAILABLE`,不会以缺表异常形成 500。单个任务同一时间只允许一个待审核访问申请;需要先审批或拒绝当前申请,避免 Agent 用不同请求号重复放大通知和事件。 + +`018_remove_synthetic_check_task_projections.sql` 清理曾被错误同步为预约任务的通用合成检查投影。它按完整 `peripheral_sample:` 标识关联外围源,只匹配 `general` 类型、没有调查动作回执关联且由专用 seed actor 创建的旧检查任务;不使用用户可编辑的标题或工单状态作为删除依据。投诉与爆管事件以及调查产生的现场采样工单均不在清理范围。同步器使用同一专用 seed actor 阻止旧种子检查再次生成。 + +旧的三条演示工单已迁入 `peripheral_sample.work_orders`,并同步为有效的 `external_work_order` 任务。`021_retire_workbench_work_orders.sql` 仅在物理数据库为 `tjwater_v2`、旧表恰有这三条且每条都有有效外围工单和任务投影时删除旧表。其他四个项目数据库原本没有旧表,不执行本迁移。旧种子和迁入脚本已退役,不能再运行历史 `011` 重建双份数据。 后续数据库变更默认只写入 `tjwater_v2`。其他项目保持现状,必须等 `tjwater_v2` 验证通过并由维护者明确确认后,再逐库扩展。禁止把“所有活跃项目”作为写操作的默认范围。 +通知生命周期迁移 `022_operational_notification_lifecycle.sql` 只修改现有通知 JSON 文档与列注释,不新增表或物理列。脚本带物理数据库名硬门禁;先在 `tjwater_v2` 核对待审批通知仍为 `active`、已审批通知变为 `resolved`,并验证 Header 只统计活动未读项。未取得维护者确认前,不向其他 BizDB 应用。 + ## 变更顺序 1. 核对目标 DSN,确认物理数据库名为 `tjwater_v2`,并确认没有其他项目共享该 BizDB。 diff --git a/docs/workbench-operations-storage.md b/docs/workbench-operations-storage.md index 588f2f1..3332d2d 100644 --- a/docs/workbench-operations-storage.md +++ b/docs/workbench-operations-storage.md @@ -1,6 +1,6 @@ # 工作台工单与告警存储契约 -工作台只聚合预约工单与任务主动推送的告警。浏览器不推导告警,也不写正式工单。 +工作台聚合外围来源工况任务与任务主动推送的告警。浏览器不推导告警,也不直接写工况任务。 ## 任务告警 @@ -8,16 +8,8 @@ DDL 位于 `resources/sql/008_workbench_alert_events.sql`。任务通过 `POST / ## 预约工单 -`workbench.work_orders` 的 DDL 位于 `resources/sql/011_workbench_work_orders.sql`。每行保存一个只读工单投影,文档必须满足 `WorkbenchRuntimeWorkOrder`: +外围样板的 `peripheral_sample.work_orders` 是演示工单的管理来源;`activity` 只记录实际发生的创建、修改和现场反馈,不由总状态补造阶段。同步器将有效工单写入 `workbench.operational_tasks` 的 `external_work_order` 投影。原有三张独立演示工单也已迁入外围样板,并由同一面板管理。 -`resources/sql/017_workbench_schema_comments.sql` 在 `008`、`011`、`014` 和 `015` 之后执行,为工作台 schema、17 张表和关键列增加数据库可见的用途说明。 +`resources/sql/011_workbench_work_orders.sql` 和 `017_workbench_schema_comments.sql` 属于历史基线;`021_retire_workbench_work_orders.sql` 在验证三个迁入目标及其任务投影后,仅在 `tjwater_v2` 删除旧表。不要重放 `011` 重建它。`GET /api/v1/workbench/runtime` 暂保留空的 `work_orders` 字段作为接口兼容。其他项目数据库没有旧表,不需要应用 `021`。 -- 一张预约工单对应一条单层投影,直接保存位置、负责人、预约时间和回执要求。 -- `activity` 只记录已经发生的创建、修改和现场反馈,按递增 `sequence` 排列;展示层不得根据工单总状态补造派发、执行或复令阶段。 -- 旧文档中的 `subtasks` 在兼容读取时被丢弃,不再进入新接口契约;历史修订保留原始审计内容。 - -预约任务演示数据通过 `scripts/seed_workbench_work_orders.py` 直接写入。工作台继续显示三张原有演示工单,保留既有 ID、标题和状态,并改用回执要求与真实活动记录表达过程。脚本可重复执行,并会先清理旧的外围合成工单投影。`scripts/seed_peripheral_sample.py` 不再创建通信、压力和零流量检查工单。 - -截至 2026-09-24,本次数据清理和三张演示工单结构重建仅应用于物理数据库 `tjwater_v2`,项目为 `324ff7ee-c2d4-5184-a84b-48fb14a2933e`。核验结果为三张演示工单均使用单层结构,旧种子检查投影为 0;其他项目数据库未执行本次变更。扩大范围必须遵循 [`workbench-database-rollout.md`](workbench-database-rollout.md) 的逐库门禁。 - -工作台运行账号对工单表只需 `SELECT`,同步账号使用独立受控写权限;对告警事件表需要 `SELECT, INSERT`,对告警状态表需要 `SELECT, INSERT, UPDATE`。 +告警事件表需要 `SELECT, INSERT`,告警状态表需要 `SELECT, INSERT, UPDATE`。迁移及扩展门禁见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。 diff --git a/docs/workbench-runtime.md b/docs/workbench-runtime.md index 0f399bc..988e4ff 100644 --- a/docs/workbench-runtime.md +++ b/docs/workbench-runtime.md @@ -1,10 +1,10 @@ # 工作台运行态 -`GET /api/v1/workbench/runtime` 按当前项目读取 BizDB 中的分析成果、预约工单和任务告警。调查任务由独立的工况任务接口读取 `workbench.operational_tasks` 和 `workbench.operational_task_runs`,`sync-snapshot` 再把两部分合并;定时任务仍保留前端 mock,不进入后端工作队列。 +`GET /api/v1/workbench/runtime` 按当前项目读取 BizDB 中的分析成果和任务告警。外围样板工单经同步器进入 `workbench.operational_tasks`,由工况任务接口和 `sync-snapshot` 提供给前端;定时任务表暂时保留,但不进入当前产品主路径。 - `analysis.runs/results` 只用于分析成果、历史成果和兼容场景字段。 -- `workbench.work_orders` 是预约工单的后端持久化投影。主工单包含多个子任务,每个子任务包含自己的有序阶段。表存在时,空列表同样是权威结果,不回退到 mock 工单。 +- 旧的 `workbench.work_orders` 已由 `021_retire_workbench_work_orders.sql` 退役。`work_orders: []` 和 `work_order_availability: not_configured` 仅保留接口兼容,不表示没有外围工单。 - `workbench.alert_events` 保存任务显式推送的事件,`workbench.alerts` 保存当前状态。分析结果、测点值和缺测状态不会自动变成告警。 - 每个可选分区独立读取并返回 `ready`、`not_configured` 或 `degraded`,一个分区失败不会污染其他分区。 -预约任务演示数据由 `scripts/seed_workbench_work_orders.py` 直接写入 `workbench.work_orders`,与外围样板的 synthetic 测试数据相互独立。种子集 `workbench-demo-orders@1` 恢复前端原有的三张 mock 工单,分别覆盖派发、执行和复令状态;脚本会在同一事务中删除旧的“外围合成数据核查”投影并幂等写入新记录。当前固定演示日期为 2026-09-17。外围种子脚本仍可重复执行,并会恢复曾被软删除的固定演示工单和测试数据。 +原有三张独立演示工单已迁入 `peripheral_sample.work_orders`,仍由外围样板面板管理,并由同步器投影为 `external_work_order` 工况任务。旧表退役前的完整数据库备份由部署记录保存,不能重新运行历史 `011_workbench_work_orders.sql` 或旧种子脚本重建双份数据。 diff --git a/resources/sql/011_workbench_work_orders.sql b/resources/sql/011_workbench_work_orders.sql index ced42ca..a337313 100644 --- a/resources/sql/011_workbench_work_orders.sql +++ b/resources/sql/011_workbench_work_orders.sql @@ -1,4 +1,5 @@ --- Read-only work-order projection synchronized by an external work-order source. +-- Historical work-order projection. Do not reapply after +-- 021_retire_workbench_work_orders.sql has retired this table. BEGIN; diff --git a/resources/sql/019_operational_task_access_requests.sql b/resources/sql/019_operational_task_access_requests.sql new file mode 100644 index 0000000..f26b5c0 --- /dev/null +++ b/resources/sql/019_operational_task_access_requests.sql @@ -0,0 +1,56 @@ +-- Task-scoped, reviewable data access requests for dynamic investigations. +-- This migration is deliberately restricted to the verified tjwater_v2 BizDB. +BEGIN; +SET LOCAL lock_timeout = '5s'; + +DO $$ +BEGIN + IF current_database() <> 'tjwater_v2' THEN + RAISE EXCEPTION 'Operational access-request migration is restricted to tjwater_v2'; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS workbench.operational_task_access_requests ( + project_id uuid NOT NULL, + task_id uuid NOT NULL, + access_request_id uuid NOT NULL, + node_id text NOT NULL CHECK (length(node_id) > 0), + status text NOT NULL CHECK (status IN ('pending','approved','rejected')), + version bigint NOT NULL CHECK (version >= 1), + document jsonb NOT NULL, + requested_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + PRIMARY KEY (project_id,task_id,access_request_id), + FOREIGN KEY (project_id,task_id) + REFERENCES workbench.operational_tasks(project_id,task_id) ON DELETE CASCADE, + CHECK ((document->>'access_request_id'=access_request_id::text + AND document->>'status'=status + AND (document->>'version')::bigint=version) IS TRUE) +); + +CREATE INDEX IF NOT EXISTS operational_task_access_pending_idx + ON workbench.operational_task_access_requests(project_id,status,requested_at) + WHERE status='pending'; + +COMMENT ON TABLE workbench.operational_task_access_requests IS + '动态调查中的任务级数据访问申请;与动作审批分离,保存申请范围及人工审核结果。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.project_id IS + '所属项目标识;当前仍要求一个项目独占一个 BizDB。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.task_id IS + '所属工况任务标识。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.access_request_id IS + '访问申请的稳定标识,同时用于计划审核节点引用。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.node_id IS + '发起申请的执行计划节点标识。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.status IS + '人工审核状态:pending、approved 或 rejected。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.version IS + '用于乐观并发控制的申请修订号。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.document IS + '经过接口模型校验的完整访问申请文档。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.requested_at IS + 'Agent 发起访问申请的时间。'; +COMMENT ON COLUMN workbench.operational_task_access_requests.updated_at IS + '申请最近一次状态变更时间。'; + +COMMIT; diff --git a/resources/sql/021_retire_workbench_work_orders.sql b/resources/sql/021_retire_workbench_work_orders.sql new file mode 100644 index 0000000..4b98640 --- /dev/null +++ b/resources/sql/021_retire_workbench_work_orders.sql @@ -0,0 +1,55 @@ +-- Retire the legacy demo-order projection only after its three rows have +-- moved to active peripheral orders and active operational task projections. +-- This migration is deliberately restricted to the verified tjwater_v2 BizDB. + +BEGIN; +SET LOCAL lock_timeout = '5s'; + +DO $$ +DECLARE + legacy_count integer; +BEGIN + IF current_database() <> 'tjwater_v2' THEN + RAISE EXCEPTION 'Legacy work-order retirement is restricted to tjwater_v2'; + END IF; + + IF to_regclass('workbench.work_orders') IS NULL THEN + RETURN; + END IF; + + LOCK TABLE workbench.work_orders IN ACCESS EXCLUSIVE MODE; + SELECT count(*) INTO legacy_count FROM workbench.work_orders; + IF legacy_count <> 3 OR EXISTS ( + SELECT 1 + FROM workbench.work_orders AS legacy + WHERE legacy.project_id <> '324ff7ee-c2d4-5184-a84b-48fb14a2933e'::uuid + OR legacy.work_order_id NOT IN ( + 'mock-work-order-incident-valve-dispatch', + 'mock-work-order-pump-boundary-reply', + 'mock-work-order-valve-operation-execution' + ) + OR legacy.document->>'migrated_to_peripheral_sample' IS NULL + OR NOT EXISTS ( + SELECT 1 FROM peripheral_sample.work_orders AS source_order + WHERE source_order.project_id = legacy.project_id + AND source_order.work_order_id::text = + legacy.document->>'migrated_to_peripheral_sample' + AND source_order.deleted_at IS NULL + ) + OR NOT EXISTS ( + SELECT 1 FROM workbench.operational_tasks AS task + WHERE task.project_id = legacy.project_id + AND task.task_type = 'external_work_order' + AND task.document#>>'{source,external_id}' = + 'peripheral_sample:' || + (legacy.document->>'migrated_to_peripheral_sample') + AND task.document#>>'{source,deleted_at}' IS NULL + ) + ) THEN + RAISE EXCEPTION 'Legacy work-order retirement preconditions failed'; + END IF; + + DROP TABLE workbench.work_orders; +END $$; + +COMMIT; diff --git a/resources/sql/022_operational_notification_lifecycle.sql b/resources/sql/022_operational_notification_lifecycle.sql new file mode 100644 index 0000000..9650b85 --- /dev/null +++ b/resources/sql/022_operational_notification_lifecycle.sql @@ -0,0 +1,225 @@ +-- Backfill actionable notification lifecycle state and business-object links. +-- This migration is deliberately restricted to the verified tjwater_v2 BizDB. + +BEGIN; +SET LOCAL lock_timeout = '5s'; + +DO $$ +BEGIN + IF current_database() <> 'tjwater_v2' THEN + RAISE EXCEPTION 'Notification lifecycle migration is restricted to tjwater_v2'; + END IF; +END $$; + +LOCK TABLE workbench.operational_task_notifications IN SHARE ROW EXCLUSIVE MODE; + +CREATE TEMP TABLE notification_lifecycle_projects ON COMMIT DROP AS +SELECT project_id, count(*)::bigint AS notification_count +FROM workbench.operational_task_notifications +GROUP BY project_id; + +WITH mapped AS ( + SELECT + notification.project_id, + notification.notification_id, + action.action_id, + action.approval_status, + action.execution_status + FROM workbench.operational_task_notifications AS notification + LEFT JOIN LATERAL ( + SELECT candidate.action_id, candidate.approval_status, candidate.execution_status + FROM workbench.operational_task_actions AS candidate + WHERE candidate.project_id = notification.project_id + AND candidate.task_id = notification.task_id + AND 1 = ( + SELECT count(*) + FROM workbench.operational_task_actions AS sibling + WHERE sibling.project_id = notification.project_id + AND sibling.task_id = notification.task_id + ) + LIMIT 1 + ) AS action ON true + WHERE notification.document->>'kind' IN ( + 'action_pending', 'action_approval_pending', 'external_coordination_failed' + ) + AND NOT (notification.document ? 'lifecycle_status') +) +UPDATE workbench.operational_task_notifications AS notification +SET document = notification.document || jsonb_strip_nulls(jsonb_build_object( + 'lifecycle_status', CASE + WHEN mapped.action_id IS NULL THEN 'active' + WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending') + AND mapped.approval_status = 'pending' THEN 'active' + WHEN notification.document->>'kind' = 'external_coordination_failed' + AND mapped.execution_status IN ('failed', 'unsupported', 'unknown') THEN 'active' + ELSE 'resolved' + END, + 'subject_type', CASE WHEN mapped.action_id IS NULL THEN 'task' ELSE 'action' END, + 'subject_id', COALESCE(mapped.action_id::text, notification.task_id::text), + 'resolved_at', CASE + WHEN (notification.document->>'kind' IN ('action_pending', 'action_approval_pending') + AND mapped.approval_status <> 'pending') + OR (notification.document->>'kind' = 'external_coordination_failed' + AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown')) + THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP)) + ELSE NULL + END, + 'resolution_reason', CASE + WHEN notification.document->>'resolution_reason' IS NOT NULL + THEN notification.document->>'resolution_reason' + WHEN notification.document->>'kind' IN ('action_pending', 'action_approval_pending') + AND mapped.approval_status <> 'pending' THEN 'migration_action_decided' + WHEN notification.document->>'kind' = 'external_coordination_failed' + AND mapped.execution_status NOT IN ('failed', 'unsupported', 'unknown') + THEN 'migration_external_coordination_recovered' + ELSE NULL + END +)) +FROM mapped +WHERE notification.project_id = mapped.project_id + AND notification.notification_id = mapped.notification_id; + +DO $block$ +BEGIN + IF to_regclass('workbench.operational_task_access_requests') IS NOT NULL THEN + EXECUTE $update$ + WITH mapped AS ( + SELECT + notification.project_id, + notification.notification_id, + access_request.access_request_id, + access_request.status + FROM workbench.operational_task_notifications AS notification + LEFT JOIN LATERAL ( + SELECT candidate.access_request_id, candidate.status + FROM workbench.operational_task_access_requests AS candidate + WHERE candidate.project_id = notification.project_id + AND candidate.task_id = notification.task_id + AND 1 = ( + SELECT count(*) + FROM workbench.operational_task_access_requests AS sibling + WHERE sibling.project_id = notification.project_id + AND sibling.task_id = notification.task_id + ) + LIMIT 1 + ) AS access_request ON true + WHERE notification.document->>'kind' = 'access_approval_pending' + AND NOT (notification.document ? 'lifecycle_status') + ) + UPDATE workbench.operational_task_notifications AS notification + SET document = notification.document || jsonb_strip_nulls(jsonb_build_object( + 'lifecycle_status', CASE + WHEN mapped.access_request_id IS NULL OR mapped.status = 'pending' + THEN 'active' ELSE 'resolved' + END, + 'subject_type', CASE + WHEN mapped.access_request_id IS NULL THEN 'task' + ELSE 'access_request' + END, + 'subject_id', COALESCE( + mapped.access_request_id::text, + notification.task_id::text + ), + 'resolved_at', CASE + WHEN mapped.access_request_id IS NOT NULL AND mapped.status <> 'pending' + THEN COALESCE( + notification.document->'resolved_at', + to_jsonb(CURRENT_TIMESTAMP) + ) + ELSE NULL + END, + 'resolution_reason', CASE + WHEN notification.document->>'resolution_reason' IS NOT NULL + THEN notification.document->>'resolution_reason' + WHEN mapped.status <> 'pending' THEN 'migration_access_request_decided' + ELSE NULL + END + )) + FROM mapped + WHERE notification.project_id = mapped.project_id + AND notification.notification_id = mapped.notification_id + $update$; + END IF; +END $block$; + +UPDATE workbench.operational_task_notifications AS notification +SET document = notification.document || jsonb_strip_nulls(jsonb_build_object( + 'lifecycle_status', CASE + WHEN notification.document->>'kind' = 'task_failed' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'failed' + THEN 'active' + WHEN notification.document->>'kind' = 'human_input_required' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') = 'waiting_human' + THEN 'active' + WHEN notification.document->>'kind' IN ('task_failed', 'human_input_required') + THEN 'resolved' + ELSE COALESCE(notification.document->>'lifecycle_status', 'active') + END, + 'subject_type', COALESCE(notification.document->>'subject_type', 'task'), + 'subject_id', COALESCE(notification.document->>'subject_id', notification.task_id::text), + 'resolved_at', CASE + WHEN notification.document->>'kind' = 'task_failed' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed' + THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP)) + WHEN notification.document->>'kind' = 'human_input_required' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human' + THEN COALESCE(notification.document->'resolved_at', to_jsonb(CURRENT_TIMESTAMP)) + ELSE notification.document->'resolved_at' + END, + 'resolution_reason', CASE + WHEN notification.document->>'kind' = 'task_failed' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'failed' + THEN 'migration_task_recovered' + WHEN notification.document->>'kind' = 'human_input_required' + AND COALESCE(task.document->>'lifecycle_status', task.document->>'status') <> 'waiting_human' + THEN 'migration_human_input_completed' + ELSE notification.document->>'resolution_reason' + END +)) +FROM workbench.operational_tasks AS task +WHERE notification.project_id = task.project_id + AND notification.task_id = task.task_id + AND notification.document->>'kind' NOT IN ( + 'action_pending', 'action_approval_pending', + 'external_coordination_failed', 'access_approval_pending' + ) + AND NOT (notification.document ? 'lifecycle_status'); + +WITH inserted_events AS ( + INSERT INTO workbench.domain_event_log ( + project_id, + event_id, + topic, + aggregate_type, + aggregate_id, + aggregate_revision, + metadata + ) + SELECT + project_id, + ( + substr(md5(project_id::text || ':notification-lifecycle-v1'), 1, 8) + || '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 9, 4) + || '-5' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 14, 3) + || '-a' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 18, 3) + || '-' || substr(md5(project_id::text || ':notification-lifecycle-v1'), 21, 12) + )::uuid, + 'operational.notification.changed', + 'operational_notification_batch', + project_id::text, + 1, + jsonb_build_object( + 'change', 'lifecycle_backfilled', + 'notification_count', notification_count + ) + FROM notification_lifecycle_projects + ON CONFLICT (project_id, event_id) DO NOTHING + RETURNING project_id, event_seq +) +SELECT pg_notify('workbench_events', project_id::text || ':' || event_seq::text) +FROM inserted_events; + +COMMENT ON COLUMN workbench.operational_task_notifications.document IS + '通知内容及 active/resolved 生命周期;用户 unread/read 状态独立存于 notification_reads。'; + +COMMIT; diff --git a/scripts/seed_workbench_work_orders.py b/scripts/seed_workbench_work_orders.py deleted file mode 100644 index c674512..0000000 --- a/scripts/seed_workbench_work_orders.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Idempotently seed the workbench's original demo reservation tasks.""" - -from __future__ import annotations - -import argparse -import json -import sys -from datetime import datetime, timedelta -from pathlib import Path -from uuid import UUID, uuid4 -from zoneinfo import ZoneInfo - -from psycopg.types.json import Jsonb - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - -from app.domain.schemas.workbench_runtime import WorkbenchRuntimeWorkOrder -from app.native.wndb.core.connection import project_connection - - -SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql" -SHANGHAI = ZoneInfo("Asia/Shanghai") -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 = ( - "合成数据预约工单", - "外围样板预约工单", -) - -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": [ - "确认阀门当前开度", - "记录影响分区和用户范围", - "回传派发确认截图", - ], - }, - { - "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, - "dispatch_lead_minutes": 20, - "reply_window_minutes": 35, - "reply_requirements": [ - "上传泵站运行截图", - "填写出水压力复核结果", - "确认模型边界已同步更新", - ], - }, -) - - -def _build_activity(template: dict, scheduled_at: datetime, updated_at: datetime) -> list[dict]: - created_at = scheduled_at - timedelta(minutes=template["dispatch_lead_minutes"]) - activity = [{"sequence": 1, "occurred_at": created_at, - "actor": template["dispatcher"], "kind": "created", - "title": "演示工单创建", "detail": template["summary"]}] - if template["phase"] != "dispatch": - activity.append({"sequence": 2, "occurred_at": updated_at, - "actor": template["assignee"], "kind": "updated", - "title": "演示状态更新", "detail": template["detail"]}) - return activity - - -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"], - "location": template["location"], - "assignee": template["assignee"], - "feedback_requirements": template["reply_requirements"], - "activity": _build_activity(template, scheduled_at, updated_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 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, - ), - ) - row = cursor.fetchone() - conn.execute( - "SELECT pg_notify('workbench_events', %s)", - (f'{project_id}:{row["event_seq"]}',), - ) - - -def main() -> int: - parser = argparse.ArgumentParser() - 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")) - replace_demo_work_orders(conn, args.project_id, documents) - publish_demo_work_order_batch_event(conn, args.project_id, len(documents)) - - print( - json.dumps( - { - "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"], - "feedback_requirement_count": len( - document["feedback_requirements"] - ), - } - for document in documents - ], - }, - ensure_ascii=False, - indent=2, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/sync_peripheral_work_orders.py b/scripts/sync_peripheral_work_orders.py index 95bfe45..66405eb 100644 --- a/scripts/sync_peripheral_work_orders.py +++ b/scripts/sync_peripheral_work_orders.py @@ -6,10 +6,14 @@ import argparse import asyncio import logging import os -from uuid import NAMESPACE_URL, uuid5 +import sys +from pathlib import Path +from uuid import NAMESPACE_URL, UUID, uuid5 import httpx +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from samples.peripheral.seed_identity import SEED_ACTOR_ID try: @@ -24,6 +28,16 @@ STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress" EVENT_WORK_ORDER_TYPES = frozenset({"water_quality_complaint", "burst_report"}) +def peripheral_order_id(external_id: str | None) -> str | None: + """Return only canonical IDs owned by this synchronizer's namespace.""" + if not external_id or not external_id.startswith("peripheral_sample:"): + return None + try: + return str(UUID(external_id.removeprefix("peripheral_sample:"))) + except ValueError: + return None + + def should_project_work_order(order: dict, linked_order_ids: set[str]) -> bool: """Exclude retired seed checks while preserving user and workflow orders.""" work_order_id = order["work_order_id"] @@ -87,6 +101,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe linked_order_ids = set(linked) cursor, synchronized = None, 0 + seen_order_ids: set[str] = set() while True: params = {"limit": 100} if cursor: @@ -98,6 +113,7 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe 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") + seen_order_ids.add(order["work_order_id"]) if not should_project_work_order(order, linked_order_ids): continue external_id = f"peripheral_sample:{order['work_order_id']}" @@ -162,7 +178,37 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe synchronized += 1 cursor = page.get("next_cursor") if not cursor: - return synchronized + break + + for external_id, task in existing.items(): + order_id = peripheral_order_id(external_id) + if order_id is None: + continue + if order_id in seen_order_ids: + continue + # The list may change during pagination. Only a scoped 404 proves that + # the source order is now absent; transient errors must stop this tick. + response = await client.get( + f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders/{order_id}", + headers=headers, + ) + if response.status_code == 200: + continue + if response.status_code != 404: + response.raise_for_status() + if response.json().get("code") != "WORK_ORDER_NOT_FOUND": + raise ValueError("peripheral work order lookup did not confirm deletion") + result = await client.post( + f"{tasks_url}/{task['task_id']}/withdraw-external-source", + headers=headers, + json={ + "request_id": str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:withdraw")), + "source_event_id": external_id, + }, + ) + result.raise_for_status() + synchronized += 1 + return synchronized async def run(once: bool): diff --git a/tests/api/test_agent_auth_endpoints.py b/tests/api/test_agent_auth_endpoints.py index 5852cc4..ceefd89 100644 --- a/tests/api/test_agent_auth_endpoints.py +++ b/tests/api/test_agent_auth_endpoints.py @@ -58,7 +58,7 @@ def test_agent_auth_context_returns_metadata_user_and_project_context(): "burst.view", "operational_action.work_order.approve", "operational_action.work_order.execute", - "operational_task.external_sync", + "operational_task.access.approve", "operational_task.view", "operational_task.write", "optimization.run", diff --git a/tests/api/test_operational_task_dynamic_flow.py b/tests/api/test_operational_task_dynamic_flow.py new file mode 100644 index 0000000..300a269 --- /dev/null +++ b/tests/api/test_operational_task_dynamic_flow.py @@ -0,0 +1,69 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.api.v1.endpoints import operational_tasks as endpoints +from app.auth.project_dependencies import ProjectContext +from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot +from app.domain.schemas.operational_task import EvidenceRecord +from app.main import app + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def _service_context(): + return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", + project_role="member", system_role="service") + + +def test_dynamic_investigation_routes_are_in_the_public_contract(): + paths = app.openapi()["paths"] + assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests"] + assert "post" in paths["/api/v1/operational-tasks/{task_id}/access-requests/{access_request_id}/decision"] + assert "patch" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/draft"] + assert "post" in paths["/api/v1/operational-tasks/{task_id}/actions/{action_id}/confirm-dispatch"] + + +@pytest.mark.anyio +async def test_data_query_requires_task_grant_before_source_request(monkeypatch): + service = SimpleNamespace(can_read_source=AsyncMock(return_value=False)) + source = AsyncMock() + monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service) + monkeypatch.setattr(endpoints, "read_task_data", source) + with pytest.raises(HTTPException) as error: + await endpoints.query_operational_task_data( + uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT", + ctx=_service_context(), conn=object(), + ) + assert error.value.status_code == 403 + source.assert_not_awaited() + + +@pytest.mark.anyio +async def test_approved_task_source_uses_server_delegation(monkeypatch): + now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc) + snapshot = TaskDataSnapshot(source="scada_devices", source_ref="scada_devices:sha256:test", + queried_at=now, origin="system_data", data={"items": []}, truncated=False) + evidence = EvidenceRecord(evidence_id=snapshot.source_ref, kind="system_data", + summary="SCADA 设备数据快照", source_label="scada_devices", + source_ref=snapshot.source_ref, observed_at=now, recorded_at=now, + recorded_by="service", has_snapshot=True) + service = SimpleNamespace(can_read_source=AsyncMock(return_value=True), + record_data_snapshot=AsyncMock(return_value=(evidence, snapshot))) + source = AsyncMock(return_value=snapshot) + monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service) + monkeypatch.setattr(endpoints, "read_task_data", source) + result = await endpoints.query_operational_task_data( + uuid4(), TaskDataQuery(source="scada_devices"), authorization="Bearer AGENT", + ctx=_service_context(), conn=object(), + ) + assert source.await_args.kwargs["delegated"] is True + assert result.evidence.evidence_id == snapshot.source_ref + service.record_data_snapshot.assert_awaited_once() diff --git a/tests/api/test_workbench_runtime_endpoints.py b/tests/api/test_workbench_runtime_endpoints.py index 0cf14df..42e63c7 100644 --- a/tests/api/test_workbench_runtime_endpoints.py +++ b/tests/api/test_workbench_runtime_endpoints.py @@ -88,7 +88,6 @@ def test_runtime_endpoint_returns_typed_empty_projection(monkeypatch): async def no_operations(_conn): return WorkbenchOperationsAvailability( schedules=False, - work_orders=False, alerts=False, ) @@ -192,7 +191,6 @@ def test_runtime_endpoint_keeps_analysis_projection_out_of_conditions(monkeypatc async def no_operations(_conn): return WorkbenchOperationsAvailability( schedules=False, - work_orders=False, alerts=False, ) @@ -374,7 +372,7 @@ def test_analysis_run_endpoint_returns_404_for_an_unknown_run(monkeypatch): assert response.status_code == 404 -def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): +def test_runtime_endpoint_reads_alert_storage_without_legacy_work_orders(monkeypatch): app = build_test_app(workbench_runtime.router, "/api/v1") configure_dependencies(app) now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc) @@ -386,46 +384,9 @@ def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): async def operations_available(_conn): return WorkbenchOperationsAvailability( schedules=False, - work_orders=True, alerts=True, ) - async def work_orders(_conn, project_id): - assert project_id == str(PROJECT_CONTEXT.project_id) - return [{ - "id": "wo-1", - "code": "WO-1", - "scheduled_at": now, - "title": "阀门复核", - "summary": "现场复核阀门。", - "status": "completed", - "risk_level": "normal", - "updated_at": now, - "detail": "正式工单同步记录。", - "recommendation": None, - "duration_minutes": 30, - "source": "生产工单系统", - "dispatcher": "调度中心", - "priority": "normal", - "subtasks": [{ - "id": "inspect-valve", - "title": "阀门复核", - "summary": "现场复核阀门。", - "status": "completed", - "scheduled_at": now, - "location": "V-1", - "assignee": "现场班组", - "duration_minutes": 30, - "reply_window_minutes": 15, - "stages": [ - {"id": "dispatch", "sequence": 1, "title": "派发", "status": "done", "owner": "调度中心", "time_label": "10:30", "description": "已派发。"}, - {"id": "execution", "sequence": 2, "title": "执行", "status": "done", "owner": "现场班组", "time_label": "10:30—11:00", "description": "已执行。"}, - {"id": "reply", "sequence": 3, "title": "复令", "status": "done", "owner": "现场班组", "time_label": "11:00—11:15", "description": "已复令。"}, - ], - "reply_requirements": ["回传结果"], - }], - }] - async def alerts(_conn, project_id): assert project_id == str(PROJECT_CONTEXT.project_id) return [{ @@ -445,7 +406,6 @@ def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs) monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "availability", operations_available) - monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_work_orders", work_orders) monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_alerts", alerts) response = TestClient(app).get("/api/v1/workbench/runtime") @@ -455,9 +415,8 @@ def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): assert payload["source"] == "none" assert payload["schedule_availability"] == "not_configured" assert payload["conditions"] == [] - assert payload["work_order_availability"] == "ready" - assert payload["work_orders"][0]["code"] == "WO-1" - assert payload["work_orders"][0]["status"] == "completed" + assert payload["work_order_availability"] == "not_configured" + assert payload["work_orders"] == [] assert payload["alert_availability"] == "ready" assert payload["alerts"][0]["title"] == "泵站通信告警" assert payload["alerts"][0]["source"] == { @@ -465,44 +424,3 @@ def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch): "task_id": "diagnosis-5m", "run_id": "trigger-1", } - - -def test_runtime_endpoint_isolates_invalid_work_order_documents(monkeypatch): - app = build_test_app(workbench_runtime.router, "/api/v1") - configure_dependencies(app) - - async def empty_runs(_conn, *, limit): - assert limit == 100 - return [] - - async def operations_available(_conn): - return WorkbenchOperationsAvailability( - schedules=False, - work_orders=True, - alerts=False, - ) - - async def invalid_work_orders(_conn, _project_id): - return [{"id": "wo-invalid", "subtasks": []}] - - monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs) - monkeypatch.setattr( - workbench_runtime.WorkbenchOperationsRepository, - "availability", - operations_available, - ) - monkeypatch.setattr( - workbench_runtime.WorkbenchOperationsRepository, - "list_work_orders", - invalid_work_orders, - ) - - response = TestClient(app).get("/api/v1/workbench/runtime") - - assert response.status_code == 200 - payload = response.json() - assert payload["source"] == "none" - assert payload["schedule_availability"] == "not_configured" - assert payload["work_order_availability"] == "degraded" - assert payload["availability"] == "degraded" - assert payload["work_orders"] == [] diff --git a/tests/unit/test_operational_data.py b/tests/unit/test_operational_data.py index 59a2c58..6a1d8c4 100644 --- a/tests/unit/test_operational_data.py +++ b/tests/unit/test_operational_data.py @@ -5,6 +5,8 @@ import pytest from pydantic import ValidationError from app.auth.project_dependencies import ProjectContext +from app.api.v1.endpoints.operational_tasks import _task_permissions +from app.auth.permissions import SCADA_VIEW, SIMULATION_VIEW from app.domain.schemas.operational_data import TaskDataQuery from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data @@ -129,3 +131,28 @@ def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count(): start="2026-09-01T00:00:00Z", end="2026-09-23T00:00:00Z") with pytest.raises(ValidationError): TaskDataQuery(source="peripheral_observations") + + +@pytest.mark.anyio +async def test_task_service_uses_approved_delegation_only_during_source_read(): + ctx = ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", + project_role="member", system_role="service") + assert SCADA_VIEW not in _task_permissions(ctx) + assert SIMULATION_VIEW not in _task_permissions(ctx) + query = TaskDataQuery(source="scada_devices") + with pytest.raises(TaskDataError, match="委托身份尚未配置"): + await read_task_data(context=ctx, credential="Bearer AGENT", query=query, + delegated=True, settings=settings()) + + def handler(request): + assert request.headers["Authorization"] == "Bearer REVIEWED_SOURCE" + assert request.headers["X-Project-Id"] == str(ctx.project_id) + return httpx.Response(200, json={"items": [], "project_id": str(ctx.project_id)}) + + snapshot = await read_task_data( + context=ctx, credential="Bearer AGENT", query=query, delegated=True, + settings=TaskDataSettings(_env_file=None, server_url="http://server.test", + delegation_bearer="REVIEWED_SOURCE"), + transport=httpx.MockTransport(handler), + ) + assert snapshot.source == "scada_devices" diff --git a/tests/unit/test_operational_tasks.py b/tests/unit/test_operational_tasks.py index 0736b8d..678de6e 100644 --- a/tests/unit/test_operational_tasks.py +++ b/tests/unit/test_operational_tasks.py @@ -5,11 +5,14 @@ from unittest.mock import AsyncMock from uuid import uuid4 import pytest +from pydantic import ValidationError from app.auth.permissions import ( OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, OPERATIONAL_TASK_VIEW, + OPERATIONAL_TASK_ACCESS_APPROVE, + SCADA_VIEW, ) from app.auth.project_dependencies import ProjectContext from app.domain.domain_errors import DomainError @@ -20,14 +23,22 @@ from app.domain.schemas.operational_task import ( AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand, + ExternalSourceWithdrawalCommand, HumanApprovalAction, NotificationAcknowledgeCommand, OperationalTaskNotification, OperationalTaskRun, OperationalTaskSummary, + ExecutionPlanInput, + TaskAccessRequestCreate, + TaskAccessRequestDecision, + TaskAccessRequest, + WorkOrderDraftUpdateCommand, + WorkOrderConfirmDispatchCommand, ) from app.domain.schemas.operational_data import TaskDataSnapshot from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository +from app.infra.integrations.operational_actions import ActionDispatchOutcome from app.services.operational_tasks import OperationalTaskService @@ -41,6 +52,7 @@ class NotificationRepository: self.notification = notification self.reads = {} self.requests = {} + self.events = [] @asynccontextmanager async def transaction(self): @@ -73,6 +85,32 @@ class NotificationRepository: assert version == 2 self.reads[user_id] = read_at + async def append_event(self, **event): + self.events.append(event) + return len(self.events) + + +@pytest.mark.anyio +async def test_access_request_read_keeps_existing_tasks_available_before_migration(): + connection = SimpleNamespace(execute=AsyncMock()) + connection.execute.return_value.fetchone = AsyncMock(return_value={"relation": None}) + repository = OperationalTaskRepository(connection, uuid4()) + + assert await repository.list_access_requests(str(uuid4())) == [] + assert connection.execute.await_count == 1 + + +@pytest.mark.anyio +async def test_access_request_read_uses_table_after_migration(): + availability = SimpleNamespace(fetchone=AsyncMock(return_value={"relation": "workbench.operational_task_access_requests"})) + records = [{"document": {"access_request_id": str(uuid4())}}] + result = SimpleNamespace(fetchall=AsyncMock(return_value=records)) + connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, result])) + repository = OperationalTaskRepository(connection, uuid4()) + + assert await repository.list_access_requests(str(uuid4())) == [records[0]["document"]] + assert connection.execute.await_count == 2 + def _context(user_id=None, *, system_role="user"): return ProjectContext( @@ -116,6 +154,11 @@ async def test_notification_read_state_is_isolated_per_user(): ) assert second_read.status == "read" assert second_read.read_by == f"principal:{second.user_id}" + assert [event["aggregate_type"] for event in repository.events] == [ + "operational_notification_read", + "operational_notification_read", + ] + assert repository.events[0]["aggregate_id"] != repository.events[1]["aggregate_id"] def test_running_plan_state_wins_over_future_human_waiting_node(): @@ -142,6 +185,20 @@ def test_pending_approval_prevents_plan_from_marking_task_completed(): assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human" +def test_pending_access_request_prevents_plan_from_marking_task_completed(): + request = TaskAccessRequest( + access_request_id=uuid4(), task_id=uuid4(), node_id="read-scada", + scopes=["scada_devices"], reason="核对监测点", status="pending", + approved_scopes=[], version=1, requested_by="principal:agent", + requested_at=datetime.now(timezone.utc), + ) + nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()] + + assert OperationalTaskService._status_from_plan( + nodes, [], [request] + ) == "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"})(), @@ -210,6 +267,7 @@ class TaskRepository: self.reports = [] self.notifications = [] self.locks = [] + self.access_requests = {} @asynccontextmanager async def transaction(self): @@ -254,6 +312,19 @@ class TaskRepository: async def insert_evidence(self, _task_id, evidence): self.evidence[evidence["evidence_id"]] = evidence + async def list_access_requests(self, _task_id): + return list(self.access_requests.values()) + + async def get_access_request(self, _task_id, access_request_id): + return self.access_requests.get(access_request_id) + + async def insert_access_request(self, _task_id, document): + self.access_requests[document["access_request_id"]] = document + + async def update_access_request(self, _task_id, document, expected_version): + assert document["version"] == expected_version + 1 + self.access_requests[document["access_request_id"]] = document + async def list_actions(self, _task_id): return [self.action] if self.action else [] @@ -271,6 +342,26 @@ class TaskRepository: async def insert_notification(self, notification): self.notifications.append(notification) + async def resolve_notifications( + self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason + ): + resolved = [] + for notification in self.notifications: + if ( + notification["task_id"] == task_id + and notification["kind"] in kinds + and notification.get("lifecycle_status", "active") == "active" + and notification.get("subject_type") == subject_type + and notification.get("subject_id") == subject_id + ): + notification.update( + lifecycle_status="resolved", + resolved_at=resolved_at.isoformat(), + resolution_reason=resolution_reason, + ) + resolved.append(notification.copy()) + return resolved + async def get_feedback(self, task_id, source_event_id): return self.feedback.get((task_id, source_event_id)) @@ -565,6 +656,17 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation(): 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 + approval_notice = next( + item for item in repository.notifications + if item["kind"] == "action_approval_pending" + ) + assert approval_notice["lifecycle_status"] == "resolved" + assert approval_notice["resolution_reason"] == "action_rejected" + assert any( + event["topic"] == "operational.notification.changed" + and event["metadata"].get("change") == "resolved" + for event in repository.events + ) @pytest.mark.anyio @@ -600,6 +702,206 @@ async def test_action_decision_rejects_a_stale_expected_version(): assert repository.action["version"] == action.version +def test_v2_plan_requires_explicit_decision_route_and_rejects_cycles(): + plan = { + "schema": "execution-plan@2", "title": "动态调查", "reason": "核对投诉与风险", + "nodes": [ + {"id": "risk", "title": "判断风险", "owner": "agent", "status": "completed", + "detail": "按已核实事件选择", "kind": "decision", "selected_edge_id": "urgent", + "evidence_refs": ["risk-evidence"]}, + {"id": "urgent-check", "title": "紧急采样", "owner": "human", "status": "waiting", + "detail": "等待现场人员", "kind": "activity"}, + {"id": "routine-check", "title": "常规核查", "owner": "human", "status": "skipped", + "detail": "紧急路线已选", "kind": "activity"}, + ], + "edges": [ + {"id": "urgent", "from": "risk", "to": "urgent-check", "condition": "有紧急风险"}, + {"id": "routine", "from": "risk", "to": "routine-check", "condition": "无紧急风险"}, + ], + } + assert ExecutionPlanInput.model_validate(plan).nodes[0].selected_edge_id == "urgent" + with pytest.raises(ValidationError, match="outgoing route"): + ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "selected_edge_id": "missing"}, *plan["nodes"][1:]]}) + with pytest.raises(ValidationError, match="acyclic"): + ExecutionPlanInput.model_validate({**plan, "edges": [*plan["edges"], {"id": "loop", "from": "urgent-check", "to": "risk"}]}) + with pytest.raises(ValidationError, match="unchosen route"): + ExecutionPlanInput.model_validate({**plan, "nodes": [plan["nodes"][0], plan["nodes"][1], + {**plan["nodes"][2], "status": "waiting"}]}) + with pytest.raises(ValidationError, match="only decision or parallel split"): + ExecutionPlanInput.model_validate({**plan, "nodes": [{**plan["nodes"][0], "kind": "activity", "selected_edge_id": None}, *plan["nodes"][1:]]}) + + +@pytest.mark.anyio +async def test_task_access_grant_is_project_scoped_reviewable_and_requeues(): + now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + initial_revision = task["revision"] + repository = RunTaskRepository(task, _active_run(task, now)) + agent = OperationalTaskService(repository, context, set(), clock=lambda: now) + request = await agent.request_access(task["task_id"], TaskAccessRequestCreate( + request_id=uuid4(), source_session_id="session-1", node_id="read-scada", + scopes=["scada_devices"], reason="核对上游监测点", + )) + assert repository.task["revision"] == initial_revision + 1 + assert any(event["topic"] == "operational.task.changed" for event in repository.events) + assert not await agent.can_read_source(task["task_id"], "scada_devices") + assert repository.notifications[0]["kind"] == "access_approval_pending" + reviewer = OperationalTaskService(repository, context, {OPERATIONAL_TASK_ACCESS_APPROVE, SCADA_VIEW}, clock=lambda: now) + approved = await reviewer.decide_access(task["task_id"], request.access_request_id, + TaskAccessRequestDecision(request_id=uuid4(), expected_version=1, + decision="approved", approved_scopes=["scada_devices"])) + assert approved.version == 2 + assert not await agent.can_read_source(task["task_id"], "scada_devices") + repository.run.update(status="running", agent_session_id="session-2", + lease_expires_at=now + timedelta(minutes=1)) + assert await agent.can_read_source(task["task_id"], "scada_devices") + assert not await agent.can_read_source(task["task_id"], "scada_readings") + assert repository.task["status"] == "queued" + access_notice = next( + item for item in repository.notifications + if item["kind"] == "access_approval_pending" + ) + assert access_notice["lifecycle_status"] == "resolved" + assert access_notice["resolution_reason"] == "access_request_approved" + + +@pytest.mark.anyio +async def test_task_access_request_rejects_a_second_pending_request(): + now = datetime(2026, 9, 24, 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.request_access(task["task_id"], TaskAccessRequestCreate( + request_id=uuid4(), source_session_id="session-1", node_id="read-scada", + scopes=["scada_devices"], reason="核对上游监测点", + )) + + with pytest.raises(DomainError) as exc: + await service.request_access(task["task_id"], TaskAccessRequestCreate( + request_id=uuid4(), source_session_id="session-1", node_id="read-scada", + scopes=["scada_devices"], reason="重复申请", + )) + + assert exc.value.code == "ACCESS_REQUEST_PENDING" + assert len(repository.access_requests) == 1 + assert len(repository.notifications) == 1 + + +@pytest.mark.anyio +async def test_task_access_request_fails_closed_before_project_migration(): + now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc) + context = _context() + task = _task_document(context, now, lease=now + timedelta(minutes=1)) + repository = RunTaskRepository(task, _active_run(task, now)) + repository.access_requests_available = AsyncMock(return_value=False) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + + with pytest.raises(DomainError) as exc: + await service.request_access(task["task_id"], TaskAccessRequestCreate( + request_id=uuid4(), source_session_id="session-1", node_id="read-scada", + scopes=["scada_devices"], reason="核对上游监测点", + )) + + assert exc.value.code == "DEPENDENCY_UNAVAILABLE" + assert exc.value.status == 503 + + +@pytest.mark.anyio +async def test_completed_gate_requires_access_request_decision(): + now = datetime(2026, 9, 24, 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) + request = await service.request_access(task["task_id"], TaskAccessRequestCreate( + request_id=uuid4(), source_session_id="session-1", node_id="read-scada", + scopes=["scada_devices"], reason="核对上游监测点", + )) + + with pytest.raises(DomainError) as exc: + await service.publish_artifacts(task["task_id"], AgentArtifactsCommand( + request_id=uuid4(), source_session_id="session-1", + plan={ + "schema": "execution-plan@2", + "title": "读取监测数据", + "reason": "等待授权后读取", + "nodes": [{ + "id": "read-scada", "title": "数据访问审核", + "owner": "human", "status": "completed", + "detail": "尚未审核", "kind": "gate", + "gate_ref": str(request.access_request_id), + }], + "edges": [], + }, + )) + + assert exc.value.code == "GATE_NOT_RESOLVED" + + +@pytest.mark.anyio +async def test_work_order_draft_edit_creates_new_review_version(): + now = datetime(2026, 9, 24, 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": "general"}, + )) + updated = await service.update_work_order_draft(task["task_id"], action.action_id, + WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1, + payload={"title": "人工修订草稿", "description": "增加复测", "work_order_type": "general"})) + assert updated.version == 2 + assert updated.payload.title == "人工修订草稿" + assert updated.approval_status == "pending" + with pytest.raises(DomainError, match="Action version changed"): + await service.update_work_order_draft(task["task_id"], action.action_id, + WorkOrderDraftUpdateCommand(request_id=uuid4(), expected_version=1, + payload={"title": "过期草稿", "description": "采样", "work_order_type": "general"})) + assert repository.action["approval_status"] == "pending" + assert repository.action["version"] == updated.version + + +@pytest.mark.anyio +async def test_one_click_work_order_confirmation_replays_without_duplicate_dispatch(): + now = datetime(2026, 9, 24, 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, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE, + }, 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": "general"}, + )) + + class Dispatcher: + calls = 0 + + async def dispatch(self, **_kwargs): + self.calls += 1 + return ActionDispatchOutcome("unsupported", "测试适配器未配置") + + dispatcher = Dispatcher() + command = WorkOrderConfirmDispatchCommand(request_id=uuid4(), expected_version=action.version) + first = await service.confirm_work_order_dispatch(task["task_id"], action.action_id, + command, dispatcher, None) + replay = await service.confirm_work_order_dispatch(task["task_id"], action.action_id, + command, dispatcher, None) + assert first.approval_status == "approved" + assert first.execution_status == "unsupported" + assert replay.version == first.version + assert dispatcher.calls == 1 + assert [item["kind"] for item in repository.notifications] == [ + "action_approval_pending", + "external_coordination_failed", + ] + + @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) @@ -700,6 +1002,85 @@ async def test_external_feedback_exposes_validated_work_order_snapshot_and_refre assert result.task.work_order_snapshot.feedback_requirements == ["上传检测结果"] +@pytest.mark.anyio +async def test_withdraw_external_source_keeps_history_and_is_idempotent(): + now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc) + context = _context() + work_order_id = uuid4() + source_id = f"peripheral_sample:{work_order_id}" + task = _task_document(context, now, lease=None) + task.update( + task_type="external_work_order", + source={"kind": "external_system", "external_id": source_id, + "external_revision": 1, "event_kind": "general"}, + ) + repository = TaskRepository(task) + service = OperationalTaskService(repository, context, set(), clock=lambda: now) + command = ExternalSourceWithdrawalCommand( + request_id=uuid4(), source_event_id=source_id + ) + + withdrawn = await service.withdraw_external_source(task["task_id"], command) + replayed = await service.withdraw_external_source(task["task_id"], command) + + assert withdrawn.source.deleted_at == now + assert withdrawn.status == "cancelled" + assert withdrawn.lifecycle_status == "cancelled" + assert withdrawn.revision == 3 + assert replayed.revision == withdrawn.revision + assert repository.task["source"]["external_revision"] == 1 + + with pytest.raises(DomainError) as exc: + await service.withdraw_external_source(task["task_id"], ExternalSourceWithdrawalCommand( + request_id=uuid4(), source_event_id=f"peripheral_sample:{uuid4()}" + )) + assert exc.value.code == "EXTERNAL_SOURCE_MISMATCH" + + +@pytest.mark.anyio +async def test_withdrawn_external_task_rejects_action_changes_and_resolves_approval(): + now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc) + context = _context() + work_order_id = uuid4() + source_id = f"peripheral_sample:{work_order_id}" + task = _task_document(context, now, lease=None) + task.update( + task_type="external_work_order", + status="scheduled", + source={"kind": "external_system", "external_id": source_id, + "external_revision": 1, "event_kind": "general"}, + ) + repository = TaskRepository(task) + 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"}, + )) + + await service.withdraw_external_source( + task["task_id"], + ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id), + ) + + with pytest.raises(DomainError) as exc: + await service.decide_action( + task["task_id"], + action.action_id, + ActionDecisionCommand( + request_id=uuid4(), expected_version=action.version, decision="approved", + ), + ) + assert exc.value.code == "TASK_NOT_ACTIONABLE" + assert repository.notifications[0]["lifecycle_status"] == "resolved" + assert repository.notifications[0]["resolution_reason"] == "external_source_withdrawn" + + @pytest.mark.anyio async def test_event_investigation_rejects_work_order_snapshot(): now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc) diff --git a/tests/unit/test_peripheral_task_sync.py b/tests/unit/test_peripheral_task_sync.py index 789ecd1..8617ad5 100644 --- a/tests/unit/test_peripheral_task_sync.py +++ b/tests/unit/test_peripheral_task_sync.py @@ -1,4 +1,7 @@ import json +import subprocess +import sys +from pathlib import Path from uuid import uuid4 import httpx @@ -6,6 +9,7 @@ import pytest from scripts.run_operational_worker import CredentialProvider, WorkerSettings from scripts.sync_peripheral_work_orders import ( + peripheral_order_id, should_project_work_order, sync_once, work_order_snapshot, @@ -19,6 +23,19 @@ def anyio_backend(): return "asyncio" +def test_documented_sync_command_can_start_from_repository_root(): + repository_root = Path(__file__).resolve().parents[2] + result = subprocess.run( + [sys.executable, str(repository_root / "scripts/sync_peripheral_work_orders.py"), "--help"], + cwd=repository_root, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "--once" in result.stdout + + def test_unlinked_synthetic_checks_are_not_operational_tasks(): order_id = str(uuid4()) order = { @@ -42,6 +59,14 @@ def test_unlinked_synthetic_checks_are_not_operational_tasks(): ) +def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces(): + order_id = str(uuid4()) + + assert peripheral_order_id(f"peripheral_sample:{order_id}") == order_id + assert peripheral_order_id("peripheral_sample:not-a-uuid") is None + assert peripheral_order_id(f"other_system:{order_id}") is None + + @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())) @@ -129,6 +154,79 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing(): assert synchronized == 0 +@pytest.mark.anyio +async def test_sync_withdraws_only_deleted_peripheral_projection(): + project_id, deleted_id, deleted_task_id, other_task_id = map( + str, (uuid4(), uuid4(), uuid4(), uuid4()) + ) + deleted_source = f"peripheral_sample:{deleted_id}" + tasks = [ + {"task_id": deleted_task_id, "task_type": "external_work_order", + "source": {"external_id": deleted_source, "external_revision": 1}}, + {"task_id": other_task_id, "task_type": "external_work_order", + "source": {"external_id": f"other_system:{uuid4()}", "external_revision": 1}}, + ] + withdrawals = [] + + def handler(request): + if request.url.host == "sample.test": + if request.url.path.endswith("/work-orders"): + return httpx.Response(200, json={"items": [], "next_cursor": None}) + assert request.url.path.endswith(f"/work-orders/{deleted_id}") + return httpx.Response(404, json={"code": "WORK_ORDER_NOT_FOUND"}) + if request.method == "GET": + return httpx.Response(200, json={"items": tasks, "next_offset": None}) + assert request.url.path.endswith(f"/{deleted_task_id}/withdraw-external-source") + withdrawals.append(json.loads(request.content)) + tasks.pop(0) + return httpx.Response(200, json={}) + + 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 + + assert len(withdrawals) == 1 + assert withdrawals[0]["source_event_id"] == deleted_source + + +@pytest.mark.anyio +@pytest.mark.parametrize("lookup_status", [200, 503]) +async def test_sync_does_not_withdraw_when_missing_order_is_not_confirmed_deleted(lookup_status): + project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4())) + source_id = f"peripheral_sample:{order_id}" + writes = [] + + def handler(request): + if request.url.host == "sample.test": + if request.url.path.endswith("/work-orders"): + return httpx.Response(200, json={"items": [], "next_cursor": None}) + return httpx.Response(lookup_status, json={"work_order_id": order_id}) + if request.method == "GET": + return httpx.Response(200, json={"items": [{ + "task_id": task_id, "task_type": "external_work_order", + "source": {"external_id": source_id, "external_revision": 1}, + }], "next_offset": None}) + writes.append(request.url.path) + return httpx.Response(200, json={}) + + settings = WorkerSettings( + "http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY" + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + if lookup_status == 503: + with pytest.raises(httpx.HTTPStatusError): + await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) + else: + assert await sync_once( + client, settings, "http://sample.test", CredentialProvider(settings) + ) == 0 + assert writes == [] + + @pytest.mark.anyio async def test_sample_sync_rejects_cross_project_data_before_writing(): def handler(request): diff --git a/tests/unit/test_seed_workbench_work_orders.py b/tests/unit/test_seed_workbench_work_orders.py deleted file mode 100644 index 834ba2f..0000000 --- a/tests/unit/test_seed_workbench_work_orders.py +++ /dev/null @@ -1,63 +0,0 @@ -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["feedback_requirements"]) == 3 for document in documents) - assert all("subtasks" not in document for document in documents) - assert all("合成" not in str(document) for document in documents) - - -def test_demo_work_orders_show_recorded_activity_without_synthetic_stages(): - documents = build_demo_work_orders() - - for document in documents: - assert document["activity"] - assert all(entry["kind"] in {"created", "updated"} for entry in document["activity"]) - assert "stages" not in document - - -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_runtime.py b/tests/unit/test_workbench_runtime.py index a37315d..1bf4de6 100644 --- a/tests/unit/test_workbench_runtime.py +++ b/tests/unit/test_workbench_runtime.py @@ -185,28 +185,7 @@ def test_sensor_location_projection_uses_latest_result_order_and_known_nodes(): @pytest.mark.anyio -async def test_snapshot_projects_persisted_work_orders_and_alerts(): - work_order = { - "id": "wo-1", - "code": "WO-20260917-001", - "scheduled_at": NOW.replace(hour=3), - "title": "北区阀门现场复核", - "summary": "复核阀门开度并回传。", - "status": "running", - "risk_level": "attention", - "updated_at": NOW, - "detail": "来自正式工单同步适配器。", - "recommendation": "完成后复核压力。", - "duration_minutes": 45, - "source": "生产工单系统", - "dispatcher": "调度中心", - "priority": "urgent", - "location": "北区阀门 V-1", - "assignee": "北区班组", - "feedback_requirements": ["回传阀门开度", "复核压力"], - "activity": [{"sequence": 1, "occurred_at": NOW, "actor": "调度中心", - "kind": "created", "title": "工单创建", "detail": "等待现场复核。"}], - } +async def test_snapshot_projects_persisted_alerts_without_legacy_work_orders(): persisted_alert = { "alert_id": "alarm-1", "condition_id": None, @@ -223,8 +202,6 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts(): } snapshot = await build_workbench_runtime_snapshot( - work_order_storage_configured=True, - work_orders=[work_order], alert_storage_configured=True, persisted_alerts=[persisted_alert], clock=lambda: NOW, @@ -233,7 +210,7 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts(): assert snapshot["availability"] == "ready" assert snapshot["source"] == "none" assert snapshot["schedule_availability"] == "not_configured" - assert snapshot["work_order_availability"] == "ready" + assert snapshot["work_order_availability"] == "not_configured" assert snapshot["alert_availability"] == "ready" assert snapshot["conditions"] == [] assert snapshot["alerts"] == [{ @@ -245,6 +222,4 @@ async def test_snapshot_projects_persisted_work_orders_and_alerts(): "occurred_at": NOW.replace(minute=29), "severity": "warning", }] - assert snapshot["work_orders"][0]["code"] == "WO-20260917-001" - assert snapshot["work_orders"][0]["activity"][0]["title"] == "工单创建" - assert "subtasks" not in snapshot["work_orders"][0] + assert snapshot["work_orders"] == []