feat: extend operational investigation workflows
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user