feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from scripts.import_agent_investigation import convert_plan, run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def snapshot():
|
||||
return {"session_id": "ses_test", "revision": 1, "updated_at": "2026-09-23T00:00:00Z",
|
||||
"title": "合成调查", "nodes": [
|
||||
{"id": "STAGE_JUDGMENT", "title": "阶段判断", "owner": "agent",
|
||||
"status": "completed", "detail": "合成证据不足以确定原因", "evidence": ["合成输入"]},
|
||||
{"id": "SAMPLE", "title": "采样", "owner": "human", "status": "waiting",
|
||||
"detail": "等待现场采样反馈", "evidence": []},
|
||||
], "edges": [{"from": "STAGE_JUDGMENT", "to": "SAMPLE"}]}
|
||||
|
||||
|
||||
def test_legacy_conversion_preserves_waiting_and_explicit_synthetic_boundary():
|
||||
plan, evidence, report = convert_plan(snapshot())
|
||||
assert plan["nodes"][1]["status"] == "waiting"
|
||||
assert all(item["kind"] == "synthetic" for item in evidence)
|
||||
assert report["pending_items"] == ["等待现场采样反馈"]
|
||||
assert report["current_conclusion"] == "合成证据不足以确定原因"
|
||||
assert "未重新查询当前数据" in report["conclusion_boundary"]
|
||||
assert report["evidence_refs"] == [evidence[0]["evidence_id"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dry_run_validates_historical_schema_before_any_network_access(tmp_path, monkeypatch, capsys):
|
||||
metadata_dir = tmp_path / "session-metadata"
|
||||
states_dir = tmp_path / "session-ui-states"
|
||||
metadata_dir.mkdir()
|
||||
states_dir.mkdir()
|
||||
(metadata_dir / "ses_test.json").write_text(json.dumps({
|
||||
"triggerWorkflow": "water_quality_complaint", "triggerSource": "peripheral_sample",
|
||||
"projectId": "10000000-0000-4000-8000-000000000001",
|
||||
}))
|
||||
state = snapshot()
|
||||
state_path = states_dir / "ses_test.json"
|
||||
state_path.write_text(json.dumps({"executionPlans": [state]}))
|
||||
|
||||
def no_network(*args, **kwargs):
|
||||
raise AssertionError("dry run must not open a network client")
|
||||
|
||||
monkeypatch.setattr("scripts.import_agent_investigation.httpx.AsyncClient", no_network)
|
||||
args = argparse.Namespace(session_id="ses_test", agent_data=tmp_path, dry_run=True)
|
||||
await run(args)
|
||||
assert "2 nodes and 1 synthetic evidence records; no writes" in capsys.readouterr().out
|
||||
state["nodes"][1]["status"] = "invented_status"
|
||||
state_path.write_text(json.dumps({"executionPlans": [state]}))
|
||||
with pytest.raises(ValidationError):
|
||||
await run(args)
|
||||
assert "Ready" not in capsys.readouterr().out
|
||||
@@ -0,0 +1,184 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.infra.integrations.operational_actions import (
|
||||
HttpOperationalActionDispatcher, OperationalActionSettings,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def inputs(kind="create_work_order"):
|
||||
project_id, task_id = uuid4(), uuid4()
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {"title": "现场采样", "description": "核验投诉水质", "work_order_type": "sampling"}
|
||||
if kind == "issue_command":
|
||||
payload = {"command_type": "set_valve", "target": {"kind": "valve", "id": "V-01"},
|
||||
"parameters": {"opening": 20}, "impact_summary": "影响V-01阀门控制范围",
|
||||
"preconditions": ["现场人员确认"],
|
||||
"expires_at": (now + timedelta(minutes=10)).isoformat()}
|
||||
return {
|
||||
"context": SimpleNamespace(project_id=project_id),
|
||||
"task": SimpleNamespace(task_id=task_id, project_id=project_id),
|
||||
"action": SimpleNamespace(task_id=task_id, action_id=uuid4(), version=1, kind=kind,
|
||||
execution_request_id=uuid4(),
|
||||
approval_status="approved", payload=payload,
|
||||
decided_by="operator", decided_at=now),
|
||||
"credential": "Bearer TEST_ONLY",
|
||||
}
|
||||
|
||||
|
||||
def settings(**kwargs):
|
||||
return OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test",
|
||||
command_gateway_url="http://commands.test", **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_work_order_confirms_creation_not_field_completion():
|
||||
data = inputs()
|
||||
calls = []
|
||||
def handler(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(201, json={"work_order_id": str(uuid4()),
|
||||
"project_id": str(data["context"].project_id), "created_at": "2026-09-23T08:00:00Z",
|
||||
"origin": "synthetic", "status": "open"})
|
||||
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||
result = await adapter.dispatch(**data)
|
||||
assert result.status == "succeeded"
|
||||
assert result.receipt.external_status == "open"
|
||||
assert "现场工作进度" in result.detail
|
||||
assert calls[0].url.path == "/api/sample/v1/work-orders"
|
||||
assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_order_receipt_accepts_awaiting_reply_status():
|
||||
data = inputs()
|
||||
def handler(request):
|
||||
return httpx.Response(201, json={
|
||||
"work_order_id": str(uuid4()),
|
||||
"project_id": str(data["context"].project_id),
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
"origin": "synthetic",
|
||||
"status": "awaiting_reply",
|
||||
})
|
||||
adapter = HttpOperationalActionDispatcher(
|
||||
settings(), transport=httpx.MockTransport(handler)
|
||||
)
|
||||
|
||||
result = await adapter.dispatch(**data)
|
||||
|
||||
assert result.status == "succeeded"
|
||||
assert result.receipt.external_status == "awaiting_reply"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event():
|
||||
data = inputs()
|
||||
data["action"].payload["work_order_type"] = "water_quality_complaint"
|
||||
submitted = {}
|
||||
def handler(request):
|
||||
submitted.update(__import__("json").loads(request.content))
|
||||
return httpx.Response(201, json={
|
||||
"work_order_id": str(uuid4()),
|
||||
"project_id": str(data["context"].project_id),
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
"origin": "synthetic",
|
||||
"status": "open",
|
||||
})
|
||||
|
||||
result = await HttpOperationalActionDispatcher(
|
||||
settings(), transport=httpx.MockTransport(handler)
|
||||
).dispatch(**data)
|
||||
|
||||
assert result.status == "succeeded"
|
||||
assert submitted["work_order_type"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lost_create_response_recovers_same_request_without_second_write():
|
||||
data, methods = inputs(), []
|
||||
def handler(request):
|
||||
methods.append(request.method)
|
||||
if request.method == "POST":
|
||||
raise httpx.ReadTimeout("lost", request=request)
|
||||
return httpx.Response(200, json={"request_id": request.url.path.split("/")[-1],
|
||||
"result": {"work_order_id": str(uuid4()), "project_id": str(data["context"].project_id),
|
||||
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}})
|
||||
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||
assert result.status == "succeeded"
|
||||
assert methods == ["POST", "GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unconfirmed_receipt_remains_unknown_not_failed_or_succeeded():
|
||||
data = inputs()
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={"work_order_id": str(uuid4()), "project_id": str(uuid4()),
|
||||
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"})
|
||||
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||
assert result.status == "unknown"
|
||||
assert result.receipt is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unapproved_and_expired_actions_never_reach_external_system():
|
||||
def handler(request):
|
||||
raise AssertionError("no external call expected")
|
||||
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||
data = inputs()
|
||||
data["action"].approval_status = "pending"
|
||||
assert (await adapter.dispatch(**data)).status == "failed"
|
||||
data = inputs("issue_command")
|
||||
data["action"].payload["expires_at"] = "2020-01-01T00:00:00Z"
|
||||
assert (await adapter.dispatch(**data)).status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_command_acceptance_is_not_execution_completion():
|
||||
data = inputs("issue_command")
|
||||
def handler(request):
|
||||
import json
|
||||
body = json.loads(request.content)
|
||||
assert body["command"]["target"]["id"] == "V-01"
|
||||
return httpx.Response(202, json={"request_id": body["request_id"],
|
||||
"project_id": body["project_id"], "receipt_id": "gateway-1", "status": "accepted",
|
||||
"submitted_at": "2026-09-23T08:00:00Z", "completed_at": None, "detail": "已接收"})
|
||||
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||
assert result.status == "submitted"
|
||||
assert result.receipt.completed_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_configuration_does_not_invent_receipt():
|
||||
adapter = HttpOperationalActionDispatcher(OperationalActionSettings(
|
||||
_env_file=None, peripheral_url="", command_gateway_url=""))
|
||||
result = await adapter.dispatch(**inputs())
|
||||
assert result.status == "unsupported"
|
||||
assert result.receipt is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reconciliation_is_read_only_and_keeps_execution_identity_across_versions():
|
||||
data, paths = inputs("issue_command"), []
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
paths.append(request.url.path)
|
||||
return httpx.Response(200, json={
|
||||
"request_id": str(data["action"].execution_request_id),
|
||||
"project_id": str(data["context"].project_id), "receipt_id": "confirmed-command",
|
||||
"status": "completed", "submitted_at": "2026-09-23T08:00:00Z",
|
||||
"completed_at": "2026-09-23T08:01:00Z", "detail": "执行回执已确认",
|
||||
})
|
||||
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
|
||||
assert (await adapter.reconcile(**data)).status == "succeeded"
|
||||
data["action"].version += 1
|
||||
assert (await adapter.reconcile(**data)).status == "succeeded"
|
||||
assert paths[0] == paths[1]
|
||||
@@ -0,0 +1,131 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_data import TaskDataQuery
|
||||
from app.infra.integrations.operational_data import TaskDataError, TaskDataSettings, read_task_data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def context():
|
||||
return ProjectContext(project_id=uuid4(), user_id=uuid4(), project_code="test", project_role="member")
|
||||
|
||||
|
||||
def settings():
|
||||
return TaskDataSettings(_env_file=None, server_url="http://server.test", peripheral_url="http://sample.test")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_actual_read_has_scoped_headers_and_synthetic_provenance():
|
||||
ctx = context()
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/api/sample/v1/demo-data"
|
||||
assert not request.url.params
|
||||
assert request.headers["X-Project-Id"] == str(ctx.project_id)
|
||||
return httpx.Response(200, json={
|
||||
"project_id": str(ctx.project_id),
|
||||
"source_id": "tjwater_v2:peripheral_sample.demo_datasets:water-demo-v1",
|
||||
"source_revision": "sha256:test",
|
||||
"points": [{"point_id": "DEMO-P001"}],
|
||||
"observations": [
|
||||
{"point_id": "DEMO-P001", "observed_at": "2026-09-23T00:30:00Z", "value": 0.22},
|
||||
{"point_id": "DEMO-P002", "observed_at": "2026-09-23T00:30:00Z", "value": 1.0},
|
||||
{"point_id": "DEMO-P001", "observed_at": "2026-09-24T00:00:00Z", "value": 2.0},
|
||||
],
|
||||
})
|
||||
result = await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="peripheral_observations", point_id="DEMO-P001",
|
||||
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
||||
transport=httpx.MockTransport(handler))
|
||||
assert result.origin == "synthetic"
|
||||
assert result.data["items"][0]["value"] == 0.22
|
||||
assert len(result.data["items"]) == 1
|
||||
assert result.data["point_id"] == "DEMO-P001"
|
||||
assert result.data["window"] == {
|
||||
"start": "2026-09-23T00:00:00+00:00", "end": "2026-09-23T01:00:00+00:00",
|
||||
}
|
||||
assert result.source_ref.startswith(
|
||||
"peripheral_demo_data:DEMO-P001:2026-09-23T00:00:00+00:00:"
|
||||
"2026-09-23T01:00:00+00:00:sha256:"
|
||||
)
|
||||
assert not result.truncated
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_historical_observations_keep_monitoring_endpoint():
|
||||
def handler(request):
|
||||
assert request.url.path == "/api/sample/v1/monitoring/observations"
|
||||
assert request.url.params["point_id"] == "100026"
|
||||
return httpx.Response(200, json={"items": [], "truncated": False, "origin": "historical_replay"})
|
||||
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="peripheral_observations", point_id="100026",
|
||||
start="2026-09-23T00:00:00Z", end="2026-09-23T01:00:00Z"),
|
||||
transport=httpx.MockTransport(handler))
|
||||
assert result.origin == "system_data"
|
||||
assert result.data["origin"] == "historical_replay"
|
||||
assert result.source_ref.startswith("peripheral_historical_replay:peripheral_observations:sha256:")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("origin,expected", [("historical_replay", "system_data"), ("synthetic", "synthetic")])
|
||||
async def test_monitoring_point_provenance_follows_source_not_transport(origin, expected):
|
||||
result = await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="peripheral_points"),
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"origin": origin, "items": []})))
|
||||
assert result.origin == expected
|
||||
assert result.data["origin"] == origin
|
||||
assert result.source_ref.startswith(
|
||||
"peripheral_historical_replay:" if origin == "historical_replay" else "peripheral_points:"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_monitoring_data_without_known_provenance_is_not_saved_as_fact():
|
||||
with pytest.raises(TaskDataError, match="来源"):
|
||||
await read_task_data(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="peripheral_points"),
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"items": []})))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_paged_data_is_marked_incomplete_and_receipt_is_content_bound():
|
||||
values = [{"items": [1], "total": 2}, {"items": [2], "total": 2}]
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=values.pop(0))
|
||||
kwargs = dict(context=context(), credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="scada_devices"), transport=httpx.MockTransport(handler))
|
||||
first, second = await read_task_data(**kwargs), await read_task_data(**kwargs)
|
||||
assert first.truncated and second.truncated
|
||||
assert first.source_ref != second.source_ref
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("shape", ["root", "items", "list"])
|
||||
async def test_cross_project_or_failed_query_cannot_become_evidence(shape):
|
||||
ctx = context()
|
||||
foreign = {"project_id": str(uuid4()), "value": 0.22}
|
||||
payload = {"root": foreign, "items": {"project_id": str(ctx.project_id), "items": [foreign]},
|
||||
"list": [foreign]}[shape]
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=payload)
|
||||
with pytest.raises(TaskDataError, match="其他项目"):
|
||||
await read_task_data(context=ctx, credential="Bearer TEST_ONLY", settings=settings(),
|
||||
query=TaskDataQuery(source="workbench_runtime"), transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def test_query_is_a_finite_read_scope_with_bounded_time_and_device_count():
|
||||
with pytest.raises(ValidationError):
|
||||
TaskDataQuery(source="http://arbitrary.test/delete")
|
||||
with pytest.raises(ValidationError):
|
||||
TaskDataQuery(source="scada_readings", device_ids=["p1"],
|
||||
start="2026-09-01T00:00:00Z", end="2026-09-23T00:00:00Z")
|
||||
with pytest.raises(ValidationError):
|
||||
TaskDataQuery(source="peripheral_observations")
|
||||
@@ -0,0 +1,852 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.permissions import (
|
||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
|
||||
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
OPERATIONAL_TASK_VIEW,
|
||||
)
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import (
|
||||
ActionDecisionCommand,
|
||||
ActionExecuteCommand,
|
||||
ActionProposalCommand,
|
||||
AgentArtifactsCommand,
|
||||
AgentRunCommand,
|
||||
ExternalFeedbackCommand,
|
||||
HumanApprovalAction,
|
||||
NotificationAcknowledgeCommand,
|
||||
OperationalTaskNotification,
|
||||
OperationalTaskRun,
|
||||
OperationalTaskSummary,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class NotificationRepository:
|
||||
def __init__(self, notification):
|
||||
self.notification = notification
|
||||
self.reads = {}
|
||||
self.requests = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield self
|
||||
|
||||
async def lock(self, _key):
|
||||
return None
|
||||
|
||||
async def get_request(self, scope, request_id):
|
||||
return self.requests.get((scope, request_id))
|
||||
|
||||
async def save_request(self, scope, request_id, request_digest, response):
|
||||
self.requests[(scope, request_id)] = {
|
||||
"request_digest": request_digest,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
async def get_notification(self, notification_id, user_id):
|
||||
if notification_id != str(self.notification.notification_id):
|
||||
return None
|
||||
read = self.reads.get(user_id)
|
||||
return self.notification.model_copy(update={
|
||||
"status": "read" if read else "unread",
|
||||
"version": 2 if read else 1,
|
||||
"read_at": read,
|
||||
"read_by": f"principal:{user_id}" if read else None,
|
||||
}).model_dump(mode="json")
|
||||
|
||||
async def mark_notification_read(self, notification_id, user_id, version, read_at):
|
||||
assert version == 2
|
||||
self.reads[user_id] = read_at
|
||||
|
||||
|
||||
def _context(user_id=None, *, system_role="user"):
|
||||
return ProjectContext(
|
||||
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
||||
project_role="member", system_role=system_role,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_read_state_is_isolated_per_user():
|
||||
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
||||
notification = OperationalTaskNotification(
|
||||
notification_id=uuid4(), task_id=uuid4(), kind="task_created",
|
||||
title="任务", description="已创建", status="unread", version=1,
|
||||
created_at=now,
|
||||
)
|
||||
repository = NotificationRepository(notification)
|
||||
first = _context()
|
||||
second = _context()
|
||||
first_service = OperationalTaskService(
|
||||
repository, first, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
||||
)
|
||||
second_service = OperationalTaskService(
|
||||
repository, second, {OPERATIONAL_TASK_VIEW}, clock=lambda: now
|
||||
)
|
||||
|
||||
request_id = uuid4()
|
||||
read = await first_service.acknowledge_notification(
|
||||
notification.notification_id,
|
||||
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
||||
)
|
||||
|
||||
assert read.status == "read"
|
||||
assert (await repository.get_notification(
|
||||
str(notification.notification_id), str(second.user_id)
|
||||
))["status"] == "unread"
|
||||
|
||||
second_read = await second_service.acknowledge_notification(
|
||||
notification.notification_id,
|
||||
NotificationAcknowledgeCommand(request_id=request_id, expected_version=1),
|
||||
)
|
||||
assert second_read.status == "read"
|
||||
assert second_read.read_by == f"principal:{second.user_id}"
|
||||
|
||||
|
||||
def test_running_plan_state_wins_over_future_human_waiting_node():
|
||||
nodes = [
|
||||
type("Node", (), {"status": "running", "owner": "agent"})(),
|
||||
type("Node", (), {"status": "waiting", "owner": "human"})(),
|
||||
]
|
||||
|
||||
assert OperationalTaskService._status_from_plan(nodes, []) == "running"
|
||||
|
||||
|
||||
def test_pending_approval_prevents_plan_from_marking_task_completed():
|
||||
action = HumanApprovalAction(
|
||||
action_id=uuid4(), task_id=uuid4(), version=1,
|
||||
kind="create_work_order", reason="现场采样",
|
||||
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
||||
evidence_refs=[], required_approval_permission="work-order.approve",
|
||||
required_execution_permission="work-order.execute",
|
||||
approval_status="pending", execution_status="not_submitted",
|
||||
proposed_by="principal:test", proposed_at=datetime.now(timezone.utc),
|
||||
)
|
||||
nodes = [type("Node", (), {"status": "completed", "owner": "agent"})()]
|
||||
|
||||
assert OperationalTaskService._status_from_plan(nodes, [action]) == "waiting_human"
|
||||
|
||||
|
||||
def test_explicit_agent_run_failure_marks_task_failed_even_with_waiting_work():
|
||||
nodes = [
|
||||
type("Node", (), {"id": "FIELD_SAMPLE", "status": "waiting", "owner": "human"})(),
|
||||
type("Node", (), {"id": "AGENT_RUN_FAILURE", "status": "failed", "owner": "agent"})(),
|
||||
]
|
||||
|
||||
assert OperationalTaskService._status_from_plan(nodes, []) == "failed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_targeted_notification_cannot_be_acknowledged_without_permission():
|
||||
now = datetime(2026, 9, 23, tzinfo=timezone.utc)
|
||||
notification = OperationalTaskNotification(
|
||||
notification_id=uuid4(), task_id=uuid4(), kind="action_pending",
|
||||
title="待确认", description="命令", required_permission="command.approve",
|
||||
status="unread", version=1, created_at=now,
|
||||
)
|
||||
service = OperationalTaskService(
|
||||
NotificationRepository(notification), _context(), {OPERATIONAL_TASK_VIEW},
|
||||
clock=lambda: now,
|
||||
)
|
||||
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.acknowledge_notification(
|
||||
notification.notification_id,
|
||||
NotificationAcknowledgeCommand(request_id=uuid4(), expected_version=1),
|
||||
)
|
||||
|
||||
assert exc.value.code == "PERMISSION_DENIED"
|
||||
|
||||
|
||||
def _task_document(context, now, *, lease=None):
|
||||
task_id = uuid4()
|
||||
return OperationalTaskSummary(
|
||||
project_id=context.project_id,
|
||||
task_id=task_id,
|
||||
task_type="event_investigation",
|
||||
workflow="water_quality_complaint",
|
||||
status="running",
|
||||
priority="normal",
|
||||
revision=2,
|
||||
execution_generation=1,
|
||||
execution_request_id=uuid4(),
|
||||
title="调查",
|
||||
summary="验证状态机",
|
||||
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
||||
scheduled_for=None,
|
||||
agent_session_id="session-1",
|
||||
agent_lease_expires_at=lease,
|
||||
current_plan_revision=None,
|
||||
current_report_revision=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
def __init__(self, task, *, action=None):
|
||||
self.task = task
|
||||
self.action = action
|
||||
self.requests = {}
|
||||
self.evidence = {}
|
||||
self.feedback = {}
|
||||
self.plans = []
|
||||
self.reports = []
|
||||
self.notifications = []
|
||||
self.locks = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield self
|
||||
|
||||
async def lock(self, key):
|
||||
self.locks.append(key)
|
||||
|
||||
async def get_request(self, scope, request_id):
|
||||
return self.requests.get((scope, request_id))
|
||||
|
||||
async def save_request(self, scope, request_id, request_digest, response):
|
||||
self.requests[(scope, request_id)] = {
|
||||
"request_digest": request_digest,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
async def get_task(self, task_id):
|
||||
return self.task if task_id == self.task["task_id"] else None
|
||||
|
||||
async def update_task(self, task):
|
||||
self.task = task
|
||||
|
||||
async def get_latest_plan(self, _task_id):
|
||||
return self.plans[-1] if self.plans else None
|
||||
|
||||
async def get_latest_report(self, _task_id):
|
||||
return self.reports[-1] if self.reports else None
|
||||
|
||||
async def insert_plan(self, _task_id, snapshot):
|
||||
self.plans.append(snapshot)
|
||||
|
||||
async def insert_report(self, _task_id, snapshot):
|
||||
self.reports.append(snapshot)
|
||||
|
||||
async def list_evidence(self, _task_id):
|
||||
return list(self.evidence.values())
|
||||
|
||||
async def get_evidence(self, _task_id, evidence_id):
|
||||
return self.evidence.get(evidence_id)
|
||||
|
||||
async def insert_evidence(self, _task_id, evidence):
|
||||
self.evidence[evidence["evidence_id"]] = evidence
|
||||
|
||||
async def list_actions(self, _task_id):
|
||||
return [self.action] if self.action else []
|
||||
|
||||
async def get_action(self, _task_id, action_id):
|
||||
if self.action and self.action["action_id"] == action_id:
|
||||
return self.action
|
||||
return None
|
||||
|
||||
async def update_action(self, _task_id, action):
|
||||
self.action = action
|
||||
|
||||
async def insert_action(self, _task_id, action):
|
||||
self.action = action
|
||||
|
||||
async def insert_notification(self, notification):
|
||||
self.notifications.append(notification)
|
||||
|
||||
async def get_feedback(self, task_id, source_event_id):
|
||||
return self.feedback.get((task_id, source_event_id))
|
||||
|
||||
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
||||
self.feedback[(task_id, feedback["source_event_id"])] = {
|
||||
"source_revision": feedback["source_revision"],
|
||||
"document": feedback,
|
||||
"accepted_at": accepted_at,
|
||||
}
|
||||
|
||||
|
||||
class RunTaskRepository(TaskRepository):
|
||||
def __init__(self, task, run):
|
||||
super().__init__(task)
|
||||
self.run = run
|
||||
self.events = []
|
||||
|
||||
async def get_active_run(self, task_id):
|
||||
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
||||
return self.run
|
||||
return None
|
||||
|
||||
async def get_run(self, run_id):
|
||||
return self.run if run_id == self.run["run_id"] else None
|
||||
|
||||
async def update_run(self, run, expected_revision):
|
||||
assert run["revision"] == expected_revision + 1
|
||||
self.run = run
|
||||
|
||||
async def insert_run(self, run):
|
||||
self.run = run
|
||||
|
||||
async def append_event(self, **event):
|
||||
self.events.append(event)
|
||||
return len(self.events)
|
||||
|
||||
|
||||
def _active_run(task, now):
|
||||
return OperationalTaskRun(
|
||||
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
||||
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
||||
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
||||
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
||||
next_dispatch_at=now, created_at=now, updated_at=now,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_heartbeat_does_not_change_task_business_revision():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
updated = await service.heartbeat_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||
),
|
||||
)
|
||||
|
||||
assert updated.revision == 3
|
||||
assert repository.task["revision"] == 2
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
completed = await service.complete_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||
),
|
||||
)
|
||||
|
||||
assert repository.run["status"] == "failed"
|
||||
assert repository.run["failure_code"] == "ARTIFACT_INCOMPLETE"
|
||||
assert completed.lifecycle_status == "failed"
|
||||
assert completed.active_run is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_external_feedback_coalesces_into_an_already_queued_run():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=None)
|
||||
task.update(
|
||||
status="queued", lifecycle_status="open", execution_generation=2,
|
||||
execution_request_id=str(uuid4()), agent_session_id=None,
|
||||
)
|
||||
run = _active_run(task, now)
|
||||
run.update(
|
||||
generation=2, execution_request_id=task["execution_request_id"],
|
||||
status="queued", revision=1, agent_session_id=None,
|
||||
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
||||
)
|
||||
repository = RunTaskRepository(task, run)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
result = await service.external_feedback(
|
||||
task["task_id"],
|
||||
ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id="sample-result", source_revision=1,
|
||||
status="completed", occurred_at=now, detail="新的现场结果",
|
||||
),
|
||||
)
|
||||
|
||||
assert result.task.execution_generation == 2
|
||||
assert str(result.task.execution_request_id) == task["execution_request_id"]
|
||||
assert result.task.active_run is not None
|
||||
assert str(result.task.active_run.run_id) == run["run_id"]
|
||||
|
||||
|
||||
def _waiting_plan(action_id=None):
|
||||
node = {
|
||||
"id": "field-work",
|
||||
"title": "现场处置",
|
||||
"owner": "human",
|
||||
"status": "waiting",
|
||||
"detail": "等待审批与执行",
|
||||
"evidence_refs": [],
|
||||
"action_refs": [str(action_id)] if action_id else [],
|
||||
}
|
||||
return {
|
||||
"schema": "execution-plan@1",
|
||||
"title": "调查计划",
|
||||
"reason": "等待现场处置",
|
||||
"nodes": [node],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
|
||||
def _current_report():
|
||||
return {
|
||||
"title": "本代调查报告",
|
||||
"current_conclusion": "等待现场处置",
|
||||
"conclusion_boundary": "尚无现场回执",
|
||||
"evidence_refs": [],
|
||||
"recommendations": ["完成现场处置"],
|
||||
"pending_items": ["现场处置"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||
))
|
||||
assert planned.task.status == "running"
|
||||
assert planned.task.agent_lease_expires_at == now + timedelta(minutes=1)
|
||||
assert planned.task.latest_plan.execution_generation == 1
|
||||
|
||||
reported = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||
))
|
||||
assert reported.task.status == "running"
|
||||
assert reported.task.latest_report.execution_generation == 1
|
||||
|
||||
completed = await service.complete_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=repository.run["revision"],
|
||||
source_session_id="session-1",
|
||||
),
|
||||
)
|
||||
assert completed.status == "waiting_human"
|
||||
assert completed.agent_lease_expires_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_report_does_not_close_from_legacy_or_prior_generation_plan():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task["execution_generation"] = 2
|
||||
repository = TaskRepository(task)
|
||||
repository.plans.append({
|
||||
"revision": 1,
|
||||
"execution_generation": None,
|
||||
"source_session_id": "session-1",
|
||||
"published_at": now.isoformat(),
|
||||
"published_by": "principal:test",
|
||||
"plan": _waiting_plan(),
|
||||
})
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||
))
|
||||
|
||||
assert result.task.status == "running"
|
||||
assert result.task.latest_report.execution_generation == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_run():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||
))
|
||||
revision_before_proposal = repository.run["revision"]
|
||||
lease_before_proposal = repository.run["lease_expires_at"]
|
||||
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
node_id="field-work", evidence_refs=[],
|
||||
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||
))
|
||||
assert repository.task["status"] == "running"
|
||||
assert repository.run["revision"] == revision_before_proposal
|
||||
assert repository.run["lease_expires_at"] == lease_before_proposal
|
||||
heartbeat = await service.heartbeat_run(
|
||||
task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=revision_before_proposal,
|
||||
source_session_id="session-1",
|
||||
),
|
||||
)
|
||||
assert heartbeat.status == "running"
|
||||
|
||||
published = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1",
|
||||
run_id=repository.run["run_id"],
|
||||
plan=_waiting_plan(action.action_id), report=_current_report(),
|
||||
))
|
||||
closed = await service.complete_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=published.task.active_run.revision,
|
||||
source_session_id="session-1",
|
||||
),
|
||||
)
|
||||
assert closed.status == "waiting_human"
|
||||
assert closed.actions[0].action_id == action.action_id
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now).copy())
|
||||
service = OperationalTaskService(
|
||||
repository,
|
||||
context,
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
evidence_refs=[],
|
||||
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||
))
|
||||
original_request_id = repository.task["execution_request_id"]
|
||||
|
||||
rejected = await service.decide_action(
|
||||
task["task_id"],
|
||||
action.action_id,
|
||||
ActionDecisionCommand(
|
||||
request_id=uuid4(), expected_version=action.version,
|
||||
decision="rejected", comment="当前方案不执行",
|
||||
),
|
||||
)
|
||||
|
||||
assert rejected.approval_status == "rejected"
|
||||
assert rejected.version == action.version + 1
|
||||
assert repository.task["status"] == "queued"
|
||||
assert repository.task["execution_generation"] == 2
|
||||
assert repository.task["execution_request_id"] != original_request_id
|
||||
assert repository.task["agent_session_id"] is None
|
||||
assert repository.task["agent_lease_expires_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_action_decision_rejects_a_stale_expected_version():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(
|
||||
repository,
|
||||
context,
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要现场核验",
|
||||
evidence_refs=[],
|
||||
payload={"title": "现场核验", "description": "等待人工执行", "work_order_type": "sampling"},
|
||||
))
|
||||
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.decide_action(
|
||||
task["task_id"],
|
||||
action.action_id,
|
||||
ActionDecisionCommand(
|
||||
request_id=uuid4(), expected_version=action.version + 1,
|
||||
decision="approved",
|
||||
),
|
||||
)
|
||||
|
||||
assert exc.value.code == "VERSION_CONFLICT"
|
||||
assert repository.action["approval_status"] == "pending"
|
||||
assert repository.action["version"] == action.version
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_payload_creates_a_new_pending_action_without_inheriting_approval():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = TaskRepository(task)
|
||||
service = OperationalTaskService(
|
||||
repository,
|
||||
context,
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_APPROVE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
original = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="需要首次核验",
|
||||
evidence_refs=[],
|
||||
payload={"title": "首次核验", "description": "原始内容", "work_order_type": "sampling"},
|
||||
))
|
||||
approved = await service.decide_action(
|
||||
task["task_id"],
|
||||
original.action_id,
|
||||
ActionDecisionCommand(
|
||||
request_id=uuid4(), expected_version=original.version, decision="approved",
|
||||
),
|
||||
)
|
||||
|
||||
revised = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||
request_id=uuid4(), kind="create_work_order", reason="内容已修改",
|
||||
evidence_refs=[],
|
||||
payload={"title": "修改后核验", "description": "新的内容", "work_order_type": "sampling"},
|
||||
))
|
||||
|
||||
assert approved.approval_status == "approved"
|
||||
assert revised.action_id != approved.action_id
|
||||
assert revised.payload != approved.payload
|
||||
assert revised.approval_status == "pending"
|
||||
assert revised.execution_status == "not_submitted"
|
||||
assert revised.version == 1
|
||||
assert revised.decided_by is None
|
||||
assert revised.decided_at is None
|
||||
|
||||
|
||||
def _work_order_snapshot(work_order_id, now):
|
||||
return {
|
||||
"work_order_id": str(work_order_id),
|
||||
"revision": 2,
|
||||
"title": "复测预约",
|
||||
"summary": "等待现场复测回令",
|
||||
"status": "awaiting_reply",
|
||||
"priority": "high",
|
||||
"assignee": "水质班组",
|
||||
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
|
||||
"work_order_type": "water_quality_complaint",
|
||||
"location": "联调小区",
|
||||
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||
"subtasks": [{
|
||||
"id": "sampling",
|
||||
"title": "现场采样",
|
||||
"summary": "采集并复测",
|
||||
"status": "running",
|
||||
"scheduled_at": (now + timedelta(hours=1)).isoformat(),
|
||||
"location": "联调小区",
|
||||
"assignee": "水质班组",
|
||||
"duration_minutes": 60,
|
||||
"reply_window_minutes": 120,
|
||||
"stages": [{
|
||||
"id": "reply",
|
||||
"sequence": 1,
|
||||
"title": "等待回令",
|
||||
"status": "current",
|
||||
"owner": "水质班组",
|
||||
"time_label": "待反馈",
|
||||
"description": "上传复测结果",
|
||||
}],
|
||||
"reply_requirements": ["上传检测结果"],
|
||||
}],
|
||||
"origin": "synthetic",
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_external_feedback_exposes_validated_work_order_snapshot_and_refreshes_summary():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
work_order_id = uuid4()
|
||||
task = _task_document(context, now, lease=None)
|
||||
task.update(
|
||||
task_type="external_work_order",
|
||||
workflow="water_quality_complaint",
|
||||
source={
|
||||
"kind": "external_system",
|
||||
"external_id": f"peripheral_sample:{work_order_id}",
|
||||
"external_revision": 1,
|
||||
"event_kind": "water_quality_complaint",
|
||||
},
|
||||
)
|
||||
repository = TaskRepository(task)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
result = await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||
request_id=uuid4(),
|
||||
source_event_id=f"peripheral_sample:{work_order_id}",
|
||||
source_revision=2,
|
||||
status="awaiting_feedback",
|
||||
occurred_at=now,
|
||||
detail="等待现场复测回令",
|
||||
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
||||
))
|
||||
|
||||
assert result.task.status == "waiting_human"
|
||||
assert result.task.title == "复测预约"
|
||||
assert result.task.summary == "等待现场复测回令"
|
||||
assert result.task.scheduled_for == now + timedelta(hours=1)
|
||||
assert result.task.priority == "urgent"
|
||||
assert result.task.work_order_snapshot is not None
|
||||
assert result.task.work_order_snapshot.subtasks[0].stages[0].status == "current"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_investigation_rejects_work_order_snapshot():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = TaskRepository(task)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
work_order_id = uuid4()
|
||||
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||
request_id=uuid4(), source_event_id=str(work_order_id), source_revision=2,
|
||||
status="awaiting_feedback", occurred_at=now, detail="不应接受",
|
||||
work_order_snapshot=_work_order_snapshot(work_order_id, now),
|
||||
))
|
||||
|
||||
assert exc.value.code == "INVALID_FEEDBACK_SNAPSHOT"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_owned_lease_is_rejected_with_stable_code():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.heartbeat_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=2, source_session_id="session-1"
|
||||
),
|
||||
)
|
||||
|
||||
assert exc.value.code == "LEASE_EXPIRED"
|
||||
assert repository.run["revision"] == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_interrupted_submit_replays_unknown_without_dispatching_twice():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task.update(status="waiting_human", agent_lease_expires_at=None)
|
||||
action = HumanApprovalAction(
|
||||
action_id=uuid4(),
|
||||
task_id=task["task_id"],
|
||||
version=2,
|
||||
kind="create_work_order",
|
||||
reason="现场核验",
|
||||
payload={"title": "采样", "description": "核验", "work_order_type": "sampling"},
|
||||
evidence_refs=[],
|
||||
required_approval_permission="work-order.approve",
|
||||
required_execution_permission=OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||
approval_status="approved",
|
||||
execution_status="not_submitted",
|
||||
proposed_by="principal:agent",
|
||||
proposed_at=now,
|
||||
decided_by="principal:approver",
|
||||
decided_at=now,
|
||||
).model_dump(mode="json")
|
||||
repository = TaskRepository(task, action=action)
|
||||
service = OperationalTaskService(
|
||||
repository,
|
||||
context,
|
||||
{OPERATIONAL_ACTION_WORK_ORDER_EXECUTE},
|
||||
clock=lambda: now,
|
||||
)
|
||||
command = ActionExecuteCommand(request_id=uuid4(), expected_version=2)
|
||||
|
||||
class LostResponseDispatcher:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def dispatch(self, **_kwargs):
|
||||
self.calls += 1
|
||||
raise RuntimeError("connection lost after submission")
|
||||
|
||||
dispatcher = LostResponseDispatcher()
|
||||
with pytest.raises(RuntimeError):
|
||||
await service.submit_action(
|
||||
task["task_id"], action["action_id"], command, dispatcher, None
|
||||
)
|
||||
|
||||
replay = await service.submit_action(
|
||||
task["task_id"], action["action_id"], command, dispatcher, None
|
||||
)
|
||||
|
||||
assert dispatcher.calls == 1
|
||||
assert replay.execution_status == "unknown"
|
||||
assert replay.execution_request_id is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_reuse_returns_the_original_provenance_and_rejects_collision():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = TaskRepository(task)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
original = TaskDataSnapshot(
|
||||
source="peripheral_points",
|
||||
source_ref="peripheral_points:sha256:same",
|
||||
queried_at=now,
|
||||
origin="synthetic",
|
||||
data={"items": [{"id": "p-1"}]},
|
||||
truncated=False,
|
||||
)
|
||||
evidence, stored = await service.record_data_snapshot(task["task_id"], original)
|
||||
|
||||
replayed = original.model_copy(update={"queried_at": now + timedelta(minutes=1)})
|
||||
replay_evidence, replay_snapshot = await service.record_data_snapshot(
|
||||
task["task_id"], replayed
|
||||
)
|
||||
|
||||
assert replay_evidence == evidence
|
||||
assert replay_snapshot == stored == original
|
||||
|
||||
collision = original.model_copy(update={"data": {"items": [{"id": "p-2"}]}})
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await service.record_data_snapshot(task["task_id"], collision)
|
||||
assert exc.value.code == "EVIDENCE_CONFLICT"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_task_update_rejects_a_stale_revision():
|
||||
cursor = SimpleNamespace(rowcount=0)
|
||||
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
|
||||
repository = OperationalTaskRepository(connection, uuid4())
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
task["revision"] = 3
|
||||
|
||||
with pytest.raises(DomainError) as exc:
|
||||
await repository.update_task(task)
|
||||
|
||||
assert exc.value.code == "VERSION_CONFLICT"
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_persisted_queue_retries_with_backend_execution_identity():
|
||||
task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4())
|
||||
submitted = []
|
||||
def handler(request):
|
||||
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
|
||||
assert not request.url.path.endswith("schedules/dispatch-due")
|
||||
if request.url.path.endswith("leases/recover"):
|
||||
return httpx.Response(200, json={"items": []})
|
||||
if request.method == "GET":
|
||||
if request.url.params.get("status") != "queued":
|
||||
return httpx.Response(200, json={"items": []})
|
||||
return httpx.Response(200, json={"items": [{
|
||||
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
|
||||
"status": "queued", "revision": 1,
|
||||
}]})
|
||||
body = json.loads(request.content)
|
||||
submitted.append(body)
|
||||
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
worker = OperationalWorker(settings, client)
|
||||
assert await worker.tick() == 1
|
||||
assert await worker.tick() == 1
|
||||
assert submitted[0] == submitted[1]
|
||||
assert submitted[0] == {
|
||||
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
|
||||
}
|
||||
assert "credential" not in submitted[0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("run_status", ["claimed", "running", "failed"])
|
||||
async def test_only_queued_runs_are_submitted(run_status):
|
||||
def handler(request):
|
||||
assert not request.url.path.endswith("schedules/dispatch-due")
|
||||
if request.method == "GET":
|
||||
requested = request.url.params.get("status")
|
||||
if requested == run_status and run_status in {"claimed", "running"}:
|
||||
return httpx.Response(200, json={"items": [{"run_id": str(uuid4()), "status": run_status}]})
|
||||
return httpx.Response(200, json={"items": []})
|
||||
assert request.url.host == "server.test"
|
||||
return httpx.Response(200, json={"items": []})
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await OperationalWorker(settings, client).tick() == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bounded_queue_continues_after_one_agent_handoff_fails():
|
||||
runs = [{"task_id": str(uuid4()), "run_id": str(uuid4()),
|
||||
"execution_request_id": str(uuid4()), "status": "queued",
|
||||
"revision": 1} for _ in range(2)]
|
||||
limits, submitted, failures = [], [], []
|
||||
|
||||
def handler(request):
|
||||
if request.method == "GET":
|
||||
if request.url.params.get("status") != "queued":
|
||||
return httpx.Response(200, json={"items": []})
|
||||
limits.append(int(request.url.params["limit"]))
|
||||
return httpx.Response(200, json={"items": runs})
|
||||
if request.url.host == "server.test":
|
||||
if request.url.path.endswith("leases/recover"):
|
||||
return httpx.Response(200, json={"items": []})
|
||||
failures.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={})
|
||||
body = json.loads(request.content)
|
||||
submitted.append(body)
|
||||
if body["task_id"] == runs[0]["task_id"]:
|
||||
return httpx.Response(503, json={"detail": "temporary failure"})
|
||||
return httpx.Response(202, json={"task_id": body["task_id"], "run_id": body["run_id"]})
|
||||
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await OperationalWorker(settings, client).tick() == 1
|
||||
assert limits == [settings.max_concurrency]
|
||||
assert [body["task_id"] for body in submitted] == [run["task_id"] for run in runs]
|
||||
assert [body["execution_request_id"] for body in submitted] == [run["execution_request_id"] for run in runs]
|
||||
assert failures[0]["retryable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_service_credentials_are_cached_and_refreshed_without_entering_task_payload(monkeypatch):
|
||||
clock = [100.0]
|
||||
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||
requests = []
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
assert request.url == "https://identity.test/token"
|
||||
assert "authorization" not in request.headers
|
||||
return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60})
|
||||
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
|
||||
token_url="https://identity.test/token", client_id="test-service",
|
||||
client_secret="TEST_ONLY_SECRET")
|
||||
provider = CredentialProvider(settings)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
first = await provider.headers(client)
|
||||
clock[0] = 140.0
|
||||
assert await provider.headers(client) == first
|
||||
assert len(requests) == 1
|
||||
clock[0] = 146.0
|
||||
refreshed = await provider.headers(client)
|
||||
assert len(requests) == 2
|
||||
assert first["Authorization"] == "Bearer TEST_ONLY_1"
|
||||
assert refreshed == {"Authorization": "Bearer TEST_ONLY_2", "X-Project-Id": settings.project_id}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_long_lived_oauth_token_is_refreshed_before_product_token_age_limit(monkeypatch):
|
||||
clock, requests = [0.0], []
|
||||
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
|
||||
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={"access_token": "TEST_ONLY", "expires_in": 3600})
|
||||
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()),
|
||||
token_url="https://identity.test/token", client_id="test-service",
|
||||
client_secret="TEST_ONLY_SECRET")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
provider = CredentialProvider(settings)
|
||||
await provider.headers(client)
|
||||
clock[0] = 301.0
|
||||
await provider.headers(client)
|
||||
assert len(requests) == 2
|
||||
@@ -0,0 +1,147 @@
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.schemas import TestDataRecord as DataRecordSchema
|
||||
from samples.peripheral.schemas import WorkOrder
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, *, row=None, rows=None):
|
||||
self.row = row
|
||||
self.rows = rows or []
|
||||
|
||||
def fetchone(self):
|
||||
return self.row
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class ManagedProjectionConnection:
|
||||
def __init__(self, table: str, document: dict):
|
||||
self.table = table
|
||||
self.document = document
|
||||
|
||||
def execute(self, statement, parameters):
|
||||
normalized = " ".join(statement.split())
|
||||
if "FROM peripheral_sample.scenarios" in normalized:
|
||||
return FakeResult(row=None)
|
||||
if f"FROM peripheral_sample.{self.table}" in normalized:
|
||||
row = {"document": self.document}
|
||||
return FakeResult(row=row, rows=[row])
|
||||
raise AssertionError(f"unexpected query: {normalized}")
|
||||
|
||||
|
||||
class ManagedProjectionStore(PostgresStore):
|
||||
def __init__(self, table: str, document: dict):
|
||||
super().__init__(SampleSettings())
|
||||
self.fake_connection = ManagedProjectionConnection(table, document)
|
||||
|
||||
@contextmanager
|
||||
def connection(self, *, readonly=False, timeseries=False):
|
||||
assert readonly is True
|
||||
assert timeseries is False
|
||||
yield self.fake_connection
|
||||
|
||||
|
||||
def work_order_document() -> dict:
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return {
|
||||
"work_order_id": str(uuid4()),
|
||||
"project_id": str(uuid4()),
|
||||
"revision": 2,
|
||||
"title": "恢复后的演示工单",
|
||||
"summary": "当前行已恢复,但旧实现留下 tombstone 字段。",
|
||||
"status": "open",
|
||||
"priority": "normal",
|
||||
"assignee": None,
|
||||
"scheduled_at": None,
|
||||
"origin": "synthetic",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"created_by": str(uuid4()),
|
||||
"updated_by": str(uuid4()),
|
||||
"work_order_type": "general",
|
||||
"location": None,
|
||||
"scenario_code": None,
|
||||
"deleted_at": now,
|
||||
"deleted_by": str(uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def test_active_work_order_projection_removes_only_tombstone_fields():
|
||||
document = work_order_document()
|
||||
stored_document = deepcopy(document)
|
||||
store = ManagedProjectionStore("work_orders", stored_document)
|
||||
who = Identity(document["project_id"], str(uuid4()), False)
|
||||
|
||||
result = store.get_work_order(who, document["work_order_id"])
|
||||
|
||||
assert "deleted_at" not in result
|
||||
assert "deleted_by" not in result
|
||||
assert stored_document == document
|
||||
WorkOrder.model_validate(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("table", "id_column", "document", "model"),
|
||||
[
|
||||
("work_orders", "work_order_id", work_order_document(), WorkOrder),
|
||||
(
|
||||
"test_data_records",
|
||||
"record_id",
|
||||
{
|
||||
"record_id": str(uuid4()),
|
||||
"project_id": str(uuid4()),
|
||||
"revision": 2,
|
||||
"label": "恢复后的演示数据",
|
||||
"point_id": "DEMO-P001",
|
||||
"observed_at": "2026-09-18T00:00:00Z",
|
||||
"value": 1.0,
|
||||
"unit": "m",
|
||||
"quality": "valid",
|
||||
"notes": "",
|
||||
"origin": "synthetic",
|
||||
"created_at": "2026-09-18T00:00:00Z",
|
||||
"updated_at": "2026-09-18T00:00:00Z",
|
||||
"created_by": str(uuid4()),
|
||||
"updated_by": str(uuid4()),
|
||||
"deleted_at": "2026-09-18T00:00:00Z",
|
||||
"deleted_by": str(uuid4()),
|
||||
},
|
||||
DataRecordSchema,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_active_managed_list_projection_removes_tombstone_fields(
|
||||
table, id_column, document, model
|
||||
):
|
||||
store = ManagedProjectionStore(table, document)
|
||||
who = Identity(document["project_id"], str(uuid4()), False)
|
||||
|
||||
page = store._list_managed(who, table, id_column, limit=10, cursor=None)
|
||||
|
||||
assert len(page["items"]) == 1
|
||||
assert "deleted_at" not in page["items"][0]
|
||||
assert "deleted_by" not in page["items"][0]
|
||||
model.model_validate(page["items"][0])
|
||||
|
||||
|
||||
def test_active_projection_does_not_hide_unrelated_fields():
|
||||
document = {
|
||||
"deleted_at": "2026-09-18T00:00:00Z",
|
||||
"deleted_by": str(uuid4()),
|
||||
"unexpected_field": "must remain visible to schema validation",
|
||||
}
|
||||
|
||||
result = PostgresStore._active_managed_document(document)
|
||||
|
||||
assert result == {"unexpected_field": "must remain visible to schema validation"}
|
||||
assert "deleted_at" in document
|
||||
assert "deleted_by" in document
|
||||
@@ -33,7 +33,9 @@ class ManagedStore:
|
||||
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
|
||||
"title": command.title or "updated", "summary": command.summary or "summary",
|
||||
"status": command.status or "open", "priority": command.priority or "normal",
|
||||
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
|
||||
"assignee": None, "scheduled_at": None, "work_order_type": "general",
|
||||
"location": command.location, "scenario_code": command.scenario_code,
|
||||
"subtasks": command.subtasks}})()) | {"revision": command.expected_revision + 1,
|
||||
"work_order_id": str(work_order_id)}
|
||||
|
||||
def delete_work_order(self, who, work_order_id, command):
|
||||
@@ -82,14 +84,34 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
|
||||
created = client.post("/api/sample/v1/work-orders", json={
|
||||
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
|
||||
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
|
||||
"work_order_type": "water_quality_complaint", "location": "演示小区",
|
||||
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||
"subtasks": [{
|
||||
"id": "sampling", "title": "现场采样", "summary": "采集二次供水样本",
|
||||
"status": "running", "scheduled_at": "2026-09-18T01:00:00Z",
|
||||
"location": "演示小区", "assignee": "水质班组", "duration_minutes": 60,
|
||||
"reply_window_minutes": 120, "reply_requirements": ["上传检测结果"],
|
||||
"stages": [{"id": "dispatch", "sequence": 1, "title": "派发",
|
||||
"status": "current", "owner": "调度员", "time_label": "待执行",
|
||||
"description": "确认采样人员"}],
|
||||
}],
|
||||
})
|
||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||
assert created.json()["work_order_type"] == "water_quality_complaint"
|
||||
assert created.json()["location"] == "演示小区"
|
||||
assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001"
|
||||
assert created.json()["subtasks"][0]["stages"][0]["status"] == "current"
|
||||
work_order_id = created.json()["work_order_id"]
|
||||
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress",
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply",
|
||||
"location": "复测点", "clear_scenario_code": True, "clear_subtasks": True,
|
||||
"clear_assignee": False, "clear_scheduled_at": False,
|
||||
})
|
||||
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
||||
assert changed.json()["status"] == "awaiting_reply"
|
||||
update_command = next(call[3] for call in fake.calls if call[0] == "update_work_order")
|
||||
assert update_command.location == "复测点"
|
||||
assert update_command.clear_scenario_code and update_command.clear_subtasks
|
||||
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
@@ -120,6 +142,8 @@ def test_managed_update_models_reject_empty_or_conflicting_changes():
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
|
||||
"value": 1.0, "clear_value": True}),
|
||||
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1,
|
||||
"location": "新位置", "clear_location": True}),
|
||||
):
|
||||
try:
|
||||
model.model_validate(payload)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||
from scripts.sync_peripheral_work_orders import sync_once, work_order_snapshot
|
||||
from app.domain.schemas.operational_task import ExternalFeedbackCommand
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
|
||||
tasks, writes = [], []
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal",
|
||||
"status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": None, "work_order_type": "general"}
|
||||
def handler(request):
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [order], "next_cursor": None})
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"items": tasks, "next_offset": None})
|
||||
body = json.loads(request.content)
|
||||
writes.append((request.url.path, body))
|
||||
if request.url.path.endswith("external-feedback"):
|
||||
tasks[0]["source"]["external_revision"] = body["source_revision"]
|
||||
tasks[0]["status"] = body["status"]
|
||||
return httpx.Response(200, json={"task": tasks[0]})
|
||||
task = {**body, "task_id": task_id}
|
||||
tasks.append(task)
|
||||
return httpx.Response(201, json=task)
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
credentials = CredentialProvider(settings)
|
||||
assert await sync_once(client, settings, "http://sample.test", credentials) == 1
|
||||
assert await sync_once(client, settings, "http://sample.test", credentials) == 0
|
||||
order.update(revision=2, status="awaiting_reply", updated_at="2026-09-23T09:00:00Z",
|
||||
title="水表维修复核", assignee="测试班组", location="测试位置",
|
||||
scheduled_at="2026-09-24T08:00:00Z")
|
||||
assert await sync_once(client, settings, "http://sample.test", credentials) == 1
|
||||
assert len(tasks) == 1
|
||||
assert len(writes) == 3
|
||||
assert writes[-1][1]["status"] == "awaiting_feedback"
|
||||
assert writes[-1][1]["source_revision"] == 2
|
||||
snapshot = writes[-1][1]["work_order_snapshot"]
|
||||
assert snapshot["status"] == "awaiting_reply"
|
||||
assert snapshot["title"] == "水表维修复核"
|
||||
assert snapshot["assignee"] == "测试班组"
|
||||
assert snapshot["location"] == "测试位置"
|
||||
assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z"
|
||||
assert snapshot["subtasks"] is None
|
||||
# Exercise the real API schema, including the prefixed external identity.
|
||||
accepted = ExternalFeedbackCommand.model_validate(writes[-1][1])
|
||||
assert str(accepted.work_order_snapshot.work_order_id) == order_id
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}]
|
||||
if request.url.host == "sample.test" else []), "next_offset": None})
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
with pytest.raises(ValueError, match="scope"):
|
||||
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("investigation_status", ["waiting_human", "queued"])
|
||||
async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status):
|
||||
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
|
||||
received = []
|
||||
def handler(request):
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [{
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
|
||||
"title": "合成采样工单", "priority": "normal",
|
||||
"work_order_type": "water_quality_complaint", "created_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": None,
|
||||
}], "next_cursor": None})
|
||||
if request.method == "POST":
|
||||
assert request.url.path.endswith("external-feedback"), "Linked child orders must not start another investigation"
|
||||
received.append(request.url.path)
|
||||
payload = json.loads(request.content)
|
||||
assert payload["source_event_id"] == f"peripheral_sample:{order_id}"
|
||||
assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback")
|
||||
return httpx.Response(200, json={})
|
||||
if request.url.path.endswith(investigation_id):
|
||||
return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": {
|
||||
"adapter": "peripheral_sample", "receipt_id": order_id,
|
||||
}}]})
|
||||
return httpx.Response(200, json={"items": [
|
||||
{"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status},
|
||||
{"task_id": projection_id, "task_type": "external_work_order", "source": {
|
||||
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
|
||||
}},
|
||||
], "next_offset": None})
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) == 2
|
||||
assert any(investigation_id in path for path in received)
|
||||
assert any(projection_id in path for path in received)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("event_kind", ["water_quality_complaint", "burst_report"])
|
||||
async def test_external_events_start_investigations_without_browser(event_kind):
|
||||
project_id, order_id = str(uuid4()), str(uuid4())
|
||||
created = []
|
||||
def handler(request):
|
||||
if request.method == "GET" and request.url.host == "server.test":
|
||||
return httpx.Response(200, json={"items": [], "next_offset": None})
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [{
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1,
|
||||
"title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open",
|
||||
"created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置",
|
||||
}], "next_cursor": None})
|
||||
payload = json.loads(request.content)
|
||||
if request.url.path.endswith("external-feedback"):
|
||||
return httpx.Response(200, json={})
|
||||
task = {**payload, "task_id": str(uuid4())}
|
||||
created.append(task)
|
||||
return httpx.Response(201, json=task)
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||
assert {task["task_type"] for task in created} == {"event_investigation", "external_work_order"}
|
||||
investigation = next(task for task in created if task["task_type"] == "event_investigation")
|
||||
assert investigation["workflow"] == event_kind
|
||||
assert investigation["source"]["external_id"] == order_id
|
||||
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
|
||||
|
||||
|
||||
def test_snapshot_preserves_nested_process_without_inventing_completed_stages():
|
||||
order = {
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "synthetic",
|
||||
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
|
||||
"subtasks": [{
|
||||
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
|
||||
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
|
||||
"location": "测试位置", "assignee": "测试班组", "duration_minutes": 30,
|
||||
"reply_window_minutes": 15, "reply_requirements": ["检查记录"],
|
||||
"stages": [{"id": "dispatch", "sequence": 1, "title": "派发", "status": "pending",
|
||||
"owner": "测试调度", "time_label": "待安排", "description": "尚未派发"}],
|
||||
}],
|
||||
}
|
||||
snapshot = work_order_snapshot(order)
|
||||
assert snapshot["subtasks"] == order["subtasks"]
|
||||
assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending"
|
||||
assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None
|
||||
@@ -1,6 +1,8 @@
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services import scheme_management
|
||||
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
|
||||
|
||||
|
||||
def _mock_connection(monkeypatch):
|
||||
@@ -97,3 +99,48 @@ def test_run_and_business_result_share_one_transaction(monkeypatch) -> None:
|
||||
|
||||
assert created_ids == [(connection, run_id)]
|
||||
assert result_ids == [(connection, run_id)]
|
||||
|
||||
|
||||
def test_completed_business_result_emits_one_event_after_result(monkeypatch) -> None:
|
||||
connection = object()
|
||||
context = MagicMock()
|
||||
context.__enter__.return_value = connection
|
||||
monkeypatch.setattr(scheme_management, "project_transaction", lambda _name: context)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
scheme_management.AnalysisRepository,
|
||||
"create_run_sync",
|
||||
lambda _conn, **_kwargs: calls.append("run"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheme_management.AnalysisRepository,
|
||||
"insert_result_sync",
|
||||
lambda _conn, _run_id, **_kwargs: calls.append("result"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scheme_management.AnalysisRepository,
|
||||
"append_run_changed_event_sync",
|
||||
lambda _conn, **event: calls.append(("event", event)),
|
||||
)
|
||||
project_id = uuid4()
|
||||
routing = ActiveProjectRouting(
|
||||
project_code="test", business_dsn="postgresql://test/db", project_id=project_id
|
||||
)
|
||||
|
||||
with activate_project_routing(routing):
|
||||
run_id = scheme_management.store_analysis_run_with_result(
|
||||
name="test", scheme_name="analysis", scheme_type="dma",
|
||||
username="alice", scheme_start_time="2026-09-23T00:00:00Z",
|
||||
scheme_detail={}, result_type="summary", result_payload={"rows": []},
|
||||
)
|
||||
|
||||
assert calls[:2] == ["run", "result"]
|
||||
assert calls[2][0] == "event"
|
||||
assert calls[2][1] == {
|
||||
"project_id": project_id,
|
||||
"run_id": run_id,
|
||||
"revision": 1,
|
||||
"status": "completed",
|
||||
"occurred_at": calls[2][1]["occurred_at"],
|
||||
"results_committed": True,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from contextlib import contextmanager
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.seed_peripheral_sample import (
|
||||
ACTOR_ID,
|
||||
previous_seed_result,
|
||||
restore_seeded_resources,
|
||||
)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
|
||||
def fetchone(self):
|
||||
return self.row
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
self.calls = []
|
||||
|
||||
def execute(self, statement, parameters):
|
||||
self.calls.append((" ".join(statement.split()), parameters))
|
||||
return FakeResult(self.row)
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, row=None):
|
||||
self.connection_value = FakeConnection(row)
|
||||
self.readonly = None
|
||||
|
||||
@contextmanager
|
||||
def connection(self, readonly=False):
|
||||
self.readonly = readonly
|
||||
yield self.connection_value
|
||||
|
||||
|
||||
def test_previous_seed_result_reuses_legacy_receipt():
|
||||
resource_id = str(uuid4())
|
||||
store = FakeStore({
|
||||
"command_key": "create_work_order",
|
||||
"result": {"work_order_id": resource_id},
|
||||
})
|
||||
seed_request_id = uuid4()
|
||||
|
||||
result = previous_seed_result(
|
||||
store,
|
||||
"project-1",
|
||||
seed_request_id=seed_request_id,
|
||||
command_key="create_work_order",
|
||||
)
|
||||
|
||||
assert result == {"work_order_id": resource_id}
|
||||
assert store.readonly is True
|
||||
assert store.connection_value.calls[0][1] == (
|
||||
"project-1",
|
||||
str(ACTOR_ID),
|
||||
seed_request_id,
|
||||
)
|
||||
|
||||
|
||||
def test_restore_seeded_resources_only_restores_allowed_tables():
|
||||
store = FakeStore()
|
||||
resource_ids = [str(uuid4()), str(uuid4())]
|
||||
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
"project-1",
|
||||
table="work_orders",
|
||||
id_column="work_order_id",
|
||||
resource_ids=resource_ids,
|
||||
)
|
||||
|
||||
statement, parameters = store.connection_value.calls[0]
|
||||
assert "UPDATE peripheral_sample.work_orders" in statement
|
||||
assert "document=document - 'deleted_at' - 'deleted_by'" in statement
|
||||
assert "deleted_at=NULL, deleted_by=NULL" in statement
|
||||
assert parameters == ("project-1", resource_ids)
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported seeded resource"):
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
"project-1",
|
||||
table="requests",
|
||||
id_column="request_id",
|
||||
resource_ids=resource_ids,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from scripts.seed_workbench_work_orders import (
|
||||
DATASET_ID,
|
||||
LEGACY_WORK_ORDER_SOURCES,
|
||||
build_demo_work_orders,
|
||||
replace_demo_work_orders,
|
||||
)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def execute(self, statement, parameters=None):
|
||||
self.calls.append((" ".join(statement.split()), parameters))
|
||||
|
||||
|
||||
def test_build_demo_work_orders_restores_original_mock_tasks():
|
||||
documents = build_demo_work_orders()
|
||||
|
||||
assert DATASET_ID == "workbench-demo-orders@1"
|
||||
assert [document["title"] for document in documents] == [
|
||||
"演示工单:华山路阀门边界确认",
|
||||
"演示工单:北辰分区阀门开度调整",
|
||||
"演示工单:泵站边界复核复令",
|
||||
]
|
||||
assert [document["status"] for document in documents] == [
|
||||
"pending",
|
||||
"running",
|
||||
"awaiting_reply",
|
||||
]
|
||||
assert all(len(document["subtasks"]) == 3 for document in documents)
|
||||
assert all("合成" not in str(document) for document in documents)
|
||||
|
||||
|
||||
def test_demo_work_orders_keep_one_current_stage_per_active_task():
|
||||
documents = build_demo_work_orders()
|
||||
|
||||
for document in documents:
|
||||
current_stages = [
|
||||
stage
|
||||
for subtask in document["subtasks"]
|
||||
for stage in subtask["stages"]
|
||||
if stage["status"] == "current"
|
||||
]
|
||||
assert len(current_stages) == 1
|
||||
|
||||
assert documents[0]["subtasks"][0]["stages"][0]["status"] == "current"
|
||||
assert documents[1]["subtasks"][1]["stages"][1]["status"] == "current"
|
||||
assert documents[2]["subtasks"][2]["stages"][2]["status"] == "current"
|
||||
|
||||
|
||||
def test_replace_removes_legacy_projection_before_inserting_demo_tasks():
|
||||
connection = FakeConnection()
|
||||
documents = build_demo_work_orders()
|
||||
|
||||
replace_demo_work_orders(connection, "project-1", documents)
|
||||
|
||||
delete_statement, delete_parameters = connection.calls[0]
|
||||
assert delete_statement.startswith("DELETE FROM workbench.work_orders")
|
||||
assert "demo-work-order-%" in delete_statement
|
||||
assert delete_parameters == (
|
||||
"project-1",
|
||||
"mock-work-order-%",
|
||||
list(LEGACY_WORK_ORDER_SOURCES),
|
||||
)
|
||||
assert len(connection.calls) == 4
|
||||
assert all(
|
||||
call[0].startswith("INSERT INTO workbench.work_orders")
|
||||
for call in connection.calls[1:]
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infra.db.postgresql.workbench_event_hub import WorkbenchEventHub, WorkbenchEventHubManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_project_subscribers_share_one_hub_and_idle_close(monkeypatch):
|
||||
start = AsyncMock()
|
||||
close = AsyncMock()
|
||||
monkeypatch.setattr(WorkbenchEventHub, "start", start)
|
||||
monkeypatch.setattr(WorkbenchEventHub, "close", close)
|
||||
manager = WorkbenchEventHubManager(idle_seconds=0.01)
|
||||
project_id = uuid4()
|
||||
|
||||
async with manager.subscribe(project_id, "postgresql://test/db") as first:
|
||||
async with manager.subscribe(project_id, "postgresql://test/db") as second:
|
||||
assert first is second
|
||||
assert first.subscribers == 2
|
||||
assert first.subscribers == 1
|
||||
|
||||
await asyncio.sleep(0.03)
|
||||
assert len(manager._hubs) == 0
|
||||
close.assert_awaited_once()
|
||||
@@ -0,0 +1,94 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints import workbench_events as endpoint
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.infra.db.project_routing import ActiveProjectRouting
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class FakeHub:
|
||||
generation = 0
|
||||
|
||||
async def oldest_cursor(self):
|
||||
return 5
|
||||
|
||||
async def current_cursor(self):
|
||||
return 8
|
||||
|
||||
|
||||
class FakeHubManager:
|
||||
@asynccontextmanager
|
||||
async def subscribe(self, _project_id, _dsn):
|
||||
yield FakeHub()
|
||||
|
||||
|
||||
class FakeMetadataRepository:
|
||||
def __init__(self, routings):
|
||||
self.routings = routings
|
||||
self.session = SimpleNamespace(closed=False, close=self.close)
|
||||
|
||||
async def close(self):
|
||||
self.session.closed = True
|
||||
|
||||
async def list_active_project_db_routings(self, _role):
|
||||
return self.routings
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_event_cursor_requires_snapshot_reset(monkeypatch):
|
||||
project_id = uuid4()
|
||||
dsn = "postgresql://test@db.example:5432/project_a"
|
||||
routing = ActiveProjectRouting("project-a", dsn, project_id=project_id)
|
||||
metadata = FakeMetadataRepository([
|
||||
SimpleNamespace(project_id=project_id, dsn=dsn)
|
||||
])
|
||||
monkeypatch.setattr(endpoint, "workbench_event_hubs", FakeHubManager())
|
||||
|
||||
response = await endpoint.workbench_events(
|
||||
after=1,
|
||||
last_event_id=None,
|
||||
ctx=ProjectContext(
|
||||
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
||||
project_role="member",
|
||||
),
|
||||
routing=routing,
|
||||
metadata_repo=metadata,
|
||||
)
|
||||
first = await anext(response.body_iterator)
|
||||
|
||||
assert metadata.session.closed is True
|
||||
assert "event: reset_required" in first
|
||||
assert '"cursor":1' in first
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_business_database_disables_event_stream():
|
||||
project_id = uuid4()
|
||||
dsn = "postgresql://user:secret@db.example:5432/shared"
|
||||
metadata = FakeMetadataRepository([
|
||||
SimpleNamespace(project_id=project_id, dsn=dsn),
|
||||
SimpleNamespace(project_id=uuid4(), dsn="postgresql://other:other@db.example/shared"),
|
||||
])
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await endpoint.workbench_events(
|
||||
after=0,
|
||||
last_event_id=None,
|
||||
ctx=ProjectContext(
|
||||
project_id=project_id, project_code="project-a", user_id=uuid4(),
|
||||
project_role="member",
|
||||
),
|
||||
routing=ActiveProjectRouting("project-a", dsn, project_id=project_id),
|
||||
metadata_repo=metadata,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 503
|
||||
Reference in New Issue
Block a user