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
+27
View File
@@ -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"
+381
View File
@@ -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)
+98
View File
@@ -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:]
)
+3 -28
View File
@@ -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"] == []