feat: extend operational investigation workflows
Generic Container CI/CD / test-build-publish (push) Successful in 3m53s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 63af998045
commit 05183aff93
82 changed files with 4644 additions and 330 deletions
+119 -2
View File
@@ -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):