feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+5
View File
@@ -56,6 +56,11 @@ def test_agent_auth_context_returns_metadata_user_and_project_context():
"permissions": [
"burst.run",
"burst.view",
"operational_action.work_order.approve",
"operational_action.work_order.execute",
"operational_task.external_sync",
"operational_task.view",
"operational_task.write",
"optimization.run",
"optimization.view",
"risk.run",
+42
View File
@@ -0,0 +1,42 @@
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from app.api.problem_details import install_problem_details_handlers
def test_structured_http_error_preserves_domain_code_and_recovery_fields():
app = FastAPI()
install_problem_details_handlers(app)
@app.get("/lease")
async def expired_lease():
raise HTTPException(
status_code=409,
detail={
"code": "LEASE_EXPIRED",
"message": "Agent lease has expired",
"retryable": False,
"related_refs": ["task-1"],
"recovery_action": "claim_new_generation",
"retry_after_seconds": None,
},
)
response = TestClient(app).get("/lease")
assert response.status_code == 409
assert response.headers["content-type"].startswith("application/problem+json")
assert response.json() == {
"type": "https://tjwater.example/problems/LEASE-EXPIRED",
"title": "Conflict",
"status": 409,
"detail": "Agent lease has expired",
"instance": "/lease",
"code": "LEASE_EXPIRED",
"trace_id": response.json()["trace_id"],
"errors": [],
"retryable": False,
"related_refs": ["task-1"],
"recovery_action": "claim_new_generation",
"retry_after_seconds": None,
}
+43
View File
@@ -8,6 +8,14 @@ from app.auth.permissions import (
ENVIRONMENT_MANAGE,
MODEL_IMPORT,
OPTIMIZATION_RUN,
OPERATIONAL_ACTION_COMMAND_APPROVE,
OPERATIONAL_ACTION_COMMAND_EXECUTE,
OPERATIONAL_ACTION_WORK_ORDER_APPROVE,
OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC,
OPERATIONAL_TASK_VIEW,
OPERATIONAL_TASK_WRITE,
SCADA_CLEAN,
SIMULATION_RUN,
SIMULATION_VIEW,
@@ -52,11 +60,36 @@ def test_project_role_permission_matrix():
assert SIMULATION_RUN in member
assert OPTIMIZATION_RUN in member
assert MODEL_IMPORT not in member
assert OPERATIONAL_TASK_VIEW in member
assert OPERATIONAL_TASK_WRITE in member
assert OPERATIONAL_TASK_EXECUTE not in member
assert OPERATIONAL_TASK_EXTERNAL_SYNC not in member
assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE in member
assert OPERATIONAL_ACTION_WORK_ORDER_EXECUTE in member
assert OPERATIONAL_ACTION_COMMAND_APPROVE not in member
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in member
assert WEBGIS_VIEW in viewer
assert SIMULATION_VIEW in viewer
assert WEBGIS_EDIT not in viewer
assert SCADA_CLEAN not in viewer
assert SIMULATION_RUN not in viewer
assert OPERATIONAL_TASK_VIEW in viewer
assert OPERATIONAL_TASK_WRITE not in viewer
assert OPERATIONAL_ACTION_WORK_ORDER_APPROVE not in viewer
def test_service_identity_gets_only_project_scoped_control_permissions():
service = resolve_permissions(
project_role="member",
system_role="service",
is_superuser=False,
)
assert OPERATIONAL_TASK_VIEW in service
assert OPERATIONAL_TASK_EXECUTE in service
assert OPERATIONAL_TASK_EXTERNAL_SYNC in service
assert ENVIRONMENT_MANAGE not in service
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in service
def test_system_admin_permissions_do_not_grant_project_business_access():
@@ -70,6 +103,16 @@ def test_system_admin_permissions_do_not_grant_project_business_access():
assert AUDIT_VIEW in permissions
assert MODEL_IMPORT in permissions
assert WEBGIS_VIEW not in permissions
assert OPERATIONAL_ACTION_COMMAND_APPROVE not in permissions
assert OPERATIONAL_ACTION_COMMAND_EXECUTE not in permissions
project_operator = resolve_permissions(
project_role="member",
system_role="admin",
is_superuser=False,
)
assert OPERATIONAL_ACTION_COMMAND_APPROVE in project_operator
assert OPERATIONAL_ACTION_COMMAND_EXECUTE in project_operator
@pytest.mark.anyio
+8
View File
@@ -41,3 +41,11 @@ def test_rbac_migration_normalizes_legacy_roles():
assert "'dispatcher'" in sql
assert "THEN 'member'" in sql
assert "ELSE 'viewer'" in sql
def test_service_identity_migration_extends_business_roles():
sql = Path("resources/sql/016_metadata_service_identities.sql").read_text(
encoding="utf-8"
)
assert "role IN ('admin', 'service', 'user')" in sql
@@ -0,0 +1,291 @@
"""Opt-in PostgreSQL lifecycle check; all schema/data changes roll back."""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
import httpx
import psycopg
import pytest
from psycopg.rows import dict_row
from app.auth.permissions import permissions_for_context
from app.auth.project_dependencies import ProjectContext
from app.core.config import get_pgconn_string
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import (
ActionDecisionCommand, ActionExecuteCommand, ActionProposalCommand, ActionReconcileCommand,
AgentArtifactsCommand, AgentRunCommand, ExternalFeedbackCommand,
NotificationAcknowledgeCommand, OperationalTaskCreate,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
ActionDispatchOutcome, HttpOperationalActionDispatcher, OperationalActionSettings,
)
from app.services.operational_tasks import OperationalTaskService
pytestmark = pytest.mark.skipif(
os.environ.get("OPERATIONAL_TASKS_STORAGE_TEST") != "1", reason="explicit PostgreSQL test opt-in required",
)
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back():
migration = "\n".join(
(Path(__file__).parents[2] / f"resources/sql/{name}").read_text()
for name in ("014_operational_tasks.sql", "015_workbench_events_and_task_runs.sql")
)
# The migration is normally atomic itself. This test owns an outer rollback
# transaction, so it executes its statements without transaction delimiters.
migration = migration.replace("\nBEGIN;", "").replace("\nCOMMIT;", "")
context = ProjectContext(project_id=uuid4(), project_code="isolated-storage-test",
user_id=uuid4(), project_role="member")
conn = await psycopg.AsyncConnection.connect(get_pgconn_string(), row_factory=dict_row, connect_timeout=3)
try:
async with conn.transaction(force_rollback=True):
await conn.execute(migration)
repository = OperationalTaskRepository(conn, context.project_id)
service = OperationalTaskService(repository, context, permissions_for_context(context))
now = datetime.now(timezone.utc)
command = OperationalTaskCreate(
request_id=uuid4(), task_type="event_investigation", workflow="water_quality_complaint",
title="AUTOTEST 合成调查", summary="隔离事务验证,不是生产投诉",
source={"kind": "operator", "event_kind": "water_quality_complaint"},
trigger={"kind": "manual", "occurred_at": now, "detail": "transactional integration test"},
)
task = await service.create(command)
assert (await service.create(command)).task_id == task.task_id
assert task.status == "queued"
queued_rows = await repository.list_tasks(
task_type=None, status="queued", limit=100, offset=0
)
assert [row["task_id"] for row in queued_rows] == [str(task.task_id)]
all_rows = await repository.list_tasks(
task_type=None, status=None, limit=100, offset=0
)
assert [row["task_id"] for row in all_rows] == [str(task.task_id)]
claimed_run = await service.claim_run(task.task_id, task.active_run.run_id, AgentRunCommand(
request_id=task.active_run.execution_request_id,
expected_revision=task.active_run.revision,
source_session_id="test-investigation-session",
))
task = await service.detail(task.task_id)
assert task.active_run == claimed_run
artifact = AgentArtifactsCommand.model_validate({
"request_id": str(uuid4()), "source_session_id": "test-investigation-session",
"run_id": str(task.active_run.run_id),
"evidence": [{"evidence_id": "synthetic-1", "kind": "synthetic", "summary": "合成采样输入",
"source_label": "事务集成测试", "observed_at": now.isoformat()}],
"plan": {"schema": "execution-plan@1", "title": "调查", "reason": "等待采样",
"nodes": [{"id": "review", "title": "核对输入", "owner": "agent", "status": "completed",
"detail": "已读取合成输入", "evidence_refs": ["synthetic-1"]},
{"id": "sample", "title": "现场采样", "owner": "human", "status": "waiting",
"detail": "等待外部反馈", "evidence_refs": []}],
"edges": [{"from": "review", "to": "sample"}]},
"report": {"title": "调查阶段结论", "current_conclusion": "待现场核验", "conclusion_boundary": "合成输入",
"evidence_refs": ["synthetic-1"], "pending_items": ["现场采样"]},
})
published = await service.publish_artifacts(task.task_id, artifact)
assert published.task.status == "running"
assert published.task.latest_report.report.evidence_refs == ["synthetic-1"]
assert (await service.publish_artifacts(task.task_id, artifact)).report_revision == 1
task = await service.complete_run(
task.task_id,
published.task.active_run.run_id,
AgentRunCommand(
request_id=uuid4(),
expected_revision=published.task.active_run.revision,
source_session_id="test-investigation-session",
),
)
assert task.status == "waiting_human"
action = await service.propose_action(task.task_id, ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要现场采样", evidence_refs=["synthetic-1"],
payload={"title": "采样", "description": "合成核验", "work_order_type": "sampling"},
))
member_notifications, member_next = await service.list_notifications(
unread_only=False, limit=100, offset=0
)
assert member_next is None
assert member_notifications
assert all(item.task_id == task.task_id for item in member_notifications)
action_pending = next(
item for item in member_notifications if item.kind == "action_pending"
)
task_created = next(
item for item in member_notifications if item.kind == "task_created"
)
assert action_pending.required_permission == action.required_approval_permission
viewer_context = ProjectContext(
project_id=context.project_id,
project_code=context.project_code,
user_id=uuid4(),
project_role="viewer",
)
viewer_service = OperationalTaskService(
OperationalTaskRepository(conn, viewer_context.project_id),
viewer_context,
permissions_for_context(viewer_context),
)
viewer_notifications, _ = await viewer_service.list_notifications(
unread_only=True, limit=100, offset=0
)
assert task_created.notification_id in {
item.notification_id for item in viewer_notifications
}
assert action_pending.notification_id not in {
item.notification_id for item in viewer_notifications
}
acknowledged = await service.acknowledge_notification(
task_created.notification_id,
NotificationAcknowledgeCommand(
request_id=uuid4(), expected_version=task_created.version
),
)
assert acknowledged.status == "read"
assert acknowledged.task_id == task.task_id
persisted_service = OperationalTaskService(
OperationalTaskRepository(conn, context.project_id),
context,
permissions_for_context(context),
)
persisted_notifications, _ = await persisted_service.list_notifications(
unread_only=False, limit=100, offset=0
)
persisted_created = next(
item for item in persisted_notifications
if item.notification_id == task_created.notification_id
)
assert persisted_created.status == "read"
assert persisted_created.version == task_created.version + 1
assert persisted_created.read_by == f"principal:{context.user_id}"
unread_notifications, _ = await persisted_service.list_notifications(
unread_only=True, limit=100, offset=0
)
assert task_created.notification_id not in {
item.notification_id for item in unread_notifications
}
viewer_after_member_ack, _ = await viewer_service.list_notifications(
unread_only=True, limit=100, offset=0
)
assert task_created.notification_id in {
item.notification_id for item in viewer_after_member_ack
}
action = await service.decide_action(task.task_id, action.action_id, ActionDecisionCommand(
request_id=uuid4(), expected_version=action.version, decision="approved",
))
assert action.execution_status == "not_submitted"
class NoAdapter:
async def dispatch(self, **kwargs):
return ActionDispatchOutcome("unsupported", "测试不执行外部动作")
action = await service.submit_action(task.task_id, action.action_id, ActionExecuteCommand(
request_id=uuid4(), expected_version=action.version,
), NoAdapter(), None)
assert action.execution_status == "unsupported"
fresh_service = OperationalTaskService(OperationalTaskRepository(conn, context.project_id),
context, permissions_for_context(context))
assert (await fresh_service.detail(task.task_id)).actions[0].action_id == action.action_id
before_feedback = await fresh_service.detail(task.task_id)
feedback = ExternalFeedbackCommand(
request_id=uuid4(), source_event_id="synthetic-sampling-order", source_revision=2,
status="completed", occurred_at=now, detail="合成采样工单完成,调查仍需分析",
evidence={"evidence_id": "synthetic-sampling-order:2", "kind": "synthetic",
"summary": "合成采样结果已回传", "source_label": "事务集成外围工单",
"observed_at": now},
)
resumed = (await fresh_service.external_feedback(task.task_id, feedback)).task
assert resumed.status == "queued"
assert resumed.execution_generation == before_feedback.execution_generation
assert resumed.execution_request_id == before_feedback.execution_request_id
assert resumed.agent_session_id is None
assert len(resumed.evidence) == 2
replayed = (await fresh_service.external_feedback(task.task_id, feedback)).task
assert replayed.execution_generation == resumed.execution_generation
assert replayed.revision == resumed.revision
claimed_run = await fresh_service.claim_run(
task.task_id,
resumed.active_run.run_id,
AgentRunCommand(
request_id=resumed.active_run.execution_request_id,
expected_revision=resumed.active_run.revision,
source_session_id="test-investigation-followup-session",
),
)
resumed = await fresh_service.detail(task.task_id)
assert resumed.active_run == claimed_run
with pytest.raises(DomainError) as stale_session:
await fresh_service.publish_artifacts(task.task_id, artifact.model_copy(update={
"request_id": uuid4(), "run_id": resumed.active_run.run_id,
}))
assert stale_session.value.code == "SESSION_MISMATCH"
assert (await fresh_service.detail(task.task_id)).agent_session_id == resumed.agent_session_id
contract_output = os.environ.get("OPERATIONAL_TASK_CONTRACT_OUTPUT")
if contract_output:
# Optional generated test artifact for cross-repository consumers.
Path(contract_output).write_text(
(await fresh_service.detail(task.task_id)).model_dump_json(indent=2, by_alias=True), encoding="utf-8"
)
outsider = OperationalTaskService(OperationalTaskRepository(conn, uuid4()),
context, permissions_for_context(context))
with pytest.raises(DomainError):
await outsider.detail(task.task_id)
sampling = await fresh_service.propose_action(task.task_id, ActionProposalCommand(
request_id=uuid4(), kind="create_work_order", reason="需要补充采样",
evidence_refs=["synthetic-1"],
payload={"title": "补充采样", "description": "隔离事务测试", "work_order_type": "sampling"},
))
sampling = await fresh_service.decide_action(task.task_id, sampling.action_id, ActionDecisionCommand(
request_id=uuid4(), expected_version=sampling.version, decision="approved",
))
calls, execution_ids = [], []
receipt_ready = False
order_id = str(uuid4())
def sample_transport(request):
calls.append(request.method)
assert request.headers["X-Project-Id"] == str(context.project_id)
if request.method == "POST":
execution_ids.append(json.loads(request.content)["request_id"])
raise httpx.ReadTimeout("simulated lost response", request=request)
if not receipt_ready:
return httpx.Response(404, json={"effect": "none"})
assert request.url.path.endswith(execution_ids[0])
return httpx.Response(200, json={"request_id": execution_ids[0], "result": {
"work_order_id": order_id, "project_id": str(context.project_id),
"created_at": now.isoformat(), "origin": "synthetic", "status": "open",
}})
dispatcher = HttpOperationalActionDispatcher(
OperationalActionSettings(_env_file=None, peripheral_url="http://sample.test"),
transport=httpx.MockTransport(sample_transport),
)
submission = ActionExecuteCommand(request_id=uuid4(), expected_version=sampling.version)
unknown = await fresh_service.submit_action(task.task_id, sampling.action_id, submission,
dispatcher, "Bearer TEST_ONLY")
assert unknown.execution_status == "unknown"
assert calls == ["POST", "GET"]
receipt_ready = True
confirmed = await fresh_service.reconcile_action(task.task_id, sampling.action_id,
ActionReconcileCommand(request_id=uuid4(), expected_version=unknown.version),
dispatcher, "Bearer TEST_ONLY")
assert confirmed.execution_status == "succeeded"
assert confirmed.receipt.receipt_id == order_id
assert confirmed.receipt.external_status == "open"
assert confirmed.execution_request_id == unknown.execution_request_id
assert calls == ["POST", "GET", "GET"]
await fresh_service.submit_action(task.task_id, sampling.action_id, submission,
dispatcher, "Bearer TEST_ONLY")
assert calls == ["POST", "GET", "GET"]
assert (await fresh_service.detail(task.task_id)).status == "queued"
finally:
await conn.close()
@@ -208,9 +208,13 @@ def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample
priority="high",
assignee="AUTOTEST",
scheduled_at="2026-09-18T01:00:00Z",
work_order_type="water_quality_complaint",
location="AUTOTEST 演示小区",
scenario_code="WQ-SECONDARY-SUPPLY-001",
)
order = store.create_work_order(who, order_command)
WorkOrder.model_validate(order)
assert order["work_order_type"] == "water_quality_complaint"
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
assert store.create_work_order(who, order_command) == order
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
@@ -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]
+131
View File
@@ -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")
+852
View File
@@ -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"
+141
View File
@@ -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)
+162
View File
@@ -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,
}
+91
View File
@@ -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:]
)
+32
View File
@@ -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()
+94
View File
@@ -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