Compare commits

...
Author SHA1 Message Date
jiang e47c61c121 refactor: reuse routing and remove unused analysis helpers
Generic Container CI/CD / test-build-publish (push) Successful in 4m3s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m3s
2026-11-09 18:49:32 +08:00
jiang 6a4478bfe4 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.
2026-11-09 17:55:00 +08:00
jiang 5197a93163 fix(skills): close recovered incidents without optional evidence gates
Generic Container CI/CD / test-build-publish (push) Successful in 3m57s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m57s
A completed field order and passed Server recovery evidence support the final closure report. Treat absent photos, valve records and field measurements as limits on confirmed details rather than new closure requirements.

Clarify already completed recovery state and keep any separate source-system administrative process conditional on its recorded requirements. Bump SCADA and burst handling skill versions.
2026-11-08 04:31:01 +08:00
jiang 549019507e fix(tasks): isolate peripheral feedback writes by execution scope
Generic Container CI/CD / test-build-publish (push) Successful in 4m15s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m17s
An old development synchronizer could overwrite a production recovery timestamp in a shared project database even after production deployment. Check task scope before feedback or source-withdrawal writes and before idempotency replay.

Cover cross-scope rejection, same-scope success and replay, and preserve actual field feedback time when later order metadata changes.
2026-11-08 04:07:07 +08:00
jiang acc8f8f7cb fix(skills): keep comparison guidance within inline response budget
Generic Container CI/CD / test-build-publish (push) Successful in 4m11s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m11s
2026-11-08 03:10:22 +08:00
jiang b8359ba83c fix(charts): validate same-time comparison publications
Generic Container CI/CD / test-build-publish (push) Failing after 2m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m10s
Prompt-only projection rules allowed historical samples to be appended to the current axis. Enforce typed comparison semantics at presentation and report publication, preserving historical read contracts and returning actionable validation errors.
2026-11-08 03:04:14 +08:00
jiang c0dea2aacf fix(contracts): export required task execution scope 2026-11-08 02:00:29 +08:00
jiang 33df25f22b fix(tasks): isolate worker execution scopes
Persist a required Server-owned execution scope and enforce it for listing, claiming, lease recovery and SCADA verification. Backfill existing tasks explicitly through migration 034 so local development workers no longer claim production tasks from the shared database.
2026-11-08 01:54:48 +08:00
38 changed files with 1080 additions and 955 deletions
+2
View File
@@ -6,6 +6,8 @@
# 基础运行 # 基础运行
PROJECT_NAME="TJWater Server" PROJECT_NAME="TJWater Server"
ENVIRONMENT="production" ENVIRONMENT="production"
# 任务领取范围;留空时沿用 ENVIRONMENT。历史任务需先执行 034 迁移。
OPERATIONAL_TASK_EXECUTION_SCOPE=""
API_V1_STR="/api/v1" API_V1_STR="/api/v1"
NETWORK_NAME="tjwater" NETWORK_NAME="tjwater"
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from psycopg import AsyncConnection from psycopg import AsyncConnection
from app.auth.metadata_dependencies import get_metadata_repository from app.auth.metadata_dependencies import get_metadata_repository
from app.core.config import settings
from app.auth.permissions import ( from app.auth.permissions import (
OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_EXECUTE,
OPERATIONAL_TASK_EXTERNAL_SYNC, OPERATIONAL_TASK_EXTERNAL_SYNC,
@@ -122,6 +123,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
OperationalTaskRepository(conn, ctx.project_id), OperationalTaskRepository(conn, ctx.project_id),
ctx, ctx,
_task_permissions(ctx), _task_permissions(ctx),
execution_scope=settings.operational_task_execution_scope,
) )
+1
View File
@@ -36,6 +36,7 @@ def _service(ctx: ProjectContext, conn: AsyncConnection) -> OperationalTaskServi
OperationalTaskRepository(conn, ctx.project_id), OperationalTaskRepository(conn, ctx.project_id),
ctx, ctx,
permissions_for_context(ctx), permissions_for_context(ctx),
execution_scope=settings.operational_task_execution_scope,
) )
+6
View File
@@ -8,6 +8,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
PROJECT_NAME: str = "TJWater Server" PROJECT_NAME: str = "TJWater Server"
ENVIRONMENT: str = "production" ENVIRONMENT: str = "production"
OPERATIONAL_TASK_EXECUTION_SCOPE: str = ""
@property
def operational_task_execution_scope(self) -> str:
return self.OPERATIONAL_TASK_EXECUTION_SCOPE.strip() or self.ENVIRONMENT
API_V1_STR: str = "/api/v1" API_V1_STR: str = "/api/v1"
NETWORK_NAME: str = "default_network" NETWORK_NAME: str = "default_network"
# 敏感配置加密密钥 (Fernet) # 敏感配置加密密钥 (Fernet)
+2 -1
View File
@@ -574,6 +574,7 @@ class OperationalTaskRun(StrictModel):
class OperationalTaskSummary(StrictModel): class OperationalTaskSummary(StrictModel):
execution_scope: NonEmptyStr
project_id: UUID project_id: UUID
task_id: UUID task_id: UUID
task_type: TaskType task_type: TaskType
@@ -721,7 +722,7 @@ class TaskSafeChartSeries(StrictModel):
class TaskSafeChart(StrictModel): class TaskSafeChart(StrictModel):
title: Annotated[str, Field(max_length=200)] title: Annotated[str, Field(max_length=200)]
chart_type: Literal["line", "bar"] chart_type: Literal["line", "bar", "time_comparison"]
x_axis_name: Annotated[str, Field(max_length=128)] | None = None x_axis_name: Annotated[str, Field(max_length=128)] | None = None
y_axis_name: Annotated[str, Field(max_length=128)] | None = None y_axis_name: Annotated[str, Field(max_length=128)] | None = None
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)] x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
+9 -4
View File
@@ -264,6 +264,7 @@ class OperationalTaskRepository:
async def list_runs( async def list_runs(
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None, self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
task_type: str | None = None, task_created_from: datetime | None = None, task_type: str | None = None, task_created_from: datetime | None = None,
execution_scope: str = "production",
) -> list[dict]: ) -> list[dict]:
result = await self.connection.execute( result = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision, """SELECT run_id,task_id,generation,execution_request_id,status,revision,
@@ -282,18 +283,20 @@ class OperationalTaskRepository:
AND t.task_id=operational_task_runs.task_id AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text) AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
AND t.document->>'execution_scope'=%s
) )
ORDER BY created_at,run_id LIMIT %s""", ORDER BY created_at,run_id LIMIT %s""",
( (
self.project_id, status, status, cursor_created_at, self.project_id, status, status, cursor_created_at,
cursor_created_at, cursor_run_id, cursor_created_at, cursor_run_id,
task_type, task_type, task_created_from, task_created_from, limit, task_type, task_type, task_created_from, task_created_from, execution_scope, limit,
), ),
) )
return [dict(row) for row in await result.fetchall()] return [dict(row) for row in await result.fetchall()]
async def claim_expired_runs( async def claim_expired_runs(
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None, self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
execution_scope: str = "production",
) -> list[dict]: ) -> list[dict]:
cursor = await self.connection.execute( cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision, """SELECT run_id,task_id,generation,execution_request_id,status,revision,
@@ -310,9 +313,10 @@ class OperationalTaskRepository:
AND t.task_id=operational_task_runs.task_id AND t.task_id=operational_task_runs.task_id
AND (%s::text IS NULL OR t.task_type=%s::text) AND (%s::text IS NULL OR t.task_type=%s::text)
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz) AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
AND t.document->>'execution_scope'=%s
) )
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""", ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit), (self.project_id, task_type, task_type, task_created_from, task_created_from, execution_scope, limit),
) )
return [dict(row) for row in await cursor.fetchall()] return [dict(row) for row in await cursor.fetchall()]
@@ -711,15 +715,16 @@ class OperationalTaskRepository:
row = await cursor.fetchone() row = await cursor.fetchone()
return dict(row) if row else None return dict(row) if row else None
async def recovery_candidates(self, limit: int, created_from=None) -> list[dict]: async def recovery_candidates(self, limit: int, created_from=None, execution_scope: str = "production") -> list[dict]:
cursor = await self.connection.execute( cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks """SELECT document FROM workbench.operational_tasks
WHERE project_id=%s AND task_type='event_investigation' WHERE project_id=%s AND task_type='event_investigation'
AND status NOT IN ('cancelled','failed') AND status NOT IN ('cancelled','failed')
AND document#>>'{handling_context,phase}'='waiting_scada' AND document#>>'{handling_context,phase}'='waiting_scada'
AND document->>'execution_scope'=%s
AND (%s::timestamptz IS NULL OR created_at>=%s) AND (%s::timestamptz IS NULL OR created_at>=%s)
ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""", ORDER BY COALESCE((document#>>'{handling_context,last_checked_at}')::timestamptz,created_at),task_id LIMIT %s""",
(self.project_id, created_from, created_from, limit), (self.project_id, execution_scope, created_from, created_from, limit),
) )
return [row["document"] for row in await cursor.fetchall()] return [row["document"] for row in await cursor.fetchall()]
+2
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from app.core.config import settings
from app.auth.permissions import permissions_for_context from app.auth.permissions import permissions_for_context
from app.infra.db.postgresql import read_queries as reads from app.infra.db.postgresql import read_queries as reads
from app.infra.db.postgresql.spatial_queries import nearby_elements from app.infra.db.postgresql.spatial_queries import nearby_elements
@@ -52,6 +53,7 @@ def task_service(scope: QueryScope) -> OperationalTaskService:
OperationalTaskRepository(scope.connection, scope.project.project_id), OperationalTaskRepository(scope.connection, scope.project.project_id),
scope.project, scope.project,
set(permissions_for_context(scope.project)), set(permissions_for_context(scope.project)),
execution_scope=settings.operational_task_execution_scope,
) )
+12 -2
View File
@@ -3,6 +3,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from psycopg import AsyncConnection from psycopg import AsyncConnection
from app.auth.project_dependencies import ProjectContext from app.auth.project_dependencies import ProjectContext
from app.infra.db.project_routing import ActiveProjectRouting from app.infra.db.project_routing import ActiveProjectRouting
@@ -18,16 +20,24 @@ from app.auth.project_dependencies import (
get_project_timescale_connection, get_project_timescale_connection,
) )
from app.infra.db.metadb.database import get_metadata_session from app.infra.db.metadb.database import get_metadata_session
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
from app.infra.db.project_routing import activate_project_routing from app.infra.db.project_routing import activate_project_routing
class QueryMetadataRepository(MetadataRepository): class QueryMetadataRepository(MetadataRepository):
async def get_project_db_routing(self, project_id, role): def __init__(self, session: AsyncSession):
super().__init__(session)
self._routing: dict[tuple[UUID, str], ProjectDbRouting | None] = {}
async def get_project_db_routing(self, project_id: UUID, role: str) -> ProjectDbRouting | None:
key = (project_id, role)
if key in self._routing:
return self._routing[key]
routing = await super().get_project_db_routing(project_id, role) routing = await super().get_project_db_routing(project_id, role)
# Existing connection generators obtain routing before awaiting a pool. # Existing connection generators obtain routing before awaiting a pool.
# End that implicit SELECT transaction here, before the network wait. # End that implicit SELECT transaction here, before the network wait.
await self.session.commit() await self.session.commit()
self._routing[key] = routing
return routing return routing
-19
View File
@@ -405,25 +405,6 @@ def _store_burst_scheme(
) )
def _validate_scada_windows(
*,
scada_burst_start: datetime | str | None,
scada_burst_end: datetime | str | None,
) -> tuple[datetime, datetime]:
values = [scada_burst_start, scada_burst_end]
if any(v is None for v in values):
raise ValueError(
"使用后端 SCADA 查询时,必须同时提供 scada_burst_start/scada_burst_end。"
)
burst_start_dt = _to_datetime(scada_burst_start)
burst_end_dt = _to_datetime(scada_burst_end)
if burst_start_dt >= burst_end_dt:
raise ValueError(
"爆管时段 SCADA 时间窗非法:scada_burst_start 必须早于 scada_burst_end。"
)
return burst_start_dt, burst_end_dt
def _validate_time_window( def _validate_time_window(
*, *,
start_value: datetime | str | None, start_value: datetime | str | None,
-68
View File
@@ -188,74 +188,6 @@ def _build_area_map_by_topology(
return area_map, areas, node_coords return area_map, areas, node_coords
def _build_area_node_map(area_map: dict[str, str]) -> dict[str, list[str]]:
area_node_map: dict[str, list[str]] = {}
for node_id, area_id in area_map.items():
area_node_map.setdefault(area_id, []).append(node_id)
for area_id in list(area_node_map.keys()):
area_node_map[area_id] = sorted(area_node_map[area_id])
return area_node_map
def _build_node_visual_payload(
area_map: dict[str, str],
node_coords: dict[str, dict[str, float]],
rows: list[dict[str, Any]],
) -> dict[str, Any]:
area_leakage_map = _build_area_leakage_map(rows)
max_leakage = max(area_leakage_map.values(), default=0.0)
features: list[dict[str, Any]] = []
for node_id, area_id in area_map.items():
coord = node_coords.get(node_id)
if not coord:
continue
leakage_flow = float(area_leakage_map.get(area_id, 0.0))
leakage_level = _classify_leakage_level(leakage_flow, max_leakage)
features.append(
{
"type": "Feature",
"properties": {
"node_id": node_id,
"area_id": area_id,
"leakage_flow_m3_per_s": leakage_flow,
"leakage_level": leakage_level,
},
"geometry": {
"type": "Point",
"coordinates": [float(coord["x"]), float(coord["y"])],
},
}
)
return {"type": "FeatureCollection", "features": features}
def _build_area_leakage_map(rows: list[dict[str, Any]]) -> dict[str, float]:
area_leakage_map: dict[str, float] = {}
for row in rows:
area_id = str(row.get("Area", "")).strip()
if not area_id:
continue
area_leakage_map[area_id] = float(row.get("LeakageFlow_m3_per_s", 0.0))
return area_leakage_map
def _classify_leakage_level(leakage_flow: float, max_leakage: float) -> str:
if max_leakage <= 0:
return "normal"
ratio = leakage_flow / max_leakage
if ratio >= 0.75:
return "high"
if ratio >= 0.4:
return "medium"
if ratio > 0:
return "low"
return "normal"
def _build_drawing_payload(node_visual_payload: dict[str, Any]) -> dict[str, Any]:
return node_visual_payload
def _build_observed_pressure_from_scada( def _build_observed_pressure_from_scada(
network: str, network: str,
sensor_nodes: list[str], sensor_nodes: list[str],
+3 -1
View File
@@ -131,9 +131,11 @@ async def reconcile_recoveries(service: OperationalTaskService, timescale: Async
from app.infra.db.timescaledb.repositories.scada import ScadaRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository
async with service.repo.transaction(): async with service.repo.transaction():
candidates = await service.repo.recovery_candidates(limit, created_from) candidates = await service.repo.recovery_candidates(limit, created_from, service.execution_scope)
resumed = 0 resumed = 0
for task in candidates: for task in candidates:
if task.get("execution_scope") != service.execution_scope:
continue
context = task["handling_context"] context = task["handling_context"]
returned_at = datetime.fromisoformat(context["returned_at"]) returned_at = datetime.fromisoformat(context["returned_at"])
incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"]) incident_at = datetime.fromisoformat(task["trigger"]["occurred_at"])
+89 -120
View File
@@ -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 (
@@ -74,6 +74,11 @@ from app.services.content_digest import digest
from app.services.operational_presentation import resolve_overlay from app.services.operational_presentation import resolve_overlay
from app.services.operational_recovery import accept_completion, track_work_order from app.services.operational_recovery import accept_completion, track_work_order
from app.services.operational_scada_coverage import require_complete_scada_evidence from app.services.operational_scada_coverage import require_complete_scada_evidence
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)
@@ -88,11 +93,13 @@ 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"):
self.repo = repository self.repo = repository
self.context = context self.context = context
self.permissions = frozenset(permissions) self.permissions = frozenset(permissions)
self.clock = clock or (lambda: datetime.now(timezone.utc)) self.clock = clock or (lambda: datetime.now(timezone.utc))
self.execution_scope = execution_scope
def _now(self) -> datetime: def _now(self) -> datetime:
now = self.clock() now = self.clock()
@@ -111,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
@@ -195,6 +174,7 @@ class OperationalTaskService:
now = self._now() now = self._now()
task_id = uuid4() task_id = uuid4()
task = OperationalTaskSummary( task = OperationalTaskSummary(
execution_scope=self.execution_scope,
project_id=self.context.project_id, project_id=self.context.project_id,
task_id=task_id, task_id=task_id,
task_type=command.task_type, task_type=command.task_type,
@@ -282,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"):
@@ -393,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,
@@ -489,6 +467,7 @@ class OperationalTaskService:
task_type: str | None = None, task_created_from: datetime | None = None, task_type: str | None = None, task_created_from: datetime | None = None,
) -> tuple[list[OperationalTaskRun], str | None]: ) -> tuple[list[OperationalTaskRun], str | None]:
rows = await self.repo.list_runs( rows = await self.repo.list_runs(
execution_scope=self.execution_scope,
status=status, status=status,
limit=limit + 1, limit=limit + 1,
cursor_created_at=cursor_created_at, cursor_created_at=cursor_created_at,
@@ -510,6 +489,9 @@ class OperationalTaskService:
scope = f"run:{run_id}:claim" scope = f"run:{run_id}:claim"
async with self.repo.transaction(): async with self.repo.transaction():
await self.repo.require_execution_storage() await self.repo.require_execution_storage()
task = await self._task(task_id)
require(task.get("execution_scope") == self.execution_scope,
"EXECUTION_SCOPE_MISMATCH", "当前环境不能领取其他执行范围的任务", status=403)
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 OperationalTaskRun.model_validate(replay["run"]) return OperationalTaskRun.model_validate(replay["run"])
@@ -608,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
@@ -759,11 +740,12 @@ class OperationalTaskService:
async with self.repo.transaction(): async with self.repo.transaction():
for run in await self.repo.claim_expired_runs( for run in await self.repo.claim_expired_runs(
limit, task_type=task_type, task_created_from=task_created_from, limit, task_type=task_type, task_created_from=task_created_from,
execution_scope=self.execution_scope,
): ):
# 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"]
@@ -775,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))
@@ -829,6 +812,11 @@ class OperationalTaskService:
"Only a running run accepts Agent artifacts", status=409) "Only a running run accepts Agent artifacts", status=409)
now = self._now() now = self._now()
self._require_active_run_lease(active_run, now) self._require_active_run_lease(active_run, now)
if command.report:
for item in command.report.ui_blocks:
for element in item.block.spec.elements.values():
if element.type == "Chart":
validate_time_comparison_chart(element.props)
if command.report and ( if command.report and (
command.report.stage == "final" or command.report.outcome == "insufficient_data" command.report.stage == "final" or command.report.outcome == "insufficient_data"
): ):
@@ -1003,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:
@@ -1087,6 +1059,7 @@ class OperationalTaskService:
self._require_active_run_lease(run, now) self._require_active_run_lease(run, now)
payload = _json(command.command) payload = _json(command.command)
if command.command.action == "open_chart": if command.command.action == "open_chart":
validate_time_comparison_chart(payload["chart"])
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))} known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
self._require_evidence_refs(command.command.evidence_refs, known) self._require_evidence_refs(command.command.evidence_refs, known)
elif command.command.action == "set_overlay": elif command.command.action == "set_overlay":
@@ -1498,11 +1471,13 @@ class OperationalTaskService:
body = _json(command) body = _json(command)
scope = f"task:{task_id}:external-feedback" scope = f"task:{task_id}:external-feedback"
async with self.repo.transaction(): async with self.repo.transaction():
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
require(task.get("execution_scope") == self.execution_scope,
"EXECUTION_SCOPE_MISMATCH", "当前环境不能同步其他执行范围的任务", status=403)
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 ExternalFeedbackResult(task=await self.detail(task_id), **replay) return ExternalFeedbackResult(task=await self.detail(task_id), **replay)
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
if task["task_type"] == "event_investigation": if task["task_type"] == "event_investigation":
self._require_actionable_task(task) self._require_actionable_task(task)
own_source = task["source"].get("external_id") own_source = task["source"].get("external_id")
@@ -1659,11 +1634,13 @@ class OperationalTaskService:
body = _json(command) body = _json(command)
scope = f"task:{task_id}:withdraw-external-source" scope = f"task:{task_id}:withdraw-external-source"
async with self.repo.transaction(): async with self.repo.transaction():
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
require(task.get("execution_scope") == self.execution_scope,
"EXECUTION_SCOPE_MISMATCH", "当前环境不能同步其他执行范围的任务", status=403)
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 await self.detail(task_id) return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
task = await self._task(task_id)
source = task["source"] source = task["source"]
require( require(
task["task_type"] == "external_work_order" task["task_type"] == "external_work_order"
@@ -1969,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,
@@ -2005,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 = (
@@ -2139,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"}},
@@ -2345,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(
@@ -2390,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,
@@ -2428,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,
@@ -2516,6 +2484,7 @@ class OperationalTaskService:
due_at: datetime, now: datetime, due_at: datetime, now: datetime,
) -> OperationalTaskSummary: ) -> OperationalTaskSummary:
return OperationalTaskSummary( return OperationalTaskSummary(
execution_scope=self.execution_scope,
project_id=self.context.project_id, task_id=task_id, project_id=self.context.project_id, task_id=task_id,
task_type="scheduled_flow", workflow=schedule.schedule_key, task_type="scheduled_flow", workflow=schedule.schedule_key,
lifecycle_status="open", status="queued", priority=schedule.priority, revision=1, lifecycle_status="open", status="queued", priority=schedule.priority, revision=1,
+5 -1
View File
@@ -6,6 +6,7 @@ import json
from uuid import NAMESPACE_URL, uuid4, uuid5 from uuid import NAMESPACE_URL, uuid4, uuid5
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings
from app.auth.permissions import permissions_for_context from app.auth.permissions import permissions_for_context
from app.domain.schemas.project_settings import ScadaMonitoringSettings from app.domain.schemas.project_settings import ScadaMonitoringSettings
@@ -439,7 +440,8 @@ class ScadaReplayService:
alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id), alerts = TaskAlertService(TaskAlertRepository(self.repo.connection, self.context.project_id),
self.context, internal_producer_id=producer) self.context, internal_producer_id=producer)
tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id), tasks = OperationalTaskService(OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, permissions_for_context(self.context)) self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope)
for event in changes: for event in changes:
# Period diagnosis owns measurement notifications during manual steps. # Period diagnosis owns measurement notifications during manual steps.
# Retain rule events as evidence; quality alerts remain independent. # Retain rule events as evidence; quality alerts remain independent.
@@ -503,6 +505,7 @@ class ScadaReplayService:
tasks = OperationalTaskService( tasks = OperationalTaskService(
OperationalTaskRepository(self.repo.connection, self.context.project_id), OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, permissions_for_context(self.context), self.context, permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope,
) )
await tasks.advance_replay_investigation(UUID(incident["task_id"]), { await tasks.advance_replay_investigation(UUID(incident["task_id"]), {
"replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"], "replay_id": document["replay_id"], "knowledge_cutoff": monitoring["knowledge_cutoff"],
@@ -520,6 +523,7 @@ class ScadaReplayService:
OperationalTaskRepository(self.repo.connection, self.context.project_id), OperationalTaskRepository(self.repo.connection, self.context.project_id),
self.context, self.context,
permissions_for_context(self.context), permissions_for_context(self.context),
execution_scope=settings.operational_task_execution_scope,
) )
external = incident["external_id"] external = incident["external_id"]
pressure = monitoring["pressure"] pressure = monitoring["pressure"]
+1 -3
View File
@@ -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:
+78
View File
@@ -0,0 +1,78 @@
"""Publication-only validation for same-time-of-day line/scatter comparisons."""
from __future__ import annotations
import re
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import Any
from app.domain.domain_errors import require
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
DAY_SECONDS = 86400
def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
"""Validate new publications; persisted historical charts keep their read contract."""
chart_type = chart["chart_type"]
series = chart["series"]
kinds = {item.get("type") or chart_type for item in series}
if chart_type != "time_comparison":
require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID",
"曲线与历史散点复合图须使用 chart_type=time_comparison", status=422)
return
def check(condition: bool, message: str) -> None:
require(condition, "TIME_COMPARISON_INVALID", message, status=422)
kinds = {item.get("type") or "line" for item in series}
check(kinds == {"line", "scatter"},
"time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar")
labels = chart["x_data"]
check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label)
for label in labels),
"time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴")
check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度")
seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1)))
for label in labels]
offsets = [0]
for index in range(1, len(seconds)):
step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS
check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位")
offsets.append(offsets[-1] + step)
check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位")
current_bases: set[datetime] = set()
historical_bases: list[datetime] = []
for series_index, item in enumerate(series):
times = item.get("sample_times")
path = f"series[{series_index}]"
check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长")
bases: set[datetime] = set()
for index, (value, at) in enumerate(zip(item["data"], times, strict=True)):
check((value is None) == (at is None),
f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null")
if at is None:
continue
try:
local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE)
base = local - timedelta(seconds=offsets[index])
except OverflowError:
check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围")
label = labels[index]
expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M")
check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0),
f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐")
bases.add(base)
check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组")
if (item.get("type") or "line") == "line":
current_bases.update(bases)
else:
historical_bases.extend(bases)
check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
current = next(iter(current_bases))
for historical in historical_bases:
delta = (current - historical).total_seconds()
check(delta > 0 and delta % DAY_SECONDS == 0,
"历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
+2 -294
View File
@@ -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,
+34 -32
View File
@@ -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,
+2 -1
View File
@@ -310,7 +310,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"server": { "server": {
"file": "server-v1.openapi.json", "file": "server-v1.openapi.json",
"sha256": "939f38733e943f21cc2e3d9f344fa8122ca7a7b35cfebf96ff3f00f469026ceb" "sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
} }
} }
} }
+14 -1
View File
@@ -4683,6 +4683,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -4846,6 +4851,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -5660,6 +5666,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -5773,6 +5784,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -10211,7 +10223,8 @@
"chart_type": { "chart_type": {
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
], ],
"title": "Chart Type", "title": "Chart Type",
"type": "string" "type": "string"
+2 -1
View File
@@ -211,7 +211,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+3 -3
View File
@@ -14,8 +14,8 @@ flowchart TD
report --> approval[人工审批与现场执行] report --> approval[人工审批与现场执行]
approval --> feedback[接收实际处置与恢复反馈] approval --> feedback[接收实际处置与恢复反馈]
feedback --> verify[复核影响与结案条件] feedback --> verify[复核影响与结案条件]
verify --> close[来源系统确认事件闭环] verify --> close[Server 完成任务并解除告警]
verify -->|新增事实或遗留事项| asset verify -->|仍异常或有未完成处置| asset
``` ```
1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。 1. **接收核对**:保留来源工单ID、单调版本、发生时间、位置、描述、现场反馈和已有动作;后续轮次从新增事实接续。
@@ -23,7 +23,7 @@ flowchart TD
3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。 3. **隔离方案**:用 analyze_valve_isolation 分析关联资产,保留必关阀、可选阀、直接隔离与供水失源节点;已知失效阀门用于替代方案分析,现场可操作性列为执行条件。
4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。 4. **影响说明**:先展示拓扑范围,再按可用SCADA、水力结果和用户关联资料解释事件当前影响、方案预计影响和保障需求。
5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。 5. **报告与处置**:保存阶段报告后提出核验、通知、保障、抢修和恢复等动作草稿;关联现场任务采用 general 工单。审批、派发和现场完成分别核对回执。
6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。原事件由有权限人员在来源系统确认行政结案。 6. **反馈复核**:已派发的关联工单保持 `waiting_work_order`,全部收到有效 `completed` 反馈后进入 `waiting_scada`。以现场反馈业务时间为 T,取严格晚于 T 的最近一个 SCADA 采样时刻进行复核;正常范围使用事件前15天同采样相位的正常包络。数据未到或仍异常时保持等待,后台工作进程定期复核;通过后持久保存恢复证据并接续 Agent。Agent 最终报告必须引用该证据,运行完成后才能完成任务并解除对应告警。现场资料缺项仅限定未核实细节,不新增本任务的关闭门槛。来源系统另有明确行政结案流程时,按其实际状态另记。
SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。 SCADA作为事件影响的辅助证据,按目标测点和事件/对照窗口读取。具备水力输入且工具已授权时,使用 analyze_valve_closure 评估给定阀门方案;各步骤记录实际结果、适用范围或具体待补项。
+26
View File
@@ -190,3 +190,29 @@ OPERATIONAL_TASKS_STORAGE_TEST=1 conda run -n server python -m pytest tests/inte
``` ```
第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。 第二条需要当前配置的 PostgreSQL 可连接及测试 DDL 权限。测试在强制回滚事务中创建表结构和隔离项目数据,结束后不保留这些修改;不代替正式迁移。外部工单调用仍使用隔离 HTTP transport,不会操作实际工单系统。该测试覆盖持久化报告、反馈重新排队、旧会话拒绝、人工确认与丢响应回执核对,而非真实模型推理质量。
### 共用数据库时的执行范围
任务创建时,Server 将 `OPERATIONAL_TASK_EXECUTION_SCOPE`(留空则使用
`ENVIRONMENT`)写入 `workbench.operational_tasks.document.execution_scope`。
本地配置 `development`,线上配置 `production`;浏览器不提交此字段,
重试保留原范围。`execution_scope` 为必填字段,运行时代码不为缺失值兜底。
现有任务通过 `resources/sql/034_operational_task_execution_scope.sql` 一次性补为
`production`;已有明确范围的任务保持原值。此回填是历史任务的迁移策略,
不代表通过旧记录推断出了创建环境。
迁移需要与新版本切换协调执行:暂停任务写入,执行每个业务库的 SQL,
启动新版本并恢复任务写入。旧版本的严格模型不接受新增 JSON 字段,
新版本也拒绝未迁移数据;不能在旧版本仍服务任务请求时单独回填。
SQL 同时增加必填范围约束,不设置数据库默认值。回滚版本前需同步处理该约束
与 JSON 字段,不得直接切回旧镜像。
Worker 应连接所属环境的 `OPERATIONAL_WORKER_SERVER_URL` 和
`OPERATIONAL_WORKER_AGENT_URL`。Server 在运行列表、领取、租约回收与
SCADA recovery 候选查询上限制执行范围;直接领取其他范围返回
`403 EXECUTION_SCOPE_MISMATCH`。外部工单反馈与来源撤回也必须匹配任务的执行范围,
并在幂等重放前校验;开发环境同步器不能更新生产任务或重置其恢复监测起点。
这属于配置隔离,不能替代服务凭据授权:
不要让本地 Worker 通过线上 Server 领取任务。普通任务与历史记录仍共用存储。
修改范围配置需要重启相应 Server。暂停的本地 Worker 不会因代码更新恢复。
@@ -0,0 +1,26 @@
-- Run against each project business database during the coordinated Server cutover.
-- Historical tasks are explicitly assigned to production; retain existing scopes.
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';
UPDATE workbench.operational_tasks
SET document = jsonb_set(document, '{execution_scope}', '"production"'::jsonb)
WHERE NOT (document ? 'execution_scope');
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'workbench.operational_tasks'::regclass
AND conname = 'operational_tasks_execution_scope_required'
) THEN
ALTER TABLE workbench.operational_tasks
ADD CONSTRAINT operational_tasks_execution_scope_required CHECK (
(jsonb_typeof(document->'execution_scope') = 'string'
AND length(btrim(document->>'execution_scope')) > 0) IS TRUE
);
END IF;
END $$;
COMMIT;
@@ -2,7 +2,7 @@
name: burst-event-handling name: burst-event-handling
description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。 description: 根据爆管上报工单处理事件,完成资产关联、隔离方案、影响说明、处置建议与反馈复核。
metadata: metadata:
version: "1.1.1" version: "1.1.2"
--- ---
# 爆管事件处理 # 爆管事件处理
@@ -50,4 +50,4 @@ metadata:
waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。 waiting_scada 表示恢复供水和监测复核阶段,以 returned_at 为反馈时刻 T,读取严格晚于 T 的最近一个 SCADA 采样时刻作为复核来源,说明测点、实际采样时间、读数、单位和正常范围;不等待额外周期,不使用 T 之前、T 当刻或更晚的正常数据替代。后台按事件前15天同期正常包络核验该时刻。最近时刻缺测或异常时保留实际问题,不宣称复核成功。保存本轮阶段成果,等待后续数据接续,不反复派发同一工单。
verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,发布 final/concluded 报告并引用该证据。运行完成后 Server 才完成任务并解除关联告警。原事件在来源系统的行政结案仍由有权限人员确认。 verification_ready 时读取 verification_evidence_id 对应的证据与快照,核对工单反馈、T、最近采样时刻和测点,展示该时刻读数与正常范围;具备真实多时刻数据时再展示曲线,工单 completed 且恢复证据 status=passed 时发布 final/concluded 恢复闭环报告并引用该证据。Server 完成运行后关闭任务并解除关联告警,phase=completed 表示已闭环。照片、阀位或复测记录未提供时仅记为资料缺项,不增设关闭前置条件。来源系统若另有明确行政结案流程,按其实际状态另记,不推断为当前任务尚未闭环。
@@ -2,7 +2,7 @@
name: scada-anomaly name: scada-anomaly
description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。 description: 从 SCADA 指标异常开展数据核验、爆管诊断、候选定位与影响分析。
metadata: metadata:
version: "1.0.13" version: "1.0.14"
--- ---
# SCADA 数据异常调查 # SCADA 数据异常调查
@@ -33,7 +33,7 @@ metadata:
先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。 先查询并发布截至本轮knowledge_cutoff或异常时刻t的[t-3小时,t]当前趋势总览,覆盖本轮全部已标识SCADA设备,包括地图定位、等级标记及表格列出的目标设备。按指标和单位分图,同组各设备各占一条当前序列,系列名带device_id;每图最多8组,超过时分组发布,各组使用不同且稳定的chart_id。缺测填null,完全无读数的设备明确列为缺测并说明查询窗口,不能静默省略。压力和流量分别发布图表,包含正常与异常测点;某指标无设备或无有效读数时明确说明。每条当前序列保留三小时窗口内全部已取得采样点,不能用用于判断突变的[t-采样周期,t]相邻两点代替三小时趋势。15分钟数据正常应有13个时刻;缺测保留对应空槽并说明实际覆盖。收尾前逐项核对目标设备、查询覆盖、已发布图表和缺测清单,不用少量代表设备替代全量总览。图表“数据”视图沿用同一完整序列,报告按指标附测点、单位、窗口、有效点数和缺测说明表格。
定位输入具备后继续推进定位。压力、流量均需发布按设备分图的七天历史复合对照,作为全量当前趋势总览之外的必要展示;不能因历史散点占用序列额度而遗漏其他设备的当前趋势。复合图采用 [t-3小时,t] 当前折线和前7天每天 t-30分钟、t-15分钟、t 的21个参考位置。15分钟数据用13槽,当前一个line、历史七个scatter;历史散点只填末3槽,其他为null。sample_times保留真实带时区时刻,坐标轴注明单位。前7天按t转换后的当地日期逐日回退,不包含当天;按实际采样周期保留缺口,按需翻页并报告实际参考点数。基线未冻结、读取失败或缺测时明确说明并保留已有三小时趋势,不能静默退化为前一天或相邻两个时刻。 定位输入具备后继续定位。压力、流量按设备另发七天同期复合图,不占用或替代全量当前总览。chart_type=time_comparison:x_data是UTC+8的HH:mm或HH:mm:ss共享槽,不拼接历史日期;当前[t-3小时,t]用series.type=line,前7天每天t-30分钟、t-15分钟、t用scatter。15分钟数据13槽,历史七组仅末3槽有值。每组sample_times与data等长,保留真实带时区日期;有效值与对应槽的本地时刻一致,缺测值和时间均null。当前曲线共用窗口,历史每组取此前某天对应窗口,跨午夜顺序排列;普通图用line/bar,坐标轴标单位。七天按t的本地日期回退,不含当天。按实际周期保留缺口,按需翻页并报告参考点数;基线未冻结、读取失败或缺测时说明并保留当前趋势,不退化为前一天或相邻两点。
影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。 影响说明列出异常前参考、异常期间和已有恢复观测中的压力、流量或水质变化、持续时间、涉及测点及已核实资产。可比对象、单位、时段与运行条件下给出变化量,分别标明实际观测、模型预测和拓扑隔离。停水范围与漏量引用相应拓扑、水力、流量平衡或现场依据;恢复观测、用户关联或现场证据缺失时列为具体待核实项。
@@ -41,7 +41,7 @@ metadata:
本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。 本轮时序分析保持任务冻结窗口,后续播放形成的观测由相应轮次处理。资产属性、空间查询和拓扑隔离使用项目当前模型,注明模型时态;水力工具按已开放的冻结输入能力调用。定位调用执行权限、设备与冻结观测校验;基线或覆盖缺口按实际返回记录。
报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修列为后续待办。现场工单与设备操作遵守服务端动作权限,本轮分析完成与现场事件闭环分别说明。 报告给出观测事实、规则信号、原因候选、定位与隔离结果、影响说明和建议。摘要以150字以内为目标,测点明细用表格、完整依据留在证据。候选、相似度和拓扑结果具备时,完成相应分析、展示与报告后再收尾;现场核验、审批和维修按实际进度列为待办,已完工事项采用回执状态。现场工单与设备操作遵守服务端动作权限,恢复复核通过后按下节闭环。
需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。 需要现场核验时,先发布本轮outcome=concluded的调查报告,再调用propose_task_action提出kind=create_work_order、work_order_type=general的关联预约工单草稿,引用候选与影响证据,写明核验对象、内容和回执要求。带replay_context的任务同样可提出草稿,描述中注明历史异常时刻及回放来源;草稿须由有权限的用户编辑、确认后发布,后续派发与反馈进入预约任务。草稿待确认时说明等待人工处理,不将调查报告完成写成现场处置完成。回放任务不支持设备命令。
@@ -49,4 +49,6 @@ metadata:
收到关联工单的completed反馈后,先读取get_task_context摘要中的handling_context,核对实际完工业务时间returned_at,不以工单创建、预约或电脑当前时间代替。Server通过现有恢复监测读取项目时序库中严格晚于returned_at的最近一帧及事件前15天同相位对照,独立保存恢复证据;例如13:00完工、15分钟采样时核对13:15。这条恢复链路适用于回放来源工单,不要求推进回放,原异常knowledge_cutoff与batch_sequence保持不变。 收到关联工单的completed反馈后,先读取get_task_context摘要中的handling_context,核对实际完工业务时间returned_at,不以工单创建、预约或电脑当前时间代替。Server通过现有恢复监测读取项目时序库中严格晚于returned_at的最近一帧及事件前15天同相位对照,独立保存恢复证据;例如13:00完工、15分钟采样时核对13:15。这条恢复链路适用于回放来源工单,不要求推进回放,原异常knowledge_cutoff与batch_sequence保持不变。
phase=waiting_work_order时等待现场完工;phase=waiting_scada时查看last_check,分别说明尚无后续采样、基线或测点缺项、实际仍异常,等待监测复核,不将原回放查询越界解释为恢复数据不存在。phase=verification_ready时,用get_task_evidence读取verification_evidence_id的完整恢复证据,比较实际样本、正常范围与现场回报,最终报告同时引用原异常全量证据与当前恢复证据。恢复数据已由Server保存,不用query_scada_readings越过原冻结上限重复查询,也不因该上限再建复测工单;确有独立现场工作时才提议新动作。完工时间修订后使用新返回的证据编号,旧证据仅作历史。正常读数不替代阀位、漏点照片等现场事实,报告分别说明监测恢复、现场待核验项与事件闭环。 waiting_work_order等待完工;waiting_scada按last_check说明尚无后续采样、数据缺项或仍异常,等待监测复核。verification_ready时用get_task_evidence读取verification_evidence_id完整快照;工单completed且恢复证据status=passed时,发布final/concluded恢复闭环报告,同时引用原异常全量证据与当前恢复证据。Server完成运行后关闭任务并解除关联告警,phase=completed表示已闭环。阀位、漏点照片、现场复测数值等未提供时仅记为资料缺项,不新增现场核验、人工确认或更多采样的闭环条件。
恢复观测由Server独立保存,原冻结上限不阻断此证据的使用;query_scada_readings仍遵守冻结窗口。完工时间修订后使用新证据编号,旧证据仅作历史。确有独立工作时再提议新动作。
+16 -14
View File
@@ -90,25 +90,27 @@ class CredentialProvider:
self.settings = settings self.settings = settings
self._token = "" self._token = ""
self._expires = 0.0 self._expires = 0.0
self._refresh_lock = asyncio.Lock()
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]: async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
if self.settings.bearer: if self.settings.bearer:
token = self.settings.bearer.removeprefix("Bearer ") token = self.settings.bearer.removeprefix("Bearer ")
else: else:
if time.monotonic() >= self._expires: async with self._refresh_lock:
response = await client.post(self.settings.token_url, data={ if time.monotonic() >= self._expires:
"grant_type": "client_credentials", "client_id": self.settings.client_id, response = await client.post(self.settings.token_url, data={
"client_secret": self.settings.client_secret, "grant_type": "client_credentials", "client_id": self.settings.client_id,
}) "client_secret": self.settings.client_secret,
response.raise_for_status() })
payload = response.json() response.raise_for_status()
self._token = payload["access_token"] payload = response.json()
if not isinstance(self._token, str) or not self._token: self._token = payload["access_token"]
raise ValueError("identity provider returned no access token") if not isinstance(self._token, str) or not self._token:
# Product auth also restricts token age, independently of the raise ValueError("identity provider returned no access token")
# provider's exp. Do not cache a long-lived OAuth token for hours. # Product auth also restricts token age, independently of the
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds) # provider's exp. Do not cache a long-lived OAuth token for hours.
self._expires = time.monotonic() + max(1, lifetime - 15) lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
self._expires = time.monotonic() + max(1, lifetime - 15)
token = self._token token = self._token
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id} return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
View File
@@ -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)
+6
View File
@@ -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)
+52
View File
@@ -0,0 +1,52 @@
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.auth.project_dependencies import ProjectContext
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository, ProjectDbRouting
from app.infra.db.project_routing import get_active_project_routing
from app.mcp import scope
@pytest.mark.anyio
async def test_mcp_scope_reads_routing_once_and_refreshes_it_on_the_next_request(monkeypatch):
project = ProjectContext(uuid4(), "fixture", uuid4(), "member")
session = SimpleNamespace(commit=AsyncMock())
connections = []
current_dsn = ["dbname=first"]
async def metadata_session():
yield session
async def read_routing(_repo, project_id, role):
assert project_id == project.project_id and role == "biz_data"
return ProjectDbRouting(project_id, role, "postgresql", current_dsn[0], 1, 4)
@asynccontextmanager
async def connection(project_id, role, dsn, minimum, maximum):
assert session.commit.await_count > 0
connections.append((project_id, role, dsn, minimum, maximum))
yield object()
read = AsyncMock(side_effect=read_routing)
monkeypatch.setattr(scope, "get_metadata_session", metadata_session)
monkeypatch.setattr(scope, "get_current_metadata_user", AsyncMock(return_value=object()))
monkeypatch.setattr(scope, "resolve_project_context", AsyncMock(return_value=project))
# Patch at the repository boundary so both real routing resolvers are exercised.
async def repository_read(self, project_id, role):
return await read(self, project_id, role)
monkeypatch.setattr(MetadataRepository, "get_project_db_routing", repository_read)
monkeypatch.setattr("app.auth.project_dependencies.project_connection_manager.pg_connection", connection)
for dsn in ("dbname=first", "dbname=second"):
current_dsn[0] = dsn
async with scope.project_scope(SimpleNamespace(claims={"sub": "fixture"}), str(project.project_id)) as value:
assert value.routing.business_dsn == dsn
assert get_active_project_routing() is value.routing
assert get_active_project_routing() is None
assert read.await_count == 2
assert [row[2:] for row in connections] == [("dbname=first", 1, 4), ("dbname=second", 1, 4)]
+229 -251
View File
@@ -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,220 +228,48 @@ 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(
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
async def test_run_claim_persists_agent_instance_ownership(): @pytest.mark.parametrize("task_scope,worker_scope,allowed", [
(None, "production", False),
(None, "development", False),
("production", "production", True),
("production", "development", False),
("development", "production", False),
("development", "development", True),
])
async def test_run_claim_persists_agent_instance_ownership(task_scope, worker_scope, allowed):
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=None) task = _task_document(context, now, lease=None)
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None) task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
if task_scope is None:
task.pop("execution_scope", None)
else:
task["execution_scope"] = task_scope
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)
if not allowed:
with pytest.raises(DomainError) as failure:
await service.claim_run(task["task_id"], run["run_id"], AgentRunCommand(
request_id=run["execution_request_id"], expected_revision=1,
source_session_id="session-1", agent_instance_id="agent-a"))
assert failure.value.code == "EXECUTION_SCOPE_MISMATCH"
assert repository.run["status"] == "queued"
assert repository.task["status"] == "queued"
return
claimed = await service.claim_run( claimed = await service.claim_run(
task["task_id"], run["run_id"], task["task_id"], run["run_id"],
@@ -464,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"],
@@ -511,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"}
@@ -532,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(
@@ -558,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"]
@@ -584,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,
@@ -593,9 +417,10 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand( result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id], command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id],
"chart": {"title": "压力", "chart_type": "line", "x_data": ["19:45", "20:00"], "chart": {"title": "压力", "chart_type": "time_comparison", "x_data": ["19:45", "20:00"],
"y_axis_name": "压力 m", "series": [ "y_axis_name": "压力 m", "series": [
{"name": "当前", "type": "line", "data": [27., 26.]}, {"name": "当前", "type": "line", "data": [27., 26.],
"sample_times": ["2026-10-10T19:45:00+08:00", "2026-10-10T20:00:00+08:00"]},
{"name": "前一天", "type": "scatter", "data": [None, 28.], {"name": "前一天", "type": "scatter", "data": [None, 28.],
"sample_times": [None, "2026-10-09T20:00:00+08:00"]}, "sample_times": [None, "2026-10-09T20:00:00+08:00"]},
]}}, ]}},
@@ -613,6 +438,65 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
assert repository.action["action_id"] == str(action.action_id) assert repository.action["action_id"] == str(action.action_id)
@pytest.mark.anyio
@pytest.mark.parametrize("destination", ["presentation", "report"])
@pytest.mark.parametrize("defect", [None, "legacy_type", "dates_on_axis", "missing_times", "wrong_slot", "future_day"])
async def test_comparison_chart_publication_is_validated_at_both_write_boundaries(destination, defect):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = TaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
origin="synthetic", data={"items": []}, truncated=False,
))
chart = {"title": "同期对照", "chart_type": "time_comparison", "x_data": ["08:00"], "series": [
{"name": "当前", "data": [25.], "sample_times": ["2026-10-10T08:00:00+08:00"]},
{"name": "历史", "type": "scatter", "data": [26.], "sample_times": ["2026-10-09T08:00:00+08:00"]},
]}
if defect == "legacy_type":
chart["chart_type"] = "line"
elif defect == "dates_on_axis":
chart["x_data"] = ["10-10 08:00"]
elif defect == "missing_times":
del chart["series"][1]["sample_times"]
elif defect == "wrong_slot":
chart["series"][1]["sample_times"] = ["2026-10-09T07:45:00+08:00"]
elif defect == "future_day":
chart["series"][1]["sample_times"] = ["2026-10-11T08:00:00+08:00"]
events_before = len(repository.events)
async def publish():
if destination == "presentation":
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": "comparison", "evidence_refs": [evidence.evidence_id],
"chart": chart},
))
else:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={
**_current_report(), "evidence_refs": [evidence.evidence_id], "ui_blocks": [{
"evidence_refs": [evidence.evidence_id], "block": {
"version": "answer-ui@1", "block_id": "comparison", "fallback_text": "同期对照",
"spec": {"root": "chart", "elements": {"chart": {
"type": "Chart", "children": [], "props": {**chart, "source": "合成数据"},
}}},
},
}],
},
))
if defect is None:
await publish()
assert len(repository.events) > events_before
else:
with pytest.raises(DomainError) as error:
await publish()
assert error.value.code == "TIME_COMPARISON_INVALID"
assert len(repository.events) == events_before
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("operation", ["propose", "approve", "submit"]) @pytest.mark.parametrize("operation", ["propose", "approve", "submit"])
async def test_replay_cannot_propose_approve_or_submit_device_commands(operation): async def test_replay_cannot_propose_approve_or_submit_device_commands(operation):
@@ -631,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())
@@ -659,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,
@@ -678,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"]},
@@ -711,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,
@@ -760,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(
@@ -807,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(
@@ -839,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="新投诉信息")
@@ -856,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(
@@ -878,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:
@@ -898,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(
@@ -930,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(
@@ -985,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,
@@ -1040,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",
@@ -1095,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": "关键数据不足"}
@@ -1145,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())
@@ -1224,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(
@@ -1250,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(
@@ -1285,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"
@@ -1326,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
@@ -1334,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(),
@@ -1357,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")
@@ -1379,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(
@@ -1415,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"}]}
@@ -1442,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",
@@ -1475,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(
@@ -1491,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(),
@@ -1540,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,
@@ -1589,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,
@@ -1653,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",
@@ -1687,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",
@@ -1710,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)
@@ -1729,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",
@@ -1766,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(
@@ -1797,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)
@@ -2041,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)
@@ -2169,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(
@@ -2206,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(
@@ -2238,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)
@@ -2262,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')
@@ -2296,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(
@@ -2371,3 +2261,91 @@ async def test_recovery_resolves_alerts_with_original_facts_and_stable_event_id(
assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1") assert command.event_id == uuid5(NAMESPACE_URL, f"recovery:{task_id}:recovery:evidence-1:incident-1")
assert command.source.model_dump(mode="json") == alert["source"] assert command.source.model_dump(mode="json") == alert["source"]
assert command.title == alert["title"] and command.description == alert["description"] assert command.title == alert["title"] and command.description == alert["description"]
@pytest.mark.anyio
@pytest.mark.parametrize("scope", ["production", "development"])
async def test_task_creation_persists_server_execution_scope(monkeypatch, scope):
from app.domain.schemas.operational_task import OperationalTaskCreate
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
context = _context()
document = _task_document(context, now)
repository = TaskRepository(document)
repository.insert_task = AsyncMock()
repository.insert_run = AsyncMock()
service = OperationalTaskService(repository, context, set(), clock=lambda: now,
execution_scope=scope)
monkeypatch.setattr(service, "_task_event", AsyncMock())
monkeypatch.setattr(service, "_run_event", AsyncMock())
monkeypatch.setattr(service, "detail", AsyncMock())
command = OperationalTaskCreate(request_id=uuid4(), **{
key: document[key] for key in ("task_type", "workflow", "title", "summary", "source", "trigger")
})
await service.create(command)
assert repository.insert_task.await_args.args[0]["execution_scope"] == scope
def test_task_scope_is_required_in_persisted_contract():
task = _task_document(_context(), datetime(2026, 11, 8, tzinfo=timezone.utc))
task.pop("execution_scope")
with pytest.raises(ValidationError) as error:
OperationalTaskSummary.model_validate(task)
assert any(item["loc"] == ("execution_scope",) and item["type"] == "missing"
for item in error.value.errors())
@pytest.mark.anyio
@pytest.mark.parametrize("operation", ["feedback", "withdrawal"])
@pytest.mark.parametrize("task_scope,server_scope,allowed", [
(None, "production", False),
(None, "development", False),
("production", "production", True),
("production", "development", False),
("development", "production", False),
("development", "development", True),
])
async def test_external_sync_only_mutates_tasks_in_its_execution_scope(operation, task_scope, server_scope, allowed):
from copy import deepcopy
now = datetime(2026, 11, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
source_id = f"peripheral_sample:{uuid4()}"
task.update(task_type="external_work_order", source={
"kind": "external_system", "external_id": source_id,
"external_revision": 1, "event_kind": "general",
})
if task_scope is None:
task.pop("execution_scope")
else:
task["execution_scope"] = task_scope
repository = TaskRepository(task)
service = OperationalTaskService(repository, context, set(), clock=lambda: now, execution_scope=server_scope)
if operation == "feedback":
command = ExternalFeedbackCommand(request_id=uuid4(), source_event_id=source_id,
source_revision=2, status="completed", occurred_at=now, detail="已关阀修复管道")
method = "external_feedback"
else:
command = ExternalSourceWithdrawalCommand(request_id=uuid4(), source_event_id=source_id)
method = "withdraw_external_source"
before = deepcopy(repository.task)
if not allowed:
with pytest.raises(DomainError) as error:
await getattr(service, method)(task["task_id"], command)
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
assert repository.task == before
assert repository.feedback == repository.evidence == repository.requests == {}
return
await getattr(service, method)(task["task_id"], command)
assert repository.task["status"] == ("completed" if operation == "feedback" else "cancelled")
# Check scope before idempotency replay as well as before the first write.
await getattr(service, method)(task["task_id"], command)
before = deepcopy(repository.task)
other = OperationalTaskService(repository, context, set(), clock=lambda: now,
execution_scope="development" if server_scope == "production" else "production")
with pytest.raises(DomainError) as error:
await getattr(other, method)(task["task_id"], command)
assert error.value.code == "EXECUTION_SCOPE_MISMATCH" and error.value.status == 403
assert repository.task == before
+27
View File
@@ -1,3 +1,4 @@
import asyncio
import json import json
import os import os
from uuid import uuid4 from uuid import uuid4
@@ -8,6 +9,32 @@ import pytest
from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings from scripts.run_operational_worker import CredentialProvider, OperationalWorker, WorkerSettings
@pytest.mark.anyio
async def test_parallel_worker_requests_share_cold_and_expired_token_refresh(monkeypatch):
clock = [100.0]
requests = []
monkeypatch.setattr("scripts.run_operational_worker.time.monotonic", lambda: clock[0])
async def handler(request):
requests.append(request)
await asyncio.sleep(0)
return httpx.Response(200, json={"access_token": f"TEST_ONLY_{len(requests)}", "expires_in": 60})
provider = CredentialProvider(WorkerSettings(
"http://server.test", "http://agent.test", str(uuid4()),
token_url="https://identity.test/token", client_id="fixture", client_secret="TEST_ONLY",
))
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
first = await asyncio.gather(provider.headers(client), provider.headers(client))
assert first[0] == first[1]
assert len(requests) == 1
clock[0] = 146.0
second = await asyncio.gather(provider.headers(client), provider.headers(client))
assert second[0] == second[1]
assert second[0] != first[0]
assert len(requests) == 2
@pytest.fixture @pytest.fixture
def anyio_backend(): def anyio_backend():
return "asyncio" return "asyncio"
+7 -3
View File
@@ -226,7 +226,7 @@ async def test_sample_sync_rejects_cross_project_data_before_writing():
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize("targeted", [False, True]) @pytest.mark.parametrize("targeted", [False, True])
@pytest.mark.parametrize("order_revision", [1, 2]) @pytest.mark.parametrize("order_revision", [1, 4])
@pytest.mark.parametrize( @pytest.mark.parametrize(
("investigation_status", "expected_feedback_count", "routes_to_investigation"), ("investigation_status", "expected_feedback_count", "routes_to_investigation"),
[ [
@@ -254,9 +254,13 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
"updated_at": "2026-09-23T09:00:00Z", "updated_at": "2026-09-23T09:00:00Z",
"title": "采样工单", "priority": "normal", "title": "采样工单", "priority": "normal",
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z", "work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None, "scheduled_at": "2026-09-23T08:30:00Z",
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员", "activity": ([{"sequence": 2, "occurred_at": "2026-09-23T08:45:00Z", "actor": "现场人员",
"kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}] "kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"},
{"sequence": 3, "occurred_at": "2026-09-23T08:50:00Z", "actor": "调度员",
"kind": "updated", "title": "工单信息更新", "detail": "更新了状态。"},
{"sequence": 4, "occurred_at": "2026-09-23T09:00:00Z", "actor": "调度员",
"kind": "updated", "title": "工单信息更新", "detail": "更新了优先级。"}]
if order_revision > 1 else []), if order_revision > 1 else []),
} }
if targeted: if targeted:
+18 -103
View File
@@ -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):
+7 -7
View File
@@ -579,7 +579,7 @@ def test_suspected_monitoring_creates_burst_report_with_frozen_replay_context(mo
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr( monkeypatch.setattr(
"app.services.operational_tasks.OperationalTaskService", "app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task), lambda *_, **_kwargs: SimpleNamespace(create=create_task),
) )
monitoring = { monitoring = {
"status": "suspected_burst", "status": "suspected_burst",
@@ -618,7 +618,7 @@ def test_monitoring_summary_counts_signals_without_copying_reading_details(monke
_, service, *_ = setup(monkeypatch) _, service, *_ = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
monitoring = incident_monitoring(0) monitoring = incident_monitoring(0)
monitoring["anomaly_signals"] = [ monitoring["anomaly_signals"] = [
{"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"} {"device_ids": [f"P{i}"], "detail": f"P{i}=28.14402136077437 m,超出冻结正常同时段范围 [28.3253, 28.8685] m"}
@@ -641,7 +641,7 @@ def test_continuing_burst_reuses_task_across_persisted_periods(monkeypatch):
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task)) lambda *_, **_kwargs: SimpleNamespace(create=create_task, advance_replay_investigation=advance_task))
document = {"replay_id": str(uuid4())} document = {"replay_id": str(uuid4())}
async def run(): async def run():
@@ -683,7 +683,7 @@ def test_incident_recovers_once_then_allows_new_accident(monkeypatch):
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService",
lambda *_: SimpleNamespace(create=create_task)) lambda *_, **_kwargs: SimpleNamespace(create=create_task))
document = {"replay_id": str(uuid4())} document = {"replay_id": str(uuid4())}
async def run(): async def run():
@@ -784,7 +784,7 @@ def test_combined_monitoring_keeps_rule_evidence_and_quality_notifications(monke
monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.task_alerts.TaskAlertRepository", lambda *_: object())
monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push)) monkeypatch.setattr("app.services.task_alerts.TaskAlertService", lambda *_args, **_kwargs: SimpleNamespace(push=push))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=AsyncMock())) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=AsyncMock()))
def event(rule, category=None): def event(rule, category=None):
return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(), return {"event_id": str(uuid4()), "rule_id": rule, "change": "raised", "occurred_at": START.isoformat(),
"detail": "检测信号", "category": category, "task_id": None} "detail": "检测信号", "category": category, "task_id": None}
@@ -812,7 +812,7 @@ def test_failed_incident_publication_rolls_back_state_and_retries_same_task_requ
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"}) monkeypatch.setattr("app.services.scada_replay.permissions_for_context", lambda _: {"task.write"})
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence): def evaluate(_doc, _rows, *, period_start, period_end, batch_sequence):
return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(), return {**incident_monitoring(0), "period_start": period_start.isoformat(), "period_end": period_end.isoformat(),
"knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence} "knowledge_cutoff": period_end.isoformat(), "batch_sequence": batch_sequence}
@@ -843,7 +843,7 @@ def test_any_single_measurement_anomaly_enters_diagnosis_below_burst_threshold(m
_, service, *_ = setup(monkeypatch) _, service, *_ = setup(monkeypatch)
create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4())) create_task = AsyncMock(return_value=SimpleNamespace(task_id=uuid4()))
monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object()) monkeypatch.setattr("app.infra.db.postgresql.operational_tasks.OperationalTaskRepository", lambda *_: object())
monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_: SimpleNamespace(create=create_task)) monkeypatch.setattr("app.services.operational_tasks.OperationalTaskService", lambda *_, **_kwargs: SimpleNamespace(create=create_task))
monitoring = incident_monitoring(0, status="attention") monitoring = incident_monitoring(0, status="attention")
for key in ("pressure", "outlet_flow", "burst_detection"): for key in ("pressure", "outlet_flow", "burst_detection"):
monitoring[key]["hit"] = key == indicator monitoring[key]["hit"] = key == indicator
+31 -19
View File
@@ -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()
+105
View File
@@ -0,0 +1,105 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_task import TaskSafeChart
from app.services.time_comparison_chart import validate_time_comparison_chart
def comparison_chart(start="2026-07-16T05:00:00+08:00", interval=15):
times = [datetime.fromisoformat(start) + timedelta(minutes=index * interval) for index in range(13)]
return {"title": "七天同期对比", "chart_type": "time_comparison",
"x_data": [at.strftime("%H:%M") for at in times], "series": [
{"name": "当前", "type": "line", "data": [25.] * 13,
"sample_times": [at.isoformat() for at in times]},
*[{"name": f"前{day}天", "type": "scatter", "data": [None] * 10 + [26.] * 3,
"sample_times": [None] * 10 + [(at - timedelta(days=day)).isoformat() for at in times[10:]]}
for day in range(1, 8)],
]}
@pytest.mark.parametrize("start,interval", [
("2026-07-16T05:00:00+08:00", 15),
("2026-07-16T23:00:00+08:00", 15),
("2026-07-16T05:00:00+08:00", 5),
])
def test_same_time_comparison_preserves_seven_sparse_days_and_sampling_intervals(start, interval):
chart = comparison_chart(start, interval)
# A missing current observation is still a real empty slot.
chart["series"][0]["data"][11] = None
chart["series"][0]["sample_times"][11] = None
# UTC and local-offset spellings refer to the same real historical instant.
chart["series"][1]["sample_times"][12] = (
datetime.fromisoformat(chart["series"][1]["sample_times"][12])
.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
)
before = deepcopy(chart)
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert chart == before
@pytest.mark.parametrize("defect", [
"appended_history", "duplicate_slots", "wrong_slot", "missing_times", "missing_point_time",
"mixed_dates", "same_day", "future_day", "bar_series", "only_line", "no_current_observations",
])
def test_special_comparison_rejects_misleading_projections(defect):
chart = comparison_chart()
history = chart["series"][1]
if defect == "appended_history":
chart["x_data"] = [f"07-16 {slot}" for slot in chart["x_data"]] + ["07-15 07:30", "07-15 07:45", "07-15 08:00"]
for series in chart["series"]:
series["data"] += [None] * 3
series["sample_times"] += [None] * 3
elif defect == "duplicate_slots":
chart["x_data"][11] = chart["x_data"][10]
elif defect == "wrong_slot":
history["sample_times"][10] = "2026-07-15T07:45:00+08:00"
elif defect == "missing_times":
del history["sample_times"]
elif defect == "missing_point_time":
history["sample_times"][10] = None
elif defect == "mixed_dates":
history["sample_times"][10] = "2026-07-14T07:30:00+08:00"
elif defect in {"same_day", "future_day"}:
day = 16 if defect == "same_day" else 17
history["sample_times"] = [at.replace("07-15", f"07-{day}") if at else None for at in history["sample_times"]]
elif defect == "bar_series":
history["type"] = "bar"
elif defect == "only_line":
chart["series"] = chart["series"][:1]
elif defect == "no_current_observations":
chart["series"][0]["data"] = chart["series"][0]["sample_times"] = [None] * 13
with pytest.raises(DomainError) as error:
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert error.value.code == "TIME_COMPARISON_INVALID"
def test_ordinary_charts_skip_comparison_validation_and_old_mixed_charts_remain_readable():
chart = {"title": "普通趋势", "chart_type": "line", "x_data": ["07-16 08:00"],
"series": [{"name": "当前", "data": [25.]}]}
validate_time_comparison_chart(chart)
chart["chart_type"] = "bar"
validate_time_comparison_chart(chart)
chart["chart_type"] = "line"
chart["series"].append({"name": "旧版散点", "type": "scatter", "data": [26.]})
assert TaskSafeChart.model_validate(chart).model_dump(mode="json") == chart
with pytest.raises(DomainError, match="time_comparison"):
validate_time_comparison_chart(chart)
@pytest.mark.parametrize("labels,times", [
(["07:45"], ["9999-12-31T23:45:00Z"]),
(["08:00"], ["0001-01-01T00:00:00+23:59"]),
(["23:45", "00:15"], [None, "0001-01-01T00:15:00+08:00"]),
])
def test_calendar_range_overflow_returns_a_domain_rejection(labels, times):
chart = {"title": "日期边界", "chart_type": "time_comparison", "x_data": labels, "series": [
{"name": "当前", "type": "line", "data": [1. if at else None for at in times], "sample_times": times},
{"name": "历史缺测", "type": "scatter", "data": [None] * len(labels), "sample_times": [None] * len(labels)},
]}
with pytest.raises(DomainError, match="日期范围") as error:
validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json"))
assert error.value.code == "TIME_COMPARISON_INVALID"
assert error.value.status == 422