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

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent 63af998045
commit 05183aff93
82 changed files with 4644 additions and 330 deletions
@@ -1,10 +1,11 @@
"""Topology-only valve isolation search."""
from collections import defaultdict, deque
from typing import Any, Iterable
from typing import Any, Iterable, Literal
VALVE_LINK_TYPE = "valve"
AccidentElementType = Literal["auto", "link", "node"]
def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
@@ -14,12 +15,37 @@ def _parse_link_entry(link_entry: str) -> tuple[str, str, str, str]:
return parts[0], parts[1], parts[2], parts[3]
def _reachable_nodes(
adjacency: dict[str, list[tuple[str, str]]],
start_nodes: Iterable[str],
blocked_links: set[str],
) -> set[str]:
visited = set(start_nodes)
queue = deque(visited)
while queue:
for neighbor, link_id in adjacency.get(queue.popleft(), ()):
if link_id not in blocked_links and neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return visited
def valve_isolation_analysis(
link_entries: Iterable[str],
accident_elements: str | list[str],
disabled_valves: list[str] | None = None,
*,
accident_element_type: AccidentElementType = "auto",
water_source_nodes: Iterable[str] | None = None,
closed_links: Iterable[str] = (),
) -> dict[str, Any]:
"""Determine boundary valves and affected nodes from a topology snapshot."""
"""Find nearest valve boundaries, then check source connectivity.
Legacy untyped IDs prefer links; explicit node mode handles cross-type ID
collisions. Supply impact is a before/after connectivity difference, not
a hydraulic pressure or demand calculation. Without source metadata only
the provisional boundary search is available.
"""
disabled_valves_set = set(disabled_valves or [])
target_elements = (
@@ -27,8 +53,13 @@ def valve_isolation_analysis(
if isinstance(accident_elements, str)
else accident_elements
)
if not target_elements:
raise ValueError("At least one accident element is required")
if accident_element_type not in {"auto", "link", "node"}:
raise ValueError(f"Invalid accident element type: {accident_element_type}")
closed_links_set = set(closed_links)
pipe_adj: dict[str, set[str]] = defaultdict(set)
adjacency: dict[str, list[tuple[str, str]]] = defaultdict(list)
all_valves: dict[str, tuple[str, str]] = {}
link_lookup: dict[str, tuple[str, str, str]] = {}
node_set: set[str] = set()
@@ -39,46 +70,31 @@ def valve_isolation_analysis(
node_set.update((node1, node2))
if link_type_name == VALVE_LINK_TYPE:
all_valves[link_id] = (node1, node2)
else:
pipe_adj[node1].add(node2)
pipe_adj[node2].add(node1)
if link_id not in closed_links_set:
adjacency[node1].append((node2, link_id))
adjacency[node2].append((node1, link_id))
unknown_disabled = disabled_valves_set - all_valves.keys()
if unknown_disabled:
raise ValueError(f"Disabled valve was not found in topology: {sorted(unknown_disabled)}")
start_nodes: set[str] = set()
for element in target_elements:
if element in node_set:
start_nodes.add(element)
elif element in link_lookup:
if accident_element_type != "node" and element in link_lookup:
node1, node2, _ = link_lookup[element]
start_nodes.update((node1, node2))
elif accident_element_type != "link" and element in node_set:
start_nodes.add(element)
else:
raise ValueError(f"Accident element {element} was not found in topology")
extra_adj: dict[str, list[str]] = defaultdict(list)
boundary_valves: dict[str, tuple[str, str]] = {}
for valve_id, (node1, node2) in all_valves.items():
if valve_id in disabled_valves_set:
extra_adj[node1].append(node2)
extra_adj[node2].append(node1)
else:
boundary_valves[valve_id] = (node1, node2)
affected_nodes: set[str] = set()
queue = deque(start_nodes)
while queue:
node = queue.popleft()
if node in affected_nodes:
continue
affected_nodes.add(node)
queue.extend(pipe_adj.get(node, set()) - affected_nodes)
queue.extend(
neighbor
for neighbor in extra_adj.get(node, ())
if neighbor not in affected_nodes
)
closable_valves = set(all_valves) - disabled_valves_set - closed_links_set
affected_nodes = _reachable_nodes(adjacency, start_nodes, closable_valves)
must_close_valves: list[str] = []
optional_valves: list[str] = []
for valve_id, (node1, node2) in boundary_valves.items():
for valve_id in closable_valves:
node1, node2 = all_valves[valve_id]
node1_affected = node1 in affected_nodes
node2_affected = node2 in affected_nodes
if node1_affected and node2_affected:
@@ -89,14 +105,37 @@ def valve_isolation_analysis(
must_close_valves.sort()
optional_valves.sort()
isolatable = bool(must_close_valves)
failure_reason = None if isolatable else "no_boundary_valves"
sources = set(water_source_nodes or ())
source_check_performed = bool(sources)
supply_affected_nodes = None
if water_source_nodes is not None:
if not sources:
isolatable = False
failure_reason = "water_sources_unavailable"
else:
after_closure = _reachable_nodes(adjacency, sources, set(must_close_valves))
if affected_nodes & after_closure:
isolatable = False
failure_reason = "connected_to_water_source"
if isolatable:
before_closure = _reachable_nodes(adjacency, sources, set())
supply_affected_nodes = sorted(before_closure - after_closure)
result: dict[str, Any] = {
"accident_elements": target_elements,
"accident_element_type": accident_element_type,
"disabled_valves": disabled_valves,
"affected_nodes": sorted(affected_nodes) if isolatable else [],
"affected_node_count": len(affected_nodes),
"must_close_valves": must_close_valves,
"optional_valves": optional_valves,
"isolatable": isolatable,
"source_check_performed": source_check_performed,
"isolation_failure_reason": failure_reason,
"supply_affected_nodes": supply_affected_nodes,
"supply_affected_node_count": (
len(supply_affected_nodes) if supply_affected_nodes is not None else None
),
}
if len(target_elements) == 1:
result["accident_element"] = target_elements[0]
+42
View File
@@ -26,6 +26,8 @@ from app.auth.project_dependencies import (
from app.domain.domain_errors import DomainError
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
from app.domain.schemas.operational_data import TaskDataQuery, TaskDataSnapshot
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
from app.services.task_questions import create_question, answer_question
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
@@ -74,6 +76,7 @@ from app.domain.schemas.operational_task import (
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
TaskPresentationHistoryPage,
)
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
from app.infra.integrations.operational_actions import (
@@ -521,6 +524,21 @@ async def get_operational_task_presentation_cursor(
_raise_domain(exc)
@router.get("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationHistoryPage)
async def get_operational_task_presentation_history(
task_id: UUID,
after: int = Query(default=0, ge=0),
through: int | None = Query(default=None, ge=0),
limit: int = Query(default=100, ge=1, le=200),
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_VIEW)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await _service(ctx, conn).presentation_history(task_id, after=after, through=through, limit=limit)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/presentation-events", response_model=TaskPresentationEventResult)
async def publish_operational_task_presentation_event(
task_id: UUID,
@@ -771,3 +789,27 @@ async def withdraw_operational_task_external_source(
return await _service(ctx, conn).withdraw_external_source(task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/questions", response_model=TaskQuestionRequest)
async def create_operational_task_question(
task_id: UUID, command: TaskQuestionCreate,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_EXECUTE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await create_question(_service(ctx, conn), task_id, command)
except DomainError as exc:
_raise_domain(exc)
@router.post("/operational-tasks/{task_id}/questions/{question_id}/answer", response_model=OperationalTaskDetail)
async def answer_operational_task_question(
task_id: UUID, question_id: UUID, command: TaskQuestionReply,
ctx: ProjectContext = Depends(require_permission(OPERATIONAL_TASK_WRITE)),
conn: AsyncConnection = Depends(get_project_pg_connection),
):
try:
return await answer_question(_service(ctx, conn), task_id, question_id, command)
except DomainError as exc:
_raise_domain(exc)
+16 -4
View File
@@ -161,6 +161,9 @@ def valve_isolation_endpoint(
network: str = Query(..., description="管网名称(或数据库名称)"),
accident_element: List[str] = Query(..., description="发生事故的管段/节点ID列表"),
disabled_valves: List[str] = Query(None, description="已故障的阀门ID列表(可选)"),
accident_element_type: Literal["auto", "link", "node"] = Query(
"auto", description="事故要素类型;auto优先管段再节点;分析同名节点须指定node,纯管段分析可指定link",
),
):
"""
阀门隔离分析
@@ -168,13 +171,19 @@ def valve_isolation_endpoint(
- **network**: 管网名称(或数据库名称)
- **accident_element**: 发生事故的管段/节点ID列表
- **disabled_valves**: 已故障的阀门ID列表(可选)
- **accident_element_type**: auto优先管段;link仅匹配管段;node仅匹配节点
返回隔离方案,包括:
- must_close_valves: 必须关闭的阀门列表
- optional_valves: 可选关闭的阀门列表
- affected_nodes: 受影响的节点列表;不可隔离时为空列表
- affected_node_count: 受影响的节点总数
- isolatable: 是否可以有效隔离
- affected_nodes / affected_node_count: 最近阀门围成的直接隔离区;不可隔离时列表为空,计数保留
- supply_affected_nodes / supply_affected_node_count: 按建议关阀后新增失去水源连接的节点;未验证或不可隔离时为null
- source_check_performed: 是否已执行水源连通检查
- isolation_failure_reason: 不可隔离原因;可隔离时为null
- isolatable: 存在关阀边界,且直接隔离区不再连通任何水源
使用模型初始开关状态进行无向拓扑分析,水池按潜在水源处理。
不包含时变控制、水池蓄水量或压力流量计算,不是现场停水范围的水力证明。
"""
# result = {
# "accident_element": "P461309",
@@ -193,7 +202,10 @@ def valve_isolation_endpoint(
# "must_close_valves": ["210521658", "V12974", "V12986", "V12993"],
# "optional_valves": [],
# }
result = analyze_valve_isolation(network, accident_element, disabled_valves)
result = analyze_valve_isolation(
network, accident_element, disabled_valves,
accident_element_type=accident_element_type,
)
return result
+22 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Annotated, Any, Literal
from jsonschema import Draft7Validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
_catalog = json.loads(
(Path(__file__).resolve().parents[3] / "contracts/answer-ui-catalog.json").read_text()
@@ -34,9 +34,24 @@ class AnswerUiSpec(UiModel):
class AnswerUiBlock(UiModel):
version: Literal["answer-ui@1"]
block_id: Key
phase: Literal["process", "final"] | None = None
fallback_text: Annotated[str, Field(min_length=1, max_length=4000)]
spec: AnswerUiSpec
@field_validator("phase", mode="before")
@classmethod
def validate_explicit_phase(cls, value):
if value is None:
raise ValueError("UI phase must be process or final when supplied")
return value
@model_serializer(mode="wrap")
def serialize_optional_phase(self, handler):
data = handler(self)
if self.phase is None:
data.pop("phase", None)
return data
@model_validator(mode="after")
def validate_components(self):
spec = self.spec
@@ -83,6 +98,12 @@ class ReportUiBlock(UiModel):
block: AnswerUiBlock
evidence_refs: list[Annotated[str, Field(min_length=1, max_length=128)]] = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_report_phase(self):
if self.block.phase == "process":
raise ValueError("Report UI blocks must contain final results")
return self
def validate_report_ui_blocks(blocks: list[ReportUiBlock], evidence_refs: list[str]) -> None:
ids = [item.block.block_id for item in blocks]
+112 -7
View File
@@ -8,6 +8,7 @@ from uuid import UUID
from pydantic import Field, JsonValue, model_validator
from app.domain.schemas.answer_ui import ReportUiBlock, validate_report_ui_blocks
from app.domain.schemas.task_question import TaskQuestionRequest
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.domain.schemas.workbench_runtime import (
@@ -218,8 +219,12 @@ class InvestigationDiagramNodeRef(StrictModel):
evidence_refs: list[NonEmptyStr] = Field(default_factory=list)
action_refs: list[UUID] = Field(default_factory=list)
status: Literal[
"pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"
] | None = None
"pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"
] | None = Field(default=None, description=(
"Evidence-confirmed node state; waiting means prerequisites/external/system result, "
"waiting_human requires manual authorization/approval/feedback. "
"Unknown stays omitted/null; host controls colors."
))
map_targets: list[InvestigationMapTarget] = Field(default_factory=list, max_length=100)
@@ -240,6 +245,21 @@ class InvestigationDiagram(StrictModel):
return self
def _undeclared_diagram_node_refs(diagram: InvestigationDiagram | None) -> list[str]:
if diagram is None:
return []
missing: list[str] = []
for node in diagram.node_refs:
escaped = re.escape(node.node_id)
if not re.search(
rf"(?<![A-Za-z0-9_-]){escaped}(?=\s*(?:[\[({{@;]|--|==|-\.|$))",
diagram.mermaid,
re.MULTILINE,
):
missing.append(node.node_id)
return missing
class InvestigationReport(StrictModel):
"""Read model: absent historical facts remain unknown, never inferred."""
@@ -341,9 +361,19 @@ class AgentArtifactsCommand(StrictModel):
progress: InvestigationProgress | None = None
@model_validator(mode="after")
def require_artifact(self):
def validate_artifacts(self):
if not self.evidence and self.plan is None and self.report is None and self.progress is None:
raise ValueError("at least one artifact must be supplied")
for diagram in (
self.report.diagram if self.report is not None else None,
self.progress.diagram if self.progress is not None else None,
):
missing = _undeclared_diagram_node_refs(diagram)
if missing:
raise ValueError(
"diagram node references must use ids declared in Mermaid source: "
+ ", ".join(missing)
)
return self
@@ -502,7 +532,7 @@ class ExternalWorkOrderSnapshot(StrictModel):
scenario_code: NonEmptyStr | None = None
feedback_requirements: list[NonEmptyStr] = Field(default_factory=list)
activity: list[WorkOrderActivity] = Field(default_factory=list)
origin: Literal["synthetic"]
origin: Literal["system_data"]
updated_at: WorkbenchDateTime
@model_validator(mode="before")
@@ -521,6 +551,7 @@ class OperationalTaskRun(StrictModel):
status: OperationalTaskRunStatus
revision: PositiveInt
agent_session_id: NonEmptyStr | None = None
agent_instance_id: NonEmptyStr | None = None
lease_expires_at: WorkbenchDateTime | None = None
heartbeat_at: WorkbenchDateTime | None = None
started_at: WorkbenchDateTime | None = None
@@ -570,6 +601,7 @@ class OperationalTaskPermissions(StrictModel):
can_decide_commands: bool
can_execute_commands: bool
can_decide_access: bool = False
can_answer_questions: bool = False
class OperationalTaskDetail(OperationalTaskSummary):
@@ -579,6 +611,7 @@ class OperationalTaskDetail(OperationalTaskSummary):
evidence: list[EvidenceRecord]
actions: list[HumanApprovalAction]
access_requests: list[TaskAccessRequest] = Field(default_factory=list)
questions: list[TaskQuestionRequest] = Field(default_factory=list)
permissions: OperationalTaskPermissions
work_order_snapshot: ExternalWorkOrderSnapshot | None = None
@@ -605,6 +638,7 @@ class AgentRunCommand(StrictModel):
request_id: UUID
expected_revision: PositiveInt
source_session_id: NonEmptyStr
agent_instance_id: NonEmptyStr | None = None
class AgentRunFailureCommand(AgentRunCommand):
@@ -671,7 +705,59 @@ class TaskHighlightFeatures(StrictModel):
action: Literal["highlight_features"]
layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
effect: Literal["steady", "pulse"] = "steady"
effect: Literal["steady", "pulse"]
class TaskLocateFeatures(StrictModel):
action: Literal["locate_features"]
layer: TaskMapLayer
feature_ids: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
class TaskZoomToMap(StrictModel):
action: Literal["zoom_to_map"]
center: tuple[
Annotated[float, Field(strict=True, ge=-180, le=180, allow_inf_nan=False)],
Annotated[float, Field(strict=True, ge=-90, le=90, allow_inf_nan=False)],
]
zoom: Annotated[float, Field(strict=True, ge=0, le=24, allow_inf_nan=False)]
class TaskScadaAnalysisItem(StrictModel):
device_id: TaskFeatureId
level: Literal["high", "medium", "low", "unrated"]
class TaskRenderScadaAnalysis(StrictModel):
action: Literal["render_scada_analysis"]
items: Annotated[list[TaskScadaAnalysisItem], Field(min_length=1, max_length=100)]
@model_validator(mode="after")
def validate_unique_devices(self):
if len({item.device_id for item in self.items}) != len(self.items):
raise ValueError("SCADA analysis device IDs must be unique")
return self
class TaskClearScadaAnalysis(StrictModel):
action: Literal["clear_scada_analysis"]
class TaskOverlayGeometry(StrictModel):
type: Literal["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]
coordinates: list[JsonValue]
class TaskOverlayFeature(StrictModel):
type: Literal["Feature"]
geometry: TaskOverlayGeometry
properties: dict[str, JsonValue] | None
id: Annotated[str, Field(strict=True)] | Annotated[float, Field(strict=True, allow_inf_nan=False)] | None = None
class TaskOverlayData(StrictModel):
type: Literal["FeatureCollection"]
features: Annotated[list[TaskOverlayFeature], Field(min_length=1, max_length=1000)]
class TaskClearHighlights(StrictModel):
@@ -696,6 +782,7 @@ class TaskSetOverlay(StrictModel):
title: Annotated[str, Field(min_length=1, max_length=200)]
mode: Literal["geometry", "heatmap"]
evidence_id: NonEmptyStr
data: TaskOverlayData | None = None
class TaskClearOverlay(StrictModel):
@@ -704,7 +791,8 @@ class TaskClearOverlay(StrictModel):
TaskPresentationCommand = Annotated[
TaskHighlightFeatures | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
TaskHighlightFeatures | TaskLocateFeatures | TaskZoomToMap | TaskRenderScadaAnalysis
| TaskClearScadaAnalysis | TaskClearHighlights | TaskOpenChart | TaskCloseChart | TaskSetOverlay | TaskClearOverlay,
Field(discriminator="action"),
]
@@ -736,6 +824,23 @@ class TaskPresentationCursor(StrictModel):
event_cursor: int = Field(ge=0)
class TaskPresentationHistoryItem(StrictModel):
event_seq: PositiveInt
run_id: UUID
execution_generation: PositiveInt
occurred_at: WorkbenchDateTime
# Stored records are validated against the current command contract by the
# reader; an obsolete record must not prevent reading the rest of the page.
command: dict[str, JsonValue]
class TaskPresentationHistoryPage(StrictModel):
task_id: UUID
event_cursor: int = Field(ge=0)
items: list[TaskPresentationHistoryItem]
next_after: int | None = Field(default=None, ge=0)
class TaskDataQueryResult(StrictModel):
evidence: EvidenceRecord
snapshot: TaskDataSnapshot
@@ -816,7 +921,7 @@ class OperationalTaskNotification(StrictModel):
description: NonEmptyStr
required_permission: NonEmptyStr | None = None
lifecycle_status: Literal["active", "resolved"] = "active"
subject_type: Literal["task", "run", "access_request", "action"] = "task"
subject_type: Literal["task", "run", "access_request", "action", "question"] = "task"
subject_id: UUID | None = None
report_reference: InvestigationReportReference | None = None
resolved_at: WorkbenchDateTime | None = None
+75
View File
@@ -0,0 +1,75 @@
"""Durable investigation questions; answers are user input, not field evidence."""
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from app.domain.schemas.shared import StrictModel, PositiveInt
from app.domain.schemas.workbench_runtime import WorkbenchDateTime
Id = Annotated[str, Field(min_length=1, max_length=128)]
class TaskQuestionOption(StrictModel):
id: Id
label: Annotated[str, Field(min_length=1, max_length=200)]
class TaskQuestion(StrictModel):
question_id: Id
title: Annotated[str, Field(min_length=1, max_length=200)]
body: Annotated[str, Field(min_length=1, max_length=2000)]
options: list[TaskQuestionOption] = Field(max_length=12)
mode: Literal["single", "multiple"]
allow_custom: bool
required: bool
@model_validator(mode="after")
def valid_options(self):
if len({o.id for o in self.options}) != len(self.options):
raise ValueError("duplicate option ids")
if not self.options and not self.allow_custom:
raise ValueError("question must offer an answer input")
return self
class TaskQuestionAnswer(StrictModel):
question_id: Id
selected_option_ids: list[Id] = Field(max_length=12)
custom_text: Annotated[str, Field(max_length=2000)]
class TaskQuestionCreate(StrictModel):
request_id: UUID
source_session_id: Id
run_id: UUID
questions: list[TaskQuestion] = Field(min_length=1, max_length=3)
@model_validator(mode="after")
def unique_questions(self):
if len({q.question_id for q in self.questions}) != len(self.questions):
raise ValueError("duplicate question ids")
if len(self.model_dump_json().encode()) > 32768:
raise ValueError("question definition too large")
return self
class TaskQuestionReply(StrictModel):
request_id: UUID
expected_version: PositiveInt
answers: list[TaskQuestionAnswer] = Field(max_length=3)
class TaskQuestionRequest(StrictModel):
question_request_id: UUID
task_id: UUID
run_id: UUID
execution_generation: PositiveInt
source_session_id: Id
questions: list[TaskQuestion]
status: Literal["pending", "answered", "cancelled"]
version: PositiveInt
created_at: WorkbenchDateTime
answered_at: WorkbenchDateTime | None = None
answered_by: str | None = None
answers: list[TaskQuestionAnswer] = Field(default_factory=list)
+74
View File
@@ -0,0 +1,74 @@
"""Database resources owned by the standalone calculation worker."""
from __future__ import annotations
from contextlib import asynccontextmanager
from uuid import UUID
from sqlalchemy import text
@asynccontextmanager
async def queue_repository(project_id):
"""Resolve the current project route and open its calculation queue."""
from app.core.config import settings
from app.infra.db.dynamic_manager import project_connection_manager
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
project_uuid = UUID(str(project_id))
async with SessionLocal() as session:
metadata = MetadataRepository(session)
project = await metadata.get_project_by_id(project_uuid)
if not project or project.status != "active":
raise ValueError("Calculation project is not active")
routing = await metadata.get_project_db_routing(project_uuid, "biz_data")
if not routing or routing.db_type != "postgresql":
raise ValueError("Project business PostgreSQL routing is unavailable")
await session.commit()
pool_min_size = (
routing.pool_min_size
if routing.pool_min_size is not None
else settings.PROJECT_PG_POOL_MIN_SIZE
)
pool_max_size = (
routing.pool_max_size
if routing.pool_max_size is not None
else settings.PROJECT_PG_POOL_SIZE
)
async with project_connection_manager.pg_connection(
project_uuid,
"biz_data",
routing.dsn,
pool_min_size,
pool_max_size,
) as connection:
yield CalculationJobRepository(connection, project_id)
class CalculationSupervisorLease:
"""Keep the singleton advisory lock alive on one metadata connection."""
def __init__(self, connection):
self.connection = connection
async def ping(self) -> None:
await self.connection.execute(text("SELECT 1"))
await self.connection.commit()
@asynccontextmanager
async def calculation_supervisor_lease():
"""Acquire and release the session-level calculation supervisor lock."""
from app.infra.db.metadb.database import engine
async with engine.connect() as connection:
acquired = await connection.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
await connection.commit()
if not acquired:
raise RuntimeError("Another calculation supervisor is already active")
try:
yield CalculationSupervisorLease(connection)
finally:
await connection.execute(text("SELECT pg_advisory_unlock(1748531781, 2)"))
await connection.commit()
+74 -27
View File
@@ -3,6 +3,14 @@ from uuid import uuid4
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from app.mcp.errors import CalculationCapacityExceeded
# Explicit metadata projection prevents status polling from decoding large JSON results.
JOB_COLUMNS = "job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,error,lease_generation,lease_owner,lease_expires_at,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
PUBLIC_JOB_COLUMNS = "job_id,status,tool,error,created_at,updated_at,started_at,finished_at,queue_expires_at,result_expires_at"
class CalculationJobRepository:
@@ -10,30 +18,65 @@ class CalculationJobRepository:
self.connection = connection
self.project_id = str(project_id)
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds):
cursor = await self.connection.execute(
"""INSERT INTO workbench.calculation_jobs
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(project_id,owner_user_id,request_key) DO NOTHING RETURNING *""",
(uuid4(), self.project_id, owner_user_id, request_key, tool,
Jsonb(parameters), Jsonb(binding), timeout_seconds),
async def maintain(self):
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status='timed_out',
error='{"code":"QUEUE_TIMEOUT"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
WHERE project_id=%s AND status='queued' AND queue_expires_at<=clock_timestamp()""",
(self.project_id,),
)
row = await cursor.fetchone()
if row:
return dict(row)
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET result=NULL
WHERE project_id=%s AND result IS NOT NULL AND result_expires_at<=clock_timestamp()""",
(self.project_id,),
)
async def enqueue(self, *, owner_user_id, request_key, tool, parameters, binding, timeout_seconds,
queue_wait_seconds=3600, max_active_jobs=100):
async with self.connection.transaction():
return await self._enqueue(owner_user_id=owner_user_id, request_key=request_key,
tool=tool, parameters=parameters, binding=binding, timeout_seconds=timeout_seconds,
queue_wait_seconds=queue_wait_seconds, max_active_jobs=max_active_jobs)
async def _enqueue(self, *, owner_user_id, request_key, tool, parameters, binding,
timeout_seconds, queue_wait_seconds, max_active_jobs):
# Serialize admission within the caller's transaction, across every API process.
await self.connection.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 1748531781))", (self.project_id,))
await self.maintain()
cursor = await self.connection.execute(
"SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
f"SELECT {JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s AND request_key=%s",
(self.project_id, owner_user_id, request_key),
)
row = dict(await cursor.fetchone())
if row['tool'] != tool or row['parameters'] != parameters or row['binding'] != binding:
raise HTTPException(409, 'Calculation request_key was used with different inputs')
return row
async def get(self, job_id, owner_user_id=None):
existing = await cursor.fetchone()
if existing:
row = dict(existing)
if (row['tool'] != tool
or {key: value for key, value in row['parameters'].items() if key != 'timeout_seconds'}
!= {key: value for key, value in parameters.items() if key != 'timeout_seconds'}
or row['binding'] != binding):
raise HTTPException(409, 'Calculation request_key was used with different inputs')
return row
cursor = await self.connection.execute(
"""SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
"""SELECT count(*) AS active FROM workbench.calculation_jobs WHERE project_id=%s
AND status IN ('queued','running','cancel_requested')""", (self.project_id,))
if (await cursor.fetchone())['active'] >= max_active_jobs:
raise CalculationCapacityExceeded()
cursor = await self.connection.execute(
f"""INSERT INTO workbench.calculation_jobs
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,queue_expires_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,clock_timestamp()+%s*interval '1 second')
RETURNING {JOB_COLUMNS}""",
(uuid4(), self.project_id, owner_user_id, request_key, tool,
Jsonb(parameters), Jsonb(binding), timeout_seconds, queue_wait_seconds),
)
return dict(await cursor.fetchone())
async def get(self, job_id, owner_user_id=None, *, include_result=False):
await self.maintain()
columns = JOB_COLUMNS + (",result" if include_result else "")
cursor = await self.connection.execute(
f"""SELECT {columns} FROM workbench.calculation_jobs WHERE project_id=%s AND job_id=%s
AND (%s::uuid IS NULL OR owner_user_id=%s)""",
(self.project_id, job_id, owner_user_id, owner_user_id),
)
@@ -41,8 +84,9 @@ class CalculationJobRepository:
return dict(row) if row else None
async def list(self, *, owner_user_id, binding, limit, offset, status=None):
await self.maintain()
cursor = await self.connection.execute(
"""SELECT * FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
f"""SELECT {PUBLIC_JOB_COLUMNS} FROM workbench.calculation_jobs WHERE project_id=%s AND owner_user_id=%s
AND binding @> %s AND (%s::text IS NULL OR status=%s) ORDER BY created_at DESC,job_id LIMIT %s OFFSET %s""",
(self.project_id, owner_user_id, Jsonb(binding), status, status, limit, offset),
)
@@ -51,28 +95,30 @@ class CalculationJobRepository:
async def cancel(self, job_id, owner_user_id):
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status=CASE WHEN status='queued' THEN 'cancelled'
ELSE 'cancel_requested' END,updated_at=clock_timestamp()
ELSE 'cancel_requested' END,finished_at=CASE WHEN status='queued' THEN clock_timestamp() ELSE finished_at END,updated_at=clock_timestamp()
WHERE project_id=%s AND job_id=%s AND owner_user_id=%s AND status IN ('queued','running')""",
(self.project_id, job_id, owner_user_id),
)
return await self.get(job_id, owner_user_id)
async def recover(self):
await self.maintain()
# A crashed write is never automatically replayed.
await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status='interrupted',
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,updated_at=clock_timestamp()
error='{"code":"WORKER_INTERRUPTED"}'::jsonb,finished_at=clock_timestamp(),updated_at=clock_timestamp()
WHERE project_id=%s AND status IN ('running','cancel_requested')
AND lease_expires_at < clock_timestamp()""", (self.project_id,),
)
async def claim(self, owner, lease_seconds):
await self.maintain()
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs j SET status='running',lease_owner=%s,
"""UPDATE workbench.calculation_jobs j SET status='running',started_at=clock_timestamp(),lease_owner=%s,
lease_generation=j.lease_generation+1,
lease_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
WHERE j.job_id=(SELECT job_id FROM workbench.calculation_jobs
WHERE project_id=%s AND status='queued' ORDER BY created_at,job_id
WHERE project_id=%s AND status='queued' AND queue_expires_at>clock_timestamp() ORDER BY created_at,job_id
FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING j.*""",
(owner, lease_seconds, self.project_id),
)
@@ -90,14 +136,15 @@ class CalculationJobRepository:
result = await cursor.fetchone()
return result['status'] if result else None
async def finish(self, row, owner, status, *, result=None, error=None):
async def finish(self, row, owner, status, *, result=None, error=None, result_retention_seconds=604800):
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,updated_at=clock_timestamp()
"""UPDATE workbench.calculation_jobs SET status=%s,result=%s,error=%s,finished_at=clock_timestamp(),
result_expires_at=clock_timestamp()+%s*interval '1 second',updated_at=clock_timestamp()
WHERE project_id=%s AND job_id=%s AND lease_owner=%s AND lease_generation=%s
AND lease_expires_at>clock_timestamp() AND status IN ('running','cancel_requested')
AND (status!='cancel_requested' OR %s IN ('cancelled','interrupted')
OR (%s='failed' AND %s='CLEANUP_FAILED')) RETURNING job_id""",
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None,
(status, Jsonb(result) if result is not None else None, Jsonb(error) if error else None, result_retention_seconds,
self.project_id, row['job_id'], owner, row['lease_generation'], status, status, (error or {}).get('code')),
)
return await cursor.fetchone() is not None
+77 -10
View File
@@ -19,6 +19,7 @@ class OperationalTaskRepository:
def __init__(self, connection: AsyncConnection, project_id):
self.connection = connection
self.project_id = str(project_id)
self._agent_ownership_available: bool | None = None
@asynccontextmanager
async def transaction(self):
@@ -89,6 +90,34 @@ class OperationalTaskRepository:
)
return [str(row["job_id"]) for row in await cursor.fetchall()]
async def list_questions(self, task_id: str) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_questions
WHERE project_id=%s AND task_id=%s
ORDER BY document->>'created_at' DESC, question_request_id DESC LIMIT 100""",
(self.project_id, task_id),
)
return [row["document"] for row in await cursor.fetchall()]
async def get_question(self, task_id: str, question_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_task_questions
WHERE project_id=%s AND task_id=%s AND question_request_id=%s""",
(self.project_id, task_id, question_id),
)
row = await cursor.fetchone()
return row["document"] if row else None
async def save_question(self, task_id: str, document: dict) -> None:
# All writers hold the owning task advisory lock.
await self.connection.execute(
"""INSERT INTO workbench.operational_task_questions
(project_id,task_id,question_request_id,document) VALUES (%s,%s,%s,%s)
ON CONFLICT (project_id,question_request_id) DO UPDATE SET document=excluded.document
WHERE operational_task_questions.task_id=excluded.task_id""",
(self.project_id, task_id, document["question_request_id"], Jsonb(document)),
)
async def get_task(self, task_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
@@ -101,7 +130,8 @@ class OperationalTaskRepository:
async def get_active_run(self, task_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -116,7 +146,8 @@ class OperationalTaskRepository:
async def get_run(self, run_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -143,13 +174,23 @@ class OperationalTaskRepository:
async def require_execution_storage(self) -> None:
"""Reject claims before changing state if the inbox cannot be used."""
cursor = await self.connection.execute(
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
"""SELECT to_regclass('workbench.agent_messages') IS NOT NULL
AND to_regclass('workbench.operational_task_questions') IS NOT NULL AS available,
EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid=to_regclass('workbench.operational_task_runs')
AND attname='agent_instance_id' AND NOT attisdropped
) AS agent_ownership_available"""
)
if not (await cursor.fetchone())["available"]:
readiness = await cursor.fetchone()
self._agent_ownership_available = bool(readiness.get("agent_ownership_available"))
if not readiness["available"] or not self._agent_ownership_available:
raise DomainError(
"DEPENDENCY_UNAVAILABLE",
"Agent message storage is not provisioned; apply "
"resources/sql/024_agent_messages.sql to this project database before starting runs",
"Agent execution storage is not provisioned; apply "
"resources/sql/024_agent_messages.sql, resources/sql/028_task_questions.sql, "
"and resources/sql/030_operational_run_agent_ownership.sql "
"to this project database before starting runs",
status=503,
)
@@ -190,14 +231,26 @@ class OperationalTaskRepository:
)
async def update_run(self, run: dict, expected_revision: int) -> None:
if self._agent_ownership_available is None:
cursor = await self.connection.execute(
"""SELECT EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid=to_regclass('workbench.operational_task_runs')
AND attname='agent_instance_id' AND NOT attisdropped
) AS available"""
)
self._agent_ownership_available = bool((await cursor.fetchone())["available"])
owner_assignment = "agent_instance_id=%s," if self._agent_ownership_available else ""
owner_values = (run.get("agent_instance_id"),) if self._agent_ownership_available else ()
cursor = await self.connection.execute(
"""UPDATE workbench.operational_task_runs SET
status=%s,revision=%s,agent_session_id=%s,lease_expires_at=%s,
f"""UPDATE workbench.operational_task_runs SET
status=%s,revision=%s,agent_session_id=%s,{owner_assignment}lease_expires_at=%s,
heartbeat_at=%s,started_at=%s,finished_at=%s,failure_code=%s,
failure_detail=%s,dispatch_attempts=%s,next_dispatch_at=%s,updated_at=%s
WHERE project_id=%s AND run_id=%s AND revision=%s""",
(
run["status"], run["revision"], run.get("agent_session_id"),
*owner_values,
run.get("lease_expires_at"), run.get("heartbeat_at"),
run.get("started_at"), run.get("finished_at"), run.get("failure_code"),
run.get("failure_detail"), run.get("dispatch_attempts", 0),
@@ -214,7 +267,8 @@ class OperationalTaskRepository:
) -> list[dict]:
result = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -243,7 +297,8 @@ class OperationalTaskRepository:
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
agent_session_id,to_jsonb(operational_task_runs)->>'agent_instance_id' AS agent_instance_id,
lease_expires_at,heartbeat_at,started_at,finished_at,
failure_code,failure_detail,dispatch_attempts,next_dispatch_at,
created_at,updated_at
FROM workbench.operational_task_runs
@@ -300,6 +355,18 @@ class OperationalTaskRepository:
async def current_event_cursor(self) -> int:
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
async def list_presentation_events(self, task_id: str, after: int, through: int, limit: int) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT event_seq,occurred_at,metadata
FROM workbench.domain_event_log
WHERE project_id=%s AND topic='operational.presentation.requested'
AND aggregate_type='operational_task_presentation' AND aggregate_id=%s
AND event_seq>%s AND event_seq<=%s
ORDER BY event_seq LIMIT %s""",
(self.project_id, task_id, after, through, limit),
)
return [dict(row) for row in await cursor.fetchall()]
async def get_task_by_create_request(self, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT document FROM workbench.operational_tasks
+1
View File
@@ -337,6 +337,7 @@ async def task_context_page(
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
"questions": ("operational_task_questions", "question_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
@@ -52,7 +52,7 @@ class SampleWorkOrderReceipt(BaseModel):
work_order_id: UUID
project_id: UUID
created_at: datetime
origin: Literal["synthetic"]
origin: Literal["system_data"]
status: Literal[
"draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"
]
@@ -195,7 +195,8 @@ class HttpOperationalActionDispatcher:
receipt = ActionReceipt(
adapter="peripheral_sample", receipt_id=str(order.work_order_id),
submitted_at=order.created_at, completed_at=order.created_at,
external_status=order.status, detail="合成外围工单已创建,现场工作进度由外围系统继续同步",
external_status=order.status,
detail="外围工单已创建,现场工作进度由外围系统继续同步",
)
return ActionDispatchOutcome("succeeded", receipt.detail, receipt)
result = CommandGatewayReceipt.model_validate(value)
+26 -1
View File
@@ -5,6 +5,7 @@ import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import httpx
from pydantic import Field
@@ -33,6 +34,23 @@ class TaskDataError(Exception):
self.detail = detail
def peripheral_records_origin(items: object) -> Literal["system_data", "synthetic"]:
"""Classify a record collection; mixed collections retain per-item provenance."""
if not isinstance(items, list) or any(
not isinstance(item, dict) or item.get("origin") not in {"system_data", "synthetic"}
for item in items
):
raise TaskDataError(502, "外围记录缺少明确来源,不能作为已核实证据")
return "synthetic" if items and all(item["origin"] == "synthetic" for item in items) else "system_data"
def normalize_peripheral_work_orders(items: object) -> list[dict[str, object]]:
"""Expose peripheral work orders only as project-owned system data."""
if not isinstance(items, list) or any(not isinstance(item, dict) for item in items):
raise TaskDataError(502, "外围工单格式无效,不能作为已核实证据")
return [{**item, "origin": "system_data"} for item in items]
def _matches_project(value: object, project_id: str) -> bool:
if isinstance(value, list):
return all(_matches_project(item, project_id) for item in value)
@@ -147,7 +165,14 @@ async def read_task_data(
data = _select_demo_observations(data, query, str(context.project_id))
elif not _matches_project(data, str(context.project_id)):
raise TaskDataError(502, "数据源返回了其他项目的数据")
origin = "synthetic" if peripheral else "system_data"
origin = "system_data"
if query.source == "peripheral_work_orders":
items = data.get("items") if isinstance(data, dict) else None
data = {**data, "items": normalize_peripheral_work_orders(items)}
elif query.source == "peripheral_test_data":
items = data.get("items") if isinstance(data, dict) else None
origin = peripheral_records_origin(items)
data = {**data, "provenance_scope": "per_record", "source_origins": sorted({item["origin"] for item in items})}
historical_replay = False
if query.source in {"peripheral_points", "peripheral_observations"}:
source_origin = data.get("origin") if isinstance(data, dict) else None
+5 -4
View File
@@ -9,13 +9,13 @@ from pydantic import Field, model_validator
from app.mcp.models import Id, StrictInput
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
MAX_REQUEST_TIMEOUT_SECONDS = 240
class CalculationInput(StrictInput):
timeout_seconds: int = Field(
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
)
@@ -38,7 +38,7 @@ class BurstDetectionInput(CalculationInput):
class BurstLocationInput(CalculationInput):
data_source: Literal["monitoring", "simulation"] = "monitoring"
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="当前爆管定位算法必需的假定漏水流量,WNTR SI单位m³/s;可用有数据和方法依据的估计或明确给定的情景假设,说明来源与不确定性,不要求现场实测确认;不能任填数值")
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
scada_burst_start: datetime
@@ -69,6 +69,7 @@ class BurstLocationInput(CalculationInput):
class ValveIsolationInput(CalculationInput):
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
+20 -6
View File
@@ -203,7 +203,11 @@ class McpQueryApplication:
return None
schema = tool.model.model_json_schema()
if tool.computation or tool.mutating:
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_calculation_timeout_seconds
schema["properties"]["timeout_seconds"]["maximum"] = self.settings.max_request_timeout_seconds
schema["properties"]["timeout_seconds"]["default"] = min(
self.settings.query_timeout_seconds,
self.settings.max_request_timeout_seconds,
)
return schema
async def list_tools(self, ctx, params):
@@ -213,6 +217,10 @@ class McpQueryApplication:
for name in sorted(TOOLS):
tool = TOOLS[name].protocol_tool()
tool.input_schema.update(self.tool_input_schema(name))
tool.meta = {**(tool.meta or {}), "io.tjwater/request-timeout": {
"defaultSeconds": self.settings.query_timeout_seconds,
"maxSeconds": self.settings.max_request_timeout_seconds,
}}
tools.append(tool)
return types.ListToolsResult(
tools=tools,
@@ -291,11 +299,13 @@ class McpQueryApplication:
return tool_error(
"INVALID_QUERY", "Query parameters do not match the tool schema"
)
if (tool.computation or tool.mutating) and "timeout_seconds" not in args.model_fields_set:
args.timeout_seconds = min(self.settings.query_timeout_seconds, self.settings.max_request_timeout_seconds)
timeout_seconds = (
args.timeout_seconds if tool.computation or tool.mutating else self.settings.query_timeout_seconds
)
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_calculation_timeout_seconds:
return tool_error("INVALID_QUERY", "Calculation timeout exceeds the server limit")
if (tool.computation or tool.mutating) and timeout_seconds > self.settings.max_request_timeout_seconds:
return tool_error("INVALID_QUERY", "HTTP request timeout exceeds the server limit")
request = ctx.request
project = request.headers.get("x-project-id", "") if request else ""
meta = ctx.meta or {}
@@ -349,7 +359,10 @@ class McpQueryApplication:
else:
from app.mcp.calculation_jobs import should_queue, submit_calculation
if should_queue(tool, args):
result = await submit_calculation(scope, binding, tool, args, call_id)
result = await submit_calculation(
scope, binding, tool, args, call_id,
job_timeout_seconds=self.settings.max_calculation_timeout_seconds,
)
else:
result = (
await self.executor(
@@ -463,6 +476,7 @@ class McpQueryApplication:
403: "QUERY_CONTEXT_DENIED",
404: "NOT_FOUND",
409: "QUERY_CONTEXT_STALE",
410: "RESULT_EXPIRED",
413: "RESULT_TOO_LARGE",
422: "INVALID_QUERY",
503: "SOURCE_UNAVAILABLE",
@@ -471,7 +485,7 @@ class McpQueryApplication:
codes.get(exc.status_code, "QUERY_FAILED"),
str(exc.detail)
if isinstance(exc.detail, str)
and exc.status_code in {403, 404, 409, 413, 422}
and exc.status_code in {403, 404, 409, 410, 413, 422}
else "Query is unavailable",
)
except (UndefinedTable, InvalidSchemaName):
@@ -480,7 +494,7 @@ class McpQueryApplication:
"MCP project storage has not been provisioned",
)
except TimeoutError:
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
except ValueError:
return tool_error(
"INVALID_QUERY", "Invalid query value or incompatible source data"
+17 -8
View File
@@ -39,12 +39,15 @@ def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
def public_job(row: dict) -> dict:
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error"}
fields = {"job_id", "status", "tool", "created_at", "started_at", "finished_at", "updated_at", "error", "queue_expires_at", "result_expires_at"}
from fastapi.encoders import jsonable_encoder
return {**jsonable_encoder({key: row[key] for key in fields if key in row}), "poll_after_ms": 1500}
expiry = row.get("result_expires_at")
available = row.get("status") == "succeeded" and (expiry is None or expiry > datetime.now(timezone.utc))
return {**jsonable_encoder({key: row[key] for key in fields if key in row}),
"result_available": available, "poll_after_ms": 1500}
async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryResult:
async def submit_calculation(scope, binding, tool, args, call_id: str, *, job_timeout_seconds: int = 3600) -> QueryResult:
from app.mcp.query_service import authorize_query
await authorize_query(scope, binding, tool, args)
if binding.purpose == "task_reader":
@@ -68,7 +71,7 @@ async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryR
if not retryable_capacity_rejection(previous["result"]):
raise HTTPException(409, "Operation already started; inspect the existing calculation or mutation status")
check_digest(args, await preview_change(scope, binding, tool.name, args))
parameters = args.model_dump(mode="json", exclude={"context_handle"})
parameters = args.model_dump(mode="json", exclude={"context_handle", "timeout_seconds"})
request_id = str(args.request_id) if tool.mutating else call_id
key = str(uuid5(NAMESPACE_URL, ":".join([binding.subject, binding.client_id, binding.native_session_id, str(binding.task_id), str(binding.run_id), request_id])))
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
@@ -84,12 +87,12 @@ async def submit_calculation(scope, binding, tool, args, call_id: str) -> QueryR
await verify_task(scope, binding)
row = await repo.enqueue(
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
)
else:
row = await repo.enqueue(
owner_user_id=scope.project.user_id, request_key=key, tool=tool.name,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=args.timeout_seconds,
parameters=parameters, binding=durable_binding(binding), timeout_seconds=job_timeout_seconds,
)
await scope.connection.commit()
return envelope(tool.name, public_job(row), job_id=row["job_id"])
@@ -107,7 +110,9 @@ async def authorize_original(scope, row):
from app.mcp.contexts import verify_task
original = restore_binding(row["binding"])
tool = TOOLS[row["tool"]]
args = tool.model.model_validate({**row["parameters"], "context_handle": "durable-calculation-context-handle"})
# Legacy queue rows stored the old HTTP timeout. It does not govern durable execution.
parameters = {key: value for key, value in row["parameters"].items() if key != "timeout_seconds"}
args = tool.model.model_validate({**parameters, "context_handle": "durable-calculation-context-handle"})
if (str(scope.project.user_id) != str(row["owner_user_id"])
or str(scope.project.project_id) != str(original.project_id)):
raise HTTPException(403, "Calculation owner is no longer authorized")
@@ -125,7 +130,8 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
})
rows = await repo.list(owner_user_id=scope.project.user_id, binding=identity_scope, limit=args.limit + 1, offset=args.offset, status=args.status)
return envelope(tool.name, [public_job(row) for row in rows[:args.limit]], page=PageInfo(limit=args.limit, offset=args.offset, has_more=len(rows) > args.limit))
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id)
row = await repo.get(args.job_id, owner_user_id=scope.project.user_id,
include_result=tool.name == "get_calculation_result")
if row is None:
raise HTTPException(404, "Calculation was not found")
check_binding(binding, row)
@@ -137,6 +143,9 @@ async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
await scope.connection.commit()
if tool.name != "get_calculation_result" or row["status"] != "succeeded":
return envelope(tool.name, public_job(row), job_id=row["job_id"])
expiry = row.get("result_expires_at")
if row.get("result") is None or (expiry is not None and expiry <= datetime.now(timezone.utc)):
raise HTTPException(410, "Calculation result retention period has expired")
result = QueryResult.model_validate(row["result"])
result = await attach_calculation_evidence(scope, row, result)
value = result.data
+3 -3
View File
@@ -59,8 +59,8 @@ _specs = [
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
@@ -213,7 +213,7 @@ _specs = [
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
m.PeripheralInput,
"simulation.view",
"peripheral",
+4 -1
View File
@@ -21,7 +21,8 @@ class McpSettings(BaseSettings):
)
allowed_origins: list[str] = Field(default_factory=list)
context_ttl_seconds: int = Field(default=600, ge=30, le=600)
query_timeout_seconds: int = Field(default=30, ge=1, le=60)
query_timeout_seconds: int = Field(default=120, ge=1, le=240)
max_request_timeout_seconds: int = Field(default=240, ge=1, le=240)
max_calculation_timeout_seconds: int = Field(default=3600, ge=120, le=86400)
model_config = SettingsConfigDict(
env_prefix="TJWATER_MCP_",
@@ -32,6 +33,8 @@ class McpSettings(BaseSettings):
@model_validator(mode="after")
def validate_resource(self):
if self.query_timeout_seconds > self.max_request_timeout_seconds:
raise ValueError("query timeout exceeds the HTTP request timeout limit")
if self.dev_http_issuer_uri:
dev_url = urlsplit(self.dev_http_issuer_uri)
try:
+1 -1
View File
@@ -22,7 +22,7 @@ class PageInput(StrictInput):
class TaskContextInput(PageInput):
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"] = "summary"
section: Literal["summary", "task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"] = "summary"
report_revision: int | None = Field(default=None, ge=1)
data_path: list[Id] = Field(default_factory=list, max_length=8)
+19 -6
View File
@@ -7,7 +7,12 @@ from app.infra.db.postgresql.read_queries import MAX_QUERY_BYTES, read_rows, sca
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataQuery
from app.services.content_digest import digest
from samples.peripheral.store import PostgresStore
from app.infra.integrations.operational_data import _select_demo_observations
from app.infra.integrations.operational_data import (
TaskDataError,
_select_demo_observations,
normalize_peripheral_work_orders,
peripheral_records_origin,
)
async def read_peripheral_query(scope, args):
@@ -37,17 +42,25 @@ async def read_peripheral_query(scope, args):
truncated = len(rows) > args.limit
if any(item.get("project_id") != project for item in items):
raise HTTPException(502, "Peripheral project mismatch")
try:
if args.source == "peripheral_work_orders":
items = normalize_peripheral_work_orders(items)
origin = "system_data"
else:
origin = peripheral_records_origin(items)
except TaskDataError as exc:
raise HTTPException(exc.status, exc.detail) from exc
data = {
"items": items,
"truncated": truncated,
"limit": args.limit,
"offset": args.offset,
}
origin = (
"synthetic"
if items and any(item.get("origin") == "synthetic" for item in items)
else "system_data"
)
if args.source == "peripheral_test_data":
data.update(
provenance_scope="per_record",
source_origins=sorted({item["origin"] for item in items}),
)
elif args.source == "peripheral_points":
rows = await scada_devices(conn, limit=args.limit + 1, offset=args.offset)
data = {
+2 -2
View File
@@ -412,14 +412,14 @@ async def execute_query(
data["display_time_zone"] = "Asia/Shanghai"
data["approved_data_scopes"] = binding.task_scope_cap
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
truncated = True
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
else:
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
total = projection["total"]
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
total = projection["snapshot_metadata"].get("total", total)
if projection["value_type"] in {"array", "object", "string"}:
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
+41 -1
View File
@@ -1,4 +1,4 @@
"""Read-only projections backed by the GIS materialized-view query layer."""
"""Read-only GIS projections and authoritative network topology snapshots."""
from typing import Any
@@ -69,6 +69,46 @@ def get_network_link_nodes(name: str) -> list[str]:
]
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
"""Read links, closed statuses and sources from one consistent SQL snapshot.
Isolation must include model links without published geometries, and use
initial statuses rather than treating every link as open. UNION ALL keeps
source nodes in the same statement snapshot without repeating them for
every link or relying on materialized view refresh timing.
"""
rows = read_all(
name,
"""
SELECT l.id, l.link_type::text AS element_type,
l.start_node_id, l.end_node_id,
COALESCE(s.status::text, p.status::text) AS status
FROM network.links AS l
LEFT JOIN network.pipes AS p ON p.link_id = l.id
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
UNION ALL
SELECT n.id, n.node_type::text AS element_type,
NULL, NULL, NULL
FROM network.nodes AS n
WHERE n.node_type::text IN ('reservoir', 'tank')
""",
)
link_entries: list[str] = []
source_nodes: list[str] = []
closed_links: list[str] = []
for row in rows:
element_id = str(row["id"])
if row["element_type"] in {"reservoir", "tank"}:
source_nodes.append(element_id)
else:
link_entries.append(
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
)
if row["status"] == "CLOSED":
closed_links.append(element_id)
return link_entries, source_nodes, closed_links
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
"""Return large pipes in the established API wire format."""
rows = read_all(
+8 -27
View File
@@ -46,22 +46,10 @@ class WorkerSettings:
@asynccontextmanager
async def queue_repository(project_id):
"""Resolve current routing from metadata, never from a job payload."""
from psycopg import AsyncConnection
from psycopg.rows import dict_row
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
async with SessionLocal() as session:
metadata = MetadataRepository(session)
project = await metadata.get_project_by_id(UUID(str(project_id)))
if not project or project.status != 'active':
raise ValueError('Calculation project is not active')
routing = await metadata.get_project_db_routing(UUID(str(project_id)), 'biz_data')
if not routing or routing.db_type != 'postgresql':
raise ValueError('Project business PostgreSQL routing is unavailable')
await session.commit()
async with await AsyncConnection.connect(routing.dsn, row_factory=dict_row) as conn:
yield CalculationJobRepository(conn, project_id)
from app.infra.db.calculation_worker import queue_repository as open_repository
async with open_repository(project_id) as repository:
yield repository
@asynccontextmanager
@@ -246,15 +234,11 @@ class CalculationWorker:
await cleanup_job_resources(row, directory)
async def run(self):
from sqlalchemy import text
from app.infra.db.metadb.database import engine
from app.infra.db.calculation_worker import calculation_supervisor_lease
# Dedicated session-level metadata lock: independent API workers never
# participate; duplicate supervisors exit before claiming any jobs.
async with engine.connect() as lock:
acquired = await lock.scalar(text("SELECT pg_try_advisory_lock(1748531781, 2)"))
await lock.commit()
if not acquired:
raise RuntimeError('Another calculation supervisor is already active')
async with calculation_supervisor_lease() as lease:
tasks = set()
project_cursor = 0
try:
@@ -264,8 +248,7 @@ class CalculationWorker:
await recover_job_resources(self.settings.project_ids)
while True:
async with asyncio.timeout(self.settings.heartbeat_seconds):
await lock.execute(text('SELECT 1'))
await lock.commit()
await lease.ping()
for task in tuple(tasks):
if task.done():
tasks.remove(task)
@@ -286,8 +269,6 @@ class CalculationWorker:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
await lock.execute(text('SELECT pg_advisory_unlock(1748531781, 2)'))
await lock.commit()
async def guard_child(input_path, output_path):
+1 -1
View File
@@ -88,7 +88,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
properties = feature.get("properties") or {}
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
normalized["id"] = feature["id"]
if mode == "heatmap":
weight = properties.get("weight", 1)
+61 -7
View File
@@ -19,6 +19,7 @@ from app.auth.permissions import (
SIMULATION_VIEW,
)
from app.domain.domain_errors import DomainError, require
from app.services import task_questions
from app.domain.schemas.operational_task import (
ActionDecisionCommand,
ActionExecuteCommand,
@@ -65,6 +66,8 @@ from app.domain.schemas.operational_task import (
TaskPresentationEventCommand,
TaskPresentationEventResult,
TaskPresentationCursor,
TaskPresentationHistoryItem,
TaskPresentationHistoryPage,
)
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
from app.services.content_digest import digest
@@ -97,6 +100,15 @@ class OperationalTaskService:
def _actor(self) -> str:
return f"principal:{self.context.user_id}"
@staticmethod
def _require_run_owner(run: dict, command: AgentRunCommand) -> None:
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
owner = run.get("agent_instance_id")
if owner is not None:
require(owner == command.agent_instance_id,
"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")
@@ -138,6 +150,7 @@ class OperationalTaskService:
permissions = self.permissions
return OperationalTaskPermissions(
can_publish_artifacts=OPERATIONAL_TASK_EXECUTE in permissions,
can_answer_questions=OPERATIONAL_TASK_WRITE in permissions,
can_propose_actions=OPERATIONAL_TASK_WRITE in permissions,
can_sync_external_feedback=OPERATIONAL_TASK_EXTERNAL_SYNC in permissions,
can_decide_work_orders=OPERATIONAL_ACTION_WORK_ORDER_APPROVE in permissions,
@@ -258,6 +271,7 @@ class OperationalTaskService:
evidence=evidence,
actions=actions,
access_requests=access_requests,
questions=await task_questions.list_questions(self, task_id),
permissions=self._permission_view(),
work_order_snapshot=work_order_snapshot,
)
@@ -483,6 +497,7 @@ class OperationalTaskService:
status="running",
revision=expected + 1,
agent_session_id=command.source_session_id,
agent_instance_id=command.agent_instance_id,
lease_expires_at=now + LEASE_DURATION,
heartbeat_at=now,
started_at=run.get("started_at") or now,
@@ -527,8 +542,7 @@ class OperationalTaskService:
"Run revision changed", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only an active run has a lease", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
self._require_run_owner(run, command)
now = self._now()
self._require_active_run_lease(run, now)
expected = run["revision"]
@@ -580,6 +594,7 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{run_id}")
run = await self._run(task_id, run_id)
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
@@ -587,6 +602,7 @@ class OperationalTaskService:
require(run["status"] in {"queued", "claimed", "running"},
"INVALID_TRANSITION", "Run is already terminal", status=409)
now = self._now()
await task_questions.cancel_pending(self, task_id, now)
expected = run["revision"]
run.update(
status="cancelled", revision=expected + 1, finished_at=now,
@@ -624,6 +640,7 @@ class OperationalTaskService:
require(await self.repo.get_active_run(str(task_id)) is None,
"ACTIVE_RUN_EXISTS", "Task already has an active run", status=409)
now = self._now()
await task_questions.cancel_pending(self, task_id, now)
generation = task["execution_generation"] + 1
run = self._new_run(task_id, generation, now)
await self.repo.insert_run(_json(run))
@@ -986,6 +1003,27 @@ class OperationalTaskService:
event_cursor=cursor,
)
async def presentation_history(
self, task_id: UUID, *, after: int = 0, through: int | None = None, limit: int = 100,
) -> TaskPresentationHistoryPage:
require(0 <= after and (through is None or through >= 0) and 1 <= limit <= 200,
"INVALID_ARGUMENT", "Invalid presentation history page", status=422)
async with self.repo.transaction():
await self._task(task_id)
cursor = await self.repo.current_event_cursor()
boundary = min(through, cursor) if through is not None else cursor
rows = await self.repo.list_presentation_events(str(task_id), after, boundary, limit + 1)
items = [TaskPresentationHistoryItem(
event_seq=row["event_seq"], occurred_at=row["occurred_at"],
run_id=row["metadata"]["run_id"],
execution_generation=row["metadata"]["execution_generation"],
command=row["metadata"]["command"],
) for row in rows[:limit]]
return TaskPresentationHistoryPage(
task_id=task_id, event_cursor=boundary, items=items,
next_after=items[-1].event_seq if len(rows) > limit else None,
)
async def publish_presentation_event(
self, task_id: UUID, command: TaskPresentationEventCommand
) -> TaskPresentationEventResult:
@@ -1019,7 +1057,16 @@ class OperationalTaskService:
evidence = StoredEvidenceRecord.model_validate(stored)
require(evidence.data_snapshot is not None, "SNAPSHOT_NOT_FOUND",
"Overlay requires a Server-ingested immutable data snapshot", status=422)
payload["data"] = resolve_overlay(evidence.data_snapshot, command.command.mode)
resolved = resolve_overlay(evidence.data_snapshot, command.command.mode)
if command.command.data is not None:
supplied = resolve_overlay(
evidence.data_snapshot.model_copy(update={
"data": command.command.data.model_dump(mode="json", exclude_unset=True),
}), command.command.mode,
)
require(supplied == resolved, "OVERLAY_DATA_MISMATCH",
"Overlay data must match the immutable evidence snapshot", status=422)
payload["data"] = resolved
metadata = {"task_id": str(task_id), "run_id": str(run["run_id"]),
"execution_generation": run["generation"], "command": payload}
try:
@@ -1890,18 +1937,21 @@ class OperationalTaskService:
replay = await self._request_replay(scope, command.request_id, body)
if replay:
return await self.detail(task_id)
await self.repo.lock(f"task:{task_id}")
await self.repo.lock(f"run:{run_id}")
run = await self._run(task_id, run_id)
require(run["revision"] == command.expected_revision, "VERSION_CONFLICT",
"Run revision changed", status=409)
require(run["status"] in {"claimed", "running"}, "INVALID_TRANSITION",
"Only an active run can finish", status=409)
require(run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Agent session does not own this run", status=409)
self._require_run_owner(run, command)
now = self._now()
self._require_active_run_lease(run, now)
task = await self._task(task_id)
final_outcome = outcome
pending_question = any(q["status"] == "pending" and str(q["run_id"]) == str(run_id)
for q in await task_questions.list_questions(self, task_id))
actions = []
if outcome == "succeeded":
await self._require_no_pending_calculations(task_id, run)
if hasattr(self.repo, "pending_run_messages"):
@@ -1929,7 +1979,9 @@ class OperationalTaskService:
and plan.source_session_id == command.source_session_id
))
)
if not same_generation or report.report.stage != "final":
if pending_question:
lifecycle = "waiting_human"
elif not same_generation or report.report.stage != "final":
final_outcome = "failed"
lifecycle = "failed"
failure_code = "ARTIFACT_INCOMPLETE"
@@ -1974,6 +2026,7 @@ class OperationalTaskService:
run_model = OperationalTaskRun.model_validate(run)
await self.repo.update_run(_json(run_model), expected)
if final_outcome != "succeeded":
await task_questions.cancel_pending(self, task_id, now)
await self._fail_run_messages(run_id, failure_code or "Run failed", now)
task.update(
lifecycle_status=lifecycle,
@@ -1994,7 +2047,7 @@ class OperationalTaskService:
failure_detail or "工况任务执行失败",
now,
)
elif lifecycle == "waiting_human":
elif lifecycle == "waiting_human" and not pending_question:
pending_actions = any(
action.approval_status == "pending" for action in actions
)
@@ -2207,6 +2260,7 @@ class OperationalTaskService:
task["agent_lease_expires_at"] = None
async def _requeue_task(self, task: dict, now: datetime) -> None:
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"]))
while active_run is not None:
+120
View File
@@ -0,0 +1,120 @@
"""Question lifecycle sharing the operational task transaction and notification bus."""
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
from app.domain.domain_errors import require
from app.domain.schemas.operational_task import OperationalTaskDetail, OperationalTaskSummary
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply, TaskQuestionRequest
if TYPE_CHECKING:
from app.services.operational_tasks import OperationalTaskService
async def list_questions(service: "OperationalTaskService", task_id: UUID) -> list[dict]:
# Legacy test repositories do not implement question persistence.
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:
for raw in await list_questions(service, task_id):
question = TaskQuestionRequest.model_validate(raw)
if question.status != "pending":
continue
question = question.model_copy(update={"status": "cancelled", "version": question.version + 1})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question.question_request_id,
now=now, reason="question_superseded")
async def create_question(service: "OperationalTaskService", task_id: UUID,
command: TaskQuestionCreate) -> TaskQuestionRequest:
require(OPERATIONAL_TASK_EXECUTE in service.permissions, "PERMISSION_DENIED",
"Current user cannot publish task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:create"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
replay = await service._request_replay(scope, command.request_id, body)
if replay:
return TaskQuestionRequest.model_validate(await service.repo.get_question(
str(task_id), replay["question_request_id"]))
task = await service._task(task_id)
service._require_actionable_task(task)
require(task["task_type"] == "event_investigation", "INVALID_TASK_TYPE",
"Questions require an investigation task", status=409)
await service.repo.lock(f"run:{command.run_id}")
run = await service._run(task_id, command.run_id)
require(run["status"] in {"claimed", "running"}
and run["generation"] == task["execution_generation"]
and run.get("agent_session_id") == command.source_session_id,
"SESSION_MISMATCH", "Question must belong to the active investigation run", status=409)
now = service._now()
service._require_active_run_lease(run, now)
await service._require_no_pending_calculations(task_id, run)
require(not any(q["status"] == "pending" for q in await list_questions(service, task_id)),
"QUESTION_PENDING", "Answer the existing questions first", status=409)
question = TaskQuestionRequest(question_request_id=uuid4(), task_id=task_id,
run_id=command.run_id, execution_generation=run["generation"],
source_session_id=command.source_session_id, questions=command.questions,
status="pending", version=1, created_at=now)
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
task.update(revision=task["revision"] + 1, updated_at=now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._notify(task_id, "human_input_required", task["title"],
";".join(q.title for q in question.questions), now,
required_permission=OPERATIONAL_TASK_WRITE, subject_type="question",
subject_id=question.question_request_id)
await service._save_request(scope, command.request_id, body,
{"question_request_id": str(question.question_request_id)})
return question
async def answer_question(service: "OperationalTaskService", task_id: UUID, question_id: UUID,
command: TaskQuestionReply) -> OperationalTaskDetail:
require(OPERATIONAL_TASK_WRITE in service.permissions, "PERMISSION_DENIED",
"Current user cannot answer task questions", status=403)
body = command.model_dump(mode="json")
scope = f"task:{task_id}:question:{question_id}:answer"
async with service.repo.transaction():
await service.repo.lock(f"task:{task_id}")
if await service._request_replay(scope, command.request_id, body):
return await service.detail(task_id)
task = await service._task(task_id)
service._require_actionable_task(task)
raw = await service.repo.get_question(str(task_id), str(question_id))
require(raw is not None, "QUESTION_NOT_FOUND", "Question not found", status=404)
question = TaskQuestionRequest.model_validate(raw)
require(question.status == "pending" and question.version == command.expected_version
and question.execution_generation == task["execution_generation"],
"VERSION_CONFLICT", "Question is no longer pending in the current generation", status=409)
require(task["lifecycle_status"] == "waiting_human"
and await service.repo.get_active_run(str(task_id)) is None,
"RUN_NOT_SETTLED", "Wait until the investigation has entered waiting_human", status=409)
answers = {a.question_id: a for a in command.answers}
known = {q.question_id for q in question.questions}
require(len(answers) == len(command.answers) and set(answers) <= known,
"ANSWER_INVALID", "Unknown or duplicate questions", status=422)
for q in question.questions:
answer = answers.get(q.question_id)
selected = answer.selected_option_ids if answer else []
text = answer.custom_text.strip() if answer else ""
require(len(set(selected)) == len(selected) and set(selected) <= {o.id for o in q.options}
and (q.mode == "multiple" or len(selected) <= 1)
and (q.allow_custom or not text) and (not q.required or bool(selected or text)),
"ANSWER_INVALID", "Answer does not satisfy the question", status=422)
now = service._now()
question = question.model_copy(update={"status": "answered", "version": question.version + 1,
"answers": command.answers, "answered_at": now, "answered_by": service._actor()})
await service.repo.save_question(str(task_id), question.model_dump(mode="json"))
await service._resolve_notifications(task_id, kinds=["human_input_required"],
subject_type="question", subject_id=question_id, now=now, reason="question_answered")
await service._requeue_task(task, now)
await service.repo.update_task(OperationalTaskSummary.model_validate(task).model_dump(mode="json", exclude={"active_run", "latest_run"}))
await service._task_event(task, now)
await service._save_request(scope, command.request_id, body, {"task_id": str(task_id)})
return await service.detail(task_id)
+14 -5
View File
@@ -1,13 +1,22 @@
from typing import Any
from app.algorithms.valve_isolation.topology_search import valve_isolation_analysis
from app.native.wndb.gis.network_views import get_network_link_nodes
from app.algorithms.valve_isolation.topology_search import (
AccidentElementType,
valve_isolation_analysis,
)
from app.native.wndb.gis.network_views import get_valve_isolation_topology
def analyze_valve_isolation(
network: str,
accident_element: str | list[str],
disabled_valves: list[str] = None,
disabled_valves: list[str] | None = None,
accident_element_type: AccidentElementType = "auto",
) -> dict[str, Any]:
link_entries = get_network_link_nodes(network)
return valve_isolation_analysis(link_entries, accident_element, disabled_valves)
link_entries, source_nodes, closed_links = get_valve_isolation_topology(network)
return valve_isolation_analysis(
link_entries, accident_element, disabled_valves,
accident_element_type=accident_element_type,
water_source_nodes=source_nodes,
closed_links=closed_links,
)