feat(workbench): add dynamic task approval lifecycle
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:]
|
||||
)
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user