feat: extend operational investigation workflows
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""Queue lifecycle SQL contracts; real races are covered by the opt-in PG suite."""
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
|
||||
from app.mcp.errors import CalculationCapacityExceeded
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, rows=()):
|
||||
self.rows = iter(rows)
|
||||
self.statements = []
|
||||
self.in_transaction = False
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
self.in_transaction = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.in_transaction = False
|
||||
|
||||
async def execute(self, sql, params):
|
||||
self.statements.append((sql, params, self.in_transaction))
|
||||
row = next(self.rows, None) if sql.lstrip().startswith(('SELECT job_id', 'SELECT count', 'INSERT')) else None
|
||||
class Cursor:
|
||||
async def fetchone(self):
|
||||
return row
|
||||
async def fetchall(self):
|
||||
return []
|
||||
return Cursor()
|
||||
|
||||
|
||||
def test_status_projection_excludes_result_and_expires_without_worker():
|
||||
connection = Connection()
|
||||
repo = CalculationJobRepository(connection, 'project')
|
||||
asyncio.run(repo.get('job'))
|
||||
assert 'QUEUE_TIMEOUT' in connection.statements[0][0]
|
||||
assert 'SET result=NULL' in connection.statements[1][0]
|
||||
assert ',result FROM' not in connection.statements[2][0]
|
||||
asyncio.run(repo.get('job', include_result=True))
|
||||
assert ',result FROM' in connection.statements[-1][0]
|
||||
|
||||
|
||||
def test_duplicate_submission_bypasses_full_queue_inside_transaction():
|
||||
existing = {'tool': 'tool', 'parameters': {'timeout_seconds': 3600}, 'binding': {}, 'job_id': 'existing'}
|
||||
connection = Connection([existing])
|
||||
row = asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
|
||||
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
|
||||
assert row['job_id'] == 'existing'
|
||||
assert 'pg_advisory_xact_lock' in connection.statements[0][0]
|
||||
assert all(statement[2] for statement in connection.statements)
|
||||
assert not any('count(*)' in sql for sql, _, _ in connection.statements)
|
||||
|
||||
|
||||
def test_capacity_rejects_new_job_before_insert():
|
||||
connection = Connection([None, {'active': 100}])
|
||||
with pytest.raises(CalculationCapacityExceeded):
|
||||
asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
|
||||
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
|
||||
assert not any('INSERT' in sql for sql, _, _ in connection.statements)
|
||||
@@ -0,0 +1,82 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas.operational_task import (
|
||||
AgentArtifactsCommand,
|
||||
InvestigationDiagramNodeRef,
|
||||
InvestigationProgress,
|
||||
InvestigationReportInput,
|
||||
)
|
||||
|
||||
|
||||
def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
|
||||
diagram = {
|
||||
"title": "等待系统结果",
|
||||
"mermaid": "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET",
|
||||
"node_refs": [
|
||||
{"node_id": "DONE", "label": "查询完成", "status": "completed"},
|
||||
{"node_id": "WAIT", "label": "等待系统结果", "status": "waiting"},
|
||||
{"node_id": "UNKNOWN", "label": "未知", "status": None},
|
||||
{"node_id": "UNSET", "label": "未确认"},
|
||||
],
|
||||
}
|
||||
progress = InvestigationProgress(summary="等待系统结果", diagram=diagram)
|
||||
report = InvestigationReportInput(
|
||||
title="报告", current_conclusion="已确认当前结果", conclusion_boundary="仅限查询范围",
|
||||
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
|
||||
)
|
||||
assert report.diagram == progress.diagram
|
||||
assert [node.status for node in report.diagram.node_refs] == ["completed", "waiting", None, None]
|
||||
assert "status" not in report.diagram.node_refs[-1].model_fields_set
|
||||
|
||||
|
||||
def test_unknown_node_state_is_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationDiagramNodeRef(node_id="A", label="查询", status="blocked")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artifact", ["progress", "report"])
|
||||
def test_artifact_write_rejects_node_refs_missing_from_mermaid(artifact):
|
||||
diagram = {
|
||||
"title": "当前路线",
|
||||
"mermaid": "flowchart TD\nA[绑定任务] --> B[核查证据]",
|
||||
"node_refs": [
|
||||
{"node_id": "ctx", "label": "绑定任务", "status": "completed"},
|
||||
{"node_id": "B", "label": "核查证据", "status": "running"},
|
||||
],
|
||||
}
|
||||
payload = (
|
||||
{"summary": "正在核查", "diagram": diagram}
|
||||
if artifact == "progress"
|
||||
else {
|
||||
"title": "调查报告",
|
||||
"current_conclusion": "已确认当前范围",
|
||||
"conclusion_boundary": "仅限已核查资料",
|
||||
"stage": "final",
|
||||
"outcome": "concluded",
|
||||
"data_gaps": [],
|
||||
"diagram": diagram,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="ctx"):
|
||||
AgentArtifactsCommand.model_validate({
|
||||
"request_id": uuid4(),
|
||||
"source_session_id": "session-1",
|
||||
artifact: payload,
|
||||
})
|
||||
|
||||
|
||||
def test_historical_progress_keeps_read_compatibility_for_undeclared_node_refs():
|
||||
progress = InvestigationProgress.model_validate({
|
||||
"summary": "旧进度",
|
||||
"diagram": {
|
||||
"title": "旧图",
|
||||
"mermaid": "flowchart TD\nA[绑定任务]",
|
||||
"node_refs": [{"node_id": "ctx", "label": "绑定任务", "status": "completed"}],
|
||||
},
|
||||
})
|
||||
|
||||
assert progress.diagram.node_refs[0].node_id == "ctx"
|
||||
@@ -218,7 +218,7 @@ def test_time_sources_and_finite_inputs_are_explicit():
|
||||
("run_simulation", {"duration": 0}),
|
||||
("run_simulation", {"duration": 86401}),
|
||||
("run_simulation", {"timeout_seconds": 0}),
|
||||
("run_simulation", {"timeout_seconds": 86401}),
|
||||
("run_simulation", {"timeout_seconds": 241}),
|
||||
("run_simulation", {"timeout_seconds": 30.5}),
|
||||
("run_simulation", {"timeout_seconds": True}),
|
||||
("run_simulation", {"output_node_ids": []}),
|
||||
@@ -316,9 +316,9 @@ def test_burst_location_optional_inputs_still_reject_partial_or_invalid_values(o
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", INPUTS)
|
||||
def test_calculations_accept_agent_execution_budget_separately_from_duration(name):
|
||||
args = arguments(name, timeout_seconds=900)
|
||||
assert args.timeout_seconds == 900
|
||||
def test_calculations_accept_http_response_budget_separately_from_duration(name):
|
||||
args = arguments(name, timeout_seconds=180)
|
||||
assert args.timeout_seconds == 180
|
||||
if hasattr(args, "duration"):
|
||||
assert args.duration == INPUTS[name].get("duration", 900)
|
||||
|
||||
@@ -350,6 +350,25 @@ def test_adapter_uses_existing_service_without_scheme_or_shared_output(monkeypat
|
||||
assert call["n_workers"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
|
||||
def test_isolation_adapter_preserves_explicit_element_type(monkeypatch, element_type):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.valve_isolation.analyze_valve_isolation",
|
||||
lambda **kwargs: calls.append(kwargs) or {"isolatable": True},
|
||||
)
|
||||
args = arguments("analyze_valve_isolation", accident_element_type=element_type)
|
||||
|
||||
asyncio.run(analysis_service.calculate(
|
||||
"analyze_valve_isolation", args, network="trusted-project", username="actor",
|
||||
))
|
||||
|
||||
assert calls[0]["accident_element_type"] == element_type
|
||||
assert calls[0]["network"] == "trusted-project"
|
||||
with pytest.raises(ValidationError):
|
||||
arguments("analyze_valve_isolation", accident_element_type="unknown")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["run_burst_location", "run_simulation"])
|
||||
def test_dispatch_ends_read_transaction_and_preserves_analysis_provenance(monkeypatch, name):
|
||||
order = []
|
||||
|
||||
@@ -137,3 +137,70 @@ def test_evidence_receipt_commits_in_root_operational_transaction(monkeypatch):
|
||||
assert attached.evidence_id == 'saved'
|
||||
assert events == ['commit-reads', 'begin-root-operational', 'commit-evidence']
|
||||
receipt_repo.save_receipt.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('job_budget', [3600, 7200])
|
||||
def test_http_timeout_does_not_limit_queued_execution(monkeypatch, job_budget):
|
||||
from unittest.mock import AsyncMock
|
||||
from app.mcp import calculation_jobs as jobs, query_service
|
||||
from app.mcp.catalog import TOOLS
|
||||
tool = TOOLS['run_simulation']
|
||||
args = tool.model.model_validate({
|
||||
'context_handle': 'x' * 32, 'timeout_seconds': 1,
|
||||
'start_time': '2026-10-08T12:00:00+08:00', 'duration': 3600,
|
||||
'output_node_ids': ['J1'],
|
||||
})
|
||||
repo = SimpleNamespace(enqueue=AsyncMock(return_value={
|
||||
'job_id': uuid4(), 'status': 'queued', 'tool': tool.name,
|
||||
}))
|
||||
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
|
||||
monkeypatch.setattr(query_service, 'authorize_query', AsyncMock())
|
||||
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
|
||||
project=SimpleNamespace(project_id=uuid4(), user_id=uuid4()))
|
||||
result = asyncio.run(jobs.submit_calculation(
|
||||
scope, binding(), tool, args, 'host-call', job_timeout_seconds=job_budget,
|
||||
))
|
||||
assert result.data['status'] == 'queued'
|
||||
assert repo.enqueue.await_args.kwargs['timeout_seconds'] == job_budget
|
||||
assert 'timeout_seconds' not in repo.enqueue.await_args.kwargs['parameters']
|
||||
|
||||
|
||||
def test_expired_result_is_safe_explicit_error(monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from app.mcp import calculation_jobs as jobs
|
||||
from app.mcp.calculation_models import CalculationResultInput
|
||||
original = binding()
|
||||
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded',
|
||||
'result': None, 'result_expires_at': datetime(2020, 1, 1, tzinfo=timezone.utc)}
|
||||
repo = SimpleNamespace(get=AsyncMock(return_value=row))
|
||||
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
|
||||
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
|
||||
attach = AsyncMock()
|
||||
monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach)
|
||||
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
|
||||
CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])))
|
||||
assert error.value.status_code == 410
|
||||
assert jobs.public_job(row)['result_available'] is False
|
||||
assert repo.get.await_args.kwargs['include_result'] is True
|
||||
attach.assert_not_awaited()
|
||||
|
||||
|
||||
def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from app.mcp import calculation_jobs as jobs, query_service
|
||||
from app.mcp.catalog import TOOLS
|
||||
original = binding()
|
||||
owner = uuid4()
|
||||
row = {'binding': durable_binding(original), 'tool': 'run_simulation',
|
||||
'owner_user_id': owner, 'parameters': {'timeout_seconds': 3600,
|
||||
'start_time': '2026-10-29T00:00:00Z', 'output_node_ids': ['1']}}
|
||||
authorize = AsyncMock()
|
||||
monkeypatch.setattr(query_service, 'authorize_query', authorize)
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_id=original.project_id, user_id=owner))
|
||||
_, tool, args = asyncio.run(jobs.authorize_original(scope, row))
|
||||
assert tool is TOOLS['run_simulation']
|
||||
assert args.timeout_seconds <= 240
|
||||
assert row['parameters']['timeout_seconds'] == 3600
|
||||
authorize.assert_awaited_once()
|
||||
|
||||
@@ -23,9 +23,15 @@ def test_explicit_private_development_issuer():
|
||||
assert local_settings().issuer_uri == ISSUER
|
||||
|
||||
|
||||
def test_calculation_limit_covers_default_without_extending_ordinary_queries():
|
||||
def test_execution_limit_is_separate_from_http_request_budget():
|
||||
config = local_settings(max_calculation_timeout_seconds=120)
|
||||
assert config.query_timeout_seconds == 30
|
||||
assert config.query_timeout_seconds == 120
|
||||
assert config.max_request_timeout_seconds == 240
|
||||
assert local_settings().max_calculation_timeout_seconds == 3600
|
||||
with pytest.raises(ValidationError):
|
||||
local_settings(query_timeout_seconds=241)
|
||||
with pytest.raises(ValidationError):
|
||||
local_settings(max_request_timeout_seconds=241)
|
||||
with pytest.raises(ValidationError):
|
||||
local_settings(max_calculation_timeout_seconds=119)
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
|
||||
|
||||
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
|
||||
assert not any("health" in name for name in TOOLS)
|
||||
assert len(TOOLS) == 67
|
||||
assert len(TOOLS) == 71
|
||||
assert sum(t.mutating for t in TOOLS.values()) == 13
|
||||
for tool in TOOLS.values():
|
||||
if tool.mutating:
|
||||
|
||||
@@ -53,11 +53,12 @@ def test_calculation_submission_returns_receipt_without_running_native_code(runt
|
||||
assert response["result"]["structuredContent"]["semantics"] == "task"
|
||||
app.executor.assert_not_awaited()
|
||||
assert submit.await_args.args[3].timeout_seconds == 2
|
||||
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
|
||||
|
||||
|
||||
def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
|
||||
app, client, calls, _ = runtime
|
||||
app.settings.max_calculation_timeout_seconds = 120
|
||||
app.settings.max_request_timeout_seconds = 120
|
||||
tools = request(client).json()["result"]["tools"]
|
||||
schema = next(tool["inputSchema"] for tool in tools if tool["name"] == "run_simulation")
|
||||
assert schema["properties"]["timeout_seconds"]["maximum"] == 120
|
||||
@@ -83,8 +84,19 @@ def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch
|
||||
assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_CONTEXT_STALE"
|
||||
|
||||
|
||||
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
|
||||
def test_expired_calculation_result_has_distinct_mcp_error(runtime):
|
||||
app, client, _, _ = runtime
|
||||
app.executor = AsyncMock(side_effect=HTTPException(410, "Calculation result retention period has expired"))
|
||||
response = request(client, "tools/call", {"name": "get_calculation_result", "arguments": {
|
||||
"context_handle": binding().context_handle, "job_id": str(uuid4()),
|
||||
}}).json()
|
||||
assert response["result"]["structuredContent"]["error"]["code"] == "RESULT_EXPIRED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("maximum,expected", [(240, 120), (120, 120)])
|
||||
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch, maximum, expected):
|
||||
app, client, calls, _ = runtime
|
||||
app.settings.max_request_timeout_seconds = maximum
|
||||
from app.mcp import calculation_jobs
|
||||
executor = AsyncMock(return_value=calculation_jobs.envelope("run_burst_location", {"job_id": str(uuid4()), "status": "queued"}))
|
||||
monkeypatch.setattr(calculation_jobs, "submit_calculation", executor)
|
||||
@@ -92,6 +104,7 @@ def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeyp
|
||||
schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"]
|
||||
if tool["name"] == "run_burst_location")
|
||||
assert "pressure_scada_ids" not in schema["required"]
|
||||
assert schema["properties"]["timeout_seconds"]["default"] == expected
|
||||
response = request(client, "tools/call", {"name": "run_burst_location", "arguments": {
|
||||
"context_handle": binding().context_handle, "burst_leakage": 0.0274,
|
||||
"scada_burst_start": "2026-10-08T12:00:00+08:00",
|
||||
@@ -99,6 +112,7 @@ def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeyp
|
||||
}}).json()
|
||||
assert response["result"].get("isError") is not True
|
||||
assert calls == []
|
||||
assert executor.await_args.args[3].timeout_seconds == expected
|
||||
|
||||
|
||||
def settings():
|
||||
@@ -286,6 +300,37 @@ def binding(task=False):
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seconds", [30, 120, 240])
|
||||
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
|
||||
app, client, _, _ = runtime
|
||||
app.settings.query_timeout_seconds = seconds
|
||||
for tool in request(client).json()["result"]["tools"]:
|
||||
assert tool["_meta"]["io.tjwater/request-timeout"] == {
|
||||
"defaultSeconds": seconds, "maxSeconds": 240,
|
||||
}
|
||||
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
|
||||
if timeout:
|
||||
assert timeout["default"] == seconds
|
||||
|
||||
|
||||
def test_omitted_calculation_http_budget_uses_server_configuration(runtime, monkeypatch):
|
||||
from app.mcp import calculation_jobs
|
||||
app, client, _, _ = runtime
|
||||
app.settings.query_timeout_seconds = 240
|
||||
monkeypatch.setattr(application, "authorize_query", AsyncMock())
|
||||
submit = AsyncMock(return_value=calculation_jobs.envelope(
|
||||
"run_simulation", {"job_id": str(uuid4()), "status": "queued"},
|
||||
))
|
||||
monkeypatch.setattr(calculation_jobs, "submit_calculation", submit)
|
||||
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
|
||||
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
|
||||
"duration": 3600, "output_node_ids": ["J1"],
|
||||
}}).json()
|
||||
assert response["result"].get("isError") is not True
|
||||
assert submit.await_args.args[3].timeout_seconds == 240
|
||||
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
|
||||
|
||||
|
||||
def test_modern_discovery_catalog_and_metadata(runtime):
|
||||
_, client, _, _ = runtime
|
||||
result = request(client, "server/discover").json()
|
||||
@@ -298,7 +343,7 @@ def test_modern_discovery_catalog_and_metadata(runtime):
|
||||
assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating)
|
||||
if timeout:
|
||||
assert timeout["default"] == 120
|
||||
assert timeout["maximum"] == 3600
|
||||
assert timeout["maximum"] == 240
|
||||
assert listed["ttlMs"] == 300000 and listed["cacheScope"] == "public"
|
||||
assert all(
|
||||
tool["inputSchema"]["additionalProperties"] is False for tool in listed["tools"]
|
||||
|
||||
@@ -48,12 +48,13 @@ async def test_create_work_order_confirms_creation_not_field_completion():
|
||||
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"})
|
||||
"origin": "system_data", "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 result.detail == "外围工单已创建,现场工作进度由外围系统继续同步"
|
||||
assert calls[0].url.path == "/api/sample/v1/work-orders"
|
||||
assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id)
|
||||
|
||||
@@ -66,7 +67,7 @@ async def test_work_order_receipt_accepts_awaiting_reply_status():
|
||||
"work_order_id": str(uuid4()),
|
||||
"project_id": str(data["context"].project_id),
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
"origin": "synthetic",
|
||||
"origin": "system_data",
|
||||
"status": "awaiting_reply",
|
||||
})
|
||||
adapter = HttpOperationalActionDispatcher(
|
||||
@@ -90,7 +91,7 @@ async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event():
|
||||
"work_order_id": str(uuid4()),
|
||||
"project_id": str(data["context"].project_id),
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
"origin": "synthetic",
|
||||
"origin": "system_data",
|
||||
"status": "open",
|
||||
})
|
||||
|
||||
@@ -111,7 +112,7 @@ async def test_lost_create_response_recovers_same_request_without_second_write()
|
||||
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"}})
|
||||
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"}})
|
||||
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||
assert result.status == "succeeded"
|
||||
assert methods == ["POST", "GET"]
|
||||
@@ -122,7 +123,7 @@ 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"})
|
||||
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"})
|
||||
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
|
||||
assert result.status == "unknown"
|
||||
assert result.receipt is None
|
||||
|
||||
@@ -54,3 +54,35 @@ def test_overlay_rejects_missing_or_invalid_recorded_spatial_data(data):
|
||||
def test_presentation_input_rejects_free_form_or_invalid_commands(command):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
|
||||
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]},
|
||||
{"action": "clear_scada_analysis"},
|
||||
])
|
||||
def test_unified_presentation_commands_preserve_flat_shape(command):
|
||||
event = TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
|
||||
assert event.command.model_dump(mode="json") == command
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
{"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"]},
|
||||
{"action": "locate_features", "layer": "pipes", "feature_ids": []},
|
||||
{"action": "zoom_to_map", "center": [181, 31], "zoom": 1},
|
||||
{"action": "zoom_to_map", "center": [121, 91], "zoom": 1},
|
||||
{"action": "zoom_to_map", "center": [121, 31, 0], "zoom": 1},
|
||||
{"action": "zoom_to_map", "center": [True, 31], "zoom": 1},
|
||||
{"action": "zoom_to_map", "center": [121, 31], "zoom": 25},
|
||||
{"action": "zoom_to_map", "center": [121, 31], "zoom": -1},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "urgent"}]},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "high"}] * 2},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": str(i), "level": "low"} for i in range(101)]},
|
||||
{"action": "open_chart", "chart_id": "X", "evidence_refs": [], "chart": {
|
||||
"title": "X", "chart_type": "line", "x_data": ["t"], "series": [{"name": "s", "data": [1]}],
|
||||
}},
|
||||
])
|
||||
def test_unified_presentation_rejects_invalid_commands(command):
|
||||
with pytest.raises(ValidationError):
|
||||
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
|
||||
|
||||
@@ -17,12 +17,19 @@ def anyio_backend():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("available", [False, True])
|
||||
async def test_execution_requires_message_storage(available):
|
||||
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
|
||||
@pytest.mark.parametrize("available,agent_ownership_available,ready", [
|
||||
(False, False, False), (True, False, False), (True, True, True),
|
||||
])
|
||||
async def test_execution_requires_message_and_agent_ownership_storage(
|
||||
available, agent_ownership_available, ready,
|
||||
):
|
||||
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={
|
||||
"available": available,
|
||||
"agent_ownership_available": agent_ownership_available,
|
||||
}))
|
||||
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
|
||||
repository = OperationalTaskRepository(connection, uuid4())
|
||||
if available:
|
||||
if ready:
|
||||
await repository.require_execution_storage()
|
||||
else:
|
||||
with pytest.raises(DomainError) as failure:
|
||||
@@ -30,9 +37,31 @@ async def test_execution_requires_message_storage(available):
|
||||
assert failure.value.status == 503
|
||||
assert failure.value.code == "DEPENDENCY_UNAVAILABLE"
|
||||
assert "024_agent_messages.sql" in failure.value.message
|
||||
assert "030_operational_run_agent_ownership.sql" in failure.value.message
|
||||
assert "to_regclass('workbench.agent_messages')" in connection.execute.call_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("available", [False, True])
|
||||
async def test_run_update_uses_agent_owner_column_only_after_migration(available):
|
||||
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
|
||||
updated = SimpleNamespace(rowcount=1)
|
||||
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, updated]))
|
||||
repository = OperationalTaskRepository(connection, uuid4())
|
||||
run_id = str(uuid4())
|
||||
await repository.update_run({
|
||||
"run_id": run_id, "status": "running", "revision": 2,
|
||||
"agent_session_id": "session-1", "agent_instance_id": "agent-a",
|
||||
"lease_expires_at": None, "heartbeat_at": None, "started_at": None,
|
||||
"finished_at": None, "failure_code": None, "failure_detail": None,
|
||||
"dispatch_attempts": 0, "next_dispatch_at": None, "updated_at": None,
|
||||
}, 1)
|
||||
|
||||
query, params = connection.execute.call_args.args
|
||||
assert ("agent_instance_id=%s" in query) is available
|
||||
assert ("agent-a" in params) is available
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_message_storage_rejects_claim_before_state_changes():
|
||||
@asynccontextmanager
|
||||
@@ -56,3 +85,16 @@ async def test_missing_message_storage_rejects_claim_before_state_changes():
|
||||
repository.lock.assert_not_awaited()
|
||||
repository.update_run.assert_not_awaited()
|
||||
repository.update_task.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_presentation_history_storage_filters_project_task_and_committed_cursor():
|
||||
project_id, task_id = uuid4(), uuid4()
|
||||
connection = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(fetchall=AsyncMock(return_value=[]))))
|
||||
repository = OperationalTaskRepository(connection, project_id)
|
||||
assert await repository.list_presentation_events(str(task_id), 7, 30, 21) == []
|
||||
query, params = connection.execute.call_args.args
|
||||
assert params == (str(project_id), str(task_id), 7, 30, 21)
|
||||
assert "project_id=%s" in query and "aggregate_id=%s" in query
|
||||
assert "operational.presentation.requested" in query and "event_seq<=%s" in query
|
||||
assert "ORDER BY event_seq LIMIT %s" in query
|
||||
|
||||
@@ -402,6 +402,9 @@ class RunTaskRepository(TaskRepository):
|
||||
async def get_run(self, run_id):
|
||||
return self.run if run_id == self.run["run_id"] else None
|
||||
|
||||
async def require_execution_storage(self):
|
||||
return None
|
||||
|
||||
async def update_run(self, run, expected_revision):
|
||||
assert run["revision"] == expected_revision + 1
|
||||
self.run = run
|
||||
@@ -416,6 +419,11 @@ class RunTaskRepository(TaskRepository):
|
||||
async def current_event_cursor(self):
|
||||
return len(self.events)
|
||||
|
||||
async def list_presentation_events(self, task_id, after, through, limit):
|
||||
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
||||
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
||||
and event["aggregate_id"] == task_id][:limit]
|
||||
|
||||
|
||||
def _active_run(task, now):
|
||||
return OperationalTaskRun(
|
||||
@@ -427,6 +435,30 @@ def _active_run(task, now):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_claim_persists_agent_instance_ownership():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=None)
|
||||
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
|
||||
run = _active_run(task, now)
|
||||
run.update(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)
|
||||
|
||||
claimed = await service.claim_run(
|
||||
task["task_id"], run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=run["execution_request_id"], expected_revision=1,
|
||||
source_session_id="session-1", agent_instance_id="agent-a",
|
||||
),
|
||||
)
|
||||
|
||||
assert claimed.agent_session_id == "session-1"
|
||||
assert claimed.agent_instance_id == "agent-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
@@ -544,6 +576,28 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
||||
await service.presentation_cursor(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
|
||||
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 15},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "high"}]},
|
||||
{"action": "clear_scada_analysis"},
|
||||
])
|
||||
async def test_unified_commands_record_canonical_events_without_mutating_task(payload):
|
||||
now = datetime(2026, 10, 10, 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)
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
||||
)
|
||||
result = await service.publish_presentation_event(task["task_id"], command)
|
||||
assert result.recorded and repository.events[0]["metadata"]["command"] == payload
|
||||
assert await service.publish_presentation_event(task["task_id"], command) == result
|
||||
assert len(repository.events) == 1 and repository.task["revision"] == task["revision"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
@@ -566,6 +620,16 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
||||
emitted = repository.events[-1]["metadata"]["command"]["data"]
|
||||
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
|
||||
assert emitted["features"][0]["properties"]["weight"] == 1
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": emitted},
|
||||
))
|
||||
spoofed = {**emitted, "features": [{**emitted["features"][0],
|
||||
"geometry": {"type": "Point", "coordinates": [120, 30]}}]}
|
||||
with pytest.raises(DomainError) as spoof:
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": spoofed},
|
||||
))
|
||||
assert spoof.value.code == "OVERLAY_DATA_MISMATCH"
|
||||
with pytest.raises(DomainError) as missing:
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
|
||||
@@ -580,7 +644,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
|
||||
))
|
||||
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
|
||||
assert len(repository.events) == 1
|
||||
assert len(repository.events) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -699,6 +763,28 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_session_id():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
run = _active_run(task, now)
|
||||
run["agent_instance_id"] = "agent-a"
|
||||
repository = RunTaskRepository(task, run)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
with pytest.raises(DomainError) as mismatch:
|
||||
await service.heartbeat_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=2,
|
||||
source_session_id="session-1", agent_instance_id="agent-b",
|
||||
),
|
||||
)
|
||||
|
||||
assert mismatch.value.code == "AGENT_INSTANCE_MISMATCH"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
@@ -1537,7 +1623,7 @@ def _work_order_snapshot(work_order_id, now):
|
||||
"feedback_requirements": ["上传检测结果"],
|
||||
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
|
||||
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
|
||||
"origin": "synthetic",
|
||||
"origin": "system_data",
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
@@ -1844,6 +1930,37 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
|
||||
assert error.value.code == "EVIDENCE_NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_presentation_history_is_task_scoped_paginated_and_readable_after_run_finishes():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now)
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
repository.run["status"] = "succeeded"
|
||||
for index in range(4):
|
||||
await repository.append_event(
|
||||
topic="operational.presentation.requested", aggregate_type="operational_task_presentation",
|
||||
aggregate_id=task["task_id"] if index != 1 else str(uuid4()), aggregate_revision=1,
|
||||
occurred_at=now, metadata={"run_id": repository.run["run_id"],
|
||||
"execution_generation": 1 if index < 2 else 2,
|
||||
"command": {"action": "clear_highlights"}},
|
||||
)
|
||||
service = OperationalTaskService(repository, context, {OPERATIONAL_TASK_VIEW}, clock=lambda: now)
|
||||
first = await service.presentation_history(task["task_id"], limit=1)
|
||||
assert [item.event_seq for item in first.items] == [1]
|
||||
assert first.event_cursor == 4 and first.next_after == 1
|
||||
# An append during pagination is outside the captured boundary.
|
||||
repository.events.append(repository.events[-1])
|
||||
second = await service.presentation_history(task["task_id"], after=first.next_after, through=first.event_cursor)
|
||||
assert [item.event_seq for item in second.items] == [3, 4]
|
||||
assert [item.execution_generation for item in second.items] == [2, 2]
|
||||
assert second.next_after is None and second.event_cursor == 4
|
||||
assert repository.run["status"] == "succeeded" and repository.task["revision"] == task["revision"]
|
||||
with pytest.raises(DomainError):
|
||||
await service.presentation_history(uuid4())
|
||||
assert len(repository.events) == 5
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("report_factory", [_current_report, _insufficient_report])
|
||||
async def test_final_report_rejected_while_original_run_has_pending_calculations(report_factory):
|
||||
|
||||
@@ -90,8 +90,7 @@ async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_re
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await OperationalWorker(settings, client).tick() == 0
|
||||
assert submitted == [row["message_id"] for row in pending]
|
||||
assert len(failures) == 1 and pending[1]["message_id"] in failures[0][0]
|
||||
assert failures[0][1]["status"] == "failed"
|
||||
assert failures == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -126,6 +125,8 @@ 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):
|
||||
if request.url.path == "/health":
|
||||
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
|
||||
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
|
||||
assert not request.url.path.endswith("schedules/dispatch-due")
|
||||
if request.url.path.endswith("leases/recover"):
|
||||
@@ -140,7 +141,10 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
|
||||
body = json.loads(request.content)
|
||||
assert str(request.url) == "http://agent.test/api/v1/task-executions"
|
||||
submitted.append(body)
|
||||
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
|
||||
return httpx.Response(202, json={
|
||||
"task_id": task_id, "run_id": run_id, "session_id": "execution-session",
|
||||
"agent_instance_id": "agent-a",
|
||||
})
|
||||
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)
|
||||
@@ -149,6 +153,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
|
||||
assert submitted[0] == submitted[1]
|
||||
assert submitted[0] == {
|
||||
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
|
||||
"agent_instance_id": "agent-a",
|
||||
}
|
||||
assert "credential" not in submitted[0]
|
||||
|
||||
@@ -178,6 +183,8 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
|
||||
limits, submitted, failures = [], [], []
|
||||
|
||||
def handler(request):
|
||||
if request.url.path == "/health":
|
||||
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
|
||||
if request.method == "GET":
|
||||
if request.url.params.get("status") != "queued":
|
||||
return httpx.Response(200, json={"items": []})
|
||||
@@ -192,7 +199,10 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
|
||||
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"]})
|
||||
return httpx.Response(202, json={
|
||||
"task_id": body["task_id"], "run_id": body["run_id"],
|
||||
"agent_instance_id": "agent-a",
|
||||
})
|
||||
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
@@ -257,10 +267,16 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
|
||||
renewed = []
|
||||
|
||||
def handler(request):
|
||||
if request.url.path == "/health":
|
||||
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
|
||||
if request.url.path.endswith("leases/recover"):
|
||||
return httpx.Response(200, json={"items": []})
|
||||
if request.method == "GET":
|
||||
items = [{"run_id": run_id, "agent_session_id": "existing-session"}] if request.url.params.get("status") == "running" else []
|
||||
items = [{
|
||||
"run_id": run_id,
|
||||
"agent_session_id": "existing-session",
|
||||
"agent_instance_id": "agent-a",
|
||||
}] if request.url.params.get("status") == "running" else []
|
||||
return httpx.Response(200, json={"items": items})
|
||||
assert request.url.host == "agent.test"
|
||||
assert str(request.url) == "http://agent.test/api/v1/session/existing-session/credentials"
|
||||
@@ -274,3 +290,34 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
assert await OperationalWorker(settings, client).tick() == 0
|
||||
assert len(renewed) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_worker_only_restores_sessions_owned_by_target_agent_instance():
|
||||
own_run_id, foreign_run_id, project_id = str(uuid4()), str(uuid4()), str(uuid4())
|
||||
renewed = []
|
||||
|
||||
def handler(request):
|
||||
if request.url.path == "/health":
|
||||
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
|
||||
if request.url.path.endswith("leases/recover"):
|
||||
return httpx.Response(200, json={"items": []})
|
||||
if request.method == "GET":
|
||||
items = [{
|
||||
"run_id": own_run_id,
|
||||
"agent_session_id": "own-session",
|
||||
"agent_instance_id": "agent-a",
|
||||
}, {
|
||||
"run_id": foreign_run_id,
|
||||
"agent_session_id": "foreign-session",
|
||||
"agent_instance_id": "agent-b",
|
||||
}] if request.url.params.get("status") == "running" else []
|
||||
return httpx.Response(200, json={"items": items})
|
||||
assert str(request.url) == "http://agent.test/api/v1/session/own-session/credentials"
|
||||
renewed.append(request)
|
||||
return httpx.Response(202, json={"accepted": True})
|
||||
|
||||
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 OperationalWorker(settings, client).tick() == 0
|
||||
assert len(renewed) == 1
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_active_work_order_projection_removes_only_tombstone_fields():
|
||||
assert "deleted_at" not in result
|
||||
assert "deleted_by" not in result
|
||||
assert stored_document == document
|
||||
WorkOrder.model_validate(result)
|
||||
assert WorkOrder.model_validate(result).origin == "system_data"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -24,7 +24,7 @@ class ManagedStore:
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"origin": getattr(command, "origin", "system_data"), "created_at": now, "updated_at": now,
|
||||
"created_by": str(USER_ID), "updated_by": str(USER_ID), "activity": [],
|
||||
}
|
||||
|
||||
@@ -88,12 +88,17 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
|
||||
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
|
||||
"feedback_requirements": ["上传检测结果"],
|
||||
})
|
||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||
assert created.status_code == 201 and created.json()["origin"] == "system_data"
|
||||
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()["feedback_requirements"] == ["上传检测结果"]
|
||||
assert "subtasks" not in created.json()
|
||||
rejected = client.post("/api/sample/v1/work-orders", json={
|
||||
"request_id": str(uuid4()), "title": "非法来源", "summary": "不接受演练来源",
|
||||
"origin": "synthetic",
|
||||
})
|
||||
assert rejected.status_code == 422
|
||||
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": "awaiting_reply",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.domain.schemas.operational_data import TaskDataQuery
|
||||
from app.infra.integrations.operational_data import TaskDataSettings, read_task_data
|
||||
from app.mcp import peripheral
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("origins", [
|
||||
[], ["system_data"], ["synthetic"], ["system_data", "synthetic"], [None], ["unverified"],
|
||||
])
|
||||
async def test_http_and_mcp_normalize_work_orders_as_system_data(monkeypatch, origins):
|
||||
project_id = uuid4()
|
||||
items = [{"work_order_id": str(uuid4()), "project_id": str(project_id), "origin": origin} for origin in origins]
|
||||
|
||||
async def rows(*_args, **_kwargs):
|
||||
return [{"document": item} for item in items]
|
||||
|
||||
monkeypatch.setattr(peripheral, "read_rows", rows)
|
||||
query = TaskDataQuery(source="peripheral_work_orders")
|
||||
context = ProjectContext(project_id=project_id, user_id=uuid4(), project_code="test", project_role="member")
|
||||
kwargs = dict(context=context, credential="Bearer TEST_ONLY", query=query,
|
||||
settings=TaskDataSettings(_env_file=None, peripheral_url="http://peripheral.test"),
|
||||
transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"items": items})))
|
||||
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=project_id))
|
||||
args = SimpleNamespace(source=query.source, limit=20, offset=0)
|
||||
snapshots = [await read_task_data(**kwargs), await peripheral.read_peripheral_query(scope, args)]
|
||||
for snapshot in snapshots:
|
||||
assert snapshot.origin == "system_data"
|
||||
assert [item["origin"] for item in snapshot.data["items"]] == ["system_data"] * len(origins)
|
||||
assert "provenance_scope" not in snapshot.data
|
||||
assert "source_origins" not in snapshot.data
|
||||
@@ -100,7 +100,7 @@ async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_f
|
||||
try:
|
||||
await asyncio.wait_for(initial_scan.wait(), timeout=3)
|
||||
orders.append({
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"revision": 1, "title": "预约维修", "summary": "等待现场处理", "priority": "normal",
|
||||
"status": "open", "created_at": "2026-10-08T00:00:00Z",
|
||||
"updated_at": "2026-10-08T00:00:00Z", "scheduled_at": None,
|
||||
|
||||
@@ -14,7 +14,6 @@ from scripts.sync_peripheral_work_orders import (
|
||||
sync_once,
|
||||
work_order_snapshot,
|
||||
)
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID
|
||||
from app.domain.schemas.operational_task import ExternalFeedbackCommand
|
||||
|
||||
|
||||
@@ -36,27 +35,21 @@ def test_documented_sync_command_can_start_from_repository_root():
|
||||
assert "--once" in result.stdout
|
||||
|
||||
|
||||
def test_unlinked_synthetic_checks_are_not_operational_tasks():
|
||||
def test_all_active_work_orders_are_operational_tasks():
|
||||
order_id = str(uuid4())
|
||||
order = {
|
||||
"work_order_id": order_id,
|
||||
"origin": "synthetic",
|
||||
"created_by": str(SEED_ACTOR_ID),
|
||||
"title": "合成压力偏差复核",
|
||||
"origin": "system_data",
|
||||
"title": "压力偏差复核",
|
||||
"work_order_type": "general",
|
||||
}
|
||||
|
||||
assert not should_project_work_order(order, set())
|
||||
assert should_project_work_order(order, set())
|
||||
assert should_project_work_order(order, {order_id})
|
||||
assert should_project_work_order(
|
||||
{**order, "work_order_type": "water_quality_complaint"}, set()
|
||||
)
|
||||
assert should_project_work_order(
|
||||
{**order, "title": "现场采样:二次供水出水及末端用户水样"}, set()
|
||||
) is False
|
||||
assert should_project_work_order(
|
||||
{**order, "created_by": str(uuid4()), "title": "合成管材接驳预约"}, set()
|
||||
)
|
||||
assert should_project_work_order({**order, "title": "现场采样"}, set())
|
||||
|
||||
|
||||
def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
|
||||
@@ -71,7 +64,7 @@ def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
|
||||
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",
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"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"}
|
||||
@@ -116,16 +109,15 @@ async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
async def test_sync_rejects_non_system_work_order_origin():
|
||||
project_id, order_id = str(uuid4()), str(uuid4())
|
||||
order = {
|
||||
"project_id": project_id,
|
||||
"work_order_id": order_id,
|
||||
"origin": "synthetic",
|
||||
"created_by": str(SEED_ACTOR_ID),
|
||||
"revision": 1,
|
||||
"title": "合成通信核查",
|
||||
"summary": "仅用于外围样板联调",
|
||||
"title": "来源异常工单",
|
||||
"summary": "验证来源边界",
|
||||
"priority": "normal",
|
||||
"status": "open",
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
@@ -135,7 +127,6 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
}
|
||||
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [order], "next_cursor": None})
|
||||
return httpx.Response(200, json={"items": [], "next_offset": None})
|
||||
@@ -144,14 +135,8 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
"http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY"
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
synchronized = await sync_once(
|
||||
client,
|
||||
settings,
|
||||
"http://sample.test",
|
||||
CredentialProvider(settings),
|
||||
)
|
||||
|
||||
assert synchronized == 0
|
||||
with pytest.raises(ValueError, match="origin mismatch"):
|
||||
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -231,7 +216,7 @@ async def test_sync_does_not_withdraw_when_missing_order_is_not_confirmed_delete
|
||||
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"}]
|
||||
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "system_data"}]
|
||||
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:
|
||||
@@ -263,11 +248,11 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
|
||||
def handler(request):
|
||||
if request.url.host == "sample.test":
|
||||
order = {
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"created_by": str(uuid4()),
|
||||
"revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open",
|
||||
"updated_at": "2026-09-23T09:00:00Z",
|
||||
"title": "合成采样工单", "priority": "normal",
|
||||
"title": "采样工单", "priority": "normal",
|
||||
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": None,
|
||||
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员",
|
||||
@@ -320,13 +305,15 @@ async def test_external_events_start_investigations_without_browser(event_kind):
|
||||
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,
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data", "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"):
|
||||
assert payload["evidence"]["kind"] == "external_feedback"
|
||||
assert payload["work_order_snapshot"]["origin"] == "system_data"
|
||||
return httpx.Response(200, json={})
|
||||
task = {**payload, "task_id": str(uuid4())}
|
||||
created.append(task)
|
||||
@@ -339,6 +326,7 @@ async def test_external_events_start_investigations_without_browser(event_kind):
|
||||
assert investigation["workflow"] == event_kind
|
||||
assert investigation["source"]["external_id"] == order_id
|
||||
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
|
||||
assert all("合成" not in item["summary"] for item in created)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -349,7 +337,7 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
|
||||
projection = {"task_id": projection_id, "task_type": "external_work_order", "source": {
|
||||
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
|
||||
}}
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal",
|
||||
"created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None,
|
||||
"work_order_type": "general"}
|
||||
@@ -398,8 +386,8 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
|
||||
|
||||
def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
order = {
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "synthetic",
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "预约工单", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "system_data",
|
||||
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
|
||||
"feedback_requirements": ["回填现场检查结果"],
|
||||
"activity": [{
|
||||
@@ -411,7 +399,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
"detail": "已创建现场检查工单。",
|
||||
}],
|
||||
"subtasks": [{
|
||||
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
|
||||
"id": "visit", "title": "现场检查", "summary": "测试数据,未现场执行",
|
||||
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
|
||||
"location": "测试位置", "assignee": "测试班组", "duration_minutes": 30,
|
||||
"reply_window_minutes": 15, "reply_requirements": ["检查记录"],
|
||||
@@ -420,6 +408,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
}],
|
||||
}
|
||||
snapshot = work_order_snapshot(order)
|
||||
assert snapshot["origin"] == "system_data"
|
||||
assert snapshot["feedback_requirements"] == ["回填现场检查结果"]
|
||||
assert snapshot["activity"] == order["activity"]
|
||||
assert "subtasks" not in snapshot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
@@ -29,6 +30,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
|
||||
feedback_requirements=["回传开度照片"],
|
||||
))
|
||||
assert created["feedback_requirements"] == ["回传开度照片"]
|
||||
assert created["origin"] == "system_data"
|
||||
assert created["activity"][0]["kind"] == "created"
|
||||
assert "subtasks" not in created
|
||||
|
||||
@@ -37,6 +39,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
|
||||
progress_note="已复核开度,等待回执确认",
|
||||
))
|
||||
assert updated["status"] == "awaiting_reply"
|
||||
assert updated["origin"] == "system_data"
|
||||
assert [entry["sequence"] for entry in updated["activity"]] == [1, 2]
|
||||
assert updated["activity"][1]["kind"] == "feedback"
|
||||
assert updated["activity"][1]["detail"] == "已复核开度,等待回执确认"
|
||||
@@ -68,3 +71,21 @@ def test_legacy_order_reads_flat_without_claiming_unrecorded_progress():
|
||||
assert "subtasks" not in projected
|
||||
assert projected["feedback_requirements"] == []
|
||||
assert projected["activity"] == []
|
||||
|
||||
|
||||
def test_work_order_contract_rejects_synthetic_origin():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateWorkOrder(
|
||||
request_id=uuid4(), title="来源校验", summary="只接受系统数据", origin="synthetic",
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_work_order_origin_is_normalized_for_reads():
|
||||
legacy = {
|
||||
"work_order_id": str(uuid4()), "project_id": str(uuid4()), "revision": 1,
|
||||
"title": "历史工单", "summary": "迁移前记录", "status": "open", "priority": "normal",
|
||||
"assignee": None, "scheduled_at": None, "origin": "synthetic",
|
||||
"created_at": "2026-09-18T00:00:00Z", "updated_at": "2026-09-18T00:00:00Z",
|
||||
"created_by": str(uuid4()), "updated_by": str(uuid4()),
|
||||
}
|
||||
assert WorkOrder.model_validate(legacy).origin == "system_data"
|
||||
|
||||
@@ -40,6 +40,26 @@ def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable(
|
||||
assert InvestigationReportRevision.model_validate(old).report.ui_blocks == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", [None, "final"])
|
||||
def test_report_ui_accepts_final_and_legacy_phases(phase):
|
||||
data = rich_report()
|
||||
if phase is not None:
|
||||
data["ui_blocks"][0]["block"]["phase"] = phase
|
||||
report = InvestigationReportInput.model_validate(data)
|
||||
assert report.ui_blocks[0].block.phase == phase
|
||||
serialized = report.model_dump()["ui_blocks"][0]["block"]
|
||||
assert ("phase" in serialized) == (phase is not None)
|
||||
assert InvestigationReportInput.model_validate(report.model_dump()).ui_blocks[0].block.phase == phase
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["process", "unknown", None])
|
||||
def test_report_ui_rejects_process_and_unknown_phases(phase):
|
||||
data = rich_report()
|
||||
data["ui_blocks"][0]["block"]["phase"] = phase
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"])
|
||||
def test_report_ui_rejects_invalid_or_unbound_content(defect):
|
||||
data = rich_report()
|
||||
@@ -72,3 +92,26 @@ def test_report_ui_rejects_invalid_or_unbound_content(defect):
|
||||
data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}]
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(data)
|
||||
|
||||
|
||||
def test_report_ui_accepts_semantic_results_without_allowing_raw_presentation_code():
|
||||
data = rich_report()
|
||||
spec = data["ui_blocks"][0]["block"]["spec"]
|
||||
spec["root"] = "layout"
|
||||
spec["elements"] = {
|
||||
"layout": {"type": "Stack", "props": {"columns": "one", "gap": "normal", "intent": "report", "density": "compact"}, "children": ["section"]},
|
||||
"section": {"type": "Section", "props": {"title": "演练结果", "description": None, "role": "result"}, "children": ["result", "note"]},
|
||||
"result": {"type": "Result", "props": {"title": "演练记录已保存", "summary": "现场处置仍待核验。", "status": "partial", "source": "演练回执"}, "children": []},
|
||||
"note": {"type": "Text", "props": {"title": "核验说明", "text": "结果仅限演练。", "tone": "neutral"}, "children": []},
|
||||
}
|
||||
restored = InvestigationReportInput.model_validate(data)
|
||||
assert restored.ui_blocks[0].block.spec.elements["result"].props["status"] == "partial"
|
||||
for extra in ({"style": {"color": "red"}}, {"className": "text-red-500"}, {"css": "display:none"}, {"action": "dispatch"}):
|
||||
invalid = deepcopy(data)
|
||||
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["props"].update(extra)
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(invalid)
|
||||
invalid = deepcopy(data)
|
||||
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["children"] = ["note"]
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(invalid)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailureCommand, OperationalTaskRetryCommand
|
||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from app.services.task_questions import create_question, answer_question
|
||||
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class QuestionRepository(RunTaskRepository):
|
||||
def __init__(self, task, run):
|
||||
super().__init__(task, run)
|
||||
self.questions = {}
|
||||
|
||||
async def list_questions(self, task_id):
|
||||
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
||||
|
||||
async def get_question(self, task_id, question_id):
|
||||
q = self.questions.get(question_id)
|
||||
return deepcopy(q) if q and q["task_id"] == task_id else None
|
||||
|
||||
async def save_question(self, task_id, document):
|
||||
assert document["task_id"] == task_id
|
||||
self.questions[document["question_request_id"]] = deepcopy(document)
|
||||
|
||||
|
||||
def setup():
|
||||
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repo = QuestionRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
||||
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
||||
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
||||
mode="single", allow_custom=True, required=True)])
|
||||
return service, repo, UUID(task["task_id"]), command
|
||||
|
||||
|
||||
async def finish(service, repo, task_id):
|
||||
return await service.complete_run(task_id, UUID(repo.run["run_id"]),
|
||||
AgentRunCommand(request_id=uuid4(), expected_revision=repo.run["revision"], source_session_id="session-1"))
|
||||
|
||||
|
||||
def reply(**overrides):
|
||||
return TaskQuestionReply(request_id=uuid4(), expected_version=1,
|
||||
answers=[{"question_id": "flow", "selected_option_ids": ["unknown"], "custom_text": "需要采用情景假设"}], **overrides)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_question_waits_without_final_report_and_answer_requeues_once_without_fabricating_evidence():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
assert (await create_question(service, task_id, command)).question_request_id == question.question_request_id
|
||||
assert len(repo.notifications) == 1
|
||||
with pytest.raises(DomainError, match="waiting_human"):
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
waiting = await finish(service, repo, task_id)
|
||||
assert waiting.lifecycle_status == "waiting_human" and waiting.active_run is None
|
||||
assert repo.run["status"] == "succeeded" and repo.run["failure_code"] is None
|
||||
assert len(repo.notifications) == 1 and repo.notifications[0]["subject_type"] == "question"
|
||||
answer = reply()
|
||||
resumed = await answer_question(service, task_id, question.question_request_id, answer)
|
||||
replay = await answer_question(service, task_id, question.question_request_id, answer)
|
||||
assert resumed.execution_generation == replay.execution_generation == 2
|
||||
assert resumed.active_run.status == "queued"
|
||||
assert resumed.questions[0].answers[0].custom_text == "需要采用情景假设"
|
||||
assert resumed.questions[0].answered_by == service._actor()
|
||||
assert not repo.evidence and not resumed.actions
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("answers", [[],
|
||||
[{"question_id": "foreign", "selected_option_ids": [], "custom_text": "x"}],
|
||||
[{"question_id": "flow", "selected_option_ids": ["bogus"], "custom_text": ""}],
|
||||
[{"question_id": "flow", "selected_option_ids": ["unknown", "unknown"], "custom_text": ""}],
|
||||
])
|
||||
async def test_invalid_answers_do_not_resume_or_resolve_notification(answers):
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id,
|
||||
TaskQuestionReply(request_id=uuid4(), expected_version=1, answers=answers))
|
||||
assert caught.value.code == "ANSWER_INVALID"
|
||||
assert repo.task["execution_generation"] == 1
|
||||
assert repo.notifications[0]["lifecycle_status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_permissions_foreign_session_duplicate_pending_and_stale_generation():
|
||||
service, repo, task_id, command = setup()
|
||||
service.permissions = frozenset()
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command)
|
||||
assert caught.value.code == "PERMISSION_DENIED"
|
||||
service.permissions = {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command.model_copy(update={"source_session_id": "foreign"}))
|
||||
assert caught.value.code == "SESSION_MISMATCH"
|
||||
question = await create_question(service, task_id, command)
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command.model_copy(update={"request_id": uuid4()}))
|
||||
assert caught.value.code == "QUESTION_PENDING"
|
||||
await finish(service, repo, task_id)
|
||||
await service.retry_task(task_id, OperationalTaskRetryCommand(request_id=uuid4(), reason="重新调查"))
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
assert caught.value.code == "VERSION_CONFLICT"
|
||||
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pending_calculations_must_finish_before_asking():
|
||||
service, repo, task_id, command = setup()
|
||||
async def pending(*_args):
|
||||
return [str(uuid4())]
|
||||
repo.pending_calculation_jobs = pending
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command)
|
||||
assert caught.value.code == "PENDING_CALCULATIONS"
|
||||
assert not repo.questions and not repo.notifications
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_answer_requires_write_permission_and_matching_version():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
service.permissions = {OPERATIONAL_TASK_EXECUTE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
assert caught.value.code == "PERMISSION_DENIED"
|
||||
service.permissions = {OPERATIONAL_TASK_WRITE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply().model_copy(update={"expected_version": 9}))
|
||||
assert caught.value.code == "VERSION_CONFLICT"
|
||||
assert repo.task["execution_generation"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_real_execution_failure_cancels_question_instead_of_disguising_failure():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
result = await service.fail_run(task_id, UUID(repo.run["run_id"]), AgentRunFailureCommand(
|
||||
request_id=uuid4(), expected_revision=2, source_session_id="session-1",
|
||||
failure_code="SOURCE_UNAVAILABLE", failure_detail="Required tool failed"))
|
||||
assert result.lifecycle_status == "failed"
|
||||
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_selection_and_optional_question_are_preserved():
|
||||
service, repo, task_id, command = setup()
|
||||
question_data = command.questions[0].model_dump()
|
||||
question_data.update(mode="multiple", allow_custom=False,
|
||||
options=[{"id": "in", "label": "入口计量"}, {"id": "out", "label": "出口计量"}])
|
||||
command = TaskQuestionCreate(**{**command.model_dump(), "questions": [question_data,
|
||||
{**question_data, "question_id": "optional", "required": False}]})
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
result = await answer_question(service, task_id, question.question_request_id, TaskQuestionReply(
|
||||
request_id=uuid4(), expected_version=1,
|
||||
answers=[{"question_id": "flow", "selected_option_ids": ["in", "out"], "custom_text": ""}]))
|
||||
assert result.execution_generation == 2
|
||||
assert result.questions[0].answers[0].selected_option_ids == ["in", "out"]
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from app.algorithms.valve_isolation import topology_search as valve
|
||||
|
||||
|
||||
@@ -23,6 +25,183 @@ def test_isolatable_keeps_affected_node_ids_and_count():
|
||||
assert result["must_close_valves"] == ["V-close"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def colliding_topology():
|
||||
# The reported adjacent pipes and their same-numbered nodes are in
|
||||
# different valve-bounded regions. Keep this fixture independent of a DB.
|
||||
return [
|
||||
"19025:pipe:17038:17037",
|
||||
"19026:pipe:17038:17039",
|
||||
"AUTO_TCV_4:valve:17037:D",
|
||||
"AUTO_TCV_5:valve:17039:E",
|
||||
"AUTO_TCV_546:valve:17038:R",
|
||||
"32252:pipe:19025:X",
|
||||
"15383:pipe:19026:Y",
|
||||
"V-wrong-25:valve:X:R",
|
||||
"V-wrong-26:valve:Y:R",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["19025", "19026", ["19025", "19026"]])
|
||||
def test_adjacent_pipes_take_priority_over_same_numbered_nodes(colliding_topology, target):
|
||||
result = valve.valve_isolation_analysis(colliding_topology, target)
|
||||
|
||||
assert result["affected_nodes"] == ["17037", "17038", "17039"]
|
||||
assert result["must_close_valves"] == ["AUTO_TCV_4", "AUTO_TCV_5", "AUTO_TCV_546"]
|
||||
assert result["optional_valves"] == []
|
||||
|
||||
|
||||
def test_explicit_node_type_resolves_the_other_side_of_an_id_collision(colliding_topology):
|
||||
result = valve.valve_isolation_analysis(
|
||||
colliding_topology, "19025", accident_element_type="node",
|
||||
)
|
||||
|
||||
assert result["affected_nodes"] == ["19025", "X"]
|
||||
assert result["must_close_valves"] == ["V-wrong-25"]
|
||||
|
||||
|
||||
def test_unambiguous_legacy_node_input_still_works():
|
||||
result = valve.valve_isolation_analysis(["P:pipe:A:B", "V:valve:B:R"], "A")
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_explicit_link_type_does_not_fall_back_to_a_node():
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B"], "A", accident_element_type="link",
|
||||
)
|
||||
|
||||
|
||||
def test_a_boundary_valve_does_not_prove_source_isolation():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:R:A", "V:valve:A:B"], "P", water_source_nodes=["R"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is False
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
assert result["affected_nodes"] == []
|
||||
assert result["affected_node_count"] == 2
|
||||
assert result["supply_affected_nodes"] is None
|
||||
|
||||
|
||||
def test_supply_impact_includes_downstream_regions_but_not_preexisting_outages():
|
||||
links = [
|
||||
"P:pipe:A:B", "V-source:valve:A:R", "V-downstream:valve:B:C",
|
||||
"P-downstream:pipe:C:D", "P-already-off:pipe:X:Y",
|
||||
]
|
||||
result = valve.valve_isolation_analysis(links, "P", water_source_nodes=["R"])
|
||||
|
||||
assert result["isolatable"] is True
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
assert result["affected_node_count"] == 2
|
||||
assert result["supply_affected_nodes"] == ["A", "B", "C", "D"]
|
||||
assert result["supply_affected_node_count"] == 4
|
||||
|
||||
|
||||
def test_closed_links_are_not_traversed_or_recommended_for_closure():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R", "V-closed:valve:B:C"],
|
||||
"P", water_source_nodes=["R"], closed_links=["P-closed", "V-closed"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is True
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
assert result["supply_affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_disabled_valve_cannot_cut_the_source_path():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V-disabled:valve:A:R", "V-other:valve:B:C"],
|
||||
"P", disabled_valves=["V-disabled"], water_source_nodes=["R"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is False
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
|
||||
|
||||
def test_joint_accidents_fail_if_any_region_remains_connected_to_a_source():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P1:pipe:A:B", "V1:valve:B:R", "P2:pipe:R:C", "V2:valve:C:D"],
|
||||
["P1", "P2"], water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
|
||||
|
||||
def test_missing_sources_do_not_claim_verified_isolation():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:C"], "P", water_source_nodes=[],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
assert result["source_check_performed"] is False
|
||||
assert result["isolation_failure_reason"] == "water_sources_unavailable"
|
||||
assert result["supply_affected_node_count"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sources", [["R1"], ["R1", "R2"]])
|
||||
def test_all_water_sources_are_considered(sources):
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:A:R1", "P2:pipe:B:R2"],
|
||||
"P", water_source_nodes=sources,
|
||||
)
|
||||
assert result["isolatable"] is (len(sources) == 1)
|
||||
|
||||
|
||||
def test_empty_targets_are_rejected():
|
||||
with pytest.raises(ValueError, match="At least one"):
|
||||
valve.valve_isolation_analysis(["P:pipe:A:B"], [])
|
||||
|
||||
|
||||
def test_unknown_disabled_valve_is_rejected():
|
||||
with pytest.raises(ValueError, match="Disabled valve"):
|
||||
valve.valve_isolation_analysis(["P:pipe:A:B"], "P", disabled_valves=["typo"])
|
||||
|
||||
|
||||
def test_service_passes_authoritative_sources_statuses_and_element_type(monkeypatch):
|
||||
from app.services import valve_isolation as service
|
||||
|
||||
projects = []
|
||||
|
||||
def snapshot(network):
|
||||
projects.append(network)
|
||||
return (
|
||||
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R"],
|
||||
["R"], ["P-closed"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "get_valve_isolation_topology", snapshot)
|
||||
result = service.analyze_valve_isolation(
|
||||
"trusted-project", ["A"], accident_element_type="node",
|
||||
)
|
||||
|
||||
assert projects == ["trusted-project"]
|
||||
assert result["isolatable"] is True
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
assert result["supply_affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_parallel_links_preserve_an_open_bypass():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:R", "P-bypass:pipe:B:R"],
|
||||
"P", water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
|
||||
|
||||
def test_closed_disabled_valve_does_not_reopen_a_known_closed_link():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:R", "V-disabled:valve:A:R"],
|
||||
"P", disabled_valves=["V-disabled"], closed_links=["V-disabled"],
|
||||
water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is True
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
|
||||
|
||||
def test_disabled_valve_expands_affected_area_before_counting():
|
||||
links = [
|
||||
"P-1:pipe:A:B",
|
||||
|
||||
@@ -52,6 +52,36 @@ def test_network_link_nodes_use_one_unified_view_query(monkeypatch) -> None:
|
||||
assert "FROM gis.network_links" in calls[0][1]
|
||||
|
||||
|
||||
def test_isolation_snapshot_reads_authoritative_links_statuses_and_sources_once(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_read_all(name, statement, params=None):
|
||||
calls.append((name, statement, params))
|
||||
return [
|
||||
{"id": "P", "element_type": "pipe", "start_node_id": "A", "end_node_id": "B", "status": None},
|
||||
{"id": "P-closed", "element_type": "pipe", "start_node_id": "A", "end_node_id": "R", "status": "CLOSED"},
|
||||
{"id": "V", "element_type": "valve", "start_node_id": "B", "end_node_id": "R", "status": "CLOSED"},
|
||||
{"id": "PU", "element_type": "pump", "start_node_id": "T", "end_node_id": "A", "status": "CLOSED"},
|
||||
{"id": "R", "element_type": "reservoir", "start_node_id": None, "end_node_id": None, "status": None},
|
||||
{"id": "T", "element_type": "tank", "start_node_id": None, "end_node_id": None, "status": None},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(network_views, "read_all", fake_read_all)
|
||||
|
||||
assert network_views.get_valve_isolation_topology("project_a") == (
|
||||
["P:pipe:A:B", "P-closed:pipe:A:R", "V:valve:B:R", "PU:pump:T:A"],
|
||||
["R", "T"], ["P-closed", "V", "PU"],
|
||||
)
|
||||
assert len(calls) == 1
|
||||
name, statement, _params = calls[0]
|
||||
assert name == "project_a"
|
||||
assert "FROM network.links" in statement
|
||||
assert "network.link_initial_settings" in statement
|
||||
assert "COALESCE(s.status::text, p.status::text)" in statement
|
||||
assert "FROM network.nodes" in statement
|
||||
assert "UNION ALL" in statement
|
||||
|
||||
|
||||
def test_topology_rows_are_loaded_in_two_batch_queries(monkeypatch) -> None:
|
||||
calls = []
|
||||
responses = [
|
||||
|
||||
Reference in New Issue
Block a user