feat(scada): add calibrated monitoring and incident recovery
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -72,6 +73,57 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_case3_location_mock_skips_queue_and_preserves_evidence_and_receipt(runtime, monkeypatch):
|
||||
from app.mcp import calculation_jobs
|
||||
|
||||
app, client, _, _ = runtime
|
||||
original_scope = app.scope_factory
|
||||
|
||||
@asynccontextmanager
|
||||
async def demo_scope(identity, project):
|
||||
async with original_scope(identity, project) as scope:
|
||||
scope.project.project_code = "tjwater2_v2"
|
||||
yield scope
|
||||
|
||||
app.scope_factory = demo_scope
|
||||
monkeypatch.setattr(application, "authorize_query", AsyncMock())
|
||||
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
|
||||
monkeypatch.setattr(application, "verify_task", AsyncMock())
|
||||
submit = AsyncMock(side_effect=AssertionError("demo entered calculation queue"))
|
||||
monkeypatch.setattr(calculation_jobs, "submit_calculation", submit)
|
||||
app.executor = AsyncMock(return_value=QueryResult(
|
||||
source="run_burst_location", source_ref="demo", queried_at=datetime.now(timezone.utc),
|
||||
semantics="analysis", origin="synthetic", data={"result": {"located_pipe": "515870",
|
||||
"top_candidates": [{"pipe_id": "515870", "similarity": 0.9708, "similarity_percent": 97.08}]}},
|
||||
))
|
||||
saved = []
|
||||
|
||||
class TaskService:
|
||||
repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0))
|
||||
|
||||
async def record_data_snapshot(self, task_id, snapshot):
|
||||
saved.append(snapshot)
|
||||
return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot
|
||||
|
||||
monkeypatch.setattr(application, "task_service", lambda scope: TaskService())
|
||||
value = binding(task=True)
|
||||
query = {"name": "run_burst_location", "arguments": {
|
||||
"context_handle": value.context_handle,
|
||||
"scada_burst_start": "2026-07-16T07:45:00+08:00",
|
||||
"scada_burst_end": "2026-07-16T08:00:00+08:00",
|
||||
}}
|
||||
first = request(client, "tools/call", query).json()["result"]["structuredContent"]
|
||||
second = request(client, "tools/call", query).json()["result"]["structuredContent"]
|
||||
assert first == second
|
||||
assert first["origin"] == "synthetic"
|
||||
assert first["data"]["result"]["top_candidates"][0]["similarity_percent"] == 97.08
|
||||
assert "mock" not in first["data"]["result"]
|
||||
assert first["evidence_id"] == saved[0].source_ref
|
||||
assert len(saved) == app.executor.await_count == 1
|
||||
assert app.executor.await_args.args[3].burst_leakage is None
|
||||
submit.assert_not_awaited()
|
||||
|
||||
|
||||
def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch):
|
||||
from app.mcp import calculation_jobs
|
||||
_, client, _, _ = runtime
|
||||
@@ -321,6 +373,45 @@ def test_replay_binding_denies_future_and_allows_device_metadata(monkeypatch):
|
||||
asyncio.run(query_service.authorize_query(SimpleNamespace(project=object()), bound, TOOLS["list_scada_devices"], SimpleNamespace()))
|
||||
|
||||
|
||||
def test_model_response_displays_utc8_without_changing_query_instants_or_source(runtime):
|
||||
app, client, _, _ = runtime
|
||||
at = datetime(2026, 12, 31, 16, tzinfo=timezone.utc)
|
||||
original = QueryResult(source="query_scada_readings", source_ref="saved:utc", queried_at=at,
|
||||
semantics="observed", data=[{"device_id": "P1", "time": "2026-12-31T16:00:00.123456789Z", "value": 20}],
|
||||
window={"start": at - timedelta(minutes=15), "end": at})
|
||||
snapshot = original.model_dump(mode="json")
|
||||
app.executor = AsyncMock(return_value=original)
|
||||
result = request(client, "tools/call", {"name": "query_scada_readings", "arguments": {
|
||||
"context_handle": binding().context_handle, "device_ids": ["P1"],
|
||||
"start": "2026-12-31T15:45:00Z", "end": "2026-12-31T16:00:00Z",
|
||||
}}).json()["result"]
|
||||
output = result["structuredContent"]
|
||||
assert output["data"][0]["time"] == "2027-01-01T00:00:00.123456789+08:00"
|
||||
assert output["display_time_zone"] == "Asia/Shanghai"
|
||||
assert output["window"]["end"] == "2027-01-01T00:00:00+08:00"
|
||||
assert json.loads(result["content"][0]["text"]) == output
|
||||
assert app.executor.await_args.args[3].end == at
|
||||
assert original.model_dump(mode="json") == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ["get_element_properties", "list_elements", "query_nearby_elements",
|
||||
"get_network_options", "get_node_connections", "get_element_gis_properties",
|
||||
"query_network_extent", "list_regions", "get_region", "get_region_nodes",
|
||||
"analyze_valve_isolation", "generate_node_coverage"])
|
||||
def test_replay_allows_asset_and_topology_analysis_without_live_telemetry(monkeypatch, tool_name):
|
||||
bound = binding(task=True)
|
||||
bound.replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-10-08T01:00:00Z", "batch_sequence": 1}
|
||||
permissions = PERMISSIONS | {"simulation.run"}
|
||||
bound.permission_cap = sorted(permissions)
|
||||
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
|
||||
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
|
||||
asyncio.run(query_service.authorize_query(scope, bound, TOOLS[tool_name], SimpleNamespace()))
|
||||
bound.permission_cap = []
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, bound, TOOLS[tool_name], SimpleNamespace()))
|
||||
assert caught.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seconds", [30, 120, 240])
|
||||
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
|
||||
app, client, _, _ = runtime
|
||||
@@ -600,6 +691,89 @@ def test_large_node_coverage_retains_publishable_hint_and_saves_complete_geometr
|
||||
assert saved[0].data["data"]["result"]["map_payload"] == geometry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", [False, True])
|
||||
@pytest.mark.parametrize("wide_ids", [False, True])
|
||||
def test_scada_catalog_keeps_all_devices_or_full_evidence_within_budget(runtime, monkeypatch, task, wide_ids):
|
||||
app, client, _, _ = runtime
|
||||
value = binding(task=task)
|
||||
saved = []
|
||||
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
|
||||
monkeypatch.setattr(application, "verify_task", AsyncMock())
|
||||
|
||||
class TaskService:
|
||||
repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0))
|
||||
|
||||
async def record_data_snapshot(self, task_id, snapshot):
|
||||
saved.append(snapshot)
|
||||
return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot
|
||||
|
||||
monkeypatch.setattr(application, "task_service", lambda scope: TaskService())
|
||||
devices = [{
|
||||
"device_id": ("d" * 150 if wide_ids else "") + str(100000 + index),
|
||||
"device_type": "pressure", "measurement_unit": "m",
|
||||
"node_id": str(index), "link_id": None, "longitude": 121.5, "latitude": 31.0,
|
||||
"transmission_mode": "non_realtime", "transmission_frequency": "0:15:00",
|
||||
"reliability": 1, "api_query_id": str(index), "x": 1000, "y": -20000,
|
||||
} for index in range(100)]
|
||||
app.executor = AsyncMock(return_value=QueryResult(
|
||||
source="list_scada_devices", source_ref="mcp:catalog", queried_at=datetime.now(timezone.utc),
|
||||
semantics="asset", data=devices,
|
||||
page={"limit": 100, "offset": 0, "total": None, "has_more": True},
|
||||
))
|
||||
response = request(client, "tools/call", {"name": "list_scada_devices", "arguments": {
|
||||
"context_handle": value.context_handle,
|
||||
}}).json()["result"]
|
||||
assert app.executor.await_args.args[3].limit == 100
|
||||
if wide_ids and not task:
|
||||
assert response["isError"] is True
|
||||
assert response["structuredContent"]["error"]["code"] == "RESULT_TOO_LARGE"
|
||||
assert not saved
|
||||
return
|
||||
assert not response.get("isError"), response
|
||||
output = response["structuredContent"]
|
||||
if wide_ids:
|
||||
assert output["data"]["resource_uri"].endswith(output["evidence_id"])
|
||||
else:
|
||||
assert [row["device_id"] for row in output["data"]] == [row["device_id"] for row in devices]
|
||||
assert all(row["device_type"] == "pressure" and row["measurement_unit"] == "m" for row in output["data"])
|
||||
assert "get_scada_device" in " ".join(output["data_gaps"])
|
||||
assert output["page"] == {"limit": 100, "offset": 0, "total": None, "has_more": True}
|
||||
assert output["truncated"] is True
|
||||
assert len(json.dumps(output, ensure_ascii=False).encode()) <= 12000
|
||||
if task:
|
||||
assert saved[0].data["data"] == devices
|
||||
assert output["evidence_id"] == saved[0].source_ref
|
||||
assert not saved[0].truncated
|
||||
else:
|
||||
assert not saved and output["evidence_id"] is None
|
||||
|
||||
|
||||
def test_scada_catalog_default_100_paginates_without_changing_other_queries(runtime, monkeypatch):
|
||||
app, client, _, _ = runtime
|
||||
value = binding()
|
||||
devices = [{"device_id": str(index), "device_type": "pressure", "measurement_unit": "m"}
|
||||
for index in range(101)]
|
||||
|
||||
async def read_devices(connection, *, device_id, limit, offset):
|
||||
return devices[offset:offset + limit]
|
||||
|
||||
reader = AsyncMock(side_effect=read_devices)
|
||||
monkeypatch.setattr(query_service.reads, "scada_devices", reader)
|
||||
app.executor = query_service.execute_query
|
||||
schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"]
|
||||
if tool["name"] == "list_scada_devices")
|
||||
assert schema["properties"]["limit"]["default"] == 100
|
||||
assert schema["properties"]["limit"]["maximum"] == 500
|
||||
pages = [request(client, "tools/call", {"name": "list_scada_devices", "arguments": {
|
||||
"context_handle": value.context_handle, "offset": offset,
|
||||
}}).json()["result"]["structuredContent"] for offset in [0, 100]]
|
||||
assert [row for page in pages for row in page["data"]] == devices
|
||||
assert pages[0]["page"]["has_more"] is True and pages[1]["page"]["has_more"] is False
|
||||
assert [call.kwargs["limit"] for call in reader.await_args_list] == [101, 101]
|
||||
assert [call.kwargs["offset"] for call in reader.await_args_list] == [0, 100]
|
||||
assert TOOLS["list_elements"].model(context_handle=value.context_handle, element_type="pipe").limit == 20
|
||||
|
||||
|
||||
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
|
||||
app, client, _, _ = runtime
|
||||
value = binding(task=True)
|
||||
@@ -653,6 +827,35 @@ def test_old_task_run_and_reader_parent_are_rejected(monkeypatch):
|
||||
asyncio.run(contexts.verify_task(scope, value))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("purpose", ["task_execution", "task_collaboration"])
|
||||
@pytest.mark.parametrize("replay", [False, True])
|
||||
def test_work_order_task_excludes_location_but_scada_replay_keeps_it(monkeypatch, purpose, replay):
|
||||
monkeypatch.setattr(contexts, "permissions_for_context", lambda _: PERMISSIONS)
|
||||
value = binding(task=True)
|
||||
value.purpose = purpose
|
||||
replay_context = {"replay_id": str(uuid4()), "knowledge_cutoff": "2026-07-16T00:00:00Z", "batch_sequence": 3} if replay else None
|
||||
|
||||
class Tasks:
|
||||
async def get_task(self, task_id):
|
||||
return {"task_id": task_id, "workflow": "burst_report", "source": {"replay_context": replay_context}}
|
||||
|
||||
async def get_active_run(self, task_id):
|
||||
return {"run_id": str(value.run_id), "execution_request_id": str(uuid4()),
|
||||
"status": "running", "agent_session_id": value.native_session_id,
|
||||
"lease_expires_at": datetime.now(timezone.utc) + timedelta(minutes=1)}
|
||||
|
||||
monkeypatch.setattr(contexts, "task_repository", lambda _: Tasks())
|
||||
scope = SimpleNamespace(project=object(), connection=Connection())
|
||||
asyncio.run(contexts.verify_task(scope, value))
|
||||
assert ("run_burst_location" in value.tool_cap) is replay
|
||||
assert "analyze_valve_isolation" in value.tool_cap
|
||||
assert "analyze_valve_closure" in value.tool_cap
|
||||
if not replay:
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
asyncio.run(query_service.authorize_query(scope, value, TOOLS["run_burst_location"], SimpleNamespace()))
|
||||
assert caught.value.status_code == 403
|
||||
|
||||
|
||||
def test_jwt_signature_issuer_audience_lifetime_and_key_cache():
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
pem = key.private_bytes(
|
||||
|
||||
Reference in New Issue
Block a user