refactor: simplify task and history services
Remove retired query paths and repository compatibility branches, and share task test fixtures. Cancel pending questions and resolve their notifications when recovering expired runs.
This commit is contained in:
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||||
|
|
||||||
from app.auth.permissions import (
|
from app.auth.permissions import (
|
||||||
@@ -77,6 +77,10 @@ from app.services.operational_scada_coverage import require_complete_scada_evide
|
|||||||
from app.services.time_comparison_chart import validate_time_comparison_chart
|
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
|
||||||
|
|
||||||
|
|
||||||
LEASE_DURATION = timedelta(seconds=60)
|
LEASE_DURATION = timedelta(seconds=60)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +93,7 @@ def _task_json(model: OperationalTaskSummary) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class OperationalTaskService:
|
class OperationalTaskService:
|
||||||
def __init__(self, repository, context, permissions: Iterable[str], *, clock=None,
|
def __init__(self, repository: OperationalTaskRepository, context, permissions: Iterable[str], *, clock=None,
|
||||||
execution_scope: str = "production"):
|
execution_scope: str = "production"):
|
||||||
self.repo = repository
|
self.repo = repository
|
||||||
self.context = context
|
self.context = context
|
||||||
@@ -114,42 +118,14 @@ class OperationalTaskService:
|
|||||||
require(owner == command.agent_instance_id,
|
require(owner == command.agent_instance_id,
|
||||||
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
"AGENT_INSTANCE_MISMATCH", "Agent instance does not own this run", status=409)
|
||||||
|
|
||||||
@property
|
|
||||||
def _supports_runs(self) -> bool:
|
|
||||||
return hasattr(self.repo, "get_active_run") and hasattr(self.repo, "update_run")
|
|
||||||
|
|
||||||
async def _active_run(self, task: dict) -> dict | None:
|
async def _active_run(self, task: dict) -> dict | None:
|
||||||
if self._supports_runs:
|
return await self.repo.get_active_run(str(task["task_id"]))
|
||||||
return await self.repo.get_active_run(str(task["task_id"]))
|
|
||||||
if task.get("status") not in {"queued", "running"}:
|
|
||||||
return None
|
|
||||||
now = self._now()
|
|
||||||
return _json(OperationalTaskRun(
|
|
||||||
run_id=uuid5(NAMESPACE_URL, f"legacy-run:{task['task_id']}:{task['execution_generation']}"),
|
|
||||||
task_id=task["task_id"],
|
|
||||||
generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"],
|
|
||||||
status="running" if task["status"] == "running" else "queued",
|
|
||||||
revision=task["revision"],
|
|
||||||
agent_session_id=task.get("agent_session_id"),
|
|
||||||
lease_expires_at=task.get("agent_lease_expires_at"),
|
|
||||||
heartbeat_at=task.get("updated_at") if task["status"] == "running" else None,
|
|
||||||
started_at=task.get("created_at") if task["status"] == "running" else None,
|
|
||||||
dispatch_attempts=0,
|
|
||||||
next_dispatch_at=task.get("created_at") or now,
|
|
||||||
created_at=task.get("created_at") or now,
|
|
||||||
updated_at=task.get("updated_at") or now,
|
|
||||||
))
|
|
||||||
|
|
||||||
async def _append_event(self, **kwargs) -> int | None:
|
async def _append_event(self, **kwargs) -> int:
|
||||||
append_event = getattr(self.repo, "append_event", None)
|
return await self.repo.append_event(**kwargs)
|
||||||
return await append_event(**kwargs) if append_event is not None else None
|
|
||||||
|
|
||||||
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
async def _list_access_requests(self, task_id: UUID | str) -> list[dict]:
|
||||||
list_access_requests = getattr(self.repo, "list_access_requests", None)
|
return await self.repo.list_access_requests(str(task_id))
|
||||||
if list_access_requests is None:
|
|
||||||
return []
|
|
||||||
return await list_access_requests(str(task_id))
|
|
||||||
|
|
||||||
def _permission_view(self) -> OperationalTaskPermissions:
|
def _permission_view(self) -> OperationalTaskPermissions:
|
||||||
permissions = self.permissions
|
permissions = self.permissions
|
||||||
@@ -286,8 +262,7 @@ class OperationalTaskService:
|
|||||||
actions = await self.repo.list_actions(str(task_id))
|
actions = await self.repo.list_actions(str(task_id))
|
||||||
access_requests = await self._list_access_requests(task_id)
|
access_requests = await self._list_access_requests(task_id)
|
||||||
active_run = await self._active_run(task)
|
active_run = await self._active_run(task)
|
||||||
latest_run = (await self.repo.get_latest_run(str(task_id))
|
latest_run = await self.repo.get_latest_run(str(task_id))
|
||||||
if hasattr(self.repo, "get_latest_run") else active_run)
|
|
||||||
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
progress = await self.repo.get_latest_progress(str(task_id), task["execution_generation"])
|
||||||
work_order_snapshot = None
|
work_order_snapshot = None
|
||||||
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
if task["task_type"] == "external_work_order" and task["source"].get("external_id"):
|
||||||
@@ -397,9 +372,8 @@ class OperationalTaskService:
|
|||||||
replay = await self._request_replay(scope, command.request_id, body)
|
replay = await self._request_replay(scope, command.request_id, body)
|
||||||
if replay:
|
if replay:
|
||||||
return TaskAccessRequest.model_validate(replay["access_request"])
|
return TaskAccessRequest.model_validate(replay["access_request"])
|
||||||
availability = getattr(self.repo, "access_requests_available", None)
|
|
||||||
require(
|
require(
|
||||||
availability is None or await availability(),
|
await self.repo.access_requests_available(),
|
||||||
"DEPENDENCY_UNAVAILABLE",
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
"Task data access requests are not enabled for this project database",
|
"Task data access requests are not enabled for this project database",
|
||||||
status=503,
|
status=503,
|
||||||
@@ -616,8 +590,7 @@ class OperationalTaskService:
|
|||||||
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
return OperationalTaskRun.model_validate(await self._run(task_id, run_id))
|
||||||
|
|
||||||
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
async def _fail_run_messages(self, run_id, reason: str, now: datetime) -> None:
|
||||||
if hasattr(self.repo, "fail_run_messages"):
|
await self.repo.fail_run_messages(str(run_id), reason, now)
|
||||||
await self.repo.fail_run_messages(str(run_id), reason, now)
|
|
||||||
|
|
||||||
async def fail_run(
|
async def fail_run(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
|
self, task_id: UUID, run_id: UUID, command: AgentRunFailureCommand
|
||||||
@@ -772,7 +745,7 @@ class OperationalTaskService:
|
|||||||
# Lease recovery already holds a row lock. Try rather than wait
|
# Lease recovery already holds a row lock. Try rather than wait
|
||||||
# for the shared run lock to avoid reversing the lock order of
|
# for the shared run lock to avoid reversing the lock order of
|
||||||
# message/finish transactions; a busy run is checked next tick.
|
# message/finish transactions; a busy run is checked next tick.
|
||||||
if hasattr(self.repo, "try_lock") and not await self.repo.try_lock(f"run:{run['run_id']}"):
|
if not await self.repo.try_lock(f"run:{run['run_id']}"):
|
||||||
continue
|
continue
|
||||||
task_id = UUID(str(run["task_id"]))
|
task_id = UUID(str(run["task_id"]))
|
||||||
expected = run["revision"]
|
expected = run["revision"]
|
||||||
@@ -784,6 +757,7 @@ class OperationalTaskService:
|
|||||||
timed_out = OperationalTaskRun.model_validate(run)
|
timed_out = OperationalTaskRun.model_validate(run)
|
||||||
await self.repo.update_run(_json(timed_out), expected)
|
await self.repo.update_run(_json(timed_out), expected)
|
||||||
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
await self._fail_run_messages(run["run_id"], "Run lease expired", now)
|
||||||
|
await task_questions.cancel_pending(self, task_id, now)
|
||||||
await self._run_event(timed_out, now)
|
await self._run_event(timed_out, now)
|
||||||
task = await self._task(task_id)
|
task = await self._task(task_id)
|
||||||
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
timeout_count = await self.repo.consecutive_timed_out_runs(str(task_id))
|
||||||
@@ -1017,25 +991,9 @@ class OperationalTaskService:
|
|||||||
lease_expires_at=now + LEASE_DURATION,
|
lease_expires_at=now + LEASE_DURATION,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
if self._supports_runs:
|
await self.repo.update_run(
|
||||||
await self.repo.update_run(
|
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
||||||
_json(OperationalTaskRun.model_validate(active_run)), run_expected
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Compatibility for pre-015 repository adapters only.
|
|
||||||
if command.report:
|
|
||||||
if task["task_type"] == "event_investigation":
|
|
||||||
task["status"] = self._investigation_status(actions.values(), task) if command.report.stage == "final" else "running"
|
|
||||||
else:
|
|
||||||
task["status"] = self._status_from_plan(current_plan.plan.nodes, actions.values()) if current_plan else "running"
|
|
||||||
if task["status"] in {"waiting_human", "completed", "failed", "cancelled"}:
|
|
||||||
task["lifecycle_status"] = task["status"]
|
|
||||||
task["agent_lease_expires_at"] = (
|
|
||||||
now + LEASE_DURATION if task["status"] == "running" else None
|
|
||||||
)
|
|
||||||
await self.repo.update_task(
|
|
||||||
_task_json(OperationalTaskSummary.model_validate(task))
|
|
||||||
)
|
|
||||||
await self._task_event(task, now)
|
await self._task_event(task, now)
|
||||||
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
response = {"plan_revision": plan_revision, "report_revision": report_revision}
|
||||||
if progress_revision is not None:
|
if progress_revision is not None:
|
||||||
@@ -1988,13 +1946,12 @@ class OperationalTaskService:
|
|||||||
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
|
||||||
|
|
||||||
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
async def _require_no_pending_calculations(self, task_id: UUID, run: dict) -> None:
|
||||||
if hasattr(self.repo, "pending_calculation_jobs"):
|
pending = await self.repo.pending_calculation_jobs(
|
||||||
pending = await self.repo.pending_calculation_jobs(
|
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
||||||
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
|
)
|
||||||
)
|
require(not pending, "PENDING_CALCULATIONS",
|
||||||
require(not pending, "PENDING_CALCULATIONS",
|
"Wait for submitted calculations before publishing a final report or completing the run",
|
||||||
"Wait for submitted calculations before publishing a final report or completing the run",
|
refs=pending, status=409, recovery="retry_same_request")
|
||||||
refs=pending, status=409, recovery="retry_same_request")
|
|
||||||
|
|
||||||
async def _finish_run(
|
async def _finish_run(
|
||||||
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
|
||||||
@@ -2024,11 +1981,10 @@ class OperationalTaskService:
|
|||||||
actions = []
|
actions = []
|
||||||
if outcome == "succeeded":
|
if outcome == "succeeded":
|
||||||
await self._require_no_pending_calculations(task_id, run)
|
await self._require_no_pending_calculations(task_id, run)
|
||||||
if hasattr(self.repo, "pending_run_messages"):
|
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
||||||
pending_messages = await self.repo.pending_run_messages(str(run_id))
|
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
||||||
require(not pending_messages, "PENDING_AGENT_MESSAGES",
|
"Run has messages not yet entered into Agent context",
|
||||||
"Run has messages not yet entered into Agent context",
|
status=409, recovery="retry_same_request", refs=pending_messages)
|
||||||
status=409, recovery="retry_same_request", refs=pending_messages)
|
|
||||||
plan_document = await self.repo.get_latest_plan(str(task_id))
|
plan_document = await self.repo.get_latest_plan(str(task_id))
|
||||||
report_document = await self.repo.get_latest_report(str(task_id))
|
report_document = await self.repo.get_latest_report(str(task_id))
|
||||||
plan = (
|
plan = (
|
||||||
@@ -2158,8 +2114,6 @@ class OperationalTaskService:
|
|||||||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||||
from app.services.task_alerts import TaskAlertService
|
from app.services.task_alerts import TaskAlertService
|
||||||
|
|
||||||
if not hasattr(self.repo, "list_investigation_alerts"):
|
|
||||||
return
|
|
||||||
for alert in await self.repo.list_investigation_alerts(task_id):
|
for alert in await self.repo.list_investigation_alerts(task_id):
|
||||||
command = TaskAlertEvent.model_validate({
|
command = TaskAlertEvent.model_validate({
|
||||||
**{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}},
|
**{key: value for key, value in alert.items() if key not in {"status", "updated_at", "revision"}},
|
||||||
@@ -2364,42 +2318,41 @@ class OperationalTaskService:
|
|||||||
|
|
||||||
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
async def _requeue_task(self, task: dict, now: datetime) -> None:
|
||||||
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
await task_questions.cancel_pending(self, UUID(str(task["task_id"])), now)
|
||||||
if self._supports_runs:
|
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
active_run = await self.repo.get_active_run(str(task["task_id"]))
|
while active_run is not None:
|
||||||
while active_run is not None:
|
await self.repo.lock(f"run:{active_run['run_id']}")
|
||||||
await self.repo.lock(f"run:{active_run['run_id']}")
|
# Completion may have won the run lock after our first read.
|
||||||
# Completion may have won the run lock after our first read.
|
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
|
||||||
refreshed_run = await self.repo.get_active_run(str(task["task_id"]))
|
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
|
||||||
if refreshed_run is None or str(refreshed_run["run_id"]) == str(active_run["run_id"]):
|
|
||||||
active_run = refreshed_run
|
|
||||||
break
|
|
||||||
active_run = refreshed_run
|
active_run = refreshed_run
|
||||||
if active_run is not None and active_run["status"] == "queued":
|
break
|
||||||
# Multiple inputs can arrive before the worker claims the next
|
active_run = refreshed_run
|
||||||
# generation. They are all visible when that queued run starts,
|
if active_run is not None and active_run["status"] == "queued":
|
||||||
# so coalesce them instead of violating the one-active-run rule.
|
# Multiple inputs can arrive before the worker claims the next
|
||||||
task.update(
|
# generation. They are all visible when that queued run starts,
|
||||||
lifecycle_status="open", status="queued",
|
# so coalesce them instead of violating the one-active-run rule.
|
||||||
revision=task["revision"] + 1,
|
task.update(
|
||||||
execution_generation=active_run["generation"],
|
lifecycle_status="open", status="queued",
|
||||||
execution_request_id=str(active_run["execution_request_id"]),
|
revision=task["revision"] + 1,
|
||||||
agent_session_id=None, agent_lease_expires_at=None,
|
execution_generation=active_run["generation"],
|
||||||
updated_at=now,
|
execution_request_id=str(active_run["execution_request_id"]),
|
||||||
)
|
agent_session_id=None, agent_lease_expires_at=None,
|
||||||
return
|
updated_at=now,
|
||||||
if active_run is not None:
|
)
|
||||||
expected = active_run["revision"]
|
return
|
||||||
active_run.update(
|
if active_run is not None:
|
||||||
status="cancelled", revision=expected + 1,
|
expected = active_run["revision"]
|
||||||
lease_expires_at=None, finished_at=now,
|
active_run.update(
|
||||||
failure_code="SUPERSEDED_BY_REQUEUE",
|
status="cancelled", revision=expected + 1,
|
||||||
failure_detail="New task input superseded the active run",
|
lease_expires_at=None, finished_at=now,
|
||||||
updated_at=now,
|
failure_code="SUPERSEDED_BY_REQUEUE",
|
||||||
)
|
failure_detail="New task input superseded the active run",
|
||||||
cancelled = OperationalTaskRun.model_validate(active_run)
|
updated_at=now,
|
||||||
await self.repo.update_run(_json(cancelled), expected)
|
)
|
||||||
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
|
cancelled = OperationalTaskRun.model_validate(active_run)
|
||||||
await self._run_event(cancelled, now)
|
await self.repo.update_run(_json(cancelled), expected)
|
||||||
|
await self._fail_run_messages(active_run["run_id"], "Run superseded by requeue", now)
|
||||||
|
await self._run_event(cancelled, now)
|
||||||
generation = task["execution_generation"] + 1
|
generation = task["execution_generation"] + 1
|
||||||
task_id = UUID(task["task_id"])
|
task_id = UUID(task["task_id"])
|
||||||
task.update(
|
task.update(
|
||||||
@@ -2409,9 +2362,8 @@ class OperationalTaskService:
|
|||||||
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
agent_session_id=None, agent_lease_expires_at=None, updated_at=now,
|
||||||
)
|
)
|
||||||
run = self._new_run(task_id, generation, now)
|
run = self._new_run(task_id, generation, now)
|
||||||
if self._supports_runs:
|
await self.repo.insert_run(_json(run))
|
||||||
await self.repo.insert_run(_json(run))
|
await self._run_event(run, now, queued=True)
|
||||||
await self._run_event(run, now, queued=True)
|
|
||||||
|
|
||||||
async def _notify(
|
async def _notify(
|
||||||
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
|
self, task_id: UUID, kind: str, title: str, description: str, now: datetime,
|
||||||
@@ -2447,10 +2399,7 @@ class OperationalTaskService:
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
reason: str,
|
reason: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
resolve = getattr(self.repo, "resolve_notifications", None)
|
notifications = await self.repo.resolve_notifications(
|
||||||
if resolve is None:
|
|
||||||
return
|
|
||||||
notifications = await resolve(
|
|
||||||
str(task_id),
|
str(task_id),
|
||||||
kinds=kinds,
|
kinds=kinds,
|
||||||
subject_type=subject_type,
|
subject_type=subject_type,
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
|
||||||
# Legacy test repositories do not implement question persistence.
|
return await service.repo.list_questions(str(task_id))
|
||||||
reader = getattr(service.repo, "list_questions", None)
|
|
||||||
return await reader(str(task_id)) if reader else []
|
|
||||||
|
|
||||||
|
|
||||||
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
async def cancel_pending(service: "OperationalTaskService", task_id: UUID, now: datetime) -> None:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
from app.algorithms.pipe_health_prediction.survival_predictor import (
|
||||||
PipeHealthSurvivalPredictor,
|
PipeHealthSurvivalPredictor,
|
||||||
@@ -15,14 +14,11 @@ from app.algorithms.scada_cleaning.pressure_series import clean_pressure_data_df
|
|||||||
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
from app.infra.db.postgresql.network_assets import NetworkAssetRepository
|
||||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
|
||||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||||
|
|
||||||
|
|
||||||
class TimeseriesAnalysisService:
|
class TimeseriesAnalysisService:
|
||||||
"""
|
"""SCADA 清洗与管道健康预测;历史查询由 TimeseriesHistoryService 负责。"""
|
||||||
复合查询类,提供跨表查询功能
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_project_scada_index(
|
async def _get_project_scada_index(
|
||||||
@@ -31,294 +27,6 @@ class TimeseriesAnalysisService:
|
|||||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||||
return {scada["device_id"]: scada for scada in scadas}
|
return {scada["device_id"]: scada for scada in scadas}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(link_devices.values())), "flow",
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(node_devices.values())), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_scada_associated_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
device_ids: List[str],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 SCADA 关联的 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
device_ids: SCADA 设备ID列表
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 device_id 为键,值为数据列表,每个数据包含 time, value 和 scada_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
link_devices: dict[str, str] = {}
|
|
||||||
node_devices: dict[str, str] = {}
|
|
||||||
for device_id in device_ids:
|
|
||||||
target_scada = scada_by_id.get(device_id)
|
|
||||||
if not target_scada:
|
|
||||||
raise ValueError(f"SCADA device {device_id} not found")
|
|
||||||
|
|
||||||
scada_type = target_scada["device_type"]
|
|
||||||
if scada_type in {"pipe_flow", "flow"}:
|
|
||||||
link_devices[device_id] = target_scada["link_id"]
|
|
||||||
elif scada_type == "pressure":
|
|
||||||
node_devices[device_id] = target_scada["node_id"]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
|
||||||
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link",
|
|
||||||
list(dict.fromkeys(link_devices.values())), start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node",
|
|
||||||
list(dict.fromkeys(node_devices.values())), start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
device_id: [
|
|
||||||
{**item, "scada_id": device_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(element_id, [])
|
|
||||||
if device_id in link_devices
|
|
||||||
else node_series.get(element_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device_id, element_id in (link_devices | node_devices).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_realtime_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当 SCADA 设备未找到或字段无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time, list(dict.fromkeys(pipe_ids)), "flow"
|
|
||||||
)
|
|
||||||
node_series = await RealtimeRepository.get_node_fields_by_ids_time_range(
|
|
||||||
timescale_conn, start_time, end_time,
|
|
||||||
list(dict.fromkeys(junction_ids)), "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_analysis_simulation_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
feature_infos: List[Tuple[str, str]],
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
run_id: UUID,
|
|
||||||
) -> Dict[str, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
获取 link/node 分析模拟值
|
|
||||||
|
|
||||||
根据传入的 feature_infos,找到关联的 link/node,
|
|
||||||
并根据对应的 type,查询对应的模拟数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
run_id: 分析运行 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当类型无效时
|
|
||||||
"""
|
|
||||||
pipe_ids: list[str] = []
|
|
||||||
junction_ids: list[str] = []
|
|
||||||
for feature_id, feature_type in feature_infos:
|
|
||||||
if feature_type.lower() == "pipe":
|
|
||||||
pipe_ids.append(feature_id)
|
|
||||||
elif feature_type.lower() == "junction":
|
|
||||||
junction_ids.append(feature_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown type: {feature_type}")
|
|
||||||
link_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "link", list(dict.fromkeys(pipe_ids)),
|
|
||||||
start_time, end_time, "flow",
|
|
||||||
)
|
|
||||||
node_series = await AnalysisResultsRepository.get_series_by_ids(
|
|
||||||
timescale_conn, run_id, "node", list(dict.fromkeys(junction_ids)),
|
|
||||||
start_time, end_time, "pressure",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
feature_id: [
|
|
||||||
{**item, "feature_id": feature_id}
|
|
||||||
for item in (
|
|
||||||
link_series.get(feature_id, [])
|
|
||||||
if feature_type.lower() == "pipe"
|
|
||||||
else node_series.get(feature_id, [])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for feature_id, feature_type in feature_infos
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_element_associated_scada_data(
|
|
||||||
timescale_conn: AsyncConnection,
|
|
||||||
postgres_conn: AsyncConnection,
|
|
||||||
element_id: str,
|
|
||||||
start_time: datetime,
|
|
||||||
end_time: datetime,
|
|
||||||
use_cleaned: bool = False,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
获取 link/node 关联的 SCADA 监测值
|
|
||||||
|
|
||||||
根据传入的 link/node id,匹配 SCADA 信息,
|
|
||||||
如果存在关联的 SCADA device_id,获取实际的监测数据
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timescale_conn: TimescaleDB 异步连接
|
|
||||||
postgres_conn: PostgreSQL 异步连接
|
|
||||||
element_id: link 或 node 的 ID
|
|
||||||
start_time: 开始时间
|
|
||||||
end_time: 结束时间
|
|
||||||
use_cleaned: 是否使用清洗后的数据 (True: "cleaned_value", False: "monitored_value")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SCADA 监测数据值,如果没有找到则返回 None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 当元素类型无效时
|
|
||||||
"""
|
|
||||||
|
|
||||||
scada_by_id = await TimeseriesAnalysisService._get_project_scada_index(postgres_conn)
|
|
||||||
associated_scada = next(
|
|
||||||
(
|
|
||||||
scada
|
|
||||||
for scada in scada_by_id.values()
|
|
||||||
if (scada.get("node_id") or scada.get("link_id")) == element_id
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not associated_scada:
|
|
||||||
return None
|
|
||||||
|
|
||||||
device_id = associated_scada["device_id"]
|
|
||||||
|
|
||||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
|
||||||
|
|
||||||
res = await ScadaRepository.get_scada_field_by_id_time_range(
|
|
||||||
timescale_conn, [device_id], start_time, end_time, data_field
|
|
||||||
)
|
|
||||||
|
|
||||||
return {element_id: res.get(device_id, [])}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clean_scada_data(
|
async def clean_scada_data(
|
||||||
timescale_conn: AsyncConnection,
|
timescale_conn: AsyncConnection,
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ def _points(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _history_series(
|
||||||
|
*, element_type: HistoryElementType, element_id: str,
|
||||||
|
source: HistorySource, source_unit: str, device_id: str | None = None,
|
||||||
|
unit_inferred: bool = False, points: list[HistoryPoint] | None = None,
|
||||||
|
) -> ElementHistorySeries:
|
||||||
|
metric = _target_metric(element_type)
|
||||||
|
return ElementHistorySeries(
|
||||||
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
|
metric=metric, source=source, source_unit=source_unit,
|
||||||
|
display_unit=display_unit_for_metric(metric.value),
|
||||||
|
unit_inferred=unit_inferred, points=points if points is not None else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_result_units(
|
||||||
|
postgres_conn: AsyncConnection, query: ElementHistoryQuery,
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
||||||
|
if query.mode.value != "analysis_comparison":
|
||||||
|
return units, False
|
||||||
|
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
||||||
|
if run is None:
|
||||||
|
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
||||||
|
recorded = (run.get("parameters") or {}).get("result_units")
|
||||||
|
return (recorded, False) if isinstance(recorded, dict) else (units, True)
|
||||||
|
|
||||||
|
|
||||||
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
async def _query_page(timescale_conn, postgres_conn, query, selected_devices, limit, offset):
|
||||||
descriptors = []
|
descriptors = []
|
||||||
series = []
|
series = []
|
||||||
@@ -66,16 +93,14 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
"source": source, "element_type": element_type,
|
"source": source, "element_type": element_type,
|
||||||
"id": device_id if source == "scada" else element_id, "field": field,
|
"id": device_id if source == "scada" else element_id, "field": field,
|
||||||
})
|
})
|
||||||
metric = _target_metric(element_type)
|
series.append(_history_series(
|
||||||
series.append(ElementHistorySeries(
|
|
||||||
element_id=element_id, element_type=element_type, device_id=device_id,
|
element_id=element_id, element_type=element_type, device_id=device_id,
|
||||||
metric=metric, source=(
|
source=(
|
||||||
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
HistorySource.SCADA_RAW if field == "monitored_value" else HistorySource.SCADA_CLEANED
|
||||||
) if source == "scada" else (
|
) if source == "scada" else (
|
||||||
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
HistorySource.ANALYSIS_SIMULATION if source == "analysis" else HistorySource.REALTIME_SIMULATION
|
||||||
),
|
),
|
||||||
source_unit=unit, display_unit=display_unit_for_metric(metric.value),
|
source_unit=unit, unit_inferred=unit_inferred,
|
||||||
unit_inferred=unit_inferred, points=[],
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
# Stable series ordering makes a page crossing raw/cleaned/model boundaries repeatable.
|
||||||
@@ -84,17 +109,8 @@ async def _query_page(timescale_conn, postgres_conn, query, selected_devices, li
|
|||||||
add(source="scada", element_type=element_type, element_id=element_id,
|
add(source="scada", element_type=element_type, element_id=element_id,
|
||||||
device_id=device_id, field=field, unit=device["measurement_unit"])
|
device_id=device_id, field=field, unit=device["measurement_unit"])
|
||||||
if query.mode.value != "observed":
|
if query.mode.value != "observed":
|
||||||
units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
units, inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
source = "analysis" if query.mode.value == "analysis_comparison" else "realtime"
|
||||||
inferred = False
|
|
||||||
if source == "analysis":
|
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
recorded = (run.get("parameters") or {}).get("result_units")
|
|
||||||
inferred = not isinstance(recorded, dict)
|
|
||||||
if not inferred:
|
|
||||||
units = recorded
|
|
||||||
targets = {(t.element_type, t.element_id) for t in query.elements}
|
targets = {(t.element_type, t.element_id) for t in query.elements}
|
||||||
for element_type, element_id in sorted(targets):
|
for element_type, element_id in sorted(targets):
|
||||||
metric = _target_metric(element_type)
|
metric = _target_metric(element_type)
|
||||||
@@ -202,21 +218,18 @@ class TimeseriesHistoryService:
|
|||||||
rows = scada_by_device.get(device_id, [])
|
rows = scada_by_device.get(device_id, [])
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
metric = _target_metric(element_type)
|
|
||||||
unit = device["measurement_unit"]
|
unit = device["measurement_unit"]
|
||||||
for source, field in (
|
for source, field in (
|
||||||
(HistorySource.SCADA_RAW, "monitored_value"),
|
(HistorySource.SCADA_RAW, "monitored_value"),
|
||||||
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
(HistorySource.SCADA_CLEANED, "cleaned_value"),
|
||||||
):
|
):
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=element_id,
|
element_id=element_id,
|
||||||
element_type=element_type,
|
element_type=element_type,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=unit,
|
source_unit=unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
value_field=field,
|
value_field=field,
|
||||||
@@ -227,15 +240,8 @@ class TimeseriesHistoryService:
|
|||||||
if query.mode.value == "observed":
|
if query.mode.value == "observed":
|
||||||
return ElementHistoryResponse(series=series)
|
return ElementHistoryResponse(series=series)
|
||||||
|
|
||||||
current_units = await NetworkSettingsRepository.get_result_units(postgres_conn)
|
result_units, unit_inferred = await _resolve_result_units(postgres_conn, query)
|
||||||
if query.mode.value == "analysis_comparison":
|
if query.mode.value == "analysis_comparison":
|
||||||
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
|
|
||||||
if run is None:
|
|
||||||
raise ValueError(f"analysis run does not exist: {query.run_id}")
|
|
||||||
parameters = run.get("parameters") or {}
|
|
||||||
recorded_units = parameters.get("result_units")
|
|
||||||
unit_inferred = not isinstance(recorded_units, dict)
|
|
||||||
result_units = recorded_units if not unit_inferred else current_units
|
|
||||||
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
link_values = await AnalysisResultsRepository.get_series_by_ids(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.run_id,
|
query.run_id,
|
||||||
@@ -256,8 +262,6 @@ class TimeseriesHistoryService:
|
|||||||
)
|
)
|
||||||
source = HistorySource.ANALYSIS_SIMULATION
|
source = HistorySource.ANALYSIS_SIMULATION
|
||||||
else:
|
else:
|
||||||
unit_inferred = False
|
|
||||||
result_units = current_units
|
|
||||||
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
|
||||||
timescale_conn,
|
timescale_conn,
|
||||||
query.start_time,
|
query.start_time,
|
||||||
@@ -285,13 +289,11 @@ class TimeseriesHistoryService:
|
|||||||
continue
|
continue
|
||||||
source_unit = str(result_units.get(metric.value, "")).strip()
|
source_unit = str(result_units.get(metric.value, "")).strip()
|
||||||
series.append(
|
series.append(
|
||||||
ElementHistorySeries(
|
_history_series(
|
||||||
element_id=target.element_id,
|
element_id=target.element_id,
|
||||||
element_type=target.element_type,
|
element_type=target.element_type,
|
||||||
metric=metric,
|
|
||||||
source=source,
|
source=source,
|
||||||
source_unit=source_unit,
|
source_unit=source_unit,
|
||||||
display_unit=display_unit_for_metric(metric.value),
|
|
||||||
unit_inferred=unit_inferred,
|
unit_inferred=unit_inferred,
|
||||||
points=_points(
|
points=_points(
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""In-memory task/run fixtures exercising the production repository contract."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
from app.auth.project_dependencies import ProjectContext
|
||||||
|
from app.domain.schemas.operational_task import OperationalTaskSummary, OperationalTaskRun
|
||||||
|
|
||||||
|
def _context(user_id=None, *, system_role="user"):
|
||||||
|
return ProjectContext(
|
||||||
|
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
||||||
|
project_role="member", system_role=system_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _task_document(context, now, *, lease=None):
|
||||||
|
task_id = uuid4()
|
||||||
|
return OperationalTaskSummary(
|
||||||
|
execution_scope="production",
|
||||||
|
project_id=context.project_id,
|
||||||
|
task_id=task_id,
|
||||||
|
task_type="event_investigation",
|
||||||
|
workflow="water_quality_complaint",
|
||||||
|
status="running",
|
||||||
|
priority="normal",
|
||||||
|
revision=2,
|
||||||
|
execution_generation=1,
|
||||||
|
execution_request_id=uuid4(),
|
||||||
|
title="调查",
|
||||||
|
summary="验证状态机",
|
||||||
|
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
||||||
|
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
||||||
|
scheduled_for=None,
|
||||||
|
agent_session_id="session-1",
|
||||||
|
agent_lease_expires_at=lease,
|
||||||
|
current_plan_revision=None,
|
||||||
|
current_report_revision=None,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
def _active_run(task, now):
|
||||||
|
return OperationalTaskRun(
|
||||||
|
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
||||||
|
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
||||||
|
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
||||||
|
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
||||||
|
next_dispatch_at=now, created_at=now, updated_at=now,
|
||||||
|
).model_dump(mode="json")
|
||||||
|
|
||||||
|
class TaskRepository:
|
||||||
|
def __init__(self, task, run=None, *, action=None):
|
||||||
|
self.task = task
|
||||||
|
self.action = action
|
||||||
|
self.requests = {}
|
||||||
|
self.evidence = {}
|
||||||
|
self.feedback = {}
|
||||||
|
self.plans = []
|
||||||
|
self.reports = []
|
||||||
|
self.progress = {}
|
||||||
|
self.notifications = []
|
||||||
|
self.locks = []
|
||||||
|
self.access_requests = {}
|
||||||
|
self.events = []
|
||||||
|
self.questions = {}
|
||||||
|
now = datetime.fromisoformat(task["created_at"])
|
||||||
|
seeded = _active_run(task, now) if run is None else run
|
||||||
|
if run is None:
|
||||||
|
seeded.update(
|
||||||
|
status=task["status"] if task["status"] in {"queued", "running"} else "succeeded",
|
||||||
|
agent_session_id=task.get("agent_session_id"),
|
||||||
|
lease_expires_at=task.get("agent_lease_expires_at"),
|
||||||
|
)
|
||||||
|
self.run = seeded
|
||||||
|
self.runs = {seeded["run_id"]: seeded}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield self
|
||||||
|
|
||||||
|
async def lock(self, key):
|
||||||
|
self.locks.append(key)
|
||||||
|
|
||||||
|
async def get_request(self, scope, request_id):
|
||||||
|
return self.requests.get((scope, request_id))
|
||||||
|
|
||||||
|
async def save_request(self, scope, request_id, request_digest, response):
|
||||||
|
self.requests[(scope, request_id)] = {
|
||||||
|
"request_digest": request_digest,
|
||||||
|
"response": response,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_task(self, task_id):
|
||||||
|
return self.task if task_id == self.task["task_id"] else None
|
||||||
|
|
||||||
|
async def update_task(self, task):
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
async def get_latest_plan(self, _task_id):
|
||||||
|
return self.plans[-1] if self.plans else None
|
||||||
|
|
||||||
|
async def get_latest_report(self, _task_id):
|
||||||
|
return self.reports[-1] if self.reports else None
|
||||||
|
|
||||||
|
async def get_latest_progress(self, task_id, generation):
|
||||||
|
return self.progress.get((task_id, generation))
|
||||||
|
|
||||||
|
async def save_progress(self, task_id, snapshot):
|
||||||
|
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
||||||
|
|
||||||
|
async def get_report(self, _task_id, revision):
|
||||||
|
return next((item for item in self.reports if item["revision"] == revision), None)
|
||||||
|
|
||||||
|
async def list_reports(self, _task_id, limit, offset):
|
||||||
|
return list(reversed(self.reports))[offset:offset + limit]
|
||||||
|
|
||||||
|
async def insert_plan(self, _task_id, snapshot):
|
||||||
|
self.plans.append(snapshot)
|
||||||
|
|
||||||
|
async def insert_report(self, _task_id, snapshot):
|
||||||
|
self.reports.append(snapshot)
|
||||||
|
|
||||||
|
async def list_evidence(self, _task_id):
|
||||||
|
return list(self.evidence.values())
|
||||||
|
|
||||||
|
async def get_evidence(self, _task_id, evidence_id):
|
||||||
|
return self.evidence.get(evidence_id)
|
||||||
|
|
||||||
|
async def insert_evidence(self, _task_id, evidence):
|
||||||
|
self.evidence[evidence["evidence_id"]] = evidence
|
||||||
|
|
||||||
|
async def list_access_requests(self, _task_id):
|
||||||
|
return list(self.access_requests.values())
|
||||||
|
|
||||||
|
async def get_access_request(self, _task_id, access_request_id):
|
||||||
|
return self.access_requests.get(access_request_id)
|
||||||
|
|
||||||
|
async def insert_access_request(self, _task_id, document):
|
||||||
|
self.access_requests[document["access_request_id"]] = document
|
||||||
|
|
||||||
|
async def update_access_request(self, _task_id, document, expected_version):
|
||||||
|
assert document["version"] == expected_version + 1
|
||||||
|
self.access_requests[document["access_request_id"]] = document
|
||||||
|
|
||||||
|
async def list_actions(self, _task_id):
|
||||||
|
return [self.action] if self.action else []
|
||||||
|
|
||||||
|
async def get_action(self, _task_id, action_id):
|
||||||
|
if self.action and self.action["action_id"] == action_id:
|
||||||
|
return self.action
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_action(self, _task_id, action):
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
async def insert_notification(self, notification):
|
||||||
|
self.notifications.append(notification)
|
||||||
|
|
||||||
|
async def resolve_notifications(
|
||||||
|
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
||||||
|
):
|
||||||
|
resolved = []
|
||||||
|
for notification in self.notifications:
|
||||||
|
if (
|
||||||
|
notification["task_id"] == task_id
|
||||||
|
and notification["kind"] in kinds
|
||||||
|
and notification.get("lifecycle_status", "active") == "active"
|
||||||
|
and notification.get("subject_type") == subject_type
|
||||||
|
and notification.get("subject_id") == subject_id
|
||||||
|
):
|
||||||
|
notification.update(
|
||||||
|
lifecycle_status="resolved",
|
||||||
|
resolved_at=resolved_at.isoformat(),
|
||||||
|
resolution_reason=resolution_reason,
|
||||||
|
)
|
||||||
|
resolved.append(notification.copy())
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
async def get_feedback(self, task_id, source_event_id):
|
||||||
|
return self.feedback.get((task_id, source_event_id))
|
||||||
|
|
||||||
|
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
||||||
|
self.feedback[(task_id, feedback["source_event_id"])] = {
|
||||||
|
"source_revision": feedback["source_revision"],
|
||||||
|
"document": feedback,
|
||||||
|
"accepted_at": accepted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_active_run(self, task_id):
|
||||||
|
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True)
|
||||||
|
if run["task_id"] == task_id and run["status"] in {"queued", "claimed", "running"}), None)
|
||||||
|
|
||||||
|
async def get_latest_run(self, task_id):
|
||||||
|
return next((run for run in sorted(self.runs.values(), key=lambda item: item["generation"], reverse=True) if run["task_id"] == task_id), None)
|
||||||
|
|
||||||
|
async def get_run(self, run_id):
|
||||||
|
return self.runs.get(run_id)
|
||||||
|
|
||||||
|
async def require_execution_storage(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_run(self, run, expected_revision):
|
||||||
|
assert run["revision"] == expected_revision + 1
|
||||||
|
self.runs[run["run_id"]] = run
|
||||||
|
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||||
|
|
||||||
|
async def insert_run(self, run):
|
||||||
|
self.runs[run["run_id"]] = run
|
||||||
|
self.run = max(self.runs.values(), key=lambda item: item["generation"])
|
||||||
|
|
||||||
|
async def append_event(self, **event):
|
||||||
|
self.events.append(event)
|
||||||
|
return len(self.events)
|
||||||
|
|
||||||
|
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]
|
||||||
|
async def access_requests_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fail_run_messages(self, run_id, reason, now):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def try_lock(self, key):
|
||||||
|
await self.lock(key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(self, task_id, run_id, execution_request_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pending_run_messages(self, run_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def list_investigation_alerts(self, task_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -44,6 +44,12 @@ class Repository:
|
|||||||
async def get_task(self, task_id):
|
async def get_task(self, task_id):
|
||||||
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
|
return {"task_type": "event_investigation"} if task_id == str(self.task_id) else None
|
||||||
|
|
||||||
|
async def list_questions(self, task_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def pending_calculation_jobs(self, task_id, run_id, execution_request_id):
|
||||||
|
return []
|
||||||
|
|
||||||
async def get_message(self, message_id):
|
async def get_message(self, message_id):
|
||||||
return self.messages.get(message_id)
|
return self.messages.get(message_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -114,11 +115,6 @@ async def test_access_request_read_uses_table_after_migration():
|
|||||||
assert connection.execute.await_count == 2
|
assert connection.execute.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
def _context(user_id=None, *, system_role="user"):
|
|
||||||
return ProjectContext(
|
|
||||||
project_id=uuid4(), project_code="test", user_id=user_id or uuid4(),
|
|
||||||
project_role="member", system_role=system_role,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -232,208 +228,12 @@ async def test_targeted_notification_cannot_be_acknowledged_without_permission()
|
|||||||
assert exc.value.code == "PERMISSION_DENIED"
|
assert exc.value.code == "PERMISSION_DENIED"
|
||||||
|
|
||||||
|
|
||||||
def _task_document(context, now, *, lease=None):
|
|
||||||
task_id = uuid4()
|
|
||||||
return OperationalTaskSummary(
|
|
||||||
execution_scope="production",
|
|
||||||
project_id=context.project_id,
|
|
||||||
task_id=task_id,
|
|
||||||
task_type="event_investigation",
|
|
||||||
workflow="water_quality_complaint",
|
|
||||||
status="running",
|
|
||||||
priority="normal",
|
|
||||||
revision=2,
|
|
||||||
execution_generation=1,
|
|
||||||
execution_request_id=uuid4(),
|
|
||||||
title="调查",
|
|
||||||
summary="验证状态机",
|
|
||||||
source={"kind": "operator", "event_kind": "water_quality_complaint"},
|
|
||||||
trigger={"kind": "manual", "occurred_at": now, "detail": "test"},
|
|
||||||
scheduled_for=None,
|
|
||||||
agent_session_id="session-1",
|
|
||||||
agent_lease_expires_at=lease,
|
|
||||||
current_plan_revision=None,
|
|
||||||
current_report_revision=None,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskRepository:
|
|
||||||
def __init__(self, task, *, action=None):
|
|
||||||
self.task = task
|
|
||||||
self.action = action
|
|
||||||
self.requests = {}
|
|
||||||
self.evidence = {}
|
|
||||||
self.feedback = {}
|
|
||||||
self.plans = []
|
|
||||||
self.reports = []
|
|
||||||
self.progress = {}
|
|
||||||
self.notifications = []
|
|
||||||
self.locks = []
|
|
||||||
self.access_requests = {}
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def transaction(self):
|
|
||||||
yield self
|
|
||||||
|
|
||||||
async def lock(self, key):
|
|
||||||
self.locks.append(key)
|
|
||||||
|
|
||||||
async def get_request(self, scope, request_id):
|
|
||||||
return self.requests.get((scope, request_id))
|
|
||||||
|
|
||||||
async def save_request(self, scope, request_id, request_digest, response):
|
|
||||||
self.requests[(scope, request_id)] = {
|
|
||||||
"request_digest": request_digest,
|
|
||||||
"response": response,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_task(self, task_id):
|
|
||||||
return self.task if task_id == self.task["task_id"] else None
|
|
||||||
|
|
||||||
async def update_task(self, task):
|
|
||||||
self.task = task
|
|
||||||
|
|
||||||
async def get_latest_plan(self, _task_id):
|
|
||||||
return self.plans[-1] if self.plans else None
|
|
||||||
|
|
||||||
async def get_latest_report(self, _task_id):
|
|
||||||
return self.reports[-1] if self.reports else None
|
|
||||||
|
|
||||||
async def get_latest_progress(self, task_id, generation):
|
|
||||||
return self.progress.get((task_id, generation))
|
|
||||||
|
|
||||||
async def save_progress(self, task_id, snapshot):
|
|
||||||
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
|
||||||
|
|
||||||
async def get_report(self, _task_id, revision):
|
|
||||||
return next((item for item in self.reports if item["revision"] == revision), None)
|
|
||||||
|
|
||||||
async def list_reports(self, _task_id, limit, offset):
|
|
||||||
return list(reversed(self.reports))[offset:offset + limit]
|
|
||||||
|
|
||||||
async def insert_plan(self, _task_id, snapshot):
|
|
||||||
self.plans.append(snapshot)
|
|
||||||
|
|
||||||
async def insert_report(self, _task_id, snapshot):
|
|
||||||
self.reports.append(snapshot)
|
|
||||||
|
|
||||||
async def list_evidence(self, _task_id):
|
|
||||||
return list(self.evidence.values())
|
|
||||||
|
|
||||||
async def get_evidence(self, _task_id, evidence_id):
|
|
||||||
return self.evidence.get(evidence_id)
|
|
||||||
|
|
||||||
async def insert_evidence(self, _task_id, evidence):
|
|
||||||
self.evidence[evidence["evidence_id"]] = evidence
|
|
||||||
|
|
||||||
async def list_access_requests(self, _task_id):
|
|
||||||
return list(self.access_requests.values())
|
|
||||||
|
|
||||||
async def get_access_request(self, _task_id, access_request_id):
|
|
||||||
return self.access_requests.get(access_request_id)
|
|
||||||
|
|
||||||
async def insert_access_request(self, _task_id, document):
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def update_access_request(self, _task_id, document, expected_version):
|
|
||||||
assert document["version"] == expected_version + 1
|
|
||||||
self.access_requests[document["access_request_id"]] = document
|
|
||||||
|
|
||||||
async def list_actions(self, _task_id):
|
|
||||||
return [self.action] if self.action else []
|
|
||||||
|
|
||||||
async def get_action(self, _task_id, action_id):
|
|
||||||
if self.action and self.action["action_id"] == action_id:
|
|
||||||
return self.action
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_action(self, _task_id, action):
|
|
||||||
self.action = action
|
|
||||||
|
|
||||||
async def insert_notification(self, notification):
|
|
||||||
self.notifications.append(notification)
|
|
||||||
|
|
||||||
async def resolve_notifications(
|
|
||||||
self, task_id, *, kinds, subject_type, subject_id, resolved_at, resolution_reason
|
|
||||||
):
|
|
||||||
resolved = []
|
|
||||||
for notification in self.notifications:
|
|
||||||
if (
|
|
||||||
notification["task_id"] == task_id
|
|
||||||
and notification["kind"] in kinds
|
|
||||||
and notification.get("lifecycle_status", "active") == "active"
|
|
||||||
and notification.get("subject_type") == subject_type
|
|
||||||
and notification.get("subject_id") == subject_id
|
|
||||||
):
|
|
||||||
notification.update(
|
|
||||||
lifecycle_status="resolved",
|
|
||||||
resolved_at=resolved_at.isoformat(),
|
|
||||||
resolution_reason=resolution_reason,
|
|
||||||
)
|
|
||||||
resolved.append(notification.copy())
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
async def get_feedback(self, task_id, source_event_id):
|
|
||||||
return self.feedback.get((task_id, source_event_id))
|
|
||||||
|
|
||||||
async def upsert_feedback(self, task_id, feedback, accepted_at):
|
|
||||||
self.feedback[(task_id, feedback["source_event_id"])] = {
|
|
||||||
"source_revision": feedback["source_revision"],
|
|
||||||
"document": feedback,
|
|
||||||
"accepted_at": accepted_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RunTaskRepository(TaskRepository):
|
|
||||||
def __init__(self, task, run):
|
|
||||||
super().__init__(task)
|
|
||||||
self.run = run
|
|
||||||
self.events = []
|
|
||||||
|
|
||||||
async def get_active_run(self, task_id):
|
|
||||||
if task_id == self.task["task_id"] and self.run["status"] in {"queued", "claimed", "running"}:
|
|
||||||
return self.run
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_run(self, run_id):
|
|
||||||
return self.run if run_id == self.run["run_id"] else None
|
|
||||||
|
|
||||||
async def require_execution_storage(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def update_run(self, run, expected_revision):
|
|
||||||
assert run["revision"] == expected_revision + 1
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def insert_run(self, run):
|
|
||||||
self.run = run
|
|
||||||
|
|
||||||
async def append_event(self, **event):
|
|
||||||
self.events.append(event)
|
|
||||||
return len(self.events)
|
|
||||||
|
|
||||||
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(
|
|
||||||
run_id=uuid4(), task_id=task["task_id"], generation=task["execution_generation"],
|
|
||||||
execution_request_id=task["execution_request_id"], status="running", revision=2,
|
|
||||||
agent_session_id="session-1", lease_expires_at=now + timedelta(minutes=1),
|
|
||||||
heartbeat_at=now, started_at=now, dispatch_attempts=0,
|
|
||||||
next_dispatch_at=now, created_at=now, updated_at=now,
|
|
||||||
).model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -457,7 +257,7 @@ async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_sc
|
|||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run.update(status="queued", revision=1, agent_session_id=None,
|
run.update(status="queued", revision=1, agent_session_id=None,
|
||||||
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
|
||||||
execution_scope=worker_scope)
|
execution_scope=worker_scope)
|
||||||
|
|
||||||
@@ -488,7 +288,7 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
first = AgentArtifactsCommand.model_validate({
|
first = AgentArtifactsCommand.model_validate({
|
||||||
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
"request_id": str(uuid4()), "source_session_id": "session-1", "run_id": repository.run["run_id"],
|
||||||
@@ -535,7 +335,7 @@ async def test_reading_publications_reject_stale_execution_ownership(kind, inval
|
|||||||
run["lease_expires_at"] = now.isoformat()
|
run["lease_expires_at"] = now.isoformat()
|
||||||
elif invalid == "finished":
|
elif invalid == "finished":
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
|
identity = {"request_id": uuid4(), "run_id": uuid4() if invalid == "run" else run["run_id"],
|
||||||
"source_session_id": "other-session" if invalid == "session" else "session-1"}
|
"source_session_id": "other-session" if invalid == "session" else "session-1"}
|
||||||
@@ -556,7 +356,7 @@ async def test_progress_unknown_evidence_does_not_publish_or_authorize_actions()
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError):
|
with pytest.raises(DomainError):
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -582,7 +382,7 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
baseline = await service.presentation_cursor(task["task_id"])
|
baseline = await service.presentation_cursor(task["task_id"])
|
||||||
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
|
assert baseline.event_cursor == 0 and str(baseline.active_run.run_id) == repository.run["run_id"]
|
||||||
@@ -608,7 +408,7 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
source="query_scada_readings", source_ref="mcp:frozen-scada", queried_at=now,
|
||||||
@@ -645,7 +445,7 @@ async def test_comparison_chart_publication_is_validated_at_both_write_boundarie
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||||
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
||||||
@@ -715,7 +515,7 @@ async def test_replay_cannot_propose_approve_or_submit_device_commands(operation
|
|||||||
required_execution_permission="command.execute", approval_status="pending",
|
required_execution_permission="command.execute", approval_status="pending",
|
||||||
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
execution_status="not_submitted", proposed_by="principal:test", proposed_at=now,
|
||||||
)
|
)
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.action = action.model_dump(mode="json")
|
repository.action = action.model_dump(mode="json")
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
dispatcher = SimpleNamespace(dispatch=AsyncMock())
|
||||||
@@ -743,7 +543,7 @@ async def test_unified_commands_record_canonical_events_without_mutating_task(pa
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = TaskPresentationEventCommand(
|
command = TaskPresentationEventCommand(
|
||||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
||||||
@@ -762,7 +562,7 @@ async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geomet
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
coverage = build_node_coverage(
|
coverage = build_node_coverage(
|
||||||
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
|
||||||
@@ -795,7 +595,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
|
snapshot = TaskDataSnapshot(source="scada_devices", source_ref="verified-points", queried_at=now,
|
||||||
origin="system_data", truncated=False,
|
origin="system_data", truncated=False,
|
||||||
@@ -844,7 +644,7 @@ async def test_completed_investigation_resumes_once_for_linked_new_field_feedbac
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
@@ -891,7 +691,7 @@ async def test_completed_investigation_rejects_unlinked_or_initial_child_feedbac
|
|||||||
task.update(status="completed", lifecycle_status="completed")
|
task.update(status="completed", lifecycle_status="completed")
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError) as unlinked:
|
with pytest.raises(DomainError) as unlinked:
|
||||||
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
await service.external_feedback(task["task_id"], ExternalFeedbackCommand(
|
||||||
@@ -923,7 +723,7 @@ async def test_original_complaint_feedback_advances_its_source_revision():
|
|||||||
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
source={"kind": "external_system", "external_id": source_id, "external_revision": 1})
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["status"] = "succeeded"
|
run["status"] = "succeeded"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
|
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=f"peripheral_sample:{source_id}",
|
||||||
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
|
source_revision=2, status="in_progress", occurred_at=now, detail="新投诉信息")
|
||||||
@@ -940,7 +740,7 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
updated = await service.heartbeat_run(
|
updated = await service.heartbeat_run(
|
||||||
@@ -962,7 +762,7 @@ async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_sessio
|
|||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
run = _active_run(task, now)
|
run = _active_run(task, now)
|
||||||
run["agent_instance_id"] = "agent-a"
|
run["agent_instance_id"] = "agent-a"
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
with pytest.raises(DomainError) as mismatch:
|
with pytest.raises(DomainError) as mismatch:
|
||||||
@@ -982,7 +782,7 @@ async def test_complete_without_same_generation_artifacts_fails_run_explicitly()
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
completed = await service.complete_run(
|
completed = await service.complete_run(
|
||||||
@@ -1014,7 +814,7 @@ async def test_external_feedback_coalesces_into_an_already_queued_run():
|
|||||||
status="queued", revision=1, agent_session_id=None,
|
status="queued", revision=1, agent_session_id=None,
|
||||||
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
lease_expires_at=None, heartbeat_at=None, started_at=None,
|
||||||
)
|
)
|
||||||
repository = RunTaskRepository(task, run)
|
repository = TaskRepository(task, run)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
result = await service.external_feedback(
|
result = await service.external_feedback(
|
||||||
@@ -1069,7 +869,7 @@ async def test_scada_final_report_requires_complete_current_input_not_selected_d
|
|||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
task["workflow"] = "scada_anomaly"
|
task["workflow"] = "scada_anomaly"
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
data = {"devices": [{"device_id": "P1"}], "readings": [], "coverage": {
|
||||||
"mode": "all_scada", "query_complete": True,
|
"mode": "all_scada", "query_complete": True,
|
||||||
@@ -1124,7 +924,7 @@ async def test_insufficient_data_exits_successfully_and_blocks_actions_until_new
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = AgentArtifactsCommand(
|
command = AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1",
|
request_id=uuid4(), source_session_id="session-1",
|
||||||
@@ -1179,7 +979,7 @@ async def test_insufficient_report_rejects_inconsistent_context(defect, expected
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = _insufficient_report()
|
report = _insufficient_report()
|
||||||
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
node = {"id": "read", "title": "查询", "owner": "agent", "status": "skipped", "detail": "关键数据不足"}
|
||||||
@@ -1229,7 +1029,7 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
"replay_id": str(uuid4()), "knowledge_cutoff": incident.isoformat(), "batch_sequence": 3,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.connection = object()
|
repository.connection = object()
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
monkeypatch.setattr(service, "_resolve_investigation_alerts", AsyncMock())
|
||||||
@@ -1308,8 +1108,10 @@ async def test_burst_waits_for_completion_then_scada_evidence_then_agent_report(
|
|||||||
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
assert repository.evidence[revised_id]["data_snapshot"]["data"]["verified_through"] == (returned + timedelta(minutes=30)).isoformat()
|
||||||
evidence_id = revised_id
|
evidence_id = revised_id
|
||||||
|
|
||||||
repository.task.update(status="running", agent_session_id="session-1")
|
await service.claim_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
repository.run = _active_run(repository.task, now)
|
request_id=repository.run["execution_request_id"], expected_revision=repository.run["revision"],
|
||||||
|
source_session_id="session-1",
|
||||||
|
))
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
with pytest.raises(DomainError) as rejected:
|
with pytest.raises(DomainError) as rejected:
|
||||||
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
@@ -1334,7 +1136,7 @@ async def test_plan_only_keeps_lease_until_same_generation_report_closes_run():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
planned = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1369,7 +1171,7 @@ async def test_interim_report_cannot_finish_even_with_a_completed_plan():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
plan = _waiting_plan()
|
plan = _waiting_plan()
|
||||||
plan["nodes"][0]["status"] = "completed"
|
plan["nodes"][0]["status"] = "completed"
|
||||||
@@ -1410,7 +1212,11 @@ async def test_report_is_not_blocked_by_a_historical_plan():
|
|||||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
))
|
))
|
||||||
assert result.task.latest_report.execution_generation == 2
|
assert result.task.latest_report.execution_generation == 2
|
||||||
assert result.task.status == "completed"
|
assert result.task.status == "running"
|
||||||
|
completed = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
|
||||||
|
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
|
||||||
|
))
|
||||||
|
assert completed.status == "completed"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -1418,7 +1224,7 @@ async def test_report_and_action_without_plan_finish_with_real_approval_pending(
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
request_id=uuid4(), source_session_id="session-1", report=_current_report(),
|
||||||
@@ -1441,7 +1247,7 @@ async def test_insufficient_data_without_graph_or_node_ends_the_round():
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = _insufficient_report()
|
report = _insufficient_report()
|
||||||
report["data_gaps"][0].pop("node_id")
|
report["data_gaps"][0].pop("node_id")
|
||||||
@@ -1463,7 +1269,7 @@ async def test_report_history_and_notifications_keep_selected_and_current_refere
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
for conclusion in ["第一次判断", "更新后的判断"]:
|
for conclusion in ["第一次判断", "更新后的判断"]:
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1499,7 +1305,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
|
diagram = {"title": "调查过程", "mermaid": "flowchart LR\nA[核查] --> B[形成判断]",
|
||||||
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
|
"node_refs": [{"node_id": "B", "label": "形成判断", "status": "running"}]}
|
||||||
@@ -1526,7 +1332,7 @@ async def test_legacy_report_diagram_reads_only_matching_prepublication_progress
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
repository.reports.append({
|
repository.reports.append({
|
||||||
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
||||||
@@ -1559,7 +1365,7 @@ async def test_optional_diagram_cannot_forge_business_references(node_ref, code)
|
|||||||
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 9, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
with pytest.raises(DomainError) as invalid:
|
with pytest.raises(DomainError) as invalid:
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
@@ -1575,7 +1381,7 @@ async def test_action_proposal_keeps_running_lease_until_plan_and_report_close_r
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||||
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
request_id=uuid4(), source_session_id="session-1", plan=_waiting_plan(),
|
||||||
@@ -1624,7 +1430,7 @@ async def test_rejected_action_requeues_task_in_a_new_execution_generation():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now).copy())
|
repository = TaskRepository(task, _active_run(task, now).copy())
|
||||||
service = OperationalTaskService(
|
service = OperationalTaskService(
|
||||||
repository,
|
repository,
|
||||||
context,
|
context,
|
||||||
@@ -1673,7 +1479,7 @@ async def test_action_decision_rejects_a_stale_expected_version():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(
|
service = OperationalTaskService(
|
||||||
repository,
|
repository,
|
||||||
context,
|
context,
|
||||||
@@ -1737,7 +1543,7 @@ async def test_task_access_grant_is_project_scoped_reviewable_and_requeues():
|
|||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
initial_revision = task["revision"]
|
initial_revision = task["revision"]
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
agent = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
|
request = await agent.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1771,7 +1577,7 @@ async def test_task_access_request_rejects_a_second_pending_request():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1794,7 +1600,7 @@ async def test_task_access_request_fails_closed_before_project_migration():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.access_requests_available = AsyncMock(return_value=False)
|
repository.access_requests_available = AsyncMock(return_value=False)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
@@ -1813,7 +1619,7 @@ async def test_completed_gate_requires_access_request_decision():
|
|||||||
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 24, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
request = await service.request_access(task["task_id"], TaskAccessRequestCreate(
|
||||||
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
request_id=uuid4(), source_session_id="session-1", node_id="read-scada",
|
||||||
@@ -1850,7 +1656,7 @@ async def test_work_order_draft_edit_creates_new_review_version(replay):
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
service = OperationalTaskService(repository, context, {OPERATIONAL_ACTION_WORK_ORDER_APPROVE}, clock=lambda: now)
|
||||||
_seed_concluded_report(repository, now)
|
_seed_concluded_report(repository, now)
|
||||||
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
action = await service.propose_action(task["task_id"], ActionProposalCommand(
|
||||||
@@ -1881,7 +1687,7 @@ async def test_one_click_work_order_confirmation_replays_without_duplicate_dispa
|
|||||||
task["source"]["replay_context"] = {
|
task["source"]["replay_context"] = {
|
||||||
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
"replay_id": str(uuid4()), "knowledge_cutoff": now.isoformat(), "batch_sequence": 1,
|
||||||
}
|
}
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, {
|
service = OperationalTaskService(repository, context, {
|
||||||
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
OPERATIONAL_ACTION_WORK_ORDER_APPROVE, OPERATIONAL_ACTION_WORK_ORDER_EXECUTE,
|
||||||
}, clock=lambda: now)
|
}, clock=lambda: now)
|
||||||
@@ -2125,7 +1931,7 @@ async def test_expired_owned_lease_is_rejected_with_stable_code():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
task = _task_document(context, now, lease=now - timedelta(seconds=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
repository.run["lease_expires_at"] = now - timedelta(seconds=1)
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
|
|
||||||
@@ -2253,7 +2059,7 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
|
|||||||
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 20, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
report = {**rich_report(), "stage": "final"}
|
report = {**rich_report(), "stage": "final"}
|
||||||
command = AgentArtifactsCommand(
|
command = AgentArtifactsCommand(
|
||||||
@@ -2290,7 +2096,7 @@ async def test_presentation_history_is_task_scoped_paginated_and_readable_after_
|
|||||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now)
|
task = _task_document(context, now)
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.run["status"] = "succeeded"
|
repository.run["status"] = "succeeded"
|
||||||
for index in range(4):
|
for index in range(4):
|
||||||
await repository.append_event(
|
await repository.append_event(
|
||||||
@@ -2322,7 +2128,7 @@ async def test_final_report_rejected_while_original_run_has_pending_calculations
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
pending_job = str(uuid4())
|
pending_job = str(uuid4())
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
@@ -2346,7 +2152,7 @@ async def test_completion_rejected_until_calculations_reach_terminal_state():
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
|
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
|
||||||
@@ -2380,7 +2186,7 @@ async def test_interim_report_can_explain_progress_while_calculation_is_pending(
|
|||||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repository = RunTaskRepository(task, _active_run(task, now))
|
repository = TaskRepository(task, _active_run(task, now))
|
||||||
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
|
||||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||||
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
|
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from app.api.v1.endpoints import scada as scada_endpoint
|
from app.api.v1.endpoints import scada as scada_endpoint
|
||||||
from app.services import timeseries_analysis as composite_queries
|
from app.services import timeseries_history as history
|
||||||
|
from app.domain.schemas.timeseries_history import ElementHistoryQuery
|
||||||
|
|
||||||
|
|
||||||
PROJECT_SCADA = {
|
PROJECT_SCADA = {
|
||||||
@@ -26,107 +26,22 @@ START_TIME = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
|||||||
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
END_TIME = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _patch_project_scadas(monkeypatch):
|
def test_history_uses_current_project_metadata(monkeypatch):
|
||||||
monkeypatch.setattr(
|
postgres = object()
|
||||||
composite_queries.ScadaInfoRepository,
|
mappings = AsyncMock(return_value=[PROJECT_SCADA.copy()])
|
||||||
"get_scadas",
|
monkeypatch.setattr(history.ScadaInfoRepository, "get_scadas_for_elements", mappings)
|
||||||
AsyncMock(return_value=[PROJECT_SCADA.copy()]),
|
monkeypatch.setattr(history.ScadaRepository, "get_scada_by_ids_time_range", AsyncMock(return_value=[{
|
||||||
)
|
"time": START_TIME, "device_id": PROJECT_SCADA["device_id"],
|
||||||
|
"monitored_value": 26.5, "cleaned_value": 26.0,
|
||||||
|
}]))
|
||||||
def test_realtime_scada_simulation_uses_current_project_metadata(monkeypatch):
|
result = asyncio.run(history.TimeseriesHistoryService.query(object(), postgres, ElementHistoryQuery(
|
||||||
_patch_project_scadas(monkeypatch)
|
start_time=START_TIME, end_time=END_TIME,
|
||||||
query_mock = AsyncMock(
|
elements=[{"element_id": "J1", "element_type": "junction", "device_ids": [PROJECT_SCADA["device_id"]]}],
|
||||||
return_value={"J1": [{"time": START_TIME, "value": 26.5}]}
|
)))
|
||||||
)
|
assert mappings.await_args.args == (postgres, ["J1"], [])
|
||||||
monkeypatch.setattr(
|
assert len(result.series) == 2
|
||||||
composite_queries.RealtimeRepository,
|
assert all(item.device_id == PROJECT_SCADA["device_id"] and item.element_id == "J1" for item in result.series)
|
||||||
"get_node_fields_by_ids_time_range",
|
assert [item.points[0].value for item in result.series] == [26.5, 26.0]
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.RealtimeRepository,
|
|
||||||
"get_link_fields_by_ids_time_range",
|
|
||||||
AsyncMock(return_value={}),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 1
|
|
||||||
assert query_mock.await_args.args[1:] == (
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
["J1"],
|
|
||||||
"pressure",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_analysis_scada_simulation_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
async def query_series(_conn, _run_id, element_type, element_ids, *_args):
|
|
||||||
if element_type == "node":
|
|
||||||
return {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
query_mock = AsyncMock(side_effect=query_series)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.AnalysisResultsRepository,
|
|
||||||
"get_series_by_ids",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
[PROJECT_SCADA["device_id"]],
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
uuid4(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[PROJECT_SCADA["device_id"]][0]["scada_id"] == PROJECT_SCADA["device_id"]
|
|
||||||
assert query_mock.await_count == 2
|
|
||||||
node_call = next(
|
|
||||||
call for call in query_mock.await_args_list if call.args[2] == "node"
|
|
||||||
)
|
|
||||||
assert node_call.args[3:] == (["J1"], START_TIME, END_TIME, "pressure")
|
|
||||||
|
|
||||||
|
|
||||||
def test_element_scada_query_uses_current_project_metadata(monkeypatch):
|
|
||||||
_patch_project_scadas(monkeypatch)
|
|
||||||
query_mock = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
PROJECT_SCADA["device_id"]: [{"time": START_TIME, "value": 26.5}]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
composite_queries.ScadaRepository,
|
|
||||||
"get_scada_field_by_id_time_range",
|
|
||||||
query_mock,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
|
||||||
composite_queries.TimeseriesAnalysisService.get_element_associated_scada_data(
|
|
||||||
object(),
|
|
||||||
object(),
|
|
||||||
"J1",
|
|
||||||
START_TIME,
|
|
||||||
END_TIME,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"J1": [{"time": START_TIME, "value": 26.5}]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
def test_scada_info_endpoint_uses_current_project_connection(monkeypatch):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailure
|
|||||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
||||||
from app.services.operational_tasks import OperationalTaskService
|
from app.services.operational_tasks import OperationalTaskService
|
||||||
from app.services.task_questions import create_question, answer_question
|
from app.services.task_questions import create_question, answer_question
|
||||||
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
from tests.support.operational_task_repository import TaskRepository, _context, _task_document, _active_run
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,28 +19,11 @@ def anyio_backend():
|
|||||||
return "asyncio"
|
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():
|
def setup():
|
||||||
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||||
context = _context()
|
context = _context()
|
||||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||||
repo = QuestionRepository(task, _active_run(task, now))
|
repo = TaskRepository(task, _active_run(task, now))
|
||||||
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: 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"],
|
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": "目前未知"}],
|
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
||||||
@@ -162,6 +146,34 @@ async def test_real_execution_failure_cancels_question_instead_of_disguising_fai
|
|||||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("timeout_count", [1, 3])
|
||||||
|
async def test_lease_timeout_cancels_old_question_and_unblocks_the_next_generation(timeout_count):
|
||||||
|
service, repo, task_id, command = setup()
|
||||||
|
question = await create_question(service, task_id, command)
|
||||||
|
service.clock = lambda: question.created_at + timedelta(minutes=2)
|
||||||
|
repo.claim_expired_runs = AsyncMock(return_value=[deepcopy(repo.run)])
|
||||||
|
repo.consecutive_timed_out_runs = AsyncMock(return_value=timeout_count)
|
||||||
|
|
||||||
|
await service.recover_expired_leases(1)
|
||||||
|
|
||||||
|
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||||
|
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||||
|
with pytest.raises(DomainError):
|
||||||
|
await answer_question(service, task_id, question.question_request_id, reply())
|
||||||
|
if timeout_count == 3:
|
||||||
|
assert repo.task["lifecycle_status"] == "failed"
|
||||||
|
return
|
||||||
|
|
||||||
|
await service.claim_run(task_id, UUID(repo.run["run_id"]), AgentRunCommand(
|
||||||
|
request_id=UUID(repo.run["execution_request_id"]), expected_revision=1,
|
||||||
|
source_session_id="session-2"))
|
||||||
|
next_question = await create_question(service, task_id, command.model_copy(update={
|
||||||
|
"request_id": uuid4(), "run_id": UUID(repo.run["run_id"]), "source_session_id": "session-2"}))
|
||||||
|
assert next_question.status == "pending"
|
||||||
|
assert next_question.execution_generation == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multiple_selection_and_optional_question_are_preserved():
|
async def test_multiple_selection_and_optional_question_are_preserved():
|
||||||
service, repo, task_id, command = setup()
|
service, repo, task_id, command = setup()
|
||||||
|
|||||||
Reference in New Issue
Block a user