feat(workbench): add dynamic task approval lifecycle

This commit is contained in:
2026-09-24 16:36:12 +08:00
parent db092b0f5e
commit ad813a7c16
35 changed files with 2956 additions and 564 deletions
+1 -1
View File
@@ -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",
@@ -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()
+3 -85
View File
@@ -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"] == []