Author SHA1 Message Date
jiang f2cfce8192 feat: add node coverage and improve investigation presentation
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m51s
2026-11-02 15:23:34 +08:00
jiang 05183aff93 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
2026-10-30 17:30:19 +08:00
jiang 63af998045 feat(mcp): queue long-running calculations 2026-10-29 18:25:15 +08:00
jiang 2865811f1b chore(config): align server environment templates 2026-10-27 17:17:08 +08:00
jiang af1dc11681 docs: record deployed API port 2026-10-23 16:12:26 +08:00
jiang 8673429ec4 docs: clarify peripheral proxy upstream 2026-10-23 16:11:34 +08:00
jiang 6d31f456e6 feat: mount peripheral API in main server
Generic Container CI/CD / test-build-publish (push) Successful in 2m59s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m59s
2026-10-23 16:05:56 +08:00
jiang cddfc8647d docs: update server operations guide
Generic Container CI/CD / test-build-publish (push) Successful in 5m20s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m20s
2026-10-23 14:29:44 +08:00
107 changed files with 6921 additions and 358 deletions
+35 -8
View File
@@ -1,6 +1,7 @@
# TJWater Server 环境变量模板
# NextTJWater Server 环境变量模板
# 复制为 .env 后按启用的功能填写;不要提交 .env 或任何真实凭据。
# 生产配置由部署主机的受控 backend.env 注入,不要把完整配置保存为仓库密钥。
# 生产配置由部署主机的受控 next-backend.env 注入,不随镜像或 CI/CD 同步本地 .env。
# 进程环境变量优先;修改 Compose env_file 后需重新创建容器。
# 基础运行
PROJECT_NAME="TJWater Server"
@@ -69,9 +70,11 @@ KEYCLOAK_ALGORITHM="RS256"
KEYCLOAK_AUDIENCE="account"
KEYCLOAK_ACCESS_TOKEN_MAX_AGE_SECONDS=900
# 工作台事件与跨域
# 认证接口必须使用明确来源,逗号分隔;不要配置为 *。
CORS_ALLOWED_ORIGINS="http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173"
# 浏览器跨域访问(主 API)
# 必须填写明确来源(协议、域名或 IP、端口),英文逗号分隔;不允许 * 或网段正则。
CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:15173,http://localhost:15173
# 工作台事件
WORKBENCH_EVENTS_ENABLED=true
WORKBENCH_EVENT_RETENTION_DAYS=7
@@ -116,6 +119,17 @@ OPERATIONAL_ACTION_PERIPHERAL_URL=""
OPERATIONAL_ACTION_COMMAND_GATEWAY_URL=""
OPERATIONAL_ACTION_TIMEOUT_SECONDS=10
# 外围样板(挂载于 /peripheral;故障注入默认关闭)
# 独立启动时用于调用主 API 验证身份;容器内按网络填写主服务地址。
PERIPHERAL_SAMPLE_NEXT_SERVER_URL="http://127.0.0.1:8000"
# 以下两个默认库选择只供种子、导出等脚本使用,HTTP 请求仍按已授权项目路由。
PERIPHERAL_SAMPLE_DB_NAME="tjwater_v2"
PERIPHERAL_SAMPLE_PROJECT_CODE="tjwater_v2"
# 子应用独立运行时的浏览器来源列表,使用 JSON 格式;挂载时还受主 API CORS 配置控制。
PERIPHERAL_SAMPLE_CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'
PERIPHERAL_SAMPLE_ENABLE_FAULTS=false
PERIPHERAL_SAMPLE_TIMEOUT_SECONDS=5
# MCP 查询服务;OAuth 和所有项目业务库就绪前保持关闭。
TJWATER_MCP_ENABLED=false
TJWATER_MCP_ISSUER_URI=""
@@ -129,7 +143,20 @@ TJWATER_MCP_HOST_CLIENT_ID="tjwater-agent-mcp"
TJWATER_MCP_ALLOWED_HOSTS='["127.0.0.1:*","localhost:*"]'
TJWATER_MCP_ALLOWED_ORIGINS='[]'
TJWATER_MCP_CONTEXT_TTL_SECONDS=600
# 普通查询预算;计算工具由 Agent 通过 timeout_seconds 按次指定。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=30
# 计算等待上限为 120–86400 秒,实际上限通过 tools/list 发布。
# 单次 HTTP 返回预算;由目录元数据公布给宿主,Agent 默认等待 180 秒。
TJWATER_MCP_QUERY_TIMEOUT_SECONDS=120
# HTTP 请求预算上限;Agent 加 60 秒余量并封顶 270 秒,外层 MCP 总时限 300 秒。
TJWATER_MCP_MAX_REQUEST_TIMEOUT_SECONDS=240
# 后台计算独立执行时限为 120–86400 秒,不受工具 timeout_seconds 控制。
TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS=3600
# 独立计算队列消费者;显式项目 UUID,以逗号分隔。不会在 API lifespan 内启动。
# 先在每个目标项目 BizDB 依次应用 026_calculation_jobs.sql 和 027_calculation_job_lifecycle.sql,再启动计算服务。
CALCULATION_WORKER_PROJECT_IDS=""
# 所有配置项目合计并发,独立于 Uvicorn --workers 4;单实例锁防止重复消费者扩容。
CALCULATION_WORKER_MAX_CONCURRENCY=2
CALCULATION_WORKER_POLL_SECONDS=2
CALCULATION_WORKER_LEASE_SECONDS=30
CALCULATION_WORKER_HEARTBEAT_SECONDS=5
# 持久化本机恢复账本;容器模板已挂载独立 volume,不能当缓存删除。
CALCULATION_WORKER_STATE_DIR="temp/calculation-jobs"
+1 -1
View File
@@ -23,7 +23,7 @@ COPY app ./app
COPY contracts ./contracts
COPY infra ./infra
COPY resources ./resources
COPY scripts/run_operational_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
COPY scripts/run_operational_worker.py scripts/run_calculation_worker.py scripts/sync_peripheral_work_orders.py ./scripts/
COPY samples/peripheral ./samples/peripheral
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/pipe_health_prediction/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/pipe_health_prediction/model/my_survival_forest_model_quxi.zip
+88 -8
View File
@@ -1,6 +1,6 @@
# TJWaterServerBinary 内部后端
# NextTJWater Server
`TJWaterServerBinary` 是 TJWater 内部版 Python 后端,基于 FastAPI 提供认证、项目、管网、模拟、爆管、漏损、SCADA 和地图服务集成能力。该仓库用于内部开发和完整功能维护。
`next-tjwater-server` 是 NextTJWater 供水系统的 FastAPI 后端,提供认证、项目、管网、模拟、爆管、漏损、SCADA、地图服务、工况任务和受控 MCP 能力。业务数据和时序数据按已认证项目路由,后台工作进程也必须使用明确的项目范围。
## 技术栈
@@ -30,6 +30,14 @@ infra/docker/ Docker Compose 编排
## 本地开发
先从样板创建本地配置,再按启用的功能填写:
```bash
cp .env.example .env
```
`.env` 只用于本地或部署主机运行,不得提交。生产配置由部署主机的受控环境文件注入,不将完整应用 `.env` 保存为 Gitea Actions 密钥。
推荐使用已有 conda 环境:
```bash
@@ -45,14 +53,50 @@ conda activate server
`GET /health` 是无需鉴权的 HTTP 服务存活检查,返回 `200` 和 `{"status":"ok"}`,不查询数据库。
`GET /ready` 检查应用启动状态、元数据库 `SELECT 1` 及已启用的 MCP 传输生命周期;
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为2秒。MCP 关闭时标记为
全部就绪返回 `200`,否则返回 `503`,元数据库探测预算为 2 秒。MCP 关闭时标记为
`disabled`,不阻止就绪。返回结果不包含数据库地址、凭据或原始异常,禁止缓存。
就绪状态不等同于 OAuth、用户权限或真实工具调用成功;指定项目的业务库和时序库仍通过
带身份与 `X-Project-Id` 的 `GET /api/v1/projects/current/database-health` 检查。
## 环境配置
`.env.example` 是唯一应提交的环境配置样板,主要分为以下几组:
- 元数据库、项目业务库、TimescaleDB 及项目数据库连接池。
- GeoServer 项目供应、Keycloak JWT 校验、CORS 和工作台事件。
- 天地图地理编码和地名搜索。
- 调查任务 worker、外围工单同步、数据与动作适配器。
- 可选的 MCP resource、OAuth issuer、宿主和超时预算。
基础数据库变量用于启动和项目供应。运行时的项目请求仍以元数据库中的 `biz_data` 和 `iot_data` 路由为准,不会因为存在默认数据库变量而回退到固定项目。
CORS 通过后端 `.env` 或部署进程环境变量中的 `CORS_ALLOWED_ORIGINS` 配置,进程环境变量优先。
必须使用英文逗号分隔的明确页面来源(协议、域名或 IP、端口,不带路径),不能填写 `*`。
未设置时默认只允许本机 `5173`、`15173` 开发来源。正式部署按实际浏览器地址填写,例如:
```dotenv
CORS_ALLOWED_ORIGINS=https://next-demo.waternetwork.cn,http://192.168.1.133,https://192.168.1.133,http://192.168.1.110,https://192.168.1.110
```
CORS 匹配浏览器页面来源,不是 Nginx 连接 IP 或域名解析结果;非默认端口须逐项列出。
保留跨域凭据支持及 `Authorization`、`X-Project-Id` 请求头,接口认证和权限校验继续生效。
修改后需重启进程;使用 Compose `env_file` 注入时需重新创建容器才能读取新值。
## 业务 Skill 与闭环
[爆管处置工程图](docs/burst-response-workflow.md)覆盖上报、候选定位、隔离阀门、影响分析、地图与数据展示、审批处置和反馈闭环。Server 通过任务 MCP 按 workflow 分发以下业务 Skill,当前版本均为 `2.0.0`:
| workflow | Skill | 调查重点 |
| --- | --- | --- |
| burst_report | [爆管处置](resources/work-order-skills/burst-report/SKILL.md) | 候选定位、隔离阀门、影响分析、抢修及恢复闭环 |
| water_quality_complaint | [水质投诉](resources/work-order-skills/water-quality-complaint/SKILL.md) | 投诉关联、监测与采样、候选影响、处置及复测回访 |
| general | [预约跟进](resources/work-order-skills/appointment-follow-up/SKILL.md) | 审批与回执、现场履约、反馈复核及与原事件的闭环区别 |
JSONL 整理遵守会话文件权限,后台图表和表格通过既有报告 `ui_blocks` 展示;Skill 不增加审批、执行或原工单关单权限。预约投影不自动启动 Agent 调查,普通协作与后台执行分别遵守其原有工具范围。
## 常用命令
外围接入样板是独立进程,复用 PostgreSQL,不自动建表;启动、授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围接入样板作为 `/peripheral` 子应用随主 API 进程启动,复用 PostgreSQL 且不自动建表;授权边界和契约见 [外围样板说明](docs/peripheral-sample.md)。
外围样板提供 synthetic 测试工单和测试数据的版本化 CRUD;工单经同步器投影为 `workbench.operational_tasks` 中的外围来源任务。固定演示时窗和造数方式见 [合成样本说明](docs/peripheral-generated-data.md),运行态读取边界见 [工作台运行态说明](docs/workbench-runtime.md)。这些数据不代表现场事实,也不会触发设备控制。
```bash
@@ -84,8 +128,8 @@ docker compose -f infra/docker/docker-compose.yml config
- 元数据、业务库和 TimescaleDB 可以部署在同一主机,也可以分别部署。
在线重算会原子替换一个时刻的节点、管段基线。历史分块已经压缩时,写入事务使用
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认100万条;配置必须
为正整数,不允许0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
`TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES` 设置解压预算,默认 100 万条;配置必须
为正整数,不允许 0。该设置仅在实时快照写入事务内生效,不改变数据库全局设置或普通读取。
预算按需根据受影响压缩批次的规模调整,超过预算仍会失败并回滚当前快照;此前完成的其他
时刻不会一起回滚。同步、异步及单表批量快照入口使用同一预算。
@@ -117,6 +161,19 @@ conda run -n server python scripts/publish_fengxian_map_layers.py --database tjw
发布命令需要配置 `GEOSERVER_URL`、`GEOSERVER_USERNAME` 和 `GEOSERVER_PASSWORD`。其中 7 个基础管网图层仍由项目供应流程创建,奉贤发布脚本追加 9 个原始 GIS 图层和 1 个水质监测点图层。
## 工况任务工作进程
调查任务 worker 和外围工单同步器是独立进程,不随 API 进程自动启动:
```bash
conda run -n server python scripts/run_operational_worker.py
conda run -n server python scripts/sync_peripheral_work_orders.py
```
`OPERATIONAL_WORKER_PROJECT_IDS` 是逗号分隔的显式项目 UUID 列表,非空时优先于单项目兼容变量 `OPERATIONAL_WORKER_PROJECT_ID`。如需覆盖所有已启用项目,应将这些项目的 UUID 全部明确写入列表。进程不会自动枚举项目,配置列表也不会自动授权;服务身份必须是每个目标项目的有效成员。
每个项目保持独立的事件游标、会话、消息游标和 OAuth 缓存。`OPERATIONAL_WORKER_MAX_CONCURRENCY` 是每个项目的上限,而不是全局上限。更改项目列表后,需要同时重启 worker 和外围同步器。完整配置、授权和重试边界见 [工况工作进程说明](docs/operational-task-workers.md)。
## 测试与发布
提交前根据改动范围运行最小有效测试:
@@ -131,7 +188,15 @@ conda run -n server python -m pytest tests/unit tests/auth -q
docker build -t tjwater-server:local .
```
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建、推送镜像并通知部署 webhook。
Dockerfile 的 `test` 阶段会执行 Python 编译检查、应用导入、OpenAPI 检查和完整 pytest。
Gitea Actions 工作流位于 `.gitea/workflows/package.yml`,可手动触发,也会在推送 `v*` tag 时运行。工作流构建 `test` 阶段,推送不可变的 `sha-*` 镜像,然后通过受限 SSH 账号对 `next-backend` 执行候选验证。只有 tag 工作流会在验证通过后提升正式版本。仓库需要以下 Actions secrets:
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD`
- `DEV_DEPLOY_SSH_KEY`
这些密钥只用于镜像仓库和受限开发机部署,不替代后端应用的运行环境配置。
## 安全规则
@@ -139,4 +204,19 @@ Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发
## 产品 MCP 查询
Server 可选启用 MCP 2026-07-28 的25项项目查询及10项受控分析,默认关闭。它使用专用 OAuth resource Bearer、宿主绑定上下文及现有项目/任务权限,调查查询返回前持久化证据。启用需更新依赖、应用项目 SQL 迁移和配置 Keycloak token exchange;这些生产操作尚未执行。详见 [实施与启用](docs/mcp-query-implementation.md) 和 [协议/旧 CLI 能力对照](docs/mcp-query-design.md)。
Server 可选启用 MCP 2026-07-28,默认关闭。当前目录包含 67 项类型化工具,其中 42 项查询、12 项计算、13 项预览式写入。MCP 使用专用 OAuth resource Bearer、宿主绑定上下文和现有项目或任务权限,不提供自由命令执行。
写入工具默认只执行 `dry_run`。真实写入必须使用同一 `request_id`,带上预览返回的 `expected_digest`,并通过宿主原生许可和项目权限检查。超时或结果不明时先查询操作状态,不盲目重试。实施、启用和授权边界见 [MCP 实施与启用](docs/mcp-query-implementation.md) 和 [协议与旧 CLI 能力对照](docs/mcp-query-design.md)。
## 后台计算队列
长计算通过项目数据库队列和独立计算 Worker 执行,API 保持4个进程,HTTP超时不延长。接入需要新增数据库迁移和独立服务,详见[计算队列部署与MCP契约](docs/calculation-queue.md)。
后台调查通过 `POST /api/v1/operational-tasks/{task_id}/questions` 保存结构化问题,并沿现有任务通知展示。当前 run 可在没有最终报告时正常结束,任务进入等待人工;具备 `operational_task.write` 权限的用户通过 `questions/{question_id}/answer` 回答,原任务排队新一轮。回答保存为交互记录,不自动转为现场证据或审批。问题支持最多三个单选、多选或自定义回答,版本校验和请求幂等保护重复提交。任务详情返回最近 100 组提问,完整历史由 MCP 的 `get_task_context(section="questions")` 分页读取。
部署任务提问功能前,在各项目业务库执行 `resources/sql/028_task_questions.sql`,再同步 Server、Agent 与前端版本。迁移不修改既有调查记录;更新代码不会自动迁移数据库或重启服务。
外围工单来源统一为系统数据。部署对应版本前,在各项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,再同步 Server、同步器、Agent 与前端版本。迁移会修正既有外围源工单、修订记录、任务快照和外围反馈证据,不修改测试数据或模拟监测数据。
多 Agent 后台调查按实例归属运行。部署对应版本前,在每个启用调查 Worker 的项目 BizDB 执行 `resources/sql/030_operational_run_agent_ownership.sql`,再同步 Server、Agent 和 Worker,并为各 Agent 配置唯一且重启稳定的 `AGENT_INSTANCE_ID`。迁移只增加运行归属列和活跃运行索引;旧运行不猜测归属,待其结束或租约恢复后,新一代运行开始记录实例。
+8
View File
@@ -3,6 +3,14 @@ import os
import pandas as pd
def calculation_temp_directory(kind: str) -> str:
"""Keep native outputs inside the supervisor-owned job directory."""
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR", "temp")
path = os.path.abspath(os.path.join(root, kind))
os.makedirs(path, exist_ok=True)
return path
def fill_time_gaps(
data: pd.DataFrame,
time_col: str = "time",
@@ -8,15 +8,14 @@ import sys
import pandas as pd
import wntr
from app.algorithms._utils import _cleanup_temp_files
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
_PIPE2LEAKNODE = None
_SIGNATURE_WORKER_DATA = {}
def _make_temp_prefix(tag):
temp_dir = os.path.abspath(os.path.join("temp", "burst_location"))
os.makedirs(temp_dir, exist_ok=True)
temp_dir = calculation_temp_directory("burst_location")
safe_tag = str(tag).replace(os.sep, "_").replace(" ", "_")
return os.path.join(temp_dir, f"{safe_tag}_{os.getpid()}")
@@ -15,7 +15,7 @@ from pymoo.optimize import minimize as pymoo_minimize
from pymoo.termination.default import DefaultSingleObjectiveTermination
from app.algorithms._utils import _cleanup_temp_files
from app.algorithms._utils import _cleanup_temp_files, calculation_temp_directory
_worker_data: dict[str, Any] = {}
DEFAULT_N_WORKERS = max(1, min(cpu_count() - 1, 4))
@@ -92,7 +92,7 @@ def _worker_evaluate(raw_ratios: np.ndarray) -> float:
demand_obj.base_value = original_val + per_node_leak
modifications.append((demand_obj, original_val))
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
temp_dir = calculation_temp_directory("leakage")
os.makedirs(temp_dir, exist_ok=True)
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
@@ -569,7 +569,7 @@ class LeakageProblem(Problem):
modifications.append((demand_obj, original_val))
# 结果保存在根目录的temp/leakage文件夹中
temp_dir = os.path.abspath(os.path.join("temp", "leakage"))
temp_dir = calculation_temp_directory("leakage")
os.makedirs(temp_dir, exist_ok=True)
prefix = os.path.join(temp_dir, f"temp_{os.getpid()}")
@@ -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]
+1
View File
@@ -58,6 +58,7 @@ McpSnapshotSource = Literal[
'run_burst_detection',
'run_burst_location',
'analyze_valve_isolation',
'generate_node_coverage',
'run_leakage_identification',
'get_element_properties',
'list_elements',
+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()
+150
View File
@@ -0,0 +1,150 @@
"""Durable project calculation queue. Every worker update is generation fenced."""
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:
def __init__(self, connection, project_id):
self.connection = connection
self.project_id = str(project_id)
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,),
)
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(
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),
)
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 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),
)
row = await cursor.fetchone()
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(
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),
)
return [dict(row) for row in await cursor.fetchall()]
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,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,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',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' 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),
)
row = await cursor.fetchone()
return dict(row) if row else None
async def heartbeat(self, row, owner, lease_seconds):
cursor = await self.connection.execute(
"""UPDATE workbench.calculation_jobs SET lease_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') RETURNING status""",
(lease_seconds, self.project_id, row['job_id'], owner, row['lease_generation']),
)
result = await cursor.fetchone()
return result['status'] if result else 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,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, 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
+91 -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):
@@ -75,6 +76,48 @@ class OperationalTaskRepository:
status=409,
)
async def pending_calculation_jobs(
self, task_id: str, run_id: str, execution_request_id: str,
) -> list[str]:
"""Only the exact original run blocks final artifacts and completion."""
cursor = await self.connection.execute(
"""SELECT job_id FROM workbench.calculation_jobs
WHERE project_id=%s AND binding->>'task_id'=%s
AND binding->>'run_id'=%s AND binding->>'execution_request_id'=%s
AND status IN ('queued','running','cancel_requested')
ORDER BY created_at,job_id LIMIT 100""",
(self.project_id, task_id, run_id, execution_request_id),
)
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
@@ -87,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
@@ -102,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
@@ -129,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,
)
@@ -176,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),
@@ -200,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
@@ -229,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
@@ -286,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
+2
View File
@@ -14,6 +14,7 @@ from app.infra.db.metadb.database import close_metadata_engine
from app.infra.db.timescaledb.sync_pool import close_all_timescale_pools
from app.native.wndb.core.connection import close_all_project_pools
from app.core.config import settings
from samples.peripheral.api import create_app as create_peripheral_app
# 导入审计中间件
from app.infra.audit.middleware import AuditMiddleware
@@ -80,6 +81,7 @@ install_problem_details_handlers(app)
from app.api.v1.endpoints.mcp_queries import router as mcp_context_router
app.state.mcp_runtime = mcp_runtime
app.include_router(mcp_context_router, prefix="/api/v1")
app.mount("/peripheral", create_peripheral_app(), name="peripheral")
# Legacy routers without version prefix
# app.include_router(api_router)
+36 -5
View File
@@ -4,21 +4,51 @@ from datetime import datetime, timedelta
from typing import Annotated, Literal
from uuid import UUID
from pydantic import Field, model_validator
from pydantic import BaseModel, ConfigDict, 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秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
)
class NodeCoverageGroup(BaseModel):
model_config = ConfigDict(extra="forbid")
group_id: Id = Field(max_length=80)
title: str = Field(min_length=1, max_length=200)
node_ids: list[Id] = Field(min_length=1, max_length=10000)
class NodeCoverageInput(CalculationInput):
groups: list[NodeCoverageGroup] = Field(min_length=1, max_length=8,
description="已核实的模型节点集合;各组为需要比较的业务或空间集合,可重叠,不接受任意坐标")
concave_ratio: float = Field(default=0.25, ge=0, le=1, allow_inf_nan=False,
description="凹包比例;越接近1越平滑接近凸包,参数只决定示意形状")
buffer_m: float = Field(default=25, gt=0, le=1000, allow_inf_nan=False,
description="展示缓冲距离,米;不是服务半径或影响距离")
split_by_connectivity: bool = Field(default=False, strict=True,
description="需要按模型连接分组时设true;地理分布比较默认false,不能据此认定实际供水关系")
excluded_link_ids: list[Id] = Field(default_factory=list, max_length=200,
description="连通分组时额外视为断开的管段/阀门,例如已有隔离建议中的关阀ID;仅影响绘图分组")
@model_validator(mode="after")
def bounded_groups(self):
if len({group.group_id for group in self.groups}) != len(self.groups):
raise ValueError("coverage group IDs must be distinct")
if len({node for group in self.groups for node in group.node_ids}) > 10000:
raise ValueError("coverage supports at most 10000 distinct nodes")
if self.excluded_link_ids and not self.split_by_connectivity:
raise ValueError("excluded_link_ids requires split_by_connectivity")
return self
class BurstDetectionInput(CalculationInput):
target_time: datetime = Field(description="带时区的侦测时刻;读取此前15天同周期压力数据,不是单小时定位")
sensor_nodes: list[Id] = Field(min_length=5, max_length=20, description="已核实的压力监测节点ID,至少5个完整传感器")
@@ -38,7 +68,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 +99,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="已知无法关闭的阀门;未提供不代表现场确认全部可用")
+4
View File
@@ -71,6 +71,10 @@ def _calculate(name: str, parameters: dict[str, Any], network: str, username: st
from app.services.valve_isolation import analyze_valve_isolation
return analyze_valve_isolation(network=network, **parameters)
if name == "generate_node_coverage":
from app.services.node_coverage import generate_node_coverage
return generate_node_coverage(network=network, **parameters)
raise ValueError("Calculation is not registered")
finally:
_slots.release()
+35 -12
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 {}
@@ -347,20 +357,28 @@ class McpQueryApplication:
)
result = QueryResult.model_validate(previous["result"])
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
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,
job_timeout_seconds=self.settings.max_calculation_timeout_seconds,
)
else:
result = (
await self.executor(
scope, binding, tool, args, full_snapshot=True
)
if include_full
else await self.executor(scope, binding, tool, args)
)
if include_full
else await self.executor(scope, binding, tool, args)
)
# Never serialize an unbounded response into a model message or database receipt.
if len(result.model_dump_json().encode()) > 8 * 1024 * 1024:
return tool_error(
"RESULT_TOO_LARGE",
"Narrow the query or request a smaller page",
)
if binding.task_id and name not in {
if binding.task_id and not should_queue(tool, args) and name not in {
"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs",
"get_task_context",
"get_task_evidence",
}:
@@ -423,6 +441,10 @@ class McpQueryApplication:
"resource_uri": uri,
"preview": "The complete query snapshot is saved in task evidence",
}
from app.mcp.presentation_preview import saved_presentation_preview
presentation = saved_presentation_preview(result)
if presentation is not None:
output["data"]["presentation"] = presentation
output["truncated"] = True
output["data_gaps"] = [
*result.data_gaps,
@@ -458,6 +480,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",
@@ -466,7 +489,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):
@@ -475,7 +498,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"
+205
View File
@@ -0,0 +1,205 @@
"""Durable MCP calculation submission and owner-scoped result retrieval.
Only safe server-owned binding fields are persisted. OAuth tokens and context
handles are never used to resume a calculation.
"""
from datetime import datetime, timezone
from uuid import uuid5, NAMESPACE_URL
from fastapi import HTTPException
from app.mcp.models import BoundContext, QueryResult, PageInfo
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
QUEUED_TOOLS = frozenset({
"run_burst_location", "run_leakage_identification", "run_burst_detection",
"run_simulation", "run_burst_simulation", "run_contaminant_simulation",
"analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation",
"optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement",
})
CONTROL_TOOLS = frozenset({"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"})
BINDING_FIELDS = {"project_id", "subject", "client_id", "native_session_id", "purpose", "parent_session_id", "task_id", "run_id", "execution_request_id", "tool_cap", "permission_cap", "task_scope_cap"}
def durable_binding(binding: BoundContext) -> dict:
return binding.model_dump(mode="json", include=BINDING_FIELDS)
def restore_binding(document: dict) -> BoundContext:
# Expiration governs admission via a context handle; durable work has its own
# lifecycle and must revalidate identity, membership and run on every use.
return BoundContext.model_validate({**document, "context_handle": "durable-calculation", "expires_at": datetime.max.replace(tzinfo=timezone.utc)})
def should_queue(tool, args) -> bool:
return tool.name in QUEUED_TOOLS and (not tool.mutating or not args.dry_run)
def envelope(source: str, data, *, job_id="list", page=None) -> QueryResult:
return QueryResult(source=source, source_ref="calculation:" + str(job_id), queried_at=datetime.now(timezone.utc), semantics="task", data=data, page=page, truncated=bool(page and (page.offset or page.has_more)),
data_gaps=["Calculation status and queue receipts are not analytical evidence."])
def public_job(row: dict) -> dict:
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
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, *, 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":
raise HTTPException(403, "Read-only task agents cannot start calculations")
if tool.mutating:
from app.mcp.mutation_service import (
preview_change, check_digest, mutation_arguments, retryable_capacity_rejection,
)
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
mutation_repo = McpQueryRepository(scope.connection, scope.project.project_id)
mutation_key = "mutation:" + binding.subject + ":" + binding.native_session_id
approved_parameters = {"tool": tool.name, "arguments": mutation_arguments(args, preview=False)}
async with scope.connection.transaction():
await mutation_repo.lock_receipt(mutation_key, str(args.request_id))
previous = await mutation_repo.receipt(mutation_key, str(args.request_id))
if previous:
if previous["parameters"] != approved_parameters:
raise HTTPException(409, "request_id already has different parameters")
if previous["result"].get("status") == "completed":
return QueryResult.model_validate(previous["result"]["output"])
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", "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)
if binding.task_id:
from app.mcp.query_service import task_service
from app.mcp.contexts import verify_task
task_repo = task_service(scope).repo
# Share the operational transaction lock with finalization. Admission
# cannot race a pending-job check and then publish work into a closed run.
async with task_repo.transaction():
await task_repo.lock(f"task:{binding.task_id}")
await task_repo.lock(f"run:{binding.run_id}")
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=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=job_timeout_seconds,
)
await scope.connection.commit()
return envelope(tool.name, public_job(row), job_id=row["job_id"])
def check_binding(binding, row):
original = row["binding"]
if any(str(original.get(key)) != str(getattr(binding, key)) for key in ("subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id")):
raise HTTPException(403, "Calculation belongs to a different session or task run")
async def authorize_original(scope, row):
from app.mcp.catalog import TOOLS
from app.mcp.query_service import authorize_query
from app.mcp.contexts import verify_task
original = restore_binding(row["binding"])
tool = TOOLS[row["tool"]]
# 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")
if original.task_id:
await verify_task(scope, original)
await authorize_query(scope, original, tool, args)
return original, tool, args
async def read_calculation_job(scope, binding, tool, args) -> QueryResult:
repo = CalculationJobRepository(scope.connection, scope.project.project_id)
if tool.name == "list_calculation_jobs":
identity_scope = binding.model_dump(mode="json", include={
"subject", "client_id", "native_session_id", "task_id", "run_id", "execution_request_id",
})
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,
include_result=tool.name == "get_calculation_result")
if row is None:
raise HTTPException(404, "Calculation was not found")
check_binding(binding, row)
await authorize_original(scope, row)
if tool.name == "cancel_calculation_job":
if binding.purpose == "task_reader":
raise HTTPException(403, "Read-only task agents cannot cancel calculations")
row = await repo.cancel(args.job_id, owner_user_id=scope.project.user_id)
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
for part in args.data_path:
if isinstance(value, dict) and part in value:
value = value[part]
else:
raise HTTPException(422, "data_path must select an existing object field")
if isinstance(value, list):
result.data = value[args.offset:args.offset + args.limit]
result.page = PageInfo(limit=args.limit, offset=args.offset, total=len(value), has_more=args.offset + args.limit < len(value))
result.truncated = result.truncated or args.offset > 0 or result.page.has_more
elif args.offset:
raise HTTPException(422, "offset requires a collection data_path")
else:
result.data = value
if len(result.model_dump_json().encode()) > 11000:
raise HTTPException(413, "Select a narrower data_path or smaller result page")
return result
async def execute_calculation_job(scope, row) -> QueryResult:
"""Worker entry point; success is persisted by the fenced supervisor."""
from app.mcp.query_service import execute_query
original, tool, args = await authorize_original(scope, row)
result = await execute_query(scope, original, tool, args)
return result
async def attach_calculation_evidence(scope, row, result):
from app.mcp.query_service import task_service
from app.mcp.contexts import verify_task
original, tool, _ = await authorize_original(scope, row)
if original.task_id:
from app.domain.schemas.operational_task import TaskDataSnapshot
from app.infra.db.postgresql.mcp_queries import McpQueryRepository
repo = McpQueryRepository(scope.connection, scope.project.project_id)
key, call_id = "calculation:" + str(row["job_id"]), "result"
# Result lookup can leave an implicit SELECT transaction open. End it so
# evidence and its receipt commit together before returning a response.
await scope.connection.commit()
task_repo = task_service(scope).repo
# Match operational finalization's lock order: event lock, then task.
async with task_repo.transaction():
await repo.lock_receipt(key, call_id)
previous = await repo.receipt(key, call_id)
if previous:
return QueryResult.model_validate(previous["result"])
await task_repo.lock(f"task:{original.task_id}")
await verify_task(scope, original)
result.source_ref = key
evidence, _ = await task_service(scope).record_data_snapshot(original.task_id, TaskDataSnapshot(
source=tool.name, source_ref=key, queried_at=result.queried_at, origin=result.origin,
data=result.model_dump(mode="json"), truncated=result.truncated))
result.evidence_id = evidence.evidence_id
await repo.save_receipt(key, call_id, {"job_id": str(row["job_id"])}, result.model_dump(mode="json"))
return result
+21
View File
@@ -0,0 +1,21 @@
"""Bounded inputs for durable computation control."""
from typing import Literal
from uuid import UUID
from pydantic import Field
from app.mcp.models import StrictInput, PageInput, Id
CalculationStatus = Literal["queued", "running", "cancel_requested", "succeeded", "failed", "cancelled", "interrupted", "timed_out"]
class CalculationJobInput(StrictInput):
job_id: UUID
class CalculationResultInput(CalculationJobInput):
data_path: list[Id] = Field(default_factory=list, max_length=8)
limit: int = Field(default=20, ge=1, le=500)
offset: int = Field(default=0, ge=0, le=1000000)
class CalculationJobsInput(PageInput):
status: CalculationStatus | None = None
+18 -6
View File
@@ -4,8 +4,9 @@ from dataclasses import dataclass
from pydantic import BaseModel
from mcp import types
from app.mcp import models as m
from app.mcp import calculation_models as jobs
from app.mcp import extended_models as e
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput
from app.mcp.analysis_models import BurstDetectionInput, BurstLocationInput, ValveIsolationInput, LeakageIdentificationInput, NodeCoverageInput
from app.mcp.analysis_models import ScenarioInput, ContaminantSimulationInput, ValveClosureInput, FlushingInput, PressureRegulationInput, SensorPlacementInput
@@ -36,11 +37,15 @@ class QueryTool:
)
return types.Tool(
name=self.name,
description=self.description + suffix,
description=self.description + suffix + (
"。执行返回持久job_id;排队回执不是分析证据,使用get_calculation_job查询状态、get_calculation_result分页读取成功结果,不重复提交。"
if self.name in {"run_burst_location", "run_leakage_identification", "run_burst_detection", "run_simulation", "run_burst_simulation", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement", "save_simulation_scenario", "create_sensor_placement"}
else ""
),
inputSchema=self.model.model_json_schema(),
outputSchema=m.QueryResult.model_json_schema(),
annotations=types.ToolAnnotations(
readOnlyHint=not (self.computation or self.mutating), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
readOnlyHint=not (self.computation or self.mutating or self.name == "cancel_calculation_job"), destructiveHint=self.mutating or self.name == "run_simulation", openWorldHint=self.name in {"search_project_places", "reverse_geocode"}
),
)
@@ -54,8 +59,9 @@ _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),
("generate_node_coverage", "为当前项目已核实的模型节点集合生成空间覆盖示意,适用于候选分布、监测覆盖比较、区域成员、隔离或模拟结果节点等,不限调查任务。支持最多8组、合计10000个不同节点,从真实GIS查询坐标;监测设备、投诉或地址须先关联模型节点。可按连接分组,记录凹包/缓冲参数、缺失节点和display_only;不计算故障、污染、实际停水或服务边界。返回result.map_payload及coverage,任务中用set_overlay引用本次evidence_id、省略data展示;生成不自动操作地图", NodeCoverageInput, "webgis.view", "analysis", False, True),
(
"get_element_properties",
"读取当前项目的单个管网元素属性",
@@ -208,7 +214,7 @@ _specs = [
),
(
"query_peripheral_data",
"查询有限外围数据源;synthetic是合成证据",
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
m.PeripheralInput,
"simulation.view",
"peripheral",
@@ -256,4 +262,10 @@ _specs += [
("retry_operational_task", "预览或重新入队已终止任务;不自动启动新Agent会话", e.TaskRetryInput, "operational_task.write", "task", False, False, True),
("cancel_task_run", "预览或取消指定任务运行,校验运行版本", e.TaskCancelInput, "operational_task.write", "task", False, False, True),
]
_specs += [
("get_calculation_job", "读取当前身份与会话的持久计算状态;排队回执不是分析证据", jobs.CalculationJobInput, "webgis.view", "task"),
("get_calculation_result", "读取成功计算的原始结果;data_path选择嵌套集合,limit/offset按需分页", jobs.CalculationResultInput, "webgis.view", "task"),
("cancel_calculation_job", "请求取消本人当前会话的计算;运行中取消由独立计算进程处理,须继续核实最终状态", jobs.CalculationJobInput, "webgis.view", "task"),
("list_calculation_jobs", "分页读取本人当前会话及任务范围的计算状态", jobs.CalculationJobsInput, "webgis.view", "task"),
]
TOOLS = {row[0]: QueryTool(*row) for row in _specs}
+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
@@ -70,7 +70,7 @@ async def bind_context(
subject=principal.subject,
client_id=principal.client_id,
tool_cap=sorted(name for name, tool in TOOLS.items()
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating))
if (command.purpose != "task_reader" or not (tool.computation or tool.mutating or name == "cancel_calculation_job"))
and (not tool.mutating or command.purpose == "ordinary_chat")),
permission_cap=sorted(permissions_for_context(scope.project)),
native_session_id=command.native_session_id,
+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 = {
+31
View File
@@ -0,0 +1,31 @@
"""Keep a saved geometry discoverable even when its inline response is shortened."""
from uuid import NAMESPACE_URL, uuid5
from app.mcp.models import QueryResult
def saved_presentation_preview(result: QueryResult) -> dict | None:
if result.source != "generate_node_coverage" or not result.evidence_id:
return None
analysis = result.data.get("result") if isinstance(result.data, dict) else None
if not isinstance(analysis, dict):
return None
payload = analysis.get("map_payload")
if not isinstance(payload, dict) or not payload.get("features"):
return None
coverage = analysis.get("coverage", {})
return {
"command": {
"action": "set_overlay", "mode": "geometry", "evidence_id": result.evidence_id,
"overlay_id": f"node-coverage-{uuid5(NAMESPACE_URL, result.evidence_id)}",
"title": "节点空间覆盖示意",
},
"display_only": True,
"coverage_status": coverage.get("status"),
"groups": {role: {
"node_count": len(group.get("node_ids", [])),
"mapped_node_count": group.get("mapped_node_count", 0),
"missing_node_count": len(group.get("missing_node_ids", [])),
} for role, group in coverage.get("groups", {}).items()},
}
+6 -2
View File
@@ -131,6 +131,9 @@ async def execute_query(
full_snapshot: bool = False,
) -> QueryResult:
await authorize_query(scope, binding, tool, args)
if tool.name in {"get_calculation_job", "get_calculation_result", "cancel_calculation_job", "list_calculation_jobs"}:
from app.mcp.calculation_jobs import read_calculation_job
return await read_calculation_job(scope, binding, tool, args)
if tool.mutating:
from app.mcp.mutation_service import execute_mutation
return await execute_mutation(scope, binding, tool, args)
@@ -176,6 +179,7 @@ async def execute_query(
"run_burst_detection": "Pressure anomalies alone do not confirm a burst or locate a pipe.",
"run_burst_location": "Candidate ranking depends on the leakage assumption, model and sensor coverage; field confirmation is required.",
"analyze_valve_isolation": "Topology-only isolation advice; valve operability, hydraulic impact and field actions are not verified or executed.",
"generate_node_coverage": "Derived node coverage for display only; geometry does not establish a fault, contamination, outage or service boundary. Missing GIS positions are reported separately.",
"calculate_demand_allocation": "Pipe-length-weighted allocation only; model demands were not modified. Units follow the current model.",
"run_burst_simulation": "Forward hydraulic simulation for assumed orifice areas, not detection or confirmed burst location; only requested elements returned.",
}[name]]
@@ -409,14 +413,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)
+6
View File
@@ -201,6 +201,8 @@ def temporary_project_name(project: str, purpose: str) -> str:
def temporary_project_database(project: str, purpose: str):
"""Clone one project's runnable model into an isolated temporary database."""
temporary_name = temporary_project_name(project, purpose)
from app.services.calculation_cleanup import record_calculation_database
record_calculation_database(temporary_name)
try:
copy_project(
get_project_template_database_name(project),
@@ -211,12 +213,15 @@ def temporary_project_database(project: str, purpose: str):
finally:
if have_project(temporary_name):
delete_project(temporary_name)
record_calculation_database(temporary_name, removed=True)
@contextmanager
def temporary_template_database(name_hint: str, purpose: str):
"""Create an empty schema-only temporary database from the fixed template."""
temporary_name = temporary_project_name(name_hint, purpose)
from app.services.calculation_cleanup import record_calculation_database
record_calculation_database(temporary_name)
try:
copy_project(
get_schema_template_database_name(),
@@ -227,6 +232,7 @@ def temporary_template_database(name_hint: str, purpose: str):
finally:
if have_project(temporary_name):
delete_project(temporary_name)
record_calculation_database(temporary_name, removed=True)
def have_project(name: str) -> bool:
+70 -1
View File
@@ -1,10 +1,39 @@
"""Read-only projections backed by the GIS materialized-view query layer."""
"""Read-only GIS projections and authoritative network topology snapshots."""
from typing import Any
from ..core.database import read, read_all
def get_node_geographic_positions(name: str, node_ids: list[str]) -> list[Any]:
"""Resolve only the selected project's stored node geometries to WGS84."""
if not node_ids:
return []
return read_all(name, """
SELECT node_id AS id,
ST_X(ST_Transform(geom,4326)) AS longitude,
ST_Y(ST_Transform(geom,4326)) AS latitude
FROM gis.node_geometries
WHERE node_id = ANY(%s) AND geom IS NOT NULL AND NOT ST_IsEmpty(geom)
ORDER BY node_id
""", (node_ids,))
def get_node_coverage_links(name: str, node_ids: list[str]) -> list[Any]:
"""Read the selected subgraph and initial statuses without changing the model."""
if not node_ids:
return []
return read_all(name, """
SELECT l.id, l.start_node_id, l.end_node_id,
COALESCE(s.status::text, p.status::text) AS status
FROM network.links l
LEFT JOIN network.pipes p ON p.link_id = l.id
LEFT JOIN network.link_initial_settings s ON s.link_id = l.id
WHERE l.start_node_id = ANY(%s) AND l.end_node_id = ANY(%s)
ORDER BY l.id
""", (node_ids, node_ids))
def _node_coord_rows(
rows: list[Any],
) -> dict[str, dict[str, Any]]:
@@ -69,6 +98,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(
+74
View File
@@ -0,0 +1,74 @@
"""Record only job-owned native databases so abrupt cancellation is recoverable."""
import asyncio
import json
import os
from pathlib import Path
import re
from uuid import UUID
def _names(directory: Path) -> list[str]:
path = directory / "databases.json"
if not path.exists():
return []
if path.is_symlink() or path.stat().st_size > 65536:
raise ValueError("Invalid calculation database ledger")
names = json.loads(path.read_text())
if not isinstance(names, list) or len(names) > 100 or any(
not isinstance(name, str) or not re.fullmatch(r"tjw_tmp_[a-z0-9_]+_[a-f0-9]{32}", name)
or len(name) > 63 for name in names
):
raise ValueError("Invalid calculation database ledger")
return names
def _write(directory: Path, names: list[str]) -> None:
path = directory / "databases.json"
staging = directory / "databases.json.new"
with staging.open("w") as output:
json.dump(names, output)
output.flush()
os.fsync(output.fileno())
staging.replace(path)
def record_calculation_database(name: str, *, removed: bool = False) -> None:
"""Called before clone creation and after successful normal cleanup."""
root = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
if not root:
return
directory = Path(root)
names = _names(directory)
if removed:
names = [value for value in names if value != name]
elif name not in names:
names.append(name)
_write(directory, names)
async def cleanup_calculation_databases(project_id: str, job_directory: str) -> None:
directory = Path(job_directory)
names = _names(directory)
if not names:
return
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
from app.infra.db.project_routing import ActiveProjectRouting, activate_project_routing
from app.native.wndb.core.projects import have_project, delete_project
async with SessionLocal() as session:
metadata = MetadataRepository(session)
project = await metadata.get_project_by_id(UUID(str(project_id)))
route = await metadata.get_project_db_routing(UUID(str(project_id)), "biz_data")
if not project or not route or route.db_type != "postgresql":
raise ValueError("Calculation cleanup routing unavailable")
routing = ActiveProjectRouting(project.code, route.dsn, project_id=project.id)
await session.commit()
def cleanup():
with activate_project_routing(routing):
for name in names[:]:
if have_project(name):
delete_project(name)
names.remove(name)
_write(directory, names)
await asyncio.to_thread(cleanup)
+299
View File
@@ -0,0 +1,299 @@
"""Independent, singleton calculation supervisor and isolated job subprocesses.
Never imported by API lifespan. Child output contains the original QueryResult;
algorithm stdout/stderr is discarded rather than interpreted as a protocol.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import signal
import shutil
import sys
import tempfile
import time
from uuid import UUID, uuid4
@dataclass(frozen=True)
class WorkerSettings:
project_ids: tuple[str, ...]
max_concurrency: int = 2
poll_seconds: float = 2
lease_seconds: int = 30
heartbeat_seconds: float = 5
@classmethod
def from_environment(cls):
projects = tuple(dict.fromkeys(str(UUID(p.strip())) for p in
os.environ['CALCULATION_WORKER_PROJECT_IDS'].split(',')))
result = cls(projects, int(os.getenv('CALCULATION_WORKER_MAX_CONCURRENCY', '2')),
float(os.getenv('CALCULATION_WORKER_POLL_SECONDS', '2')),
int(os.getenv('CALCULATION_WORKER_LEASE_SECONDS', '30')),
float(os.getenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '5')))
if not projects or not 1 <= result.max_concurrency <= 32:
raise ValueError('Configure explicit project IDs and concurrency between 1 and 32')
if not 0 < result.heartbeat_seconds < result.lease_seconds / 3 or result.poll_seconds <= 0:
raise ValueError('Heartbeat must be positive and below one third of the lease')
return result
@asynccontextmanager
async def queue_repository(project_id):
"""Resolve current routing from metadata, never from a job payload."""
from app.infra.db.calculation_worker import queue_repository as open_repository
async with open_repository(project_id) as repository:
yield repository
@asynccontextmanager
async def execution_scope(row):
from fastapi import HTTPException
from app.mcp.scope import QueryMetadataRepository, QueryScope
from app.auth.project_dependencies import (resolve_project_context,
resolve_project_business_routing, get_project_pg_connection)
from app.infra.db.metadb.database import SessionLocal
from app.infra.db.project_routing import activate_project_routing
async with SessionLocal() as session:
metadata = QueryMetadataRepository(session)
user = await metadata.get_user_by_id(UUID(str(row['owner_user_id'])))
if not user or not user.is_active:
raise HTTPException(403, 'Calculation owner is inactive')
project = await resolve_project_context(str(row['project_id']), user, metadata)
routing = await resolve_project_business_routing(project, metadata)
await session.commit()
async with asynccontextmanager(get_project_pg_connection)(project, metadata) as conn:
await session.commit()
with activate_project_routing(routing):
yield QueryScope(project, user, conn, routing, metadata)
async def execute_child(input_path: str, output_path: str):
from app.mcp.calculation_jobs import execute_calculation_job
row = json.loads(Path(input_path).read_text())
try:
async with execution_scope(row) as scope:
result = await execute_calculation_job(scope, row)
payload = {'status': 'succeeded', 'result': result.model_dump(mode='json')}
except Exception as exc:
# Do not persist exception text: DSNs, SQL, credentials or model data can occur there.
category = 'CALCULATION_FAILED'
if getattr(exc, 'status_code', None) in (401, 403):
category = 'AUTHORIZATION_DENIED'
elif getattr(exc, 'status_code', None) == 409:
category = 'EXECUTION_CONFLICT'
elif getattr(exc, 'status_code', None) in (404, 410):
category = 'EXECUTION_CONTEXT_UNAVAILABLE'
payload = {'status': 'failed', 'error': {'code': category}}
encoded = json.dumps(payload, allow_nan=False)
if len(encoded.encode()) > 8 * 1024 * 1024:
encoded = json.dumps({'status': 'failed', 'error': {'code': 'RESULT_TOO_LARGE'}})
Path(output_path).write_text(encoded)
async def stop_process_group(process):
"""Always kill the group, even if its original leader already exited."""
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(process.wait(), 2)
except TimeoutError:
pass
# Native pools can ignore SIGTERM or survive the Python leader.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
await process.wait()
if process.stdin is not None:
process.stdin.close()
@contextmanager
def job_directory(row):
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
root.mkdir(parents=True, exist_ok=True)
directory = Path(tempfile.mkdtemp(prefix=f"{row.get('job_id', 'job')}-", dir=root))
directory.chmod(0o700)
(directory / 'scope.json').write_text(json.dumps({'project_id': str(row['project_id']), 'job_id': str(row.get('job_id', 'job')),
'lease_generation': row.get('lease_generation')}))
try:
yield str(directory.resolve())
finally:
# Failed external resource cleanup must remain operator-recoverable.
if not (directory / 'cleanup-failed').exists():
shutil.rmtree(directory)
async def cleanup_job_resources(row, directory):
from app.services.calculation_cleanup import cleanup_calculation_databases
try:
await cleanup_calculation_databases(row['project_id'], directory)
except BaseException:
(Path(directory) / 'cleanup-failed').touch()
raise
async def recover_job_resources(project_ids):
"""Reconcile only this worker's explicit scopes after obtaining the singleton."""
root = Path(os.getenv('CALCULATION_WORKER_STATE_DIR', 'temp/calculation-jobs'))
if not root.exists():
return
for directory in root.iterdir():
if directory.is_symlink() or not directory.is_dir():
continue
scope_file = directory / 'scope.json'
if not scope_file.exists():
continue
row = json.loads(scope_file.read_text())
if str(row.get('project_id')) not in project_ids:
continue
job_id = str(UUID(row['job_id']))
if not directory.name.startswith(job_id + '-'):
raise RuntimeError('Calculation state directory identity is invalid')
async with queue_repository(row['project_id']) as repository:
stored = await repository.get(job_id)
if not stored or stored['lease_generation'] != row['lease_generation']:
raise RuntimeError('Calculation state ownership cannot be verified')
expiry = stored.get('lease_expires_at')
if expiry and expiry > datetime.now(timezone.utc) and stored['status'] in ('running', 'cancel_requested'):
raise RuntimeError('Previous calculation lease has not expired')
await cleanup_job_resources(row, str(directory.resolve()))
async with queue_repository(row['project_id']) as repository:
await repository.recover()
shutil.rmtree(directory)
class CalculationWorker:
def __init__(self, settings, *, repository=queue_repository):
self.settings = settings
self.repository = repository
self.owner = str(uuid4())
async def run_job(self, row):
from fastapi.encoders import jsonable_encoder
with job_directory(row) as directory:
input_path = Path(directory) / 'input.json'
output_path = Path(directory) / 'result.json'
input_path.write_text(json.dumps(jsonable_encoder(row)))
process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'app.services.calculation_worker',
'--guard', str(input_path), str(output_path), start_new_session=True,
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
env={**os.environ, 'TMPDIR': directory, 'TJWATER_CALCULATION_JOB_DIR': directory},
)
started = time.monotonic()
cleaned = False
try:
while True:
async with asyncio.timeout(self.settings.heartbeat_seconds):
async with self.repository(row['project_id']) as repository:
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
if status is None:
return # Finally stops all owned computation on a lost lease.
terminal = None
if status == 'cancel_requested':
terminal = {'status': 'cancelled'}
elif time.monotonic() - started >= row['timeout_seconds']:
terminal = {'status': 'timed_out', 'error': {'code': 'EXECUTION_TIMEOUT'}}
elif process.returncode is not None:
try:
if output_path.stat().st_size > 8 * 1024 * 1024:
raise ValueError('Result exceeds persistence limit')
terminal = json.loads(output_path.read_text())
except (OSError, ValueError):
terminal = {'status': 'failed', 'error': {'code': 'CHILD_PROCESS_FAILED'}}
if terminal:
await stop_process_group(process)
try:
await cleanup_job_resources(row, directory)
cleaned = True
except Exception:
terminal = {'status': 'failed', 'error': {'code': 'CLEANUP_FAILED'}}
cleaned = True
async with self.repository(row['project_id']) as repository:
finished = await repository.finish(row, self.owner, **terminal)
if not finished:
status = await repository.heartbeat(row, self.owner, self.settings.lease_seconds)
if status == 'cancel_requested':
await repository.finish(row, self.owner, 'cancelled')
return
await asyncio.sleep(self.settings.heartbeat_seconds)
finally:
await stop_process_group(process)
if not cleaned:
await cleanup_job_resources(row, directory)
async def run(self):
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 calculation_supervisor_lease() as lease:
tasks = set()
project_cursor = 0
try:
# Allow any old supervisor to detect a broken lock connection and
# stop its children before this replacement claims capacity.
await asyncio.sleep(self.settings.lease_seconds)
await recover_job_resources(self.settings.project_ids)
while True:
async with asyncio.timeout(self.settings.heartbeat_seconds):
await lease.ping()
for task in tuple(tasks):
if task.done():
tasks.remove(task)
task.result() # Fail closed if heartbeat/persistence failed.
projects = self.settings.project_ids
ordered = projects[project_cursor:] + projects[:project_cursor]
project_cursor = (project_cursor + 1) % len(projects)
for project_id in ordered:
if len(tasks) >= self.settings.max_concurrency:
break
async with self.repository(project_id) as repository:
await repository.recover()
row = await repository.claim(self.owner, self.settings.lease_seconds)
if row:
tasks.add(asyncio.create_task(self.run_job(row)))
await asyncio.sleep(min(self.settings.poll_seconds, self.settings.heartbeat_seconds))
finally:
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def guard_child(input_path, output_path):
"""A parent-death pipe kills the whole group even when the supervisor is SIGKILLed."""
reader = asyncio.StreamReader()
protocol = asyncio.StreamReaderProtocol(reader)
transport, _ = await asyncio.get_running_loop().connect_read_pipe(lambda: protocol, sys.stdin)
process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'app.services.calculation_worker', input_path, output_path,
stdin=asyncio.subprocess.DEVNULL,
)
completed = asyncio.create_task(process.wait())
parent_gone = asyncio.create_task(reader.read(1))
try:
done, _ = await asyncio.wait((completed, parent_gone), return_when=asyncio.FIRST_COMPLETED)
if parent_gone in done:
os.killpg(os.getpgrp(), signal.SIGKILL)
await completed
finally:
parent_gone.cancel()
transport.close()
if __name__ == '__main__':
if sys.argv[1] == '--guard':
asyncio.run(guard_child(sys.argv[2], sys.argv[3]))
else:
asyncio.run(execute_child(sys.argv[1], sys.argv[2]))
+128
View File
@@ -0,0 +1,128 @@
"""Reusable geometric coverage of verified project nodes, independent of business analysis."""
import json
from math import isfinite
from typing import Any
from pyproj import CRS, Transformer
from shapely import MultiPoint, concave_hull, get_num_coordinates
from shapely.geometry import mapping
from shapely.ops import transform, unary_union
from app.native.wndb.gis.network_views import get_node_coverage_links, get_node_geographic_positions
CONCAVE_RATIO = 0.25
BUFFER_METERS = 25.0
SIMPLIFY_METERS = 2.0
MAX_COVERAGE_NODES = 10000
def _components(node_ids: list[str], links: list[dict[str, Any]], closed: set[str]) -> list[list[str]]:
parent = {node_id: node_id for node_id in node_ids}
def root(node: str) -> str:
while parent[node] != node:
parent[node] = parent[parent[node]]
node = parent[node]
return node
for link in links:
start, end = link["start_node_id"], link["end_node_id"]
if link["id"] not in closed and link.get("status") != "CLOSED" and start in parent and end in parent:
parent[root(start)] = root(end)
groups: dict[str, list[str]] = {}
for node_id in node_ids:
groups.setdefault(root(node_id), []).append(node_id)
return list(groups.values())
def build_node_coverage(
groups: list[dict[str, Any]], positions: list[dict[str, Any]],
links: list[dict[str, Any]], *, split_by_connectivity: bool = False,
excluded_link_ids: list[str] | None = None,
concave_ratio: float = CONCAVE_RATIO, buffer_m: float = BUFFER_METERS,
) -> dict[str, Any]:
"""Keep caller-defined comparison groups and derive geometry without assigning business meaning."""
coordinates = {}
for row in positions:
longitude, latitude = row.get("longitude"), row.get("latitude")
if (isinstance(longitude, (int, float)) and not isinstance(longitude, bool)
and isinstance(latitude, (int, float)) and not isinstance(latitude, bool)
and isfinite(longitude) and isfinite(latitude)
and -180 <= longitude <= 180 and -90 < latitude < 90):
coordinates[str(row["id"])] = (longitude, latitude)
features, groups_metadata = [], {}
closed = set(excluded_link_ids or [])
simplify_m = min(SIMPLIFY_METERS, buffer_m / 4)
for group in groups:
group_id, label = group["group_id"], group["title"]
ids = sorted(set(group["node_ids"]))
mapped = [node for node in ids if node in coordinates]
missing = [node for node in ids if node not in coordinates]
metadata = {"node_ids": ids, "mapped_node_count": len(mapped), "missing_node_ids": missing}
groups_metadata[group_id] = {"title": label, **metadata}
metadata = groups_metadata[group_id]
if not mapped:
metadata["component_count"] = 0
continue
points = [coordinates[node] for node in mapped]
longitude = sum(point[0] for point in points) / len(points)
latitude = sum(point[1] for point in points) / len(points)
local = CRS.from_proj4(
f"+proj=aeqd +lat_0={latitude} +lon_0={longitude} +datum=WGS84 +units=m"
)
project = Transformer.from_crs(4326, local, always_xy=True).transform
unproject = Transformer.from_crs(local, 4326, always_xy=True).transform
envelopes = []
components = _components(ids, links, closed) if split_by_connectivity else [ids]
for nodes in components:
component_points = [coordinates[node] for node in nodes if node in coordinates]
if not component_points:
continue
cloud = transform(project, MultiPoint(component_points))
# A point or collinear cloud remains drawable through the same metric buffer.
envelope = concave_hull(cloud, ratio=concave_ratio).buffer(buffer_m)
envelopes.append(envelope)
metadata["component_count"] = len(envelopes)
geometry = unary_union(envelopes).simplify(simplify_m, preserve_topology=True)
if geometry.is_empty or not geometry.is_valid or get_num_coordinates(geometry) > 20000:
raise ValueError("Invalid derived coverage")
feature = {
"type": "Feature", "id": group_id,
"properties": {
"coverage_group": group_id, "label": label, "derived": True, "display_only": True,
"node_count": len(ids), "mapped_node_count": len(mapped),
"missing_node_count": len(missing), "component_count": len(envelopes),
},
"geometry": mapping(transform(unproject, geometry)),
}
features.append(feature)
return {
"status": "partial" if any(group["missing_node_ids"] for group in groups_metadata.values())
else "ready" if features else "empty",
"method": "concave_hull_buffer",
"parameters": {"concave_ratio": concave_ratio, "buffer_m": buffer_m,
"split_by_connectivity": split_by_connectivity,
"excluded_link_ids": sorted(closed),
"simplify_m": simplify_m, "projection": "local_azimuthal_equidistant"},
"output_crs": "EPSG:4326", "display_only": True,
"position_source": "gis.node_geometries",
"boundary": "节点空间覆盖示意;不推断故障、污染、停水、水力影响或用户服务边界。",
"groups": groups_metadata,
"map_payload": {"type": "FeatureCollection", "features": features},
}
def generate_node_coverage(network: str, groups: list[dict[str, Any]], **parameters: Any) -> dict[str, Any]:
node_ids = sorted({node for group in groups for node in group["node_ids"]})
if len(node_ids) > MAX_COVERAGE_NODES:
raise ValueError("Node coverage supports at most 10000 distinct nodes")
positions = get_node_geographic_positions(network, node_ids)
links = get_node_coverage_links(network, node_ids) if parameters.get("split_by_connectivity") else []
coverage = build_node_coverage(groups, positions, links, **parameters)
payload = coverage.pop("map_payload")
if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > 400 * 1024:
raise ValueError("Coverage exceeds presentation budget; narrow the node selection")
return {"coverage": coverage, "map_payload": payload}
+8 -1
View File
@@ -51,12 +51,19 @@ def _geometry(value):
def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
"""Use recorded GeoJSON or recorded coordinates; never geocode or infer a boundary."""
data = snapshot.data
# MCP persists its complete QueryResult envelope, unlike direct data queries.
if (isinstance(data, dict) and data.get("source") == snapshot.source
and isinstance(data.get("data"), (dict, list))):
data = data["data"]
candidates = [data]
if isinstance(data, dict):
candidates.extend([data.get("map_payload"), data.get("drawing_payload")])
latest = data.get("latest_analysis")
if isinstance(latest, dict):
candidates.append(latest.get("map_payload"))
result = data.get("result")
if isinstance(result, dict):
candidates.append(result.get("map_payload"))
collection = next((item for item in candidates
if isinstance(item, dict) and item.get("type") == "FeatureCollection"), None)
if collection is not None:
@@ -88,7 +95,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)
+75 -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))
@@ -778,6 +795,10 @@ class OperationalTaskService:
"Only a running run accepts Agent artifacts", status=409)
now = self._now()
self._require_active_run_lease(active_run, now)
if command.report and (
command.report.stage == "final" or command.report.outcome == "insufficient_data"
):
await self._require_no_pending_calculations(task_id, active_run)
previous_report = await self.repo.get_latest_report(str(task_id))
require(not previous_report or previous_report.get("execution_generation") != task["execution_generation"]
or previous_report["report"].get("outcome") != "insufficient_data",
@@ -982,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:
@@ -1015,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:
@@ -1866,6 +1917,15 @@ class OperationalTaskService:
task_ids.append(task_id)
return DispatchDueSchedulesResult(task_ids=task_ids, dispatched_at=now)
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(
str(task_id), str(run["run_id"]), str(run["execution_request_id"]),
)
require(not pending, "PENDING_CALCULATIONS",
"Wait for submitted calculations before publishing a final report or completing the run",
refs=pending, status=409, recovery="retry_same_request")
async def _finish_run(
self, task_id: UUID, run_id: UUID, command: AgentRunCommand,
*, outcome: str, failure_code: str | None = None,
@@ -1877,19 +1937,23 @@ 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"):
pending_messages = await self.repo.pending_run_messages(str(run_id))
require(not pending_messages, "PENDING_AGENT_MESSAGES",
@@ -1915,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"
@@ -1960,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,
@@ -1980,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
)
@@ -2193,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:
+5 -2
View File
@@ -2,6 +2,7 @@ from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile
import os
from app.native.wndb.inp.exporter import dump_inp
@@ -17,9 +18,11 @@ def temporary_project_inp(
version: str = "2",
) -> Iterator[Path]:
"""Export one project model to an isolated INP and remove it afterwards."""
PROJECT_INP_DIRECTORY.mkdir(parents=True, exist_ok=True)
job_directory = os.environ.get("TJWATER_CALCULATION_JOB_DIR")
directory = Path(job_directory) / "inp" if job_directory else PROJECT_INP_DIRECTORY
directory.mkdir(parents=True, exist_ok=True)
with NamedTemporaryFile(
dir=PROJECT_INP_DIRECTORY,
dir=directory,
prefix=f"{purpose}_",
suffix=".inp",
delete=False,
+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,
)
+71
View File
@@ -17,6 +17,7 @@
"Stack",
"Section",
"Text",
"Result",
"Metric",
"Table",
"Chart",
@@ -66,6 +67,7 @@
"Stack",
"Section",
"Text",
"Result",
"Metric",
"Table",
"Chart",
@@ -90,6 +92,21 @@
"compact",
"normal"
]
},
"intent": {
"type": "string",
"enum": [
"response",
"comparison",
"report"
]
},
"density": {
"type": "string",
"enum": [
"compact",
"comfortable"
]
}
},
"required": [
@@ -116,6 +133,15 @@
"type": "null"
}
]
},
"role": {
"type": "string",
"enum": [
"result",
"analysis",
"evidence",
"follow_up"
]
}
},
"required": [
@@ -139,6 +165,10 @@
"info",
"warning"
]
},
"title": {
"type": "string",
"maxLength": 200
}
},
"required": [
@@ -148,6 +178,47 @@
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Result": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"summary": {
"type": "string",
"maxLength": 4000
},
"status": {
"type": "string",
"enum": [
"success",
"partial",
"unconfirmed",
"error"
]
},
"source": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
}
},
"required": [
"title",
"summary",
"status",
"source"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
"Metric": {
"type": "object",
"properties": {
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692"
"sha256": "2c1d757ccf06ae72b10c592e1e9b3d32b4f72cda8489e224cf2c5f2690fc6972"
}
}
}
+10 -4
View File
@@ -103,7 +103,7 @@
"$ref": "#/components/schemas/ManagedResourceCapabilities"
},
"origin": {
"const": "synthetic",
"const": "system_data",
"title": "Origin",
"type": "string"
},
@@ -273,6 +273,12 @@
],
"title": "Location"
},
"origin": {
"const": "system_data",
"default": "system_data",
"title": "Origin",
"type": "string"
},
"priority": {
"default": "normal",
"enum": [
@@ -1843,7 +1849,7 @@
"title": "Location"
},
"origin": {
"const": "synthetic",
"const": "system_data",
"title": "Origin",
"type": "string"
},
@@ -2036,8 +2042,8 @@
}
},
"info": {
"description": "测试监测与核查反馈样板,不是正式工单平台或设备控制服务。",
"title": "TJWater 外围系统模拟接入",
"description": "按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。",
"title": "TJWater 外围系统接入",
"version": "1.0.0"
},
"openapi": "3.1.0",
File diff suppressed because it is too large Load Diff
+663
View File
@@ -0,0 +1,663 @@
{
"anyOf": [
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "locate_features"
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"action",
"layer",
"feature_ids"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "zoom_to_map"
},
"center": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
]
},
"zoom": {
"type": "number",
"minimum": 0,
"maximum": 24
}
},
"required": [
"action",
"center",
"zoom"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "render_scada_analysis"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"device_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"level": {
"type": "string",
"enum": [
"high",
"medium",
"low",
"unrated"
]
}
},
"required": [
"device_id",
"level"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"action",
"items"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_scada_analysis"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "highlight_features"
},
"layer": {
"type": "string",
"enum": [
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]
},
"feature_ids": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"effect": {
"type": "string",
"enum": [
"steady",
"pulse"
]
}
},
"required": [
"action",
"layer",
"feature_ids",
"effect"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_highlights"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "open_chart"
},
"chart_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"chart": {
"type": "object",
"properties": {
"title": {
"type": "string",
"maxLength": 200
},
"chart_type": {
"type": "string",
"enum": [
"line",
"bar"
]
},
"x_data": {
"type": "array",
"items": {
"type": "string",
"maxLength": 128
},
"minItems": 1,
"maxItems": 100
},
"series": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 128
},
"data": {
"type": "array",
"items": {
"type": "number"
},
"minItems": 1,
"maxItems": 100
}
},
"required": [
"name",
"data"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 8
}
},
"required": [
"title",
"chart_type",
"x_data",
"series"
],
"additionalProperties": false
},
"evidence_refs": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"minItems": 0,
"maxItems": 100
}
},
"required": [
"action",
"chart_id",
"chart",
"evidence_refs"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "close_chart"
},
"chart_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"action",
"chart_id"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "set_overlay"
},
"overlay_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"mode": {
"type": "string",
"enum": [
"geometry",
"heatmap"
]
},
"evidence_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"data": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "FeatureCollection"
},
"features": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Feature"
},
"id": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
}
]
},
"properties": {
"anyOf": [
{
"type": "object",
"additionalProperties": {}
},
{
"type": "null"
}
]
},
"geometry": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Point"
},
"coordinates": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiPoint"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "LineString"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 2,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiLineString"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 2,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "Polygon"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 4,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "MultiPolygon"
},
"coordinates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "array",
"minItems": 2,
"items": [
{
"type": "number",
"minimum": -180,
"maximum": 180
},
{
"type": "number",
"minimum": -90,
"maximum": 90
}
],
"additionalItems": {
"type": "number"
}
},
"minItems": 4,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
},
"minItems": 1,
"maxItems": 20000
}
},
"required": [
"type",
"coordinates"
],
"additionalProperties": false
}
]
}
},
"required": [
"type",
"properties",
"geometry"
],
"additionalProperties": false
},
"minItems": 1,
"maxItems": 1000
}
},
"required": [
"type",
"features"
],
"additionalProperties": false
}
},
"required": [
"action",
"overlay_id",
"title",
"mode",
"evidence_id",
"data"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"const": "clear_overlay"
},
"overlay_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"action",
"overlay_id"
],
"additionalProperties": false
}
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
+248
View File
@@ -0,0 +1,248 @@
# 从爆管上报到处置闭环
本文定义爆管调查的完整业务路线,供维护者审阅和验收。运行时指令位于 [burst-report/SKILL.md](../resources/work-order-skills/burst-report/SKILL.md),由 Server 根据绑定任务的 `workflow=burst_report` 经 MCP 分发。
重点成果是有依据的事故候选位置、隔离阀门清单和影响范围,以及经审批执行、反馈核验后的闭环记录。Agent 根据证据选择、跳过或重做分支,不按图逐节点机械调用工具。工程图覆盖多轮调查和人工业务;每轮发布的进度图只标注实际进行的工作,不复制整图冒充执行记录。
## 完整工程图
```mermaid
flowchart TB
subgraph intake[工单进入与后台任务]
order[用户或外围系统提交爆管上报工单]
source[保存原工单及来源版本、发生时间、位置和描述]
sync[同步器按来源关联调查及工单投影]
queue[Server 保存调查任务和 queued run]
claim[Worker 投递,Agent 领取并维持租约]
context[读取任务摘要、业务 Skill、历史报告和既有动作]
order --> source --> sync --> queue --> claim --> context
end
subgraph investigation[Agent 调查与工程分析]
scope[确定事件时刻、地点、调查目标和真实或演练来源]
location[核对已有资产编号;必要时解析地点和查询附近管段]
monitor[核对相关测点映射、异常窗口、正常对照和数据质量]
cover{查询是否实际覆盖事件与对照窗口}
more[缩小对象和窗口,按需补页,核对缺测及零值]
hypothesis{是否具备定位所需观测和漏水量依据}
locate[计算爆管候选排序,保留假设、覆盖范围与证据]
candidate[整合上报、资产关联、算法与现场反馈得到候选集合]
usable{是否存在可用于隔离评估的候选资产}
isolation[逐候选分析拓扑隔离边界、必关阀及受影响节点]
isolateok{可隔离且阀门可用性有可核验依据}
alternate[纳入已知不可关闭阀门,重新评估替代隔离方案]
hydraulic[按需模拟候选关阀组合的压力和流量影响]
impact[区分拓扑停水区、水力影响区和有资料支持的用户范围]
findings[形成候选位置、阀门清单、影响对比和建议方案]
fieldbasis{是否已有足以支持具体现场核验的结论}
fielddraft[形成有位置、核验目标及回执要求的现场核验建议]
gap[真实关键数据缺失:发布 final insufficient_data 并结束本轮]
error[接口、权限或配置异常:记录具体失败并由宿主结束失败运行]
scope --> location --> candidate
scope --> monitor --> cover
cover -->|未覆盖且可补齐| more --> monitor
cover -->|已覆盖或确认实际缺失| hypothesis
hypothesis -->|具备依据| locate --> candidate
hypothesis -->|条件不具备,明确原因| candidate
candidate --> usable
usable -->|候选有效,标明待现场确认| isolation --> isolateok
isolateok -->|有可评估方案| hydraulic --> impact --> findings
isolateok -->|已知存在不可关阀| alternate --> isolation
isolateok -->|无法形成方案| fieldbasis
usable -->|尚无候选| fieldbasis
fieldbasis -->|可形成具体核验建议| fielddraft
fieldbasis -->|当前判断所需证据确实缺失| gap
monitor -->|不可恢复的查询异常| error
location -->|不可恢复的必要查询异常| error
end
subgraph approval[报告、审批与现场处置]
interim[保存本轮 interim concluded 阶段报告]
proposals[创建具体待审批草稿,关联证据和执行前置条件]
finalround[保存本轮 final 报告,由宿主结束本轮 run]
review[有权限人员审核当前草稿版本]
decision{审批结果}
revise[驳回理由保存并触发原调查新一轮]
submit[Server 通过配置的适配器幂等提交]
receipt{外部提交结果}
reconcile[使用同一执行请求号核对回执,不重复下发]
unsupported[未配置或明确失败:记录原因并重新分析]
workkind{本次获批的是核验还是处置}
fieldcheck[现场核验准确位置、漏水迹象及资产,回传实际记录]
notice[人工实施获批的通知、供水保障和应急安排]
isolatefield[现场核验阀门及实际隔离结果]
repair[现场定位、止水和抢修,回传实际故障管段及修复记录]
clean[按现场风险及获批方案清洗、消毒或冲洗]
restore[核验修复与适用复测,按批准方案恢复供水]
follow[记录恢复后压力流量、漏水复查及用户回访]
findings --> interim
fielddraft --> interim
interim --> proposals --> finalround --> review --> decision
decision -->|驳回| revise
decision -->|批准| submit --> receipt
receipt -->|结果不明或仍在执行| reconcile --> receipt
receipt -->|明确失败或不支持| unsupported
receipt -->|工单已创建或设备动作有回执| workkind
workkind -->|现场核验事项| fieldcheck
workkind -->|有依据的处置事项| notice
notice --> isolatefield --> repair --> clean --> restore --> follow
end
subgraph closure[现场反馈、复核与原事件闭环]
feedback[外围系统保存关联工单的新反馈和单调版本]
requeue[Server 校验来源并为原调查排队新 generation]
reread[重新读取现场事实、执行回执和最新监测证据]
verify[对照预测与现场结果,复核修复、恢复和风险]
faultstate{复核结果支持故障、误报还是尚待核验}
falsealarm[核对排除故障的证据及人工确认]
closed{处置及闭环证据是否齐全}
remaining[列出未完成事项及对应已有或待审批动作]
sourceclosed{原上报工单已结案且版本已核对}
readyreport[发布具备结案条件报告并结束本轮]
closedreport[发布闭环复核 final 报告并结束本轮]
closeowner[有权限人员在原工单系统核对并完成原事件结案]
closedcase[报告与来源事实一致,确认业务闭环]
follow --> feedback --> requeue
fieldcheck --> feedback
reread --> verify --> faultstate
faultstate -->|已确认故障| closed
faultstate -->|有依据排除故障| falsealarm --> sourceclosed
faultstate -->|尚需核验| remaining
closed -->|尚未齐全或复测异常| remaining --> interim
closed -->|证据齐全| sourceclosed
sourceclosed -->|是| closedreport --> closedcase
sourceclosed -->|否| readyreport --> closeowner --> feedback
reread -->|现场位置或阀门条件改变| candidate
end
subgraph presentation[贯穿调查的地图、图表与数据整理]
progress[发布真实调查进展和 Mermaid 状态]
map[高亮候选管段、闪烁建议关阀、展示已有证据覆盖层]
chart[打开异常与对照曲线,展示候选及影响对比表]
filepermit{会话是否已有文件写入权限}
jsonl[按权限整理有来源引用的 JSONL 调查数据]
reportui[通过报告 ui_blocks 发布 json-render 结构化界面]
sourceview[展示保持证据来源,失败不阻断业务调查]
progress --> map --> chart --> filepermit
filepermit -->|已获准且需要导出| jsonl --> reportui
filepermit -->|未获准或无需导出| reportui
reportui --> sourceview
end
context -->|首次或需要重新分析| scope
context -->|已有新反馈待复核| reread
revise --> requeue
unsupported --> requeue
requeue --> queue
gap -->|补充事实被 Server 接受后才重开| requeue
findings -->|无须新增动作| finalonly[无需新增动作时保存 final 报告并结束本轮]
scope -.-> progress
candidate -.-> map
impact -.-> chart
verify -.-> reportui
```
图中的现场处置由实际审批、工单与回执推进;顺序按现场紧迫性和获批方案调整。必要的紧急通知、现场核验和供水保障不应等待可选的完整模型计算。`clean` 为按风险适用的步骤,不能把每次爆管都判断为污染。`hydraulic` 在缺少模型、时序输入或权限时记录具体限制,仍可保留已完成的拓扑隔离结果。
`requeue` 表示 Server 接受了新业务事实后分配下一轮,不是 Agent 复活旧会话或自行循环等待。原工单结案由原系统有权限人员处理,后台 Agent 目前没有直接关闭原工单的工具。`completed`、`concluded`、动作 `succeeded` 和“业务闭环”分别核对,不能互相推导。
## 将业务步骤映射为现有能力
以下产品查询与计算均使用 `mcp_tjwater_` 前缀。参数以本轮实际发现的工具 schema 为准。
| 业务动作 | 已有入口 | 必须形成的结果 |
| --- | --- | --- |
| 接续原任务 | `get_task_context`,依次读取 summary、skill 及所需分节;`get_task_run_history` | 原工单来源、事件时刻、调查代次、流程版本、既有动作与现场反馈 |
| 查找地点和资产 | `geocode` / `search_project_places`、`query_nearby_elements`、`get_element_properties`、`get_node_connections` | 实际候选管段或节点、位置来源、匹配理由;邻近不代表故障确认 |
| 核对监测 | `list_scada_devices`、`get_scada_device`、`query_scada_readings`、`query_element_history` | 设备和模型节点的映射、异常及对照窗口、单位、缺测、分页覆盖 |
| 压力异常侦测 | `run_burst_detection` | 至少 5 个完整压力测点及此前 15 天数据满足时才调用;不把侦测当作定位 |
| 爆管候选定位 | `run_burst_location` | 候选排序、算法实际返回的评价值、观测范围、漏水量假设及来源 |
| 逐候选隔离 | `analyze_valve_isolation` | `accident_elements`、`isolatable`、`must_close_valves`、`optional_valves`、`affected_nodes`、`affected_node_count` 及已知 `disabled_valves` |
| 水力影响 | `analyze_valve_closure` | 指定关阀组合、时间窗、输出对象和模拟压力流量;参数不足时保留明确原因 |
| 用户影响 | 已授权资产属性、区域及外围资料查询 | 受影响对象、关联依据和覆盖限制;没有用户资料不估造户数 |
| 发布和提议 | `publish_task_progress`、`publish_task_report`、`propose_task_action` | 持久化报告、证据引用和待审批动作;动作不是执行事实 |
| 反馈接续 | Server `external-feedback` / `human-evidence` 及 worker | 同一任务的新轮次、真实来源版本、修复和复测证据 |
定位的 `burst_leakage` 必须有依据,单位为 m³/s;`pressure_scada_ids` 是模型节点 ID,不能填设备查询编号,也可省略并使用服务的项目配置。monitoring 模式正常窗可成对省略,使用前一天同窗,但这只是一项对照假设,仍要核对数据有效性。simulation 模式必须引用已有分析运行 UUID,不把在线重算当成已有分析运行。已知事故管段可直接做候选隔离;不必先完成一次没有必要的定位计算,也不能把已知答案作为算法独立定位成功的证明。
隔离工具支持明确标注的候选管段,无须等到现场确认才评估方案。多个候选的比较优先分开调用;把多个 ID 一次传入表示联合事故场景,不等同于逐个比较。`disabled_valves` 仅填已有事实支持的不可关阀;空数组不证明现场阀门均可用。`isolatable=false` 不能解释为零影响,即使 `affected_nodes` 为空也须解释返回的计数和限制。
## 像调查员一样查询、查看、比较和复核
每一步先明确待回答的问题,再查小范围资料,观察实际结果后决定下一步。例如:核实地址,查附近管段并查看属性,关联压力点,比较事件前后及正常对照,检查候选,评估关阀影响。只把必要的查询、事实和判断依据写入进度及报告,不保存模型内部推理。
- 不满足于“查过 SCADA”:必须确认返回的时间实际覆盖事件。查询 24 小时而只返回当天前 45 分钟时,应缩到事件前后的小窗,按需补页,不直接进入最终判断。
- 正常基线、实测、模型模拟、外围演练和人工反馈分别标注;缺测不是零值,异常不是爆管确认,候选评分不是校准后的概率。
- 查不到事故管段时先检查地址解析、附近资产与上下游关联;能查询的字段先查询,不能直接全部交给现场。
- 既有证据通过 `get_task_evidence` 的有界分页读取,不重复拉取同一批事实。失败、权限拒绝和存储未配置不当作“无异常”或“无数据”。
- 可以用最多两个前台 `investigation-reader` 并行核对无依赖的问题。读取、计算、发布和文件权限仍按角色执行;reader 只返回查询事实及真实证据编号。
## 地图与图表操作
后台调查统一调用 `publish_task_presentation`,每个不同命令使用不同 UUID `request_id`,相同命令重试复用原编号。以下为工具已有能力,不需要浏览器自动化。
| 调查进展 | command.action | 取值及依据 |
| --- | --- | --- |
| 找到实际候选资产 | `highlight_features` | `layer=pipes` / `junctions`,`feature_ids` 为已核实的地图要素编号,`effect=steady` |
| 正在核查候选或建议关阀 | `highlight_features` | 对应 pipes 或 valves,`effect=pulse`;闪烁只表示调查关注,不代表设备正在操作 |
| 查看异常与对照 | `open_chart` | 有限长度的时间和值,引用真实 `evidence_refs`;不同日期的对照对齐方式在标题或来源中说明 |
| 查看已有空间结果 | `set_overlay` | 提供 `overlay_id/title/mode/evidence_id`,Server 从证据提取几何,不接收手绘影响区 |
| 撤下旧的临时展示 | `close_chart` / `clear_overlay` / `clear_highlights` | 清理自己记录的临时展示;清理失败不修改业务结果 |
支持的图层为 junctions、pipes、valves、pumps、tanks、reservoirs、scada。先核实业务 ID 与地图 feature ID 的映射,不能把设备编号、节点编号与 GIS 编号混用。每次高亮最多 100 个要素,需要更多时说明所显示的子集;不要假定多次命令会累积成完整范围。没有几何时可以按已知 ID 高亮、用表格解释结果,不画包络多边形冒充影响范围。
图表最多 100 个横轴值和 8 组数值序列,单位须在系列名称或标题中明确。不能把 null 缺测填零以通过 schema;可以按有数据的区间分图,并保留缺测说明。`recorded` 仅表示请求保存。浏览器未开 Dev 跟随时仍继续调查,不能等待地图显示回执。后台接口目前没有摄像机缩放、平移、鼠标点击或任意图层开关命令;不要调用仅供聊天的 `locate_features`、`zoom_to_map` 或原生浏览器工具替代。
## JSONL 调查数据与 json-render 展示
JSONL 用于整理已经取得的数据与分析结果,不补造监测、阀门、现场反馈或审批。推荐每行一个对象,记录 `record_type/origin/source_evidence_refs`,并按对象保留时间窗、单位、模型或假设、覆盖限制和实际结果。`origin` 沿用来源分类;本地派生记录引用输入证据,并说明转换方法。不要把 JSONL 文件自身当作 Server 已保存的业务证据。
按内容选择 `event_context`、`monitoring_coverage`、`candidate`、`isolation`、`hydraulic_impact`、`field_feedback`、`closure_check`,无需为没有结果的类别制造空壳文件。候选记录写实际排序及评价值;隔离记录保留建议必关阀、可选阀、已知不可关阀及受影响节点;闭环记录逐项保留已核验、待核验或不适用及原因。JSONL 是导出约定,不新增 MCP 输入字段或服务端数据协议。
以下两行只演示文件格式,`synthetic`、`example_only` 与空证据引用表示它们不可用于实际报告或审批,不包含真实分析结果:
```jsonl
{"record_type":"candidate","origin":"synthetic","example_only":true,"source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":null,"evaluation_value":null,"status":"not_computed","limitations":["仅说明字段,未执行定位"]}
{"record_type":"closure_check","origin":"synthetic","example_only":true,"source_evidence_refs":[],"item":"repair_verified","status":"not_verified","observed_at":null,"limitations":["无现场反馈,不能声称闭环"]}
```
如果当前会话已获准使用原生文件写入,则在当前 workspace 下写 `files/burst-investigation-<generation>.jsonl`,保留原始证据不变;通过允许的读取工具逐行复核 JSON、行数、对象 ID、时间和单位。只引用宿主绑定的 generation,不能从用户文本自选。不得读取兄弟会话、业务数据库、宿主源码或凭据;需要本地转换时只使用已获准的原生工具及沙箱 Shell,不引入 HTTP、SQL 或自由命令查询桥。
当前没有专用的免审批 JSONL 导出工具。文件权限未授予时,不通过 Shell 绕过、不让后台无限等待批准、不声称已生成文件;继续完成报告内的结构化数据,并如实说明文件未保存。恢复写入授权须由有权限的人或宿主处理,Skill 不扩大权限。
图表、表格和指标使用 `publish_task_report.report.ui_blocks`,每块为 `answer-ui@1`,包含独立 `block_id`、`fallback_text` 和 json-render 的 `spec.root/elements`,外层附真实 `evidence_refs`。最多 8 块、总计 400 KB,单块还受现有 schema 限制。优先组织以下内容:
| 展示 | 建议组件 | 数据来源 |
| --- | --- | --- |
| 候选管段及理由 | Table、Evidence、MapLocation | 地点、资产与定位证据;明确候选和已确认的区别 |
| 异常及正常对照 | Chart | 同单位的实际序列,说明时间对齐及缺测 |
| 关阀与隔离范围 | Table、Metric、MapLocation | 隔离证据、阀门属性及候选场景 |
| 水力与用户影响 | Table、Chart | 指定输出节点的模拟结果及真实用户关联资料 |
| 处置与闭环核对 | Table、Evidence | 动作回执、现场反馈、恢复监测和原工单版本 |
只用工具公布的组件属性。JSONL 可作为整理中间格式,但目前没有“把任意 JSONL 直接传给 json-render”的通道;需转换为已注册组件的 JSON 属性。后台不用 `present_answer`,不把原始 JSONL 输出到聊天正文,也不把表单提交当成已保存现场证据。小数据和简单结论可直接文字表达。
## 本轮成果与爆管闭环的验收
报告至少回答下列四类问题,逐项给出实际结果或经过查询证实的具体限制;无需固定排版或创建固定数量的动作。
1. **位置**:事故已确认还是仅有候选?每个候选的模型/地图编号、匹配或定位依据、排名及未确认事项是什么?
2. **隔离**:每个候选场景是否可隔离?应关、可选和已知不可关的阀门分别是什么?有无替代方案及现场前置核验?
3. **影响**:拓扑受影响节点、水力压降或失供范围、用户关联分别有哪些实际结果?未覆盖什么?没有水力或用户资料时分别说明,不能推定为零。
4. **处置与闭环**:当前可审批的工作是什么?已有哪些动作、审批、提交回执和现场事实?还缺什么反馈才具备结案条件?
有具体结论和动作建议时,先保存 `interim/concluded`,再 `propose_task_action`,最后保存 `final`。人工执行事项使用 `work_order_type=general`,保留原爆管事件。工单描述写位置、任务内容、依赖、执行前核验、需回传的事实和责任要求;`propose_task_action` 当前 payload 没有独立的 dependencies 或 feedback_requirements 字段,不发明字段。没有客户通知适配器时,将通知作为人工工单事项,不宣称已发送。
闭环复核至少核对实际故障资产及原因、隔离/阀门实施记录、抢修结果、适用的清洗及水质复测、恢复供水记录和恢复后监测、用户通知及回访、未决动作与遗留风险、原上报工单结案状态。不适用事项必须有事件依据;未收到结果写待核验,不把记录创建时间当现场完成时间。只齐备处置证据而原工单未结案时,结论为“具备结案条件,待原系统确认”,而非“已闭环”。误报或未发现故障的结案也要有支持排除的实际核查与人工确认。
必要查询失败、权限拒绝和存储未配置属于执行异常,按 schema 修正可纠正问题;仍无法取得当前判断必需的资料时交宿主记录失败。已通过有效查询确认关键数据确实缺失且无法补齐,才发布 `final/insufficient_data`,提供 blocking 缺口和查询依据,建议与待办为空,不在该退出分支再生成补数据工单。能够明确形成一项现场核验结论时可提议具体核验动作,但不能把“尚未开展定位”包装为“已完成定位调查”。
## 能力边界与验证
本次交付为工程图和运行时 Skill 文本,使用现有 MCP、发布、审批与反馈能力;不新增自动关单、鼠标自动化、摄像机操作、JSONL 导出接口或数据库字段。Server 目前没有按 Skill 检查定位/隔离成果的强制验收器,本文的业务验收要求仍由 Agent 执行并由人复核。
当前 Skill 仅通过 `section=skill` 整份分发,没有相对路径 reference 读取协议,因此运行时指令保持自包含,不能要求 Agent 读取本工程文档。内容需控制在既有内联预算内。Server 更新资源后,应按原发布方式让实际 Server 实例取得新文件;Agent 读取时核对 `name/version/content_sha256`,不把本地文件更新当成运行中实例已生效。
验证包含 Mermaid 语法解析、本地路径和工具 ID 核对、Skill 分发/权限/体积测试及 `git diff --check`。后续真实行为验收应分别覆盖:有效观测和漏水量依据下的定位;已知候选但缺漏水量时的候选隔离;不可关阀替代方案;SCADA 首屏未覆盖事件时继续读取;接口失败明确终止;文件权限缺失不绕过;无浏览器时照常调查;实际反馈触发新轮及最终闭环复核。没有真实模型计算或现场回执,不报告定位准确性或业务闭环已通过。
关联契约:[分析 MCP](mcp-analysis-tools.md)、[调查展示](investigation-progress-and-presentation.md)、[报告契约](investigation-report-contract.md)、[任务与多轮反馈](operational-tasks.md)、[后台工作进程](operational-task-workers.md)。
+94
View File
@@ -0,0 +1,94 @@
# MCP 后台计算队列
API 保留 `uvicorn --workers 4`。长计算在项目 BizDB 持久化入队,由独立计算进程领取;所有 API 进程读取相同任务状态,不依赖粘性会话。HTTP 返回预算由宿主管理,Server 默认 120 秒、Agent 默认 180 秒。Server 通过工具目录 `_meta["io.tjwater/request-timeout"]` 统一发布 `defaultSeconds` 和 `maxSeconds`,所有工具(包括状态和结果读取)使用同一配置来源。Server 兼容保留 `timeout_seconds` 参数;Agent 对模型隐藏并拒绝该参数,按目录预算代填。Server 请求上限为 240 秒,Agent 加 60 秒返回余量并封顶 270 秒,原生 MCP 与本地连接的总时限为 300 秒;上游 HTTP 的 5 分钟空闲超时保留。提交、查询状态和读取结果都应及时返回,不等待整个计算。模拟时间仍由 `duration` 表示。
后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),入队时单独写入任务记录。HTTP 参数不参与作业幂等参数比较,也不会缩短或延长后台执行。任务表的内部 `timeout_seconds` 列仍保存执行时限。HTTP 超时或断开只结束本次等待,后台作业需通过独立取消工具取消。
```text
前端 / Agent → API × 4 → 项目 BizDB calculation_jobs
↑
独立计算 supervisor
↓
受管理的计算子进程组
```
## 工具契约
原工具名继续使用。以下工具提交后返回 `QueryResult`,其中 `semantics=task`,`data` 包含 `job_id`、`status`、`tool` 和 `poll_after_ms`;这只证明提交状态,不是分析结果或调查证据:
- `run_burst_location`、`run_leakage_identification`、`run_burst_detection`
- `run_simulation`、`run_burst_simulation`、`run_contaminant_simulation`
- `analyze_valve_closure`、`analyze_flushing`、`analyze_pressure_regulation`
- `optimize_sensor_placement`
- `save_simulation_scenario`、`create_sensor_placement` 的 `dry_run=false` 执行阶段
后两项保留预览、摘要校验和原有审批;提交与执行时均校验权限及预览摘要,已有写入回执继续约束重复请求。`analyze_valve_isolation`、`calculate_demand_allocation` 保持同步执行。普通查询和其他写入不进入此队列。
| MCP 工具 | 输入 | 返回 |
| --- | --- | --- |
| `get_calculation_job` | `job_id` | 状态、工具、时间和安全错误码 |
| `get_calculation_result` | `job_id`,可选 `data_path/limit/offset` | 成功后读取原分析结果;未成功返回任务状态 |
| `list_calculation_jobs` | 可选 `status/limit/offset` | 当前授权会话及调查轮次的任务列表 |
| `cancel_calculation_job` | `job_id` | 排队任务可直接取消;运行任务先进入 `cancel_requested` |
状态为 `queued/running/cancel_requested/succeeded/failed/cancelled/timed_out/interrupted`。返回创建、开始、结束和排队/结果到期时间;`result_available` 表示结果是否仍可读取。任务取消不等于在线模型写入回滚;发生失败、超时或中断时,需要核查已有写入,不能自动重算整个窗口。
当前使用普通 MCP 业务工具承载队列契约,没有宣称已实现协议级 Tasks 扩展。Server 和 Agent 目录必须配套更新,旧 Agent 不应接收新的队列返回契约。
## 身份、幂等和结果
任务记录保存提交人的元数据用户 ID、项目、原工具参数和受限授权快照,不保存 Bearer、数据库 DSN 或短期 `context_handle`。执行和读取结果时重新查询当前用户、成员关系、项目路由、权限及调查运行租约。任务不会因为短期 MCP 句柄失效而丢失;也不会借持久化任务绕过撤销的权限。
普通计算使用宿主从原生工具调用记录解析出的稳定调用 ID;同一次传输重试复用任务,不将新的业务计算自动按参数合并。重写工具同时保留原审批 `request_id` 幂等语义。不同参数复用同一操作编号会被拒绝。
调查的排队回执不写成分析证据。成功结果读取时,按原计算来源、任务及运行验证,并幂等地保存证据。未完成的计算阻止提交最终报告、`insufficient_data` 或将运行标记成功;中间进度仍可保存。
Agent 调查执行在原生回合空闲后由宿主短轮询等待,继续维持调查心跳。新作业进入终态即触发模型读取结果,其余作业继续后台运行。状态读取遇到网络、限流或临时服务错误,最多重试 3 次,采用指数退避和随机抖动;权限拒绝、上下文失效立即停止,重试耗尽明确报错,不重新提交或取消计算。普通聊天展示任务编号,可在当前会话后续查询。异常流结束时若无法安全继续处理新结果,会明确失败而不是虚报完成;Server 中的计算结果仍保留。
## 排队期限与结果保留
每项目最多 100 个未结束作业(queued/running/cancel_requested),跨 API 进程用数据库事务锁串行执行幂等检查与容量准入;同一请求的重试优先返回已有作业。排队最长 3600 秒,超过后标为 `timed_out`,错误码为 `QUEUE_TIMEOUT`;开始执行后使用独立的执行期限。提交、查询和 Worker 领取都会核查到期任务,所以 Worker 停止时,查询也能结束过期排队。部署监控仍须覆盖 Worker 进程状态和最老排队任务年龄,队列到期机制不代替服务可用性监控。
结果正文在终态后保留 7 天,到期由队列维护清除,读取返回 `RESULT_EXPIRED`。任务状态、输入、幂等标识和已归档调查证据不随之删除,也不会因结果过期重新计算。状态列表只读取状态字段,不加载完整计算结果。
## 并发、取消和恢复
一个 supervisor 默认全项目合计并发 2 项,独立于 API 的 4 个进程。它持有元数据库的会话级 advisory lock,第二个 supervisor 会拒绝启动;当前不是多台计算节点各自扩容的模式。各项目轮转领取,使用 PostgreSQL 短事务 `SKIP LOCKED`,租约代次约束心跳和结果写回。
每项任务有独立子进程组;取消、执行超时或失去租约时先停止进程组,再写终态。守护管道在 supervisor 被强杀后关闭,负责终止仍在运行的算法进程。API 重启不影响计算 supervisor;计算进程崩溃后,旧任务标为中断,不自动重放。
原生临时文件写入独立任务目录。临时模型数据库创建前登记到该目录的账本,正常结束或取消后只清理明确登记的临时数据库。清理失败返回 `CLEANUP_FAILED` 并保留恢复目录;启动时检查项目、任务和执行代次,确认旧租约过期后恢复清理。无法验证归属或清理失败时停止领取新工作。
## 部署
1. 先对每个目标项目的 BizDB 依次应用 `resources/sql/026_calculation_jobs.sql` 和 `resources/sql/027_calculation_job_lifecycle.sql`,包括未来项目使用的相应业务模板。已有 026 的库只需追加 027。不要应用到 TimescaleDB。027 增加时间字段与索引,不清除结果;已有结果从首次迁移起获得完整 7 天保留期。必须先迁移,再加载新 API/Worker 代码。
2. 配置 `CALCULATION_WORKER_PROJECT_IDS` 为明确的项目 UUID 列表,配置现有元数据库连接及数据库路由解密密钥。Worker 不需要新 OAuth 密钥;仅部署侧工作进程读取这些设置。
3. 同步发布 Server 和 Agent,在接入长计算前启动独立 Worker。不要从 API `lifespan` 启动,不要把调查 Worker 当作计算 Worker。
从 Server 仓库启动:
```bash
conda run -n server python scripts/run_calculation_worker.py
```
systemd 模板:`infra/systemd/tjwater-calculation-worker.service`。Docker 使用同一 Server 镜像的独立服务:
```bash
docker compose -f infra/docker/docker-compose.yml --profile calculations up -d calculation-worker
```
Compose 使用 `../../.env` 注入配置,需确保其中数据库地址在容器内可达。`calculation-state` volume 保存任务恢复账本,不能当作临时缓存删除。非容器启动可用 `CALCULATION_WORKER_STATE_DIR` 指定持久目录。
并发默认 2、轮询默认 2 秒、租约 30 秒、心跳 5 秒。算法内部并行度仍消耗额外 CPU/内存;调整并发必须依据实际资源测量。
回滚前暂停接收新的队列计算并等待或明确取消在途任务,核对有副作用任务的写入。保留任务表、结果和恢复目录;旧同步版本不支持继续处理这些作业,不能直接删除记录后重新提交。
## 验证
```bash
conda run -n server python -m pytest tests/unit/test_mcp_calculation_jobs.py tests/unit/test_calculation_worker.py tests/unit/test_calculation_cleanup.py tests/unit/test_mcp_query.py tests/unit/test_mcp_analysis.py tests/unit/test_operational_tasks.py -q
```
真实 PostgreSQL 并发测试为 `tests/integration/test_calculation_queue_storage.py`。它要求 `CALCULATION_QUEUE_TEST_DSN` 指向名称以 `tjwater_calc_test_` 开头的专用可丢弃数据库,不能指向业务库。覆盖四连接重复提交、参数冲突、并发领取、项目和用户隔离、取消竞争、失效代次拒绝和过期租约恢复。
这些验证不代替真实项目算法运行、浏览器断线重连或部署验收。生产接入还应观察超过 5 分钟的真实计算、恢复后结果读取和原生临时资源清理。
@@ -55,21 +55,60 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
| command.action | 参数 |
| --- | --- |
| highlight_features | layer、feature_ids、effect:steady 或 pulse |
| locate_features | layer、feature_ids |
| zoom_to_map | center:WGS84 经度纬度、zoom:0–24 |
| highlight_features | layer、feature_ids、必填 effect:steady 或 pulse |
| clear_highlights | 无 |
| open_chart | chart_id、chart、非空 evidence_refs |
| close_chart | chart_id |
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id |
| set_overlay | overlay_id、title、mode:geometry 或 heatmap、evidence_id,可选 data |
| clear_overlay | overlay_id |
| render_scada_analysis | items:device_id 与 high/medium/low/unrated 等级,设备编号不重复 |
| clear_scada_analysis | 无 |
以上十种动作与 Chat 的 `present_view`、Dev JSONL 共用扁平 `command: {action, ...字段}`。Chat 使用 `frontend-action@2` 和执行回执;任务仍只记录展示请求,Dev 只做本地预览。三者使用独立的地图源、SCADA 图层和图表状态。Dev 的动作数据行必须为 `{"record_type":"presentation","command":{...}}`;界面块行使用 `record_type: ui_block`,新示例明确带 `phase: final`。普通调查数据保持来源字段,不执行其中的 action。仓库示例见[前端统一展示夹具](../../next-tjwater-frontend/tests/fixtures/unified-presentation.jsonl),其中编号来自测试夹具。
图表限定折线和柱形,包含 `title/chart_type/x_data/series`。最多 100 个横轴值和 8 组有限数值序列,
序列长度与横轴对齐,引用证据必须属于当前任务。地图高亮来源沿用上述七类。
覆盖层输入不接受模型提供的 `data`。Server 从证据的不可变数据快照提取现有 GeoJSON,
覆盖层的权威数据来自不可变证据。省略 `data` 时,Server 从证据快照提取现有 GeoJSON,
或使用已记录的 `longitude/latitude` 点位,将 `FeatureCollection` 加入事件中的 `command.data`。
若传入 `data`,必须与同一证据快照解析出的内容一致;Server 始终发布快照解析结果,不能用调用参数覆盖证据。
热力图只接受记录中的点,权重为有限非负数,未记录权重时取 1。
没有坐标的地址、空数据、非法几何、模型自绘区域均不能生成覆盖层。该接口不做地址解析、
范围推算或污染传播计算。展示事件总大小上限为 512 KiB,超限返回 422。
`generate_node_coverage` 是独立的节点空间覆盖工具,适用于普通项目聊天和后台调查,
不绑定爆管、阀门隔离或固定执行顺序。调用者提供需要比较的节点分组,Server 从当前项目
`gis.node_geometries` 读取真实位置。监测设备、投诉、地址等须先关联模型节点,
不接受模型自由生成的坐标。具体适用范围和参数见[节点覆盖工具](node-coverage-tool.md)。
计算先投影到局部米制坐标,取凹包(默认 ratio=0.25)、加展示缓冲(默认 25 m),
再以 2 m 容差简化并转回 WGS84;凹包比例与缓冲距离可在有界参数内选择。
默认展示各组的地理包络,不判断连接关系;需要时可按模型连接拆分,额外指定视为断开的连接。
少量点、重合点和共线点同样通过缓冲形成面。多组结果一起保存在 `result.map_payload`,
避免连续 `set_overlay` 覆盖前一组;`result.coverage` 保存来源节点、映射完整性及算法参数。
该面不推断故障、污染、实际停水、水力影响或用户服务边界,缓冲距离也不是供水服务半径。
缺失几何时标记 `partial`,没有可用位置时不生成面。输入最多 8 组、合计 10000 个不同节点;
几何输出遵守覆盖层顶点和大小限制,超限要求缩小范围,不静默抽样或改变原分析结果。
MCP 保存完整查询响应作为不可变证据,展示解析器同时支持响应信封中的
`data.result.map_payload`。Agent 使用 `set_overlay` 引用本次 `evidence_id` 并省略 `data`。
超过内联返回预算时,响应的 `data.presentation.command` 仍保留可发布的展示命令,
`data.presentation.groups` 保留覆盖完整性计数,
完整几何留在证据中,不能把缩略返回误认为没有空间成果。调查按实际步骤同步发布展示:
地址得到明确坐标后缩放定位,取得候选资产后定位候选,检查影响节点后生成并展示覆盖示意,
形成或调整关阀方案后定位阀门,读取监测对照后展示点位及序列。
图表同样随结果返回发布:监测趋势与事件对照用折线图,可比候选评价或方案用柱状图,
水力及污染传播等模拟按实际返回的时刻、对象和指标画图。压力、流量、浓度等不同单位分图,
注明来源、范围与实测或模型属性;没有同口径基准时不计算或绘制改善量,缺测不补零。
过程图表通过 `open_chart` 提供 `chart_id/chart/evidence_refs`,每图最多100个横轴值、8组序列,
超限时分图或明确说明抽样、聚合范围;单值或无可比数据用文字或表格。
关键趋势与比较结论保留到报告中,最终报告的图表不替代过程发布。
业务 Skill 说明本步骤应展示什么,调查指令统一说明发布方法、非空几何检查、去重和失败处理。
同一阶段批量展示,数据或方案没有变化时不重复请求;不为地图动作增加无关业务分析。
报告的 `map_targets` 仅为手动定位入口。关键步骤的数据缺失或展示失败需记录具体限制,
没有浏览器连接也不阻止分析或报告收尾。
`GET /api/v1/operational-tasks/{task_id}/presentation-cursor` 在同一事务中读取
`task_id/execution_generation/active_run/event_cursor`,其中活跃 run 仅返回 `run_id/generation/status`。
浏览器打开 Dev 联动、切换任务或断线重连时,以此建立新基线,只消费之后的展示事件。
@@ -98,7 +137,7 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围系统工单成功;没有新增
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
+2 -1
View File
@@ -43,7 +43,8 @@
完整图语法的解析失败只影响图示,正文、依据和审核仍可阅读。
每条 node_ref 包含 `node_id`、`label`、`evidence_refs`、`action_refs`;后两项默认空数组。
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一;
node_id 为最多 80 字符的 ASCII 标识符,必须以字母或下划线开头,在本图引用内唯一,
并与 Mermaid 源码中实际节点使用完全相同的 ID;禁止为同一节点另造引用 ID;
最多 100 条引用。证据必须来自报告 evidence_refs,动作必须属于当前任务。
节点可以说明候选方向与条件关系;图中的分支、状态文字不能证明实际并发、动作执行或现场完成。
图的颜色、字体和交互由前端统一配置。正文不包含 SVG 或 HTML。
+24 -4
View File
@@ -52,17 +52,37 @@ Agent 原生工具统一使用 `mcp_tjwater_` 前缀。Server目录共67项:42
- 数据源和计算假设必须有依据。定位漏水量和DMA总漏损约束使用m³/s;冲洗使用服务实际接收的m³/h,不沿用旧REST误标的L/s。工频泵序列0/1,变频泵序列为Hz;每项对应模型水力步长。冲洗当前限LPS/CMH模型。选址当前推荐压力点,不冒充污染溯源优化。
- 调查结果经现有快照/证据/回执链保存,只有返回evidence_id后才引用为系统证据;普通聊天不自动保存调查证据。模拟结果始终是analysis而非实测。已知模拟事件源管段是输入背景,不是算法定位准确性的证明。
## 关阀隔离输入与影响范围
REST `/api/v1/valve-isolation-analyses` 与 MCP `analyze_valve_isolation` 共用同一服务。
`accident_element_type` 默认为 `auto`:先识别管段 ID,再识别节点 ID;指定 `link` 时只匹配管段,指定 `node` 时只匹配节点。
节点表和管段表分别保证 ID 唯一,但不同类型可以重号;分析同名节点必须显式指定 `node`。
多个 ID 表示联合事故,不表示分别比较;类型参数作用于本次调用中的所有 ID。
服务从模型主表的一致快照读取连接关系、初始开关状态和水源,不依赖 GIS 几何或物化视图刷新。
不可关阀按保持现状处理;已关闭的管段、泵和阀门不参与连通,已关闭阀门不再列为建议关阀。
未知的事故 ID、未知的不可关阀 ID 和空事故列表会被拒绝。
- `affected_nodes` / `affected_node_count` 仍表示最近可关阀门围成的直接隔离区。不可隔离时列表为空,计数保留。
- `supply_affected_nodes` / `supply_affected_node_count` 表示按建议关阀后新增失去水源连接的节点,包含间接受影响区域,排除原本就不连通水源的节点。不可隔离或缺少水源资料时为 `null`。
- `source_check_performed` 表示是否已执行水源连通检查。`isolatable=true` 要求存在关阀边界,且关闭这些阀门后直接隔离区不再连通任何水源。
- `isolation_failure_reason` 可为 `connected_to_water_source`、`water_sources_unavailable`、`no_boundary_valves`;可隔离时为 `null`。
以上是模型初始开关状态下的无向拓扑分析,水库和水池均按潜在水源处理;不模拟泵或止回阀的单向性、时变控制、水池蓄水量、压力与流量。
因此“失去水源连接”不能直接当作经过水力计算或现场核实的停水范围。已保存的旧调查证据不会自动重算,需重新调用隔离分析生成新结果。
本次无需数据库迁移。
## 资源边界与发布
12项计算和13项预览式写入工具均提供 `timeout_seconds`,由Agent根据模型规模、模拟步数及已有耗时按次选择;它表示实际等待预算,与 `duration` 表示的模拟时间不同。未填写默认120秒。服务端上限由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 控制,默认3600秒,管理员可设120至86400秒;`tools/list` schema 发布实际有效上限,超限在计算前拒绝。
长计算采用[后台计算队列](calculation-queue.md)。10项分析计算与2项重计算写入的正式执行阶段快速返回任务编号,再使用 `get_calculation_job`、`get_calculation_result`、`list_calculation_jobs`、`cancel_calculation_job` 管理。原有预览与审批仍有效;排队回执不作为分析证据。
例如 `duration=3600, timeout_seconds=900` 表示模拟1小时、允许等待15分钟。Agent的上游HTTP/SDK预算为所选预算加5秒,整个宿主调用另留60秒处理认证、上下文绑定和清理;OpenCode连接上限从工具目录计算并写入宿主连接文件。本地MCP请求关闭Bun空闲断连,仍受上述调用截止时间控制。普通查询继续使用原30秒Server预算与35秒宿主预算。
HTTP 预算由宿主从目录元数据读取,Server 默认 120 秒、Agent 默认 180 秒;爆管定位不再设置单独的长超时。`timeout_seconds` 保留在 Server 兼容入口,Agent 对模型隐藏并拒绝覆盖。Server 上限 240 秒,Agent 加 60 秒余量并封顶 270 秒,外层原生 MCP 保留 300 秒总时限与上游 Bun 的 5 分钟空闲超时。后台执行时限由管理员配置 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS`(默认 3600 秒),不受该 HTTP 参数控制;模拟时间仍由 `duration` 表示。
计算在线程执行,不阻塞事件循环,单Server进程最多两项计算。超时不能杀死底层原生计算,槽位一直保留到计算真正退出;超时结果不会补写为调查成功结果,不自动反复重试。本次未增加后台计算作业队列。普通聊天大结果仍受现有12KB内联限制,调查结果可经证据分页读取。
`analyze_valve_isolation` 和 `calculate_demand_allocation` 保持同步。队列消费者独立于Uvicorn的4个API进程,默认全部配置项目合计并发2项,并通过元数据库单实例锁防止重复启动。取消、超时和租约失效需要停止计算子进程组;成功结果按原运行授权保存证据,未完成计算阻止调查提前结案。
在线重算逐个时刻写入基线,超时或中途失败可能已有部分时刻更新,不能把工具失败解释为整个窗口已回滚;不要自动重试整个窗口。
无需新增数据库迁移或模型配置。须同步发布Server、Agent和前端证据枚举;先更新Server目录,再重启Agent,client模式还需重载原生连接。保持既有OAuth、MCP开关及数据库路由配置,不自动更改权限或重启调查。
队列接入需要在目标项目BizDB依次应用 `resources/sql/026_calculation_jobs.sql`、`resources/sql/027_calculation_job_lifecycle.sql` 并启动独立计算Worker。Server与Agent需配套发布,client模式还需重载原生连接。既有OAuth与项目权限保持不变;完整部署与回滚步骤见后台计算队列文档。
本次验证限schema、权限、协议目录和mock业务函数,不运行真实模拟、优化或付费模型。真实项目的模板同步状态、运算耗时、SCADA覆盖和结果准确性仍需另行验收。
+4 -4
View File
@@ -1,10 +1,10 @@
# 产品 MCP 查询:本地实施与启用
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和67项工具(42项查询、12项计算、13项预览式写入);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
实现使用 MCP 2026-07-28、Python mcp 2.3.0、TypeScript @modelcontextprotocol/client 与 @modelcontextprotocol/server 2.3.1。Server 提供 `/mcp` 和71项工具(42项原查询、12项计算、13项预览式写入、4项计算任务管理);Agent 将其投影合并到本地 MCP,由 OpenCode 原生发现为 `mcp_tjwater_*`,普通项目会话、调查主 Agent 和已验证的直接只读子 Agent 共享42项查询;12项计算仅向非reader会话开放,13项写入仅向项目成员的普通聊天开放。个人会话不能获取项目凭据。旧 CLI 只作为能力清单,没有运行时 CLI 桥接。
`run_simulation` 是在线重算,复用 REST 在线计算服务而不复制数据库;按水力步长更新模型条件和实时基线,不创建正式方案。MCP 时长为秒,原 REST 时长仍为分钟。其 annotation 为 `readOnlyHint=false`、`destructiveHint=true`;只读 reader 不能调用,其他方案模拟继续使用隔离临时模型。详细边界见 [MCP 分析工具说明](mcp-analysis-tools.md)。
计算工具支持Agent按次指定 `timeout_seconds`,默认120秒;计算上限默认1小时,由 `TJWATER_MCP_MAX_CALCULATION_TIMEOUT_SECONDS` 配置并在工具schema公布。Agent、OpenCode和本地HTTP调用预算随目录同步,普通查询仍保持原有短超时。等待超时不意味着底层线程已停止或在线基线已回滚,禁止自动重试整个写入窗口。
长计算使用持久化队列,原工具快速返回任务编号,新增状态、结果、列表和取消工具。HTTP 返回预算由目录元数据和宿主管理,Server 默认120秒、Agent默认180秒;模型不再提供 `timeout_seconds`,Server保留兼容入口。后台执行时限由管理员独立配置。提交和读取保持短调用,不延长上游5分钟空闲超时。独立计算Worker、迁移与权限边界见[后台计算队列](calculation-queue.md)。
## 认证与数据边界
@@ -14,13 +14,13 @@ Agent 根据401 challenge 或路径/根 well-known 发现受保护资源元数
调查数据返回前,在短事务内追加现有任务证据并保存调用回执。同一主体、会话、任务、run 和原生 call ID 的重试复用证据;不同参数或失效范围明确报错。七类原有访问申请 scope 保持原契约,MCP 的新增来源只扩充快照来源枚举。没有新增报告发布、审批、控制、Shell 或递归委派权限。
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次普通查询仍为30秒),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
普通查询默认20条;目录类最多500条,时序最多10000条、20个标识、24小时;offset 最大10000。Agent 首次优先单对象、必要的短时间窗和小页,仅在证据不足时按需扩大,不自动遍历全网。SQL 有 limit/offset 和 statement_timeout。历史对比在同一条SQL中按序列、时间对数据点分页,返回 page.has_more;非零offset也标明覆盖不完整。历史曲线展示调用初始每页500点,`RESULT_TOO_LARGE` 时原位减半重试,最多读取1000条、发出16次请求,共用120秒超时(单次请求同时受该剩余总预算约束),图表最多100个有效点;这不改变模型首次查询默认值或时序接口上限。
单份结果超过8MiB报错;超过12KB的调查工具消息只返回证据资源引用,完整快照仍保存,Server 的 `resources/read` 可以读取它。Agent 本地 MCP 只提供工具,将上游资源引用转为 `evidence_read` 中的 `mcp_tjwater_get_task_evidence` 参数,不向 OpenCode 泄露不可在本地读取的链接。`get_task_evidence` 在数据库中按 data_path、limit、offset 选页,嵌套集合和超过256字符的字符串返回路径描述;字符串按 Unicode 字符分页。页和完整资源均在数据库返回前检查8MiB上限,保留原证据编号和合成来源;普通查询要求缩小范围。其他结果仍保留执行后的整体8MiB检查,不能把它理解为所有查询均有数据库端字节限制。原始/清洗实测、模型基线、分析结果、比较和 synthetic 均明确标记。非有限数值转为 null 并记录数据缺口。
`get_task_context` 默认 section=summary,只查固定元数据、160字符任务标题及200字符结论预览,并返回 available_sections;preview_only 和 truncated 明确说明不是完整报告。按 section=task/report/evidence/actions/progress/access_requests/plan/run 读取详情,report_revision 保留选择旧报告的语义。data_path 选择嵌套值,limit 默认20、最多500,offset 最大10000;证据、动作和授权申请集合以稳定 ID 为键,按 ID 字典序分页。字符串按字符分页。详情在数据库返回前检查8KB预算,超限缩页或选更深路径。缺少可选进度或授权申请表时返回 metadata.available=false,不冒充空事实。绑定权限范围只读取七类既有 scope,不再加载整个任务上下文。不增加工具数量、数据库表或迁移,也不改变原 REST 完整上下文契约。
外围 MCP 直接读取所选项目 BizDB 的样板事实和既有时序。它不执行外围样板 HTTP 的故障场景注入;混有合成记录的结果按 synthetic 保守标记。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选并保留 synthetic;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的合成点冒充实测设备。
外围 MCP 直接读取所选项目 BizDB 的工单事实和既有时序。外围工单统一返回 `system_data`;测试数据和 DEMO-* 观测仍按各自数据来源处理。DEMO-* 观测在数据库端拒绝超过8MiB的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
## 启用步骤
+43
View File
@@ -0,0 +1,43 @@
# 节点空间覆盖工具
`generate_node_coverage` 增加 Agent 可选择的空间表达能力,将已取得的模型节点集合转为
可解释、可复核的覆盖示意。它可以在项目聊天、任务协作和后台调查中使用,
不要求爆管任务、隔离成功或特定报告。生成、发布展示和业务分析是三个独立操作。
## 适用范围
| 问题 | 输入依据 | 输出含义 |
| --- | --- | --- |
| 候选对象分布在哪里 | 已核实的候选模型节点 | 候选节点空间包络 |
| 两组监测点覆盖有何不同 | 已核对的设备到模型节点映射 | 已接入节点分布比较,不证明监测可观测性 |
| 区域成员分布在哪里 | 已查询的区域成员节点 | 成员节点包络,不替代已有行政区或 DMA 边界 |
| 哪些节点属于模型影响集合 | 已有拓扑或模拟结果中的节点集合 | 结果节点的空间示意,不重新计算影响 |
| 投诉关联的节点如何分布 | 已解析并核对的投诉到模型节点关联 | 已关联节点分布,不证明投诉或污染真实边界 |
只有地址或未关联设备时,先查询位置和资产关联;已有权威区域几何时优先展示原始边界。
本工具不接收任意坐标、HTML、SQL、脚本或调用者指定的项目身份,不执行关阀、模拟或方案保存。
## 参数和结果
- `groups`:最多 8 组,每组提供唯一 `group_id`、`title` 和 `node_ids`;组之间可重叠,
合计最多 10000 个不同节点。不按组名自动赋予隔离、污染或服务覆盖语义。
- `concave_ratio`:0–1,默认 0.25;越接近 1 越接近凸包。分散的点取单一包络可能包含较大空白,
应按实际比较目标分组,并明确示意含义。
- `buffer_m`:大于 0、最多 1000 m,默认 25 m,仅是展示缓冲。
- `split_by_connectivity`:默认 false。true 时按所选节点的模型连接拆分,并排除初始 CLOSED 连接。
`excluded_link_ids` 可额外指定最多 200 个断开连接,适用于已有关阀建议等有依据的绘图情景。
这不会修改模型,也不能替代完整拓扑分析。
`result.map_payload` 是多组共存的 GeoJSON;`result.coverage` 记录算法参数、节点 ID、
已映射数量、缺失几何节点、分组数量和 `display_only` 边界。缺少部分位置时返回 `partial`,
不补造坐标。单点、重合点或共线点通过米制缓冲生成可绘制面。
## 与展示工具配合
生成工具不会移动地图。项目聊天可用 `present_view` 展示本次返回的几何;后台调查用
`publish_task_presentation` 的 `set_overlay`,引用生成结果自己的 `evidence_id` 并省略 `data`,
Server 从不可变证据解析完整几何。大结果内联缩略时,`data.presentation.command` 保留可发布命令。
`recorded` 仍只表示请求已记录,浏览器显示和后台调查完成各自独立。
本次爆管报告只是一个验证样例:可将 24 个直接隔离节点、295 个供水影响节点作为两组,
按已有三阀建议设置连通分组;不将这种调用固定进 `analyze_valve_isolation`。
+11 -8
View File
@@ -72,8 +72,8 @@ OPERATIONAL_WORKER_AGENT_URL=http://127.0.0.1:8787
OPERATIONAL_WORKER_TOKEN_URL=https://<identity-host>/realms/<realm>/protocol/openid-connect/token
OPERATIONAL_WORKER_CLIENT_ID=<dedicated-service-client>
OPERATIONAL_WORKER_CLIENT_SECRET=<inject-securely>
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8011
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8011
OPERATIONAL_ACTION_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
OPERATIONAL_DATA_PERIPHERAL_URL=http://127.0.0.1:8000/peripheral
```
选择 OAuth 时不要同时配置 `OPERATIONAL_WORKER_BEARER`,否则固定 Bearer 优先。不要配置设备命令网关进行本次合成联调。
@@ -109,8 +109,9 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
原对话用户可以确认收到的回复,但不能改写目标会话或原生消息 ID。
既有 worker 每轮最多对账四页、每页 50 条私有待投递消息,即使调查并发已满仍继续对账,
不增加进程或配置项。临时网络错误保留消息等待重试;权限拒绝、不存在等确定性
拒绝保存失败结果。消息正文、发送人和会话归属不写入项目事件日志。
不增加进程或配置项。临时网络错误保留消息等待重试;400、403、410、422 等确定性
拒绝保存失败结果。404 可能表示目标会话属于另一个 Agent 实例,因此保持待投递,
不把路由未命中误记为消息终态失败。消息正文、发送人和会话归属不写入项目事件日志。
待投递接口按创建时间与消息 ID 返回 keyset 游标,worker 跨轮保留扫描位置;
扫到末页后下一轮从头重试。长期空闲对话的待收消息不会一直占住首屏、阻止后续调查投递。
@@ -122,6 +123,8 @@ Server 校验该会话属于当前活动运行。跨用户投递查询与任务
`resources/sql/015_workbench_events_and_task_runs.sql` 在项目 BizDB 中增加 `domain_event_log` 和 `operational_task_runs`。后续迁移默认只应用到 `tjwater_v2`,扩展规则见 [`workbench-database-rollout.md`](workbench-database-rollout.md)。事件与业务投影在同一事务提交,`LISTEN/NOTIFY` 只负责唤醒;浏览器与 worker 始终按持久游标补读。每个 API 进程对活跃项目最多保留一条 LISTEN 连接,无订阅者 60 秒后关闭。
`resources/sql/030_operational_run_agent_ownership.sql` 为运行增加 `agent_instance_id`。Worker 从目标 Agent 的 `/health` 读取实例标识,只恢复、续期该实例拥有的本地会话,并在投递时要求 Agent 回显同一标识。Server 在领取、心跳和完成时保存或校验归属,避免多个独立 Agent 共用项目队列时,一个实例反复刷新另一个实例并不存在的 session。部署顺序是:先迁移各目标 BizDB,再发布 Server、Agent 与 Worker;每个 Agent 的 `AGENT_INSTANCE_ID` 必须唯一且跨重启稳定。旧运行的归属保持为空,不会由任意 Worker 猜测接管。
事件默认保留 7 天。清理脚本会从 Metadata DB 解析项目路由,并在发现多个项目共享同一 BizDB 时拒绝运行。先确认 `OPERATIONAL_WORKER_PROJECT_ID` 对应 `tjwater_v2`,然后运行定向 dry-run:
```bash
@@ -138,7 +141,7 @@ conda run -n server python scripts/cleanup_workbench_events.py \
禁止省略 `--project-id` 执行清理。数据库维护脚本同样遵循先在 `tjwater_v2` 验证、再逐库扩展的规则。
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源。
浏览器使用 `GET /api/v1/workbench/sync-snapshot` 建立原子快照,并以该游标连接 `GET /api/v1/workbench/events`。游标早于保留窗口、超前或对应事件已全部清理时返回 `reset_required`,客户端必须重建快照。SSE 需要 Bearer 与 `X-Project-Id`,且 `CORS_ALLOWED_ORIGINS` 必须配置为明确来源,不允许 `*`。配置说明见 [README](../README.md#环境配置)。
## 外围样板工单同步
@@ -147,11 +150,11 @@ conda run -n server python scripts/sync_peripheral_work_orders.py --once
conda run -n server python scripts/sync_peripheral_work_orders.py
```
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本投影到 `external_work_order` 任务;默认每 300 秒兜底对账。合成来源在摘要和证据中明确标注;工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
使用相同工作身份,并配置 `OPERATIONAL_ACTION_PERIPHERAL_URL`。同步器订阅源工单变更事件,收到消息即读取分页工单,将外围版本按系统数据投影到 `external_work_order` 任务;默认每 300 秒兜底对账。工单完成只表示该外部任务完成,不代表其他调查自动完成。事件协议、`tjwater_v2` 范围与启用步骤见 [外围工单主动同步](peripheral-work-order-events.md)。
工况任务面板的刷新按钮只重新读取当前项目的工作台快照,不直接触发外围系统全量对账。外围工单由独立同步器使用服务身份响应后端事件,避免多标签页或普通用户刷新放大全量扫描,也避免浏览器项目与同步器固定项目不一致。
通用合成检查工单不属于预约任务。早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;同步器按不可编辑的专用 seed actor 跳过旧种子工单。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。`018_remove_synthetic_check_task_projections.sql` 只清理专用 seed actor 创建的旧检查投影,不按用户可编辑的标题或状态删除任务。三张独立演示工单已迁入外围样板,由同一同步器维护。
早期种子脚本创建的通信、压力和零流量检查工单已经退役,种子脚本不再恢复它们;`018_remove_synthetic_check_task_projections.sql` 清理了当时错误生成的预约投影。当前同步器投影所有有效外围工单,`029_peripheral_work_orders_system_data.sql` 将既有工单及任务快照统一为系统数据。投诉、爆管事件以及调查动作实际创建的现场工单继续同步。
水质投诉和爆管上报类型的外围工单同时创建关联调查,即使当时没有浏览器打开也会入队。两类事件从外围样板测试面板模拟上报;工况页负责展示调查。调查中创建的外围工单通过回执编号关联,后续反馈送回原调查,由 Agent 重新分析。
@@ -165,7 +168,7 @@ conda run -n server python scripts/sync_peripheral_work_orders.py
## 人工确认后的动作
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建合成外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
- `OPERATIONAL_ACTION_PERIPHERAL_URL`:使用既有 `/api/sample/v1/work-orders` 创建外围工单。成功回执说明工单已创建,不表示现场工作已经完成。
- `OPERATIONAL_ACTION_COMMAND_GATEWAY_URL`:可选设备命令网关。未配置时明确返回不支持执行,不能生成成功回执。
- `OPERATIONAL_ACTION_TIMEOUT_SECONDS`:外部请求超时,默认 10 秒。
+17 -14
View File
@@ -1,8 +1,12 @@
# 外围样板服务
# 外围系统接入服务
该独立 FastAPI 服务提供历史监测回放、固定合成数据,以及 synthetic 测试工单和测试数据的版本化 CRUD。管理入口位于供水工作台“外围样板”。它不控制设备,也不修改正式业务资产。
该 FastAPI 子应用接入真实业务工单,提供版本化 CRUD、处理反馈和事件调查入口,同时保留独立的历史监测回放与演练数据接口。生产环境挂载在主 Server 的 `/peripheral`,随 `next-backend` 单容器发布;源码仍可独立启动用于本地隔离调试。管理入口位于供水工作台“外围系统”。
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接样板服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。
外围工单的 `origin` 固定为 `system_data`,前端录入、同步器、任务快照、执行回执和工单查询均使用该来源,不再提供演练来源分支。业务录入代表系统中的上报,不代表投诉内容或故障原因已核实,Agent 仍需查询实际证据。
此次变更不修改表结构;部署时需在每个项目业务库执行 `resources/sql/029_peripheral_work_orders_system_data.sql`,统一修正源工单、修订记录、任务快照和外围反馈证据。API 路径、配置键中的 `sample` 为兼容名称,不代表工单是模拟数据。更新需发布 Server、同步器、Agent 与前端对应版本。
浏览器通过 `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` 连接外围服务。服务复用主系统的 Bearer 身份和 `X-Project-Id`,读取需要 `simulation.view`,写入需要 `simulation.run`。权限键沿用现有契约,不用于判断数据来源。
生产环境不需要为外围服务新增域名。前端设置
`TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL=/peripheral`,在现有前端 NPMplus Proxy Host 中增加以下
@@ -10,17 +14,16 @@ Custom Location:
| 配置项 | 值 |
| --- | --- |
| Location | `/peripheral/`,必须保留末尾斜杠 |
| Location | `/peripheral`,不添加末尾斜杠 |
| Scheme | `http` |
| Forward Hostname / IP | NPMplus 能访问的应用机地址,通常与现有前端上游相同 |
| Forward Port | `8011` |
| Forward Hostname / IP | `192.168.1.114`,不追加路径或末尾斜杠 |
| Forward Port | `8001` |
| Forward Path | 留空 |
NPMplus 会去掉 `/peripheral/` 前缀,因此浏览器请求
`/peripheral/api/sample/v1/context` 时,上游收到 `/api/sample/v1/context`。不要在 Forward
Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网络开放 `8011`,不要直接开放公网。
本机开发和预览模式也提供等价的 `/peripheral` 代理,可通过
`TJWATER_PERIPHERAL_SAMPLE_UPSTREAM` 覆盖默认上游 `http://127.0.0.1:8011`。
容器内部仍监听 `8000`,生产发布在宿主机映射为 `8001`,避免与已有服务冲突。该配置保留 `/peripheral` 前缀,因此浏览器请求
`/peripheral/api/sample/v1/context` 时,主 Server 收到相同路径并交给挂载的子应用。不要在
Forward Hostname 后追加 `/api/sample/v1`。本机开发和预览模式也将 `/peripheral` 代理到
`TJWATER_SERVER_API_BASE_URL`,默认是 `http://127.0.0.1:8000`。
外围 API 支持已授权项目切换。每次请求先由主系统验证项目成员身份和权限,再从 Metadata DB 解析该项目的 `biz_data` 路由;历史观测另解析 `iot_data` 路由。逻辑项目代码、业务物理库名和时序物理库名可以不同,连接沿用各自完整 DSN。路由缺失返回 `503 PROJECT_STORAGE_NOT_CONFIGURED`,不会回退到其他项目数据库。未配置存储表的项目仍需单独完成结构部署;API 不自动创建表或复制演示数据。
@@ -38,8 +41,8 @@ Hostname 后再追加 `/api/sample/v1`。外围服务应只向 NPMplus 所在网
演示数据通过 `scripts/seed_peripheral_sample.py` 写入 `tjwater_v2` 的 `peripheral_sample` schema。当前固定窗口为 2026-09-17,所有记录标记为 `synthetic`,并使用 `DEMO-` 前缀。
## 水质投诉 Agent 演示
## 工单事件调查
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的合成投诉事件,或 `work_order_type=burst_report` 的合成爆管上报。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。
`POST /api/sample/v1/work-orders` 可创建 `work_order_type=water_quality_complaint` 的水质投诉事件,或 `work_order_type=burst_report` 的爆管上报,均默认使用业务来源。二者由外围同步器转换为统一事件调查任务,再由后端 worker 可靠交给 Agent;浏览器不再负责启动调查。事件和执行请求 ID 均由稳定业务身份确定,回执恢复或重试不会重复创建任务。普通业务录入无需绑定演练场景码,也不会向 Agent 注入预设监测结果或邻区投诉情况。
样板服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
外围服务只保存工单,不直接调用 Agent。`scripts/sync_peripheral_work_orders.py` 使用受认证的后台身份同步外部版本并创建调查任务,`scripts/run_operational_worker.py` 再将持久化的待执行任务交给 Agent,因此浏览器关闭后流程仍可继续。
+23
View File
@@ -21,6 +21,26 @@ services:
- timescaledb
- postgis
# Enable after provisioning calculation storage and explicit project scopes.
calculation-worker:
profiles: [calculations]
image: ${TJWATER_SERVER_IMAGE:-tjwater-server:local}
init: true
restart: on-failure
command: ["python", "scripts/run_calculation_worker.py"]
env_file:
- ../../.env
environment:
- PYTHONPATH=/app
volumes:
- ../../app:/app/app
- ../../resources:/app/resources
- calculation-state:/app/temp/calculation-jobs
stop_grace_period: 30s
depends_on:
- timescaledb
- postgis
# ==========================================
# Infrastructure Services
# ==========================================
@@ -127,6 +147,9 @@ services:
volumes:
- ./mapservice/geoserver_data:/opt/geoserver_data
volumes:
calculation-state:
networks:
default:
driver: bridge
@@ -0,0 +1,17 @@
[Unit]
Description=TJWater queued calculation worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/NextTJWater/next-tjwater-server
ExecStart=%h/miniconda3/envs/server/bin/python scripts/run_calculation_worker.py
Restart=on-failure
RestartSec=5
KillMode=control-group
TimeoutStopSec=30
UMask=0077
[Install]
WantedBy=default.target
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS workbench.operational_task_runs (
)),
revision bigint NOT NULL CHECK (revision >= 1),
agent_session_id text,
agent_instance_id text,
lease_expires_at timestamptz,
heartbeat_at timestamptz,
started_at timestamptz,
+23
View File
@@ -0,0 +1,23 @@
-- Apply independently to each project's business database.
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.calculation_jobs (
job_id uuid PRIMARY KEY,
project_id uuid NOT NULL,
owner_user_id uuid NOT NULL,
request_key text NOT NULL,
tool text NOT NULL,
parameters jsonb NOT NULL,
binding jsonb NOT NULL,
timeout_seconds integer NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 86400),
status text NOT NULL DEFAULT 'queued' CHECK (status IN
('queued','running','cancel_requested','succeeded','failed','cancelled','timed_out','interrupted')),
result jsonb,
error jsonb,
lease_generation bigint NOT NULL DEFAULT 0,
lease_owner text,
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE(project_id,owner_user_id,request_key)
);
CREATE INDEX IF NOT EXISTS calculation_jobs_queue ON workbench.calculation_jobs(project_id,status,created_at);
@@ -0,0 +1,21 @@
-- Apply after 026, independently to every project business database.
-- Preserve job identity, deduplication and audit metadata, including legacy results.
ALTER TABLE workbench.calculation_jobs
ADD COLUMN IF NOT EXISTS started_at timestamptz,
ADD COLUMN IF NOT EXISTS finished_at timestamptz,
ADD COLUMN IF NOT EXISTS queue_expires_at timestamptz,
ADD COLUMN IF NOT EXISTS result_expires_at timestamptz;
UPDATE workbench.calculation_jobs SET queue_expires_at=created_at+interval '1 hour'
WHERE queue_expires_at IS NULL;
UPDATE workbench.calculation_jobs SET finished_at=updated_at
WHERE finished_at IS NULL AND status IN ('succeeded','failed','cancelled','timed_out','interrupted');
-- Give pre-deployment results a complete retention window, even on reruns.
UPDATE workbench.calculation_jobs SET result_expires_at=clock_timestamp()+interval '7 days'
WHERE result IS NOT NULL AND result_expires_at IS NULL;
ALTER TABLE workbench.calculation_jobs
ALTER COLUMN queue_expires_at SET DEFAULT (clock_timestamp()+interval '1 hour'),
ALTER COLUMN queue_expires_at SET NOT NULL;
CREATE INDEX IF NOT EXISTS calculation_jobs_queue_expiry
ON workbench.calculation_jobs(project_id,queue_expires_at) WHERE status='queued';
CREATE INDEX IF NOT EXISTS calculation_jobs_result_expiry
ON workbench.calculation_jobs(project_id,result_expires_at) WHERE result IS NOT NULL;
+15
View File
@@ -0,0 +1,15 @@
-- Apply to every project business database before enabling task questions.
CREATE TABLE IF NOT EXISTS workbench.operational_task_questions (
project_id uuid NOT NULL,
task_id uuid NOT NULL,
question_request_id uuid NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id, question_request_id),
FOREIGN KEY (project_id, task_id)
REFERENCES workbench.operational_tasks(project_id, task_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS operational_task_questions_task
ON workbench.operational_task_questions(project_id, task_id);
CREATE UNIQUE INDEX IF NOT EXISTS operational_task_questions_pending
ON workbench.operational_task_questions(project_id, task_id)
WHERE document->>'status' = 'pending';
@@ -0,0 +1,141 @@
-- Peripheral work orders are business-system records. Remove the former
-- synthetic provenance branch from source rows and their task projections.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE FUNCTION pg_temp.normalize_peripheral_work_order_text(value jsonb)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
normalized text := value::text;
BEGIN
normalized := replace(normalized, '对应外围记录明确标为 synthetic(系统测试)', '对应外围记录为 system_data 系统数据');
normalized := replace(normalized, '有一条演练上报', '有一条系统上报');
normalized := replace(normalized, '证据覆盖为 synthetic 上报工单快照', '证据覆盖为 system_data 上报工单快照');
normalized := replace(normalized, '记录快照带 synthetic 标识', '记录快照为 system_data');
normalized := replace(normalized, '外围工单结果注明为synthetic演练数据', '外围工单为system_data系统数据');
normalized := replace(normalized, '工单为synthetic演练', '工单为system_data系统数据');
normalized := replace(normalized, 'synthetic 爆管上报记录', '系统爆管上报记录');
normalized := replace(normalized, '合成上报记录', '系统上报记录');
normalized := replace(normalized, '外围模拟工单', '外围系统工单');
normalized := replace(normalized, '外围记录为synthetic', '外围记录为system_data');
normalized := replace(normalized, '证据快照整体为synthetic演练来源', '工单快照为system_data系统来源');
normalized := replace(normalized, '来源标记为synthetic,仅能作为演练资料', '来源为system_data系统数据');
normalized := replace(normalized, 'synthetic演练记录', '系统记录');
normalized := replace(normalized, 'synthetic演练来源', 'system_data系统来源');
normalized := replace(normalized, 'synthetic 演练来源', 'system_data 系统来源');
normalized := replace(normalized, '外围样板合成摘要', '外围工单摘要');
normalized := replace(normalized, '合成外围演练', '外围系统记录');
normalized := replace(normalized, '合成外围', '外围');
normalized := replace(normalized, '合成联调', '系统联调');
normalized := replace(normalized, '合成演练', '系统测试');
normalized := replace(normalized, '合成事件', '系统测试事件');
RETURN normalized::jsonb;
END
$$;
UPDATE peripheral_sample.work_orders
SET document = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
)
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
UPDATE peripheral_sample.work_order_revisions
SET document = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(document, '{origin}', '"system_data"'::jsonb, true)
)
WHERE document->>'origin' IS DISTINCT FROM 'system_data'
OR document::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
UPDATE peripheral_sample.requests
SET result = pg_temp.normalize_peripheral_work_order_text(
jsonb_set(result, '{origin}', '"system_data"'::jsonb, true)
)
WHERE command_key IN ('create_work_order', 'update_work_order')
AND jsonb_typeof(result) = 'object'
AND result ? 'work_order_id'
AND (
result->>'origin' IS DISTINCT FROM 'system_data'
OR result::text ~* '(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)'
);
UPDATE workbench.operational_task_external_feedback
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
document,
'{work_order_snapshot,origin}',
'"system_data"'::jsonb,
false
),
'{evidence,kind}',
'"external_feedback"'::jsonb,
false
),
'{evidence,source_label}',
'"外围工单"'::jsonb,
false
),
'{detail}',
to_jsonb(replace(COALESCE(document->>'detail', ''), '合成外围工单', '外围工单')),
false
))
WHERE document ? 'work_order_snapshot';
UPDATE workbench.operational_task_evidence
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
jsonb_set(
document,
'{kind}',
'"external_feedback"'::jsonb,
false
),
'{source_label}',
'"外围工单"'::jsonb,
false
))
WHERE document->>'source_ref' LIKE 'peripheral_sample:%'
OR evidence_id LIKE 'provenance-correction:peripheral_sample:%';
UPDATE workbench.operational_tasks AS task
SET document = pg_temp.normalize_peripheral_work_order_text(jsonb_set(
task.document,
'{summary}',
to_jsonb(
replace(
replace(COALESCE(task.document->>'summary', ''), '合成外围工单', '外围工单'),
'合成外围事件',
'外围事件'
)
),
false
))
FROM peripheral_sample.work_orders AS source_order
WHERE task.project_id = source_order.project_id
AND (
task.document#>>'{source,external_id}' = source_order.work_order_id::text
OR task.document#>>'{source,external_id}' =
'peripheral_sample:' || source_order.work_order_id::text
);
UPDATE workbench.operational_task_reports AS report
SET document = pg_temp.normalize_peripheral_work_order_text(report.document)
FROM workbench.operational_tasks AS task
JOIN peripheral_sample.work_orders AS source_order
ON task.project_id = source_order.project_id
AND (
task.document#>>'{source,external_id}' = source_order.work_order_id::text
OR task.document#>>'{source,external_id}' =
'peripheral_sample:' || source_order.work_order_id::text
)
WHERE report.project_id = task.project_id
AND report.task_id = task.task_id
AND report.document::text ~*
'(synthetic.*演练|synthetic 爆管上报|外围记录为synthetic|外围样板合成摘要|外围模拟工单|合成上报记录|合成外围|合成联调|合成事件)';
COMMIT;
@@ -0,0 +1,13 @@
-- Bind durable task runs to the Agent instance that owns their local session.
BEGIN;
ALTER TABLE workbench.operational_task_runs
ADD COLUMN IF NOT EXISTS agent_instance_id text;
CREATE INDEX IF NOT EXISTS operational_task_runs_agent_owner_idx
ON workbench.operational_task_runs(project_id,agent_instance_id,status,created_at,run_id)
WHERE agent_instance_id IS NOT NULL
AND status IN ('claimed','running');
COMMIT;
@@ -1,16 +1,36 @@
---
name: appointment-follow-up
description: 调查关联预约任务的执行状态与反馈,核对审批、派发和现场结果,避免把预约任务当成新的爆管或投诉事件。
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
metadata:
version: "1.0.0"
version: "2.0.4"
---
# 预约任务跟进
预约工单承载已安排的人工工作。先读取原任务、已审批动作、预约时间、派发状态和现场反馈;需要时追溯关联的投诉或爆管事件。预约时间、工单派发时间、现场完成时间和事件发生时间分别说明。
目标是说明预约安排了什么、流程到哪一步、现场实际做了什么、证据是否满足要求,以及还有哪些责任事项。预约工单承载已安排的人工工作,不自动代表新的爆管或水质事件,也不因存在待办而反复创建跟进工单。
核对预约工作的内容、执行条件和实际回执。只有现场反馈与证据支持时,才能认定采样、通知、抢修、冲洗或复测完成;工单创建或派发成功仅代表流程状态变化。对尚未完成的工作,报告责任事项、已有进展、缺少的反馈和下一步依赖。
## 接续与核查
预约工单不自动触发隔离停水或污染水排放,也不生成同类型的投诉、爆管事件。确有新增处置需要且已有证据支持时,保存阶段报告,再提议具体的关联预约工单(work_order_type=general),供人工审批;先核查已有有效草稿,避免在跟进过程中递归建单。
读取当前任务、关联工单、已有报告、证据、动作和最新反馈,核对预约时间、地点、负责人、工作内容及回执要求;需要时追溯关联的投诉或爆管。新一轮优先检查新增事实和旧未决项,保留本流程名称和版本。
如发现新的爆管或水质风险,明确记录有依据的发现及待核验项,不擅自更改当前任务类型。审批、现场操作、业务重新排队和恢复供水均遵守现有 Server 流程。正式报告保留所用流程名称和版本,便于追溯。
围绕原工作目标完成必要核查:
1. 将要求与来源工单最新版本逐项对照,区分预约、派发、到场、完成和原事件发生时间,不用创建或更新时间替代现场时间,也不因预约已过推定已经执行或失约。
2. 核对动作的审批、提交、执行状态和回执。`approved` 只表示允许提交,`submitted` 只表示已接收,`succeeded` 的建单动作只证明工单创建成功;这些状态均不证明采样、通知、抢修、冲洗或复测完成。`unknown` 由 Server 复核原执行请求,不重复下发。
3. 用 `query_peripheral_data` 查询外围工单、点位和现场观察,按来源 ID、关联动作及版本核对;目录首屏不能证明没有反馈。区分缺失、重复、旧版本和相互冲突的记录。
4. 按原目标查询位置、资产和工作前后的监测。地点和资产明确后展示正在核查的工作位置;取得监测对照后展示核查点位,并画出实际取得的工作前后趋势或对比图,核对现场发生时间与数据覆盖,不能把计划时间当实际执行分界;已有影响结果时展示其真实几何或节点覆盖示意。核实设备与模型要素、时间覆盖、单位和数据质量;缺测不是零值,局部正常不证明整项工作完成。
5. 将现场描述、来源系统附件或照片说明、监测变化和原工作要求交叉核对。没有图像查看能力时不声称已检查照片内容。每项要求标为已核验、待核验或不适用并给出依据;状态与证据冲突时明确列出。
## 结果与后续
报告分别说明原预约要求、审批及派发事实、已核实的现场工作和效果、冲突或缺口、当前责任事项,以及它与原事件的关系。等待回执不等于调查完成,更不等于现场工作完成。
位置、资产、监测和已有影响结果随核查步骤同步展示,遵循后台地图与图表规则,不用最终报告代替过程展示;单个数值或无可比数据时用文字、指标或表格,不为凑图新增业务分析。无数据或展示失败时说明具体限制,调查不等待浏览器。
只有证据支持新的具体工作时才提议动作。先核对已有有效动作和关联工单,优先沿原工单的反馈与人工修改流程推进;不要因缺少一项反馈就新建催办或复查工单。新增人工工作使用关联的 `work_order_type=general` 草稿,写明地点、内容、依赖、执行前核验和回执要求。
本流程不自动发起隔离停水、污染水排放或设备命令。发现新的爆管或水质风险时,列出证据、紧迫事项和建议,由原事件的合法调查继续专业分析;不要改写当前任务类型或递归创建同类事件。
预约履约完成须有与原要求对应的现场记录、结果与时间、必要回执、适用的监测或复测证据,以及未决事项核对。由有权限人员在来源系统确认工单完成,核对其新版本后才能认定履约完成。
预约完成不等于关联爆管或水质投诉闭环。报告须分别说明预约履约和原事件处置;只具备履约证据时,不宣称原事件风险已消除或已经结案。
@@ -1,26 +1,41 @@
---
name: burst-report
description: 调查供水爆管上报,形成隔离停水、用户通知、抢修和恢复供水的处置方案及待审批动作。
description: 调查供水爆管上报,形成有依据的候选位置、隔离阀门、影响范围和后续处置建议。
metadata:
version: "1.0.0"
version: "2.0.6"
---
# 爆管上报处置
依据上报位置、时间、现场描述、相关管段台账和同窗压力流量证据,区分上报事实、爆管候选、已确认故障与演练假设。完成初步风险判断不要求抢修、复测和回访已经发生;这些属于后续执行反馈。演练数据可支持明确标注的演练判断,不能证明生产故障。
目标是形成有依据的候选位置、隔离方案、影响判断和可审批处置,并在后续轮次根据真实反馈复核事件闭环。区分候选方案、现场确诊和事件闭环;后两者不能提前阻断当前可完成的分析。演练数据可以用于比较,但必须标注 `synthetic`,不能证明生产故障。
## 调查与处置路线
## 接续与证据
1. 核对上报对象与时间窗,查询相关管段、周边监测和既有处置反馈。先确定实际覆盖范围;目录首屏或查询错误不能证明全网没有异常。具备输入时使用爆管侦测或定位分析,候选结果仍需现场核验。
2. 形成隔离停水方案。已确认目标管段时,按需分析隔离阀门、关阀影响、失供范围和供水保障。资产映射、阀门状态或影响范围不清时,明确待现场核验条件;不得编造阀门编号或直接生成无目标的设备命令。
3. 安排用户通知和供水保障。草稿写明已知影响区域、拟停水时段或“待确认”、通知内容、通知渠道及责任岗位;关阀前通知与紧急处置后的补通知取决于现场紧迫性和获批方案。未知用户数量不填虚构值。通知草稿或内部调查消息均不能说成用户已收到通知。
4. 安排现场定位、止水与抢修。说明施工位置、隔离前置条件、抢修目标和需要回传的现场事实。存在管内污染侵入风险时,再安排适用的清洗、消毒或冲洗评估,不能把每次爆管都判断为污染事件。
5. 恢复供水前核验修复、压力、漏水复查及适用的水质复测结果,按获批方案恢复并通知用户;缺少现场反馈时保留“待执行/待核验”,不能宣称恢复完成。
读取当前任务、事件时刻、地点、来源、已有报告、证据、动作和反馈。新一轮优先检查新增事实和上轮未决项,按时间、来源、对象及输入比较新旧结果,不按 ID 推断先后,也不因原工单未更新就断言没有新证据。保留本流程名称和版本。
## 报告与动作草稿
## 定位与影响分析
报告说明当前判断、证据覆盖、隔离影响、通知安排、抢修及恢复条件。能够形成有边界的初步判断时,不因后续维修或复测尚未完成而把整个调查判为关键数据不足。确实缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不强造结论或动作。
根据现有线索自主选择必要步骤:
在已有证据足以支持具体处置建议时,先保存阶段报告,再通过现有动作提议入口保存待人工审批的具体草稿。现场核验、停水通知、抢修等人工执行事项使用关联预约工单(work_order_type=general);原爆管上报事件保持独立。草稿含位置、工作内容、先后依赖、执行前核验条件和回执要求,避免为每个图节点机械建单。没有实际客户通知工具时,将通知列为待执行的人工工单步骤。
1. 核对已有管段或节点。只有地点描述时,先解析位置,再查询附近资产、属性和连接。地点明确后展示调查位置,候选资产取得后展示正在核查的管段或节点。空间邻近只支持候选,不能单独证明故障。
2. 核对 SCADA 设备与模型节点映射,用 `query_scada_readings` 或 `query_element_history` 查询事件前后及正常对照。先用少量对象和合适时间窗,确认返回时间实际覆盖事件;必要时缩窗或翻页。同步展示核查点位,并将取得的事件、对照压力或流量序列画成过程曲线,分清指标和单位;保留缺测、真实零值和覆盖限制。
3. 有异常观测时评估爆管定位输入。`run_burst_location` 必须有 `burst_leakage`(m³/s);先查询已有估计及方法依据,或结合边界流量、用水和储量变化判断能否估计。不能把单表增量、压力下降或任意数值直接当漏量。
4. 有依据的漏量估计或明确的情景假设可用于条件定位,须说明来源、方法和不确定性。`pressure_scada_ids` 使用模型节点 ID;监测模式读取 SCADA,模拟模式须引用已有分析运行。取得多个可比候选的评价值后,用柱状图展示实际排序及评价含义。压力点和历史数据满足工具要求时可选 `run_burst_detection`,但侦测不代替定位,评价值也不表述为概率。缺少定位参数时说明已查来源和影响,继续其他可独立完成的分析。
5. 对已确认或有依据的候选资产使用 `analyze_valve_isolation` 评估阀门隔离,无需等待现场确诊。逐候选比较;多个事故 ID 表示联合场景。保留可隔离性、必关阀、可选阀及受影响节点。结果取得后展示直接隔离与供水影响节点的覆盖示意,形成或调整关阀方案时展示对应阀门;区分两种影响及必关、可选阀。未知阀门状态不能写成可用,不可隔离或空结果也不能解释为零影响;已知阀门失效时评估替代边界。
6. 输入具备时用 `analyze_valve_closure` 分析关阀水力影响,核对阀门组合、起算时间、时长和输出对象。结果返回后,用曲线展示请求节点的压力、请求管段的流量;有同口径方案或基准时再画对比图,不自造前后变化。不同单位分图,标明局部模型结果。区分拓扑范围、水力影响和有依据的用户影响,不虚构户数,不用局部结果代表全网。
有真实且可控制的设备目标、明确命令及充分依据时,才提议设备命令。审批和执行由 Server 控制,调查不直接关闭阀门或发送用户通知。复用已存在的有效草稿,结合实际回执继续调查,不重复提议同一动作。正式报告保留所用流程名称和版本,便于追溯。
查询错误、权限拒绝或接口故障不是缺数证据。必要分析无法执行时,说明失败步骤和已经完成的部分,不借有限摘要宣布调查完成。
## 处置与报告
报告分别给出位置候选及依据、隔离阀门及可用性、替代方案、拓扑与水力影响、用户影响边界,以及处置和闭环缺口。只有初步结果时明确范围,不宣称已经定位、确诊或闭环。位置、候选、监测、影响和阀门方案按实际调查步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。覆盖面保留分组、缺失几何和示意边界,不能当作实际停水、水力影响或用户服务边界。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
形成具体建议后,先保存阶段结论,再提议必要动作。人工核验、通知、定位隔离、抢修、适用的清洗复测和恢复回访使用关联的 `work_order_type=general` 草稿;原爆管事件保持独立。草稿写明位置、任务、依赖、执行前核验和回执要求。没有通知适配器时明确由人工实施。设备命令仅用于真实可控目标,审批和执行由 Server 控制。
抢修或复测尚未发生不阻止有边界的初步方案。现场阀门状态应写为执行前核验条件;工单创建、审批或派发均不证明现场完成。
## 闭环复核
后续轮次核对实际故障资产和原因、隔离实施、抢修记录、适用的清洗与水质复测、恢复供水及恢复后监测、用户通知回访、未决动作和遗留风险。每项标明已核验、待核验或不适用及依据。
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。原工单未结案时写明待确认,读取其新版本后才能认定闭环;误报结案也须有足够的排除证据和人工确认。
@@ -1,26 +1,41 @@
---
name: water-quality-complaint
description: 调查供水水质投诉,按证据和风险提出采样、隔离、用户通知、污染水处置及复测恢复方案。
description: 调查供水水质投诉,核查投诉与监测覆盖,评估候选原因、影响和采样处置建议。
metadata:
version: "1.0.0"
version: "2.0.4"
---
# 水质投诉处置
区分用户感官投诉、监测异常、已确认的水质问题与污染源假设。单个投诉不能直接证明供水管网污染;上游监测正常也不能排除末梢或二次供水问题。不预设异常必然来自二次供水,不把模拟扩散结果当成污染源反演。
目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。演练结果须标注 `synthetic`,不能证明生产风险。
## 调查与处置路线
## 接续与证据
1. 核对投诉时间、位置、异常描述与同类投诉,查询供水路径、相关水质和压力监测、适用的历史记录及现场采样反馈。分别说明实际覆盖的上游、末梢和二次供水范围,不混用模型基线与实测。
2. 评估是否需要隔离停水及饮用水风险告知。已确认或有充分证据支持局部供水风险时,提出与风险相称的隔离范围和保障措施,核对阀门与影响区域。尚不能确定风险时,先提出有依据的现场核验或采样安排,不因一条投诉直接建议全网停水,也不承诺水可安全饮用。
3. 将用户通知纳入处置方案:明确已知影响范围、需人工确认的用水提示、通知渠道、责任岗位和更新条件。没有实际发送工具时形成待审批的人工通知步骤,不把调查消息当成客户通知。
4. 对已支持的污染水风险,安排排查来源、控制污染进入并评估受影响管段的冲洗或排放。仅在来源参数、目标管段或冲洗节点等必需输入已知时使用扩散或冲洗分析;参数不足不虚构源浓度、排放量或节点。具体排水位置、承接条件及现场操作要求需核验后审批执行,不默认污染水可任意排放。
5. 清洗或冲洗后取得适用采样和复测反馈,再评估恢复供水与用户回访。现场处置、复测和恢复是下游执行步骤,其尚未发生不能单独作为拒绝初步风险分析的理由。
读取当前任务、投诉时刻、位置、现象、来源版本、已有报告、证据、采样、动作和处置反馈。新一轮先核对新增事实和上轮未决项,保留本流程名称和版本。
## 报告与动作草稿
## 风险与工程分析
报告列明当前风险判断、证据覆盖、隔离与通知条件、污染水处置路线和复测恢复条件。演练输入只支持标注为演练的判断。真正缺少支撑当前判断的关键证据时,遵守 insufficient_data 契约,不用通用处置模板强造已确认污染结论。
根据当前证据选择必要步骤:
已能形成有边界的处置建议时,保存阶段报告后,通过现有动作提议入口生成待人工审批的关联预约工单(work_order_type=general)。保留原投诉事件,草稿写明实际位置、采样或处置内容、责任要求、执行依赖、现场待核验条件和回执要求;可将通知、采样、冲洗和复测组织成合理的人工执行事项,不机械逐节点建单。
1. 核实投诉位置及相近时段的同类投诉,用 `query_peripheral_data` 按实际时间、位置和来源标识核对外围工单或观察。目录首屏不能证明没有其他投诉;未知点位先查目录,不猜编号或把演示点当真实资产。
2. 解析未知地址,查询附近资产、属性和连接,区分上游、管网、末梢、二次供水和用户端的实际覆盖。地点明确后展示投诉位置,取得关联资产后展示正在核查的管段或节点。拓扑连接不等于实际流向,方向判断需要同窗模型或监测依据。
3. 核对 SCADA 指标、单位和模型映射,用 `query_scada_readings` 或 `query_element_history` 查询投诉前后及对照,检查时间覆盖、在线状态、漂移、长期缺测和质量标识。同步展示核查点位,将实际取得的水质、压力等指标画成趋势或对比图,不同单位分图。缺测不是零值,上游正常不能排除末梢问题,压力正常不能证明水质合格。
4. 综合投诉分布、监测、供水关联和实际采样,列出候选原因、支持证据、反证和区分原因所需的核验。未证实污染源时保留假设,不把感官描述换算成浓度。水质限值只能引用已取得且适用的依据,不能自行承诺安全饮用。
5. 有合理目标管段或节点时,可用 `analyze_valve_isolation` 和 `analyze_valve_closure` 评估候选隔离及关阀影响,无需等待污染确诊,但不能据此直接执行停水。逐候选区分可隔离性、必关阀、可选阀、受影响节点和未知阀门状态。取得影响节点后展示覆盖示意,形成或调整关阀方案时展示对应阀门。拓扑隔离区、水力影响、污染范围和用户影响分别说明;节点覆盖面不能当作污染传播边界,无用户资料时不虚构户数。
6. 只有假设源节点、浓度及必要模型输入有依据时,才用 `run_contaminant_simulation` 正向比较传播;它不是源头反演,未模拟范围不能宣布安全。具备实际排放节点、流量和前置条件时才用 `analyze_flushing` 评估冲洗。取得模拟结果后,按实际返回的对象、时刻和指标展示浓度、压力或流量曲线;同口径的隔离、冲洗方案可画对比图,标明情景假设和局部覆盖。冲洗模拟不证明消毒合格,排放位置和承接条件仍需现场核验与审批。
设备命令只针对真实且可控制的目标,审批与执行由 Server 控制。草稿保存或工单派发不等于采样、通知、冲洗、复测完成。核对已有草稿和现场反馈后再继续,避免重复提议。正式报告保留所用流程名称和版本,便于追溯。
缺少源参数不阻止已有证据支持的采样、候选隔离或人工告知建议。查询错误、权限拒绝或接口故障属于执行异常,不能写成无数据或未见异常。
## 处置与报告
报告分别说明投诉与监测覆盖、候选原因和未排除路径、隔离及影响、通知保障、采样或污染水处置,以及复测与恢复条件。每项给出实际结果或经查询确认的限制;区分当前风险判断与尚未发生的未来处置。投诉位置、监测、候选隔离和影响按实际步骤同步展示,遵循后台展示规则,不用报告定位链接代替过程展示。无数据或展示失败时说明具体限制;展示不替代证据,也不要求等待浏览器。
形成具体建议后,先保存阶段结论,再提议关联的 `work_order_type=general` 草稿。写明实际位置、采样或处置目标、时间要求、依赖、现场待核验条件和回执要求。通知、采样、清洗冲洗与复测按依赖合理组织,不机械逐节点建单,也不重复已有有效动作。没有客户通知适配器时明确由人工实施;调查消息不能当作用户通知。设备命令仅针对真实可控目标,审批与执行由 Server 控制。
采样、处置和回访尚未发生不阻止有边界的初步建议。工单创建、审批或派发不证明采样、通知、冲洗或复测完成。
## 闭环复核
后续轮次核对实际采样位置、时间、结果和判定依据,以及污染进入控制、隔离与通知、清洗冲洗、复测、恢复供水、用户回访、未决动作和遗留风险。不适用项说明依据,缺少反馈保留待核验。
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。核对来源新版本后才能认定闭环;误报或未发现异常也须有足够核查和人工确认,不能用有限正常数据、模拟结果或流程状态证明水质安全。
+5 -5
View File
@@ -1,4 +1,4 @@
"""Independent sample API for synthetic work orders and monitoring data."""
"""Project-scoped peripheral work orders, monitoring history and explicit test data."""
from typing import get_args
from uuid import UUID
@@ -34,8 +34,8 @@ async def store(request: Request, who: Identity = Depends(identity),
def create_app(settings: SampleSettings | None = None) -> FastAPI:
app = FastAPI(title="TJWater 外围系统模拟接入", version="1.0.0",
description="测试监测与核查反馈样板,不是正式工单平台或设备控制服务。")
app = FastAPI(title="TJWater 外围系统接入", version="1.0.0",
description="按项目管理业务工单与反馈、读取监测历史;测试数据保留独立来源标记。")
app.state.settings = settings or SampleSettings()
app.add_middleware(CORSMiddleware, allow_origins=app.state.settings.cors_origins,
allow_credentials=False, allow_methods=["GET", "POST", "PATCH", "DELETE"],
@@ -51,7 +51,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
@app.get("/health")
def health():
return {"service": "peripheral-sample", "origin": "synthetic", "storage": "postgresql",
return {"service": "peripheral-sample", "origin": "system_data", "storage": "postgresql",
"status": "up", "storage_verified": False}
prefix = "/api/sample/v1"
@@ -59,7 +59,7 @@ def create_app(settings: SampleSettings | None = None) -> FastAPI:
@app.get(prefix + "/context", response_model=Context, responses=errors)
def context(request: Request, who: Identity = Depends(identity), db=Depends(store)):
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "synthetic",
return {"protocol_version": "tjwater-peripheral-sample@1", "origin": "system_data",
"project_id": who.project_id, "user_id": who.user_id, "can_write": who.can_write,
"scenarios": list(get_args(Scenario)) if request.app.state.settings.enable_faults else ["normal"],
"state_transitions": TRANSITIONS, "managed_resources": db.managed_resource_capabilities()}
+9 -3
View File
@@ -122,6 +122,7 @@ WorkOrderStatus = Literal[
]
Priority = Literal["low", "normal", "high", "urgent"]
WorkOrderType = Literal["general", "water_quality_complaint", "burst_report"]
WorkOrderOrigin = Literal["system_data"]
class CreateWorkOrder(Model):
request_id: UUID
title: Annotated[str, Field(min_length=1, max_length=200)]
@@ -131,6 +132,7 @@ class CreateWorkOrder(Model):
assignee: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scheduled_at: WireDateTime | None = None
work_order_type: WorkOrderType = "general"
origin: WorkOrderOrigin = "system_data"
location: Annotated[str, Field(min_length=1, max_length=200)] | None = None
scenario_code: Annotated[str, Field(min_length=1, max_length=128)] | None = None
feedback_requirements: list[Text] = Field(default_factory=list)
@@ -193,7 +195,7 @@ class WorkOrder(Model):
priority: Priority
assignee: str | None
scheduled_at: WireDateTime | None
origin: Literal["synthetic"]
origin: WorkOrderOrigin
created_at: WireDateTime
updated_at: WireDateTime
created_by: str
@@ -208,7 +210,11 @@ class WorkOrder(Model):
@classmethod
def accept_legacy_document(cls, value):
if isinstance(value, dict):
return {key: item for key, item in value.items() if key != "subtasks"}
return {
key: "system_data" if key == "origin" else item
for key, item in value.items()
if key != "subtasks"
}
return value
@@ -314,7 +320,7 @@ class ManagedResourceCapabilities(Model):
class Context(Model):
protocol_version: Literal["tjwater-peripheral-sample@1"]
origin: Literal["synthetic"]
origin: Literal["system_data"]
project_id: UUID
user_id: UUID
can_write: StrictBool
+1 -1
View File
@@ -369,7 +369,7 @@ class PostgresStore:
now = datetime.now(timezone.utc).isoformat()
document = command.model_dump(mode="json", exclude={"request_id"}) | {
"work_order_id": str(uuid4()), "project_id": who.project_id, "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now,
"origin": "system_data", "created_at": now, "updated_at": now,
"created_by": who.user_id, "updated_by": who.user_id,
"activity": [{"sequence": 1, "occurred_at": now, "actor": who.user_id,
"kind": "created", "title": "工单创建",
@@ -309,8 +309,8 @@ def _update_worker_environment(
"OPERATIONAL_WORKER_SERVER_URL": "http://127.0.0.1:8000",
"OPERATIONAL_WORKER_AGENT_URL": "http://127.0.0.1:18787",
"OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS": "300",
"OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8011",
"OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8011",
"OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8000/peripheral",
"OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8000/peripheral",
"OPERATIONAL_DATA_SERVER_URL": "http://127.0.0.1:8000",
}
for key, value in values.items():
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Run the independent calculation supervisor (never an API lifespan task)."""
import argparse
import asyncio
from pathlib import Path
import signal
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dotenv import load_dotenv
from app.services.calculation_worker import CalculationWorker, WorkerSettings
async def main():
load_dotenv(Path(__file__).resolve().parents[1] / '.env', override=False)
task = asyncio.create_task(CalculationWorker(WorkerSettings.from_environment()).run())
loop = asyncio.get_running_loop()
for signum in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(signum, task.cancel)
try:
await task
except asyncio.CancelledError:
pass
if __name__ == '__main__':
argparse.ArgumentParser(description=__doc__).parse_args()
asyncio.run(main())
+30 -4
View File
@@ -124,6 +124,20 @@ class OperationalWorker:
async def tick(self) -> int:
headers = await self.credentials.headers(self.client)
server = self.settings.server_url.rstrip("/")
agent = self.settings.agent_url.rstrip("/")
target_agent_instance_id: str | None = None
async def target_instance_id() -> str:
nonlocal target_agent_instance_id
if target_agent_instance_id is None:
response = await self.client.get(f"{agent}/health")
response.raise_for_status()
value = response.json().get("agent_instance_id")
if not isinstance(value, str) or not value.strip():
raise ValueError("Agent health response has no instance identity")
target_agent_instance_id = value.strip()
return target_agent_instance_id
scope = {"task_type": "event_investigation"}
if self.settings.task_created_from:
scope["task_created_from"] = self.settings.task_created_from
@@ -136,10 +150,13 @@ class OperationalWorker:
for status_value in ("claimed", "running"):
response = await self.client.get(
f"{server}/api/v1/operational-task-runs", headers=headers,
params={"status": status_value, "limit": self.settings.max_concurrency, **scope},
params={"status": status_value, "limit": 100, **scope},
)
response.raise_for_status()
for item in response.json()["items"]:
owner = item.get("agent_instance_id")
if not isinstance(owner, str) or owner != await target_instance_id():
continue
run_id = str(item["run_id"])
active_run_ids.add(run_id)
session_id = item.get("agent_session_id")
@@ -152,7 +169,7 @@ class OperationalWorker:
# Refresh the Agent vault with the worker's existing OAuth provider.
# Background runs never depend on an interactive browser refresh.
renewed = await self.client.post(
f"{self.settings.agent_url.rstrip('/')}/api/v1/session/{session_id}/credentials",
f"{agent}/api/v1/session/{session_id}/credentials",
headers=headers, json={},
)
if renewed.status_code not in (202, 404, 409):
@@ -167,6 +184,7 @@ class OperationalWorker:
)
response.raise_for_status()
queued = response.json()["items"]
dispatch_instance_id = await target_instance_id() if queued else None
async def dispatch(run: dict) -> bool:
if run.get("status") != "queued":
@@ -176,18 +194,21 @@ class OperationalWorker:
request_id = str(UUID(run["execution_request_id"]))
try:
result = await self.client.post(
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-executions",
f"{agent}/api/v1/task-executions",
headers=headers,
json={
"task_id": task_id,
"run_id": run_id,
"execution_request_id": request_id,
"agent_instance_id": dispatch_instance_id,
},
)
if result.is_success:
payload = result.json()
if payload.get("task_id") != task_id or payload.get("run_id") != run_id:
raise ValueError("Agent returned a different task run")
if payload.get("agent_instance_id") != dispatch_instance_id:
raise ValueError("Agent returned a different instance identity")
session_id = payload.get("session_id")
if isinstance(session_id, str) and session_id:
self._agent_sessions[run_id] = session_id
@@ -221,7 +242,12 @@ class OperationalWorker:
f"{self.settings.agent_url.rstrip('/')}/api/v1/task-messages/deliver",
headers=headers, json={"message_id": message_id},
)
if delivered.status_code in {400, 403, 404, 410, 422}:
if delivered.status_code == 404:
# A different Agent instance may own the target session.
# Keep the durable message pending instead of turning a
# routing miss into a terminal delivery failure.
logger.info("Agent message target is not local; delivery remains pending: %s", message_id)
elif delivered.status_code in {400, 403, 410, 422}:
failed = await self.client.post(
f"{self.settings.server_url.rstrip('/')}/api/v1/agent-messages/{message_id}/delivery",
headers=headers, json={
+15 -21
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
"""Synchronize peripheral work orders as project-owned system data."""
from __future__ import annotations
import argparse
@@ -15,7 +15,6 @@ import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from samples.peripheral.seed_identity import SEED_ACTOR_ID
from app.domain.schemas.workbench_events import WorkbenchEvent
try:
@@ -42,23 +41,15 @@ def peripheral_order_id(external_id: str | None) -> str | None:
def should_project_work_order(order: dict, linked_order_ids: set[str]) -> bool:
"""Exclude retired seed checks while preserving user and workflow orders."""
work_order_id = order["work_order_id"]
work_order_type = order.get("work_order_type", "general")
if work_order_type in EVENT_WORK_ORDER_TYPES or work_order_id in linked_order_ids:
return True
return not (
work_order_type == "general"
and order.get("origin") == "synthetic"
and order.get("created_by") == str(SEED_ACTOR_ID)
)
"""Project every active work order returned by the peripheral system."""
return True
def work_order_snapshot(order: dict) -> dict:
"""Preserve the flat order and its recorded activity without inventing stages."""
required = (
"work_order_id", "revision", "title", "summary", "status", "priority",
"origin", "updated_at",
"updated_at",
)
snapshot = {key: order[key] for key in required}
snapshot.update({key: order.get(key) for key in (
@@ -67,6 +58,7 @@ def work_order_snapshot(order: dict) -> dict:
snapshot["feedback_requirements"] = order.get("feedback_requirements") or []
snapshot["activity"] = order.get("activity") or []
snapshot["work_order_type"] = order.get("work_order_type", "general")
snapshot["origin"] = "system_data"
return snapshot
@@ -148,8 +140,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
work_order_ids, deleted_ids,
):
for order in page["items"]:
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
if order["project_id"] != settings.project_id or order["origin"] != "system_data":
raise ValueError("peripheral work order scope or origin mismatch")
source_label = "外围工单"
seen_order_ids.add(order["work_order_id"])
if not should_project_work_order(order, linked_order_ids):
continue
@@ -165,12 +158,13 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
event = await client.post(tasks_url, headers=headers, json={
"request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind,
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}",
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查",
"summary": f"外围事件:{order['summary']}",
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
"source": {"kind": "external_system", "external_id": order["work_order_id"],
"external_revision": order["revision"], "event_kind": event_kind},
"trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"],
"detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"},
"detail": f"外围事件上报 · {order.get('location') or '位置待确认'}"},
})
event.raise_for_status()
event_tasks[order["work_order_id"]] = event.json()
@@ -179,12 +173,12 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create"))
result = await client.post(tasks_url, headers=headers, json={
"request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order",
"title": order["title"], "summary": f"合成外围工单:{order['summary']}",
"title": order["title"], "summary": f"{source_label}:{order['summary']}",
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
"source": {"kind": "external_system", "external_id": external_id,
"event_kind": order.get("work_order_type", "general")},
"trigger": {"kind": "external_sync", "occurred_at": order["created_at"],
"detail": "外围样板工单同步,进度以外部系统反馈为准"},
"detail": "外围工单同步,进度以外部系统反馈为准"},
"scheduled_for": order["scheduled_at"],
})
result.raise_for_status()
@@ -225,9 +219,9 @@ async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, periphe
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
"source_revision": order["revision"], "status": STATUS[order["status"]],
"occurred_at": order["updated_at"],
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": evidence_summary,
"source_label": "外围样板合成工单", "source_ref": external_id,
"detail": f"{source_label} {order['work_order_id']}:{order['status']}",
"evidence": {"evidence_id": event_id, "kind": "external_feedback", "summary": evidence_summary,
"source_label": source_label, "source_ref": external_id,
"observed_at": order["updated_at"]},
}
# A child order's creation is already represented by its dispatch
+6 -2
View File
@@ -254,14 +254,16 @@ def test_committed_write_refresh_failure_has_explicit_http_contract() -> None:
assert response.json()["code"] == "materialized_view_refresh_failed_after_commit"
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch, element_type) -> None:
captured: dict[str, object] = {}
def fake_analyze_valve_isolation(network, accident_element, disabled_valves):
def fake_analyze_valve_isolation(network, accident_element, disabled_valves, accident_element_type):
captured.update(
network=network,
accident_element=accident_element,
disabled_valves=disabled_valves,
accident_element_type=accident_element_type,
business_dsn=get_project_pgconn_string(network),
timescale_dsn=get_project_timescale_pgconn_string(network),
)
@@ -288,6 +290,7 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
("accident_element", "P-1"),
("accident_element", "P-2"),
("disabled_valves", "V-9"),
*([] if element_type == "auto" else [("accident_element_type", element_type)]),
],
)
@@ -297,6 +300,7 @@ def test_valve_isolation_runtime_accepts_frontend_query(monkeypatch) -> None:
"network": "fengyang",
"accident_element": ["P-1", "P-2"],
"disabled_valves": ["V-9"],
"accident_element_type": element_type,
"business_dsn": "postgresql://user:password@biz/fengyang",
"timescale_dsn": "postgresql://user:password@timescale/fengyang",
}
@@ -33,6 +33,7 @@ def test_dynamic_investigation_routes_are_in_the_public_contract():
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/reports/{revision}"]
assert "post" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-events"]
assert "get" in paths["/api/v1/operational-tasks/{task_id}/presentation-cursor"]
context_params = paths["/api/v1/operational-tasks/{task_id}/agent-context"]["get"]["parameters"]
assert any(item["name"] == "report_revision" and item["in"] == "query" for item in context_params)
@@ -123,3 +124,21 @@ async def test_report_endpoints_preserve_interactive_blocks_and_version_context(
schemas = app.openapi()["components"]["schemas"]
assert "ui_blocks" in schemas["InvestigationReportInput"]["properties"]
assert "ui_blocks" in schemas["InvestigationReport"]["properties"]
@pytest.mark.anyio
async def test_presentation_history_route_forwards_read_only_paging_and_domain_errors(monkeypatch):
service = SimpleNamespace(presentation_history=AsyncMock(return_value={"items": []}))
monkeypatch.setattr(endpoints, "_service", lambda _ctx, _conn: service)
task_id = uuid4()
await endpoints.get_operational_task_presentation_history(
task_id, after=7, through=30, limit=20, ctx=_service_context(), conn=object(),
)
service.presentation_history.assert_awaited_once_with(task_id, after=7, through=30, limit=20)
from app.domain.domain_errors import DomainError
service.presentation_history.side_effect = DomainError("TASK_NOT_FOUND", "missing", status=404)
with pytest.raises(HTTPException) as error:
await endpoints.get_operational_task_presentation_history(
task_id, after=0, through=None, limit=100, ctx=_service_context(), conn=object(),
)
assert error.value.status_code == 404
+23
View File
@@ -0,0 +1,23 @@
from fastapi.testclient import TestClient
from app.main import app
def test_main_server_mounts_peripheral_health_endpoint():
response = TestClient(app).get("/peripheral/health")
assert response.status_code == 200
assert response.json() == {
"service": "peripheral-sample",
"origin": "system_data",
"storage": "postgresql",
"status": "up",
"storage_verified": False,
}
def test_mounted_peripheral_api_preserves_its_authentication_boundary():
response = TestClient(app).get("/peripheral/api/sample/v1/context")
assert response.status_code == 401
assert response.json()["code"] == "AUTHENTICATION_REQUIRED"
+39
View File
@@ -0,0 +1,39 @@
import asyncio
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1.endpoints import operational_tasks as endpoints
from tests.unit.test_task_questions import setup, finish
def test_question_http_contract_waits_and_resumes_original_task(monkeypatch):
service, repo, task_id, command = setup()
app = FastAPI()
app.include_router(endpoints.router)
monkeypatch.setattr(endpoints, "_service", lambda *_args: service)
# Exercise HTTP parsing and response contracts; service tests cover authorization.
for route in app.routes:
if getattr(route, "name", "") in {"create_operational_task_question", "answer_operational_task_question"}:
for dependency in route.dependant.dependencies:
app.dependency_overrides[dependency.call] = lambda: None
with TestClient(app) as client:
path = f"/operational-tasks/{task_id}/questions"
invalid = client.post(path, json={**command.model_dump(mode="json"), "project_id": str(uuid4())})
assert invalid.status_code == 422
created = client.post(path, json=command.model_dump(mode="json"))
assert created.status_code == 200, created.text
question_id = created.json()["question_request_id"]
asyncio.run(finish(service, repo, task_id))
answered = client.post(f"{path}/{question_id}/answer", json={
"request_id": str(uuid4()), "expected_version": 1,
"answers": [{"question_id": "flow", "selected_option_ids": ["unknown"], "custom_text": ""}],
})
assert answered.status_code == 200, answered.text
result = answered.json()
assert result["task_id"] == str(task_id) and result["execution_generation"] == 2
assert result["active_run"]["status"] == "queued"
assert result["questions"][0]["status"] == "answered"
assert result["questions"][0]["answered_by"] == service._actor()
assert result["evidence"] == []
@@ -0,0 +1,127 @@
"""Opt-in real PostgreSQL queue races, using an explicitly disposable database."""
import asyncio
import os
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi import HTTPException
from psycopg import AsyncConnection
from psycopg.conninfo import conninfo_to_dict
from psycopg.rows import dict_row
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
def test_concurrent_submission_claims_fencing_and_project_isolation():
dsn = os.getenv("CALCULATION_QUEUE_TEST_DSN")
if not dsn:
pytest.skip("Set CALCULATION_QUEUE_TEST_DSN to a disposable PostgreSQL database")
assert conninfo_to_dict(dsn)["dbname"].startswith("tjwater_calc_test_")
async def run():
project, other, owner = uuid4(), uuid4(), uuid4()
async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
migrations = Path(__file__).parents[2] / "resources/sql"
await conn.execute((migrations / "026_calculation_jobs.sql").read_text())
legacy_job = uuid4()
await conn.execute("""INSERT INTO workbench.calculation_jobs
(job_id,project_id,owner_user_id,request_key,tool,parameters,binding,timeout_seconds,status,result,updated_at)
VALUES (%s,%s,%s,'legacy','run_simulation','{}','{}',1800,'succeeded','{"preserved":true}',now()-interval '30 days')""",
(legacy_job, uuid4(), owner))
lifecycle = (migrations / "027_calculation_job_lifecycle.sql").read_text()
await conn.execute(lifecycle)
cursor = await conn.execute("SELECT result,result_expires_at,result_expires_at>now()+interval '6 days' AS grace FROM workbench.calculation_jobs WHERE job_id=%s", (legacy_job,))
first = await cursor.fetchone()
assert first["result"] == {"preserved": True} and first["grace"]
await conn.execute(lifecycle)
cursor = await conn.execute("SELECT result_expires_at FROM workbench.calculation_jobs WHERE job_id=%s", (legacy_job,))
assert (await cursor.fetchone())["result_expires_at"] == first["result_expires_at"]
async def submit(key, parameters=None, selected_project=project):
async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
return await CalculationJobRepository(conn, selected_project).enqueue(
owner_user_id=owner, request_key=key, tool="run_burst_location",
parameters=parameters or {"x": 1}, binding={"native_session_id": "session"}, timeout_seconds=600)
# Four independent API connections, same logical submission.
submissions = await asyncio.gather(*(submit("same") for _ in range(4)))
assert len({str(row["job_id"]) for row in submissions}) == 1
with pytest.raises(HTTPException) as conflict:
await submit("same", {"x": 2})
assert conflict.value.status_code == 409
second = await submit("second")
foreign = await submit("foreign", selected_project=other)
async def claim(worker):
async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
return await CalculationJobRepository(conn, project).claim(worker, 60)
claimed = await asyncio.gather(*(claim(f"worker-{index}") for index in range(4)))
rows = [row for row in claimed if row]
assert len(rows) == 2
assert len({str(row["job_id"]) for row in rows}) == 2
assert str(foreign["job_id"]) not in {str(row["job_id"]) for row in rows}
async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
repo = CalculationJobRepository(conn, project)
assert await repo.get(foreign["job_id"], owner) is None
assert await repo.get(second["job_id"], uuid4()) is None
row = rows[0]
stale = {**row, "lease_generation": row["lease_generation"] - 1}
assert await repo.heartbeat(stale, row["lease_owner"], 60) is None
assert not await repo.finish(stale, row["lease_owner"], "succeeded", result={})
cancelled = await repo.cancel(row["job_id"], owner)
assert cancelled["status"] == "cancel_requested"
assert not await repo.finish(row, row["lease_owner"], "succeeded", result={})
assert await repo.finish(row, row["lease_owner"], "cancelled")
expired = rows[1]
await conn.execute("UPDATE workbench.calculation_jobs SET lease_expires_at=now()-interval '1 second' WHERE job_id=%s", (expired["job_id"],))
await repo.recover()
assert (await repo.get(expired["job_id"], owner))["status"] == "interrupted"
assert not await repo.finish(expired, expired["lease_owner"], "succeeded", result={})
assert await repo.claim("replacement", 60) is None
# Queued cancellation is terminal immediately, with no later claim.
queued = await repo.enqueue(owner_user_id=owner, request_key="cancel-before-start",
tool="run_simulation", parameters={}, binding={}, timeout_seconds=30)
cancelled = await repo.cancel(queued["job_id"], owner)
assert cancelled["status"] == "cancelled" and cancelled["finished_at"] is not None
assert cancelled["started_at"] is None
assert await repo.claim("replacement", 60) is None
# A polling client expires queued jobs without any worker process.
waiting = await repo.enqueue(owner_user_id=owner, request_key="expired-queue",
tool="run_simulation", parameters={}, binding={}, timeout_seconds=30)
await conn.execute("UPDATE workbench.calculation_jobs SET queue_expires_at=now()-interval '1 second' WHERE job_id=%s", (waiting["job_id"],))
status = await repo.get(waiting["job_id"], owner)
assert status["status"] == "timed_out" and status["error"]["code"] == "QUEUE_TIMEOUT"
assert status["finished_at"] is not None
assert "result" not in status
# Retention scrubs only payload, keeping deduplication and status.
completed = await repo.enqueue(owner_user_id=owner, request_key="retained-identity",
tool="run_simulation", parameters={}, binding={}, timeout_seconds=30)
await conn.execute("""UPDATE workbench.calculation_jobs SET status='succeeded',
result='{"large":"payload"}'::jsonb,finished_at=now(),
result_expires_at=now()-interval '1 second' WHERE job_id=%s""", (completed["job_id"],))
expired_result = await repo.get(completed["job_id"], owner, include_result=True)
assert expired_result["result"] is None and expired_result["status"] == "succeeded"
duplicate = await repo.enqueue(owner_user_id=owner, request_key="retained-identity",
tool="run_simulation", parameters={}, binding={}, timeout_seconds=30, max_active_jobs=0)
assert duplicate["job_id"] == completed["job_id"]
# Concurrent unique requests cannot exceed an otherwise empty project's cap.
capacity_project = uuid4()
async def bounded_submit(index):
try:
async with await AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
return await CalculationJobRepository(conn, capacity_project).enqueue(
owner_user_id=owner, request_key=str(index), tool="run_simulation",
parameters={}, binding={}, timeout_seconds=30, max_active_jobs=2)
except HTTPException as exc:
return exc
bounded = await asyncio.gather(*(bounded_submit(i) for i in range(8)))
assert sum(isinstance(value, dict) for value in bounded) == 2
assert all(isinstance(value, dict) or value.status_code == 503 for value in bounded)
asyncio.run(run())
@@ -293,7 +293,7 @@ async def test_postgres_task_artifacts_approval_and_project_isolation_roll_back(
assert request.url.path.endswith(execution_ids[0])
return httpx.Response(200, json={"request_id": execution_ids[0], "result": {
"work_order_id": order_id, "project_id": str(context.project_id),
"created_at": now.isoformat(), "origin": "synthetic", "status": "open",
"created_at": now.isoformat(), "origin": "system_data", "status": "open",
}})
dispatcher = HttpOperationalActionDispatcher(
+25
View File
@@ -0,0 +1,25 @@
import json
import pytest
from app.services.calculation_cleanup import _names, record_calculation_database
from app.algorithms._utils import calculation_temp_directory
def test_job_owned_database_ledger_survives_until_successful_cleanup(tmp_path, monkeypatch):
monkeypatch.setenv("TJWATER_CALCULATION_JOB_DIR", str(tmp_path))
name = "tjw_tmp_run_project_" + "a" * 32
record_calculation_database(name)
record_calculation_database(name)
assert _names(tmp_path) == [name]
assert json.loads((tmp_path / "databases.json").read_text()) == [name]
record_calculation_database(name, removed=True)
assert _names(tmp_path) == []
assert calculation_temp_directory("leakage") == str(tmp_path / "leakage")
@pytest.mark.parametrize("names", [["production"], ["tjw_tmp_not_random"], {"database": "production"}])
def test_cleanup_rejects_non_job_database_names(tmp_path, names):
(tmp_path / "databases.json").write_text(json.dumps(names))
with pytest.raises(ValueError):
_names(tmp_path)
@@ -0,0 +1,63 @@
"""Queue lifecycle SQL contracts; real races are covered by the opt-in PG suite."""
import asyncio
from contextlib import asynccontextmanager
import pytest
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
from app.mcp.errors import CalculationCapacityExceeded
class Connection:
def __init__(self, rows=()):
self.rows = iter(rows)
self.statements = []
self.in_transaction = False
@asynccontextmanager
async def transaction(self):
self.in_transaction = True
try:
yield
finally:
self.in_transaction = False
async def execute(self, sql, params):
self.statements.append((sql, params, self.in_transaction))
row = next(self.rows, None) if sql.lstrip().startswith(('SELECT job_id', 'SELECT count', 'INSERT')) else None
class Cursor:
async def fetchone(self):
return row
async def fetchall(self):
return []
return Cursor()
def test_status_projection_excludes_result_and_expires_without_worker():
connection = Connection()
repo = CalculationJobRepository(connection, 'project')
asyncio.run(repo.get('job'))
assert 'QUEUE_TIMEOUT' in connection.statements[0][0]
assert 'SET result=NULL' in connection.statements[1][0]
assert ',result FROM' not in connection.statements[2][0]
asyncio.run(repo.get('job', include_result=True))
assert ',result FROM' in connection.statements[-1][0]
def test_duplicate_submission_bypasses_full_queue_inside_transaction():
existing = {'tool': 'tool', 'parameters': {'timeout_seconds': 3600}, 'binding': {}, 'job_id': 'existing'}
connection = Connection([existing])
row = asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
assert row['job_id'] == 'existing'
assert 'pg_advisory_xact_lock' in connection.statements[0][0]
assert all(statement[2] for statement in connection.statements)
assert not any('count(*)' in sql for sql, _, _ in connection.statements)
def test_capacity_rejects_new_job_before_insert():
connection = Connection([None, {'active': 100}])
with pytest.raises(CalculationCapacityExceeded):
asyncio.run(CalculationJobRepository(connection, 'project').enqueue(
owner_user_id='owner', request_key='key', tool='tool', parameters={}, binding={}, timeout_seconds=30))
assert not any('INSERT' in sql for sql, _, _ in connection.statements)
+241
View File
@@ -0,0 +1,241 @@
"""Worker boundaries use real managed subprocesses, without external databases."""
import asyncio
from functools import wraps
from types import SimpleNamespace
from contextlib import asynccontextmanager
import os
from pathlib import Path
import signal
import sys
import pytest
from app.services.calculation_worker import CalculationWorker, WorkerSettings, stop_process_group
@pytest.fixture(autouse=True)
def no_external_cleanup(monkeypatch):
async def cleanup(*args):
pass
monkeypatch.setattr('app.services.calculation_worker.cleanup_job_resources', cleanup)
def run_async(function):
@wraps(function)
def wrapper(*args, **kwargs):
return asyncio.run(function(*args, **kwargs))
return wrapper
def test_worker_settings_require_explicit_valid_scope(monkeypatch):
monkeypatch.setenv('CALCULATION_WORKER_PROJECT_IDS', '6b39b128-2ba8-454f-9bbe-e9553902ab53')
settings = WorkerSettings.from_environment()
assert settings.max_concurrency == 2
monkeypatch.setenv('CALCULATION_WORKER_HEARTBEAT_SECONDS', '30')
with pytest.raises(ValueError):
WorkerSettings.from_environment()
@run_async
async def test_process_group_cleanup_kills_native_style_descendant(tmp_path):
pid_file = tmp_path / 'pid'
code = ('import subprocess,time,pathlib,sys; '
'p=subprocess.Popen([sys.executable,"-c","import time; time.sleep(60)"]); '
'pathlib.Path(sys.argv[1]).write_text(str(p.pid)); time.sleep(60)')
process = await asyncio.create_subprocess_exec(sys.executable, '-c', code,
str(pid_file), start_new_session=True)
try:
for _ in range(100):
if pid_file.exists():
break
await asyncio.sleep(.02)
child_pid = int(pid_file.read_text())
await stop_process_group(process)
assert process.returncode is not None
# Linux may retain a dead grandchild as a zombie until PID 1 reaps it.
stat_path = Path(f'/proc/{child_pid}/stat')
for _ in range(100):
if not stat_path.exists() or stat_path.read_text().split()[2] == 'Z':
break
await asyncio.sleep(.01)
assert not stat_path.exists() or stat_path.read_text().split()[2] == 'Z'
finally:
await stop_process_group(process)
@run_async
@pytest.mark.parametrize('lease_status, expected', [('cancel_requested', 'cancelled'), (None, None)])
async def test_cancel_and_lost_lease_stop_process_before_persisting(monkeypatch, lease_status, expected):
processes = []
original = asyncio.create_subprocess_exec
async def spawn(*args, **kwargs):
process = await original(sys.executable, '-c', 'import time; time.sleep(60)',
start_new_session=True)
processes.append(process)
return process
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
finishes = []
class Repository:
async def heartbeat(self, *args):
return lease_status
async def finish(self, row, owner, status, **kwargs):
assert processes[0].returncode is not None
finishes.append(status)
return True
@asynccontextmanager
async def repository(project):
yield Repository()
worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.05), repository=repository)
await worker.run_job({'project_id': 'project', 'job_id': 'job', 'timeout_seconds': 60})
assert finishes == ([expected] if expected else [])
assert processes[0].returncode is not None
@run_async
async def test_timeout_stops_process_before_recording(monkeypatch):
original = asyncio.create_subprocess_exec
processes = []
async def spawn(*args, **kwargs):
p = await original(sys.executable, '-c', 'import time; time.sleep(60)', start_new_session=True)
processes.append(p)
return p
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
statuses = []
class Repository:
async def heartbeat(self, *args):
return 'running'
async def finish(self, row, owner, status, **kwargs):
assert processes[0].returncode is not None
statuses.append(status)
return True
@asynccontextmanager
async def repository(project):
yield Repository()
worker = CalculationWorker(WorkerSettings(('project',), heartbeat_seconds=.02), repository=repository)
await worker.run_job({'project_id': 'project', 'timeout_seconds': 0})
assert statuses == ['timed_out']
@run_async
async def test_duplicate_supervisor_never_claims(monkeypatch):
from app.infra.db.metadb import database
class Lock:
async def scalar(self, statement):
assert 'pg_try_advisory_lock' in str(statement)
return False
async def commit(self):
pass
@asynccontextmanager
async def connection():
yield Lock()
monkeypatch.setattr(database, 'engine', SimpleNamespace(connect=connection))
@asynccontextmanager
async def repository(project):
pytest.fail('A duplicate supervisor must never open the queue')
yield
with pytest.raises(RuntimeError, match='already active'):
await CalculationWorker(WorkerSettings(('project',)), repository=repository).run()
@run_async
async def test_repository_terminal_updates_are_fenced():
from app.infra.db.postgresql.calculation_jobs import CalculationJobRepository
statements = []
class Cursor:
async def fetchone(self):
return None
class Connection:
async def execute(self, sql, parameters):
statements.append((sql, parameters))
return Cursor()
repository = CalculationJobRepository(Connection(), 'project')
row = {'job_id': 'job', 'lease_generation': 7}
assert await repository.finish(row, 'owner', 'succeeded', result={'original': True}) is False
sql, parameters = statements[-1]
assert 'lease_generation=%s' in sql and 'lease_owner=%s' in sql
assert 'lease_expires_at>clock_timestamp()' in sql
assert "status!='cancel_requested'" in sql
assert 7 in parameters
@run_async
async def test_guard_parent_death_pipe_kills_entire_group(tmp_path):
pid_file = tmp_path / 'guard-child'
child = 'import os,time,pathlib,sys; pathlib.Path(sys.argv[1]).write_text(str(os.getpid())); time.sleep(60)'
launcher = '''import asyncio,sys
from app.services.calculation_worker import guard_child
original = asyncio.create_subprocess_exec
async def spawn(*args, **kwargs):
return await original(sys.executable, '-c', sys.argv[2], sys.argv[1])
asyncio.create_subprocess_exec = spawn
asyncio.run(guard_child('unused','unused'))
'''
guard = await asyncio.create_subprocess_exec(sys.executable, '-c', launcher,
str(pid_file), child, stdin=asyncio.subprocess.PIPE, start_new_session=True)
try:
for _ in range(100):
if pid_file.exists():
break
await asyncio.sleep(.02)
child_pid = int(pid_file.read_text())
guard.stdin.close()
await asyncio.wait_for(guard.wait(), 3)
assert guard.returncode == -signal.SIGKILL
stat = Path(f'/proc/{child_pid}/stat')
for _ in range(100):
if not stat.exists() or stat.read_text().split()[2] == 'Z':
break
await asyncio.sleep(.01)
assert not stat.exists() or stat.read_text().split()[2] == 'Z'
finally:
await stop_process_group(guard)
@run_async
async def test_cleanup_failure_is_not_reported_cancelled(monkeypatch, tmp_path):
monkeypatch.setenv('CALCULATION_WORKER_STATE_DIR', str(tmp_path))
async def cleanup(row, directory):
(Path(directory) / 'cleanup-failed').touch()
raise RuntimeError('external resource unavailable')
monkeypatch.setattr('app.services.calculation_worker.cleanup_job_resources', cleanup)
original = asyncio.create_subprocess_exec
async def spawn(*args, **kwargs):
return await original(sys.executable, '-c', 'import time; time.sleep(60)', start_new_session=True)
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
results = []
class Repository:
async def heartbeat(self, *args):
return 'cancel_requested'
async def finish(self, row, owner, status, **kwargs):
results.append((status, kwargs['error']['code']))
return True
@asynccontextmanager
async def repository(project):
yield Repository()
worker = CalculationWorker(WorkerSettings(('project',)), repository=repository)
await worker.run_job({'project_id': 'project', 'timeout_seconds': 60})
assert results == [('failed', 'CLEANUP_FAILED')]
assert len(list(tmp_path.glob('*/cleanup-failed'))) == 1
@run_async
async def test_startup_recovery_checks_persisted_generation(monkeypatch, tmp_path):
import json
from app.services.calculation_worker import recover_job_resources
project = '6b39b128-2ba8-454f-9bbe-e9553902ab53'
job = '2d3a6231-a8ee-4acb-bb13-58328d29b88f'
directory = tmp_path / (job + '-owned')
directory.mkdir()
(directory / 'scope.json').write_text(json.dumps({
'project_id': project, 'job_id': job, 'lease_generation': 1}))
monkeypatch.setenv('CALCULATION_WORKER_STATE_DIR', str(tmp_path))
class Repository:
async def get(self, job_id):
return {'lease_generation': 2}
@asynccontextmanager
async def repository(project):
yield Repository()
monkeypatch.setattr('app.services.calculation_worker.queue_repository', repository)
with pytest.raises(RuntimeError, match='ownership'):
await recover_job_resources((project,))
assert directory.exists()
+73
View File
@@ -0,0 +1,73 @@
import pytest
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
from app.core.config import Settings
ALLOWED_ORIGINS = [
"https://next-demo.waternetwork.cn",
"http://192.168.1.133",
"https://192.168.1.133",
"http://192.168.1.110",
"https://192.168.1.110",
]
@pytest.mark.parametrize("origins", ["*", " *, http://localhost:5173", "http://localhost:5173,*"])
def test_wildcard_origins_are_rejected(origins: str):
with pytest.raises(ValueError, match="must list explicit origins"):
Settings(_env_file=None, CORS_ALLOWED_ORIGINS=origins).cors_allowed_origins
@pytest.mark.parametrize(
"origin",
ALLOWED_ORIGINS + [
"http://192.168.1.114",
"http://192.168.1.133:5173",
"https://demo.waternetwork.cn",
"http://192.168.1.133.evil.example",
"null",
],
)
def test_explicit_origin_preflight(origin: str):
settings = Settings(_env_file=None, CORS_ALLOWED_ORIGINS=", ".join(ALLOWED_ORIGINS))
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
with TestClient(app) as client:
response = client.options(
"/api/v1/projects",
headers={
"Origin": origin,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,x-project-id",
},
)
allowed = origin in ALLOWED_ORIGINS
assert response.status_code == (200 if allowed else 400)
assert response.headers.get("access-control-allow-origin") == (origin if allowed else None)
if allowed:
assert response.headers["access-control-allow-credentials"] == "true"
assert "authorization" in response.headers["access-control-allow-headers"]
def test_default_origins_are_explicit_local_sources(monkeypatch):
monkeypatch.delenv("CORS_ALLOWED_ORIGINS", raising=False)
settings = Settings(_env_file=None)
assert settings.cors_allowed_origins == [
"http://127.0.0.1:5173", "http://localhost:5173",
"http://127.0.0.1:15173", "http://localhost:15173",
]
def test_environment_controls_origins(monkeypatch):
monkeypatch.setenv("CORS_ALLOWED_ORIGINS", "http://192.168.1.133,https://192.168.1.110")
settings = Settings(_env_file=None)
assert settings.cors_allowed_origins == ["http://192.168.1.133", "https://192.168.1.110"]
@@ -0,0 +1,94 @@
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.domain.schemas.operational_task import (
AgentArtifactsCommand,
InvestigationDiagramNodeRef,
InvestigationProgress,
InvestigationReportInput,
)
def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
diagram = {
"title": "等待系统结果",
"mermaid": "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET",
"node_refs": [
{"node_id": "DONE", "label": "查询完成", "status": "completed"},
{"node_id": "WAIT", "label": "等待系统结果", "status": "waiting"},
{"node_id": "UNKNOWN", "label": "未知", "status": None},
{"node_id": "UNSET", "label": "未确认"},
],
}
progress = InvestigationProgress(summary="等待系统结果", diagram=diagram)
report = InvestigationReportInput(
title="报告", current_conclusion="已确认当前结果", conclusion_boundary="仅限查询范围",
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
)
assert report.diagram == progress.diagram
assert [node.status for node in report.diagram.node_refs] == ["completed", "waiting", None, None]
assert "status" not in report.diagram.node_refs[-1].model_fields_set
def test_unknown_node_state_is_rejected():
with pytest.raises(ValidationError):
InvestigationDiagramNodeRef(node_id="A", label="查询", status="blocked")
def test_class_attached_nodes_are_valid_declarations_without_accepting_partial_ids():
payload = {"request_id": uuid4(), "source_session_id": "session-1", "progress": {
"summary": "核查", "diagram": {"title": "路线", "mermaid": "flowchart TD\nQUERY:::running --> REVIEW:::pending",
"node_refs": [{"node_id": "QUERY", "label": "核查", "status": "running"},
{"node_id": "REVIEW", "label": "复核", "status": "pending"}]},
}}
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "running"
payload["progress"]["diagram"]["node_refs"][0]["node_id"] = "QUER"
with pytest.raises(ValidationError, match="QUER"):
AgentArtifactsCommand.model_validate(payload)
@pytest.mark.parametrize("artifact", ["progress", "report"])
def test_artifact_write_rejects_node_refs_missing_from_mermaid(artifact):
diagram = {
"title": "当前路线",
"mermaid": "flowchart TD\nA[绑定任务] --> B[核查证据]",
"node_refs": [
{"node_id": "ctx", "label": "绑定任务", "status": "completed"},
{"node_id": "B", "label": "核查证据", "status": "running"},
],
}
payload = (
{"summary": "正在核查", "diagram": diagram}
if artifact == "progress"
else {
"title": "调查报告",
"current_conclusion": "已确认当前范围",
"conclusion_boundary": "仅限已核查资料",
"stage": "final",
"outcome": "concluded",
"data_gaps": [],
"diagram": diagram,
}
)
with pytest.raises(ValidationError, match="ctx"):
AgentArtifactsCommand.model_validate({
"request_id": uuid4(),
"source_session_id": "session-1",
artifact: payload,
})
def test_historical_progress_keeps_read_compatibility_for_undeclared_node_refs():
progress = InvestigationProgress.model_validate({
"summary": "旧进度",
"diagram": {
"title": "旧图",
"mermaid": "flowchart TD\nA[绑定任务]",
"node_refs": [{"node_id": "ctx", "label": "绑定任务", "status": "completed"}],
},
})
assert progress.diagram.node_refs[0].node_id == "ctx"
+26 -6
View File
@@ -35,6 +35,7 @@ INPUTS = {
"run_burst_detection": {"target_time": START, "sensor_nodes": ["1", "2", "3", "4", "5"]},
"run_burst_location": {"data_source": "simulation", "simulation_run_id": str(uuid4()), "burst_leakage": 0.02, "pressure_scada_ids": ["1"], "scada_burst_start": START, "scada_burst_end": END},
"analyze_valve_isolation": {"accident_element": ["540672"]},
"generate_node_coverage": {"groups": [{"group_id": "monitoring", "title": "监测节点分布", "node_ids": ["J1"]}]},
"run_leakage_identification": {"scada_start": START, "scada_end": END, "sensor_nodes": ["1"], "q_sum": 0.02, "start_time": 12, "dma_count": 1},
}
@@ -129,7 +130,7 @@ def test_missing_timescale_route_stops_online_simulation_before_model_changes(mo
calculate.assert_not_awaited()
@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement"])
@pytest.mark.parametrize("name", ["analyze_valve_isolation", "optimize_sensor_placement", "generate_node_coverage"])
def test_business_only_calculations_do_not_require_timescale(monkeypatch, routed_calculation_scope, name):
scope, _, events = routed_calculation_scope
@@ -192,7 +193,7 @@ def test_catalog_and_snapshots_accept_calculations_but_reject_model_owned_author
@pytest.mark.parametrize("name", INPUTS)
def test_calculation_permission_and_reader_boundary(monkeypatch, name):
permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run"}
permissions = {"burst.run", "simulation.run", "simulation.view", "scada.view", "optimization.run", "webgis.view"}
monkeypatch.setattr(query_service, "permissions_for_context", lambda _: permissions)
scope = SimpleNamespace(project=SimpleNamespace(project_role="member"))
binding = SimpleNamespace(tool_cap=list(TOOLS), purpose="task_execution", permission_cap=list(permissions), task_id=uuid4(), task_scope_cap=["scada_readings"])
@@ -218,7 +219,7 @@ def test_time_sources_and_finite_inputs_are_explicit():
("run_simulation", {"duration": 0}),
("run_simulation", {"duration": 86401}),
("run_simulation", {"timeout_seconds": 0}),
("run_simulation", {"timeout_seconds": 86401}),
("run_simulation", {"timeout_seconds": 241}),
("run_simulation", {"timeout_seconds": 30.5}),
("run_simulation", {"timeout_seconds": True}),
("run_simulation", {"output_node_ids": []}),
@@ -316,9 +317,9 @@ def test_burst_location_optional_inputs_still_reject_partial_or_invalid_values(o
@pytest.mark.parametrize("name", INPUTS)
def test_calculations_accept_agent_execution_budget_separately_from_duration(name):
args = arguments(name, timeout_seconds=900)
assert args.timeout_seconds == 900
def test_calculations_accept_http_response_budget_separately_from_duration(name):
args = arguments(name, timeout_seconds=180)
assert args.timeout_seconds == 180
if hasattr(args, "duration"):
assert args.duration == INPUTS[name].get("duration", 900)
@@ -350,6 +351,25 @@ def test_adapter_uses_existing_service_without_scheme_or_shared_output(monkeypat
assert call["n_workers"] == 1
@pytest.mark.parametrize("element_type", ["auto", "link", "node"])
def test_isolation_adapter_preserves_explicit_element_type(monkeypatch, element_type):
calls = []
monkeypatch.setattr(
"app.services.valve_isolation.analyze_valve_isolation",
lambda **kwargs: calls.append(kwargs) or {"isolatable": True},
)
args = arguments("analyze_valve_isolation", accident_element_type=element_type)
asyncio.run(analysis_service.calculate(
"analyze_valve_isolation", args, network="trusted-project", username="actor",
))
assert calls[0]["accident_element_type"] == element_type
assert calls[0]["network"] == "trusted-project"
with pytest.raises(ValidationError):
arguments("analyze_valve_isolation", accident_element_type="unknown")
@pytest.mark.parametrize("name", ["run_burst_location", "run_simulation"])
def test_dispatch_ends_read_transaction_and_preserves_analysis_provenance(monkeypatch, name):
order = []
+206
View File
@@ -0,0 +1,206 @@
import asyncio
from datetime import datetime, timezone
from uuid import uuid4
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.mcp.models import BoundContext
from app.mcp.calculation_jobs import durable_binding, restore_binding, check_binding, should_queue, QUEUED_TOOLS
def binding():
return BoundContext(context_handle='secret-handle', project_id=uuid4(), subject='user', client_id='host', native_session_id='session', purpose='ordinary_chat', expires_at=datetime.now(timezone.utc))
def test_durable_binding_never_stores_expiring_secret_handle():
original = binding()
safe = durable_binding(original)
assert 'context_handle' not in safe
assert 'expires_at' not in safe
assert restore_binding(safe).subject == original.subject
def test_original_session_boundary_is_enforced():
original = binding()
row = {'binding': durable_binding(original)}
check_binding(original, row)
original.native_session_id = 'other'
with pytest.raises(HTTPException) as error:
check_binding(original, row)
assert error.value.status_code == 403
def test_only_executed_expensive_mutations_are_queued():
tool = SimpleNamespace(name='save_simulation_scenario', mutating=True)
assert not should_queue(tool, SimpleNamespace(dry_run=True))
assert should_queue(tool, SimpleNamespace(dry_run=False))
assert 'analyze_valve_isolation' not in QUEUED_TOOLS
assert 'calculate_demand_allocation' not in QUEUED_TOOLS
assert len(QUEUED_TOOLS) == 12
def test_result_pages_keep_original_semantics_and_provenance(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
from app.mcp.models import QueryResult
original = binding()
result = QueryResult(source='run_burst_location', source_ref='calculation:one', queried_at=datetime.now(timezone.utc), semantics='analysis', data={'result': {'items': list(range(10))}}, evidence_id='evidence-one')
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded', 'result': result.model_dump(mode='json')}
repo = SimpleNamespace(get=AsyncMock(return_value=row))
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
monkeypatch.setattr(jobs, 'attach_calculation_evidence', AsyncMock(side_effect=lambda scope, row, result: result))
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
args = CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'], data_path=['result', 'items'], limit=3, offset=2)
page = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), args))
assert page.data == [2, 3, 4]
assert page.semantics == 'analysis'
assert page.source == 'run_burst_location'
assert page.evidence_id == 'evidence-one'
assert page.truncated and page.page.has_more and page.page.total == 10
def test_pending_result_is_task_receipt_without_evidence(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
original = binding()
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'running', 'tool': 'run_simulation'}
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: SimpleNamespace(get=AsyncMock(return_value=row)))
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
attach = AsyncMock()
monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach)
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
result = asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'), CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])))
assert result.semantics == 'task' and result.evidence_id is None
assert result.data['status'] == 'running'
attach.assert_not_awaited()
def test_mutation_submission_cannot_bypass_existing_approval_receipt(monkeypatch):
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service
from app.mcp.catalog import TOOLS
from app.mcp.extended_models import SensorCreateInput
from app.mcp.mutation_service import mutation_arguments
from app.infra.db.postgresql import mcp_queries
args = SensorCreateInput(context_handle='x' * 32, request_id=uuid4(), dry_run=False, expected_digest='a' * 64, name='approved', method='kmeans', sensor_count=2)
@asynccontextmanager
async def transaction():
yield
previous = {'parameters': {'tool': 'create_sensor_placement', 'arguments': mutation_arguments(args, preview=False)}, 'result': {'status': 'started'}}
receipt = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=previous))
monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt)
monkeypatch.setattr(query_service, 'authorize_query', AsyncMock())
queue = AsyncMock()
monkeypatch.setattr(jobs, 'CalculationJobRepository', queue)
scope = SimpleNamespace(connection=SimpleNamespace(transaction=transaction), project=SimpleNamespace(project_id=uuid4(), user_id=uuid4()))
with pytest.raises(HTTPException) as error:
asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], args, 'host-call'))
assert error.value.status_code == 409
assert 'already started' in str(error.value.detail)
queue.assert_not_called()
changed = args.model_copy(update={'name': 'changed'})
with pytest.raises(HTTPException) as conflict:
asyncio.run(jobs.submit_calculation(scope, binding(), TOOLS['create_sensor_placement'], changed, 'host-call'))
assert conflict.value.detail == 'request_id already has different parameters'
queue.assert_not_called()
def test_evidence_receipt_commits_in_root_operational_transaction(monkeypatch):
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service, contexts
from app.infra.db.postgresql import mcp_queries
from app.mcp.models import QueryResult
original = binding().model_copy(update={'task_id': uuid4(), 'run_id': uuid4()})
result = QueryResult(source='run_simulation', source_ref='original', queried_at=datetime.now(timezone.utc), semantics='analysis', data={})
events = []
async def commit():
events.append('commit-reads')
@asynccontextmanager
async def transaction():
events.append('begin-root-operational')
yield
events.append('commit-evidence')
task_repo = SimpleNamespace(transaction=transaction, lock=AsyncMock())
receipt_repo = SimpleNamespace(lock_receipt=AsyncMock(), receipt=AsyncMock(return_value=None), save_receipt=AsyncMock())
service = SimpleNamespace(repo=task_repo, record_data_snapshot=AsyncMock(return_value=(SimpleNamespace(evidence_id='saved'), None)))
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock(return_value=(original, SimpleNamespace(name='run_simulation'), None)))
monkeypatch.setattr(contexts, 'verify_task', AsyncMock())
monkeypatch.setattr(query_service, 'task_service', lambda scope: service)
monkeypatch.setattr(mcp_queries, 'McpQueryRepository', lambda *a: receipt_repo)
scope = SimpleNamespace(connection=SimpleNamespace(commit=commit), project=SimpleNamespace(project_id=original.project_id))
attached = asyncio.run(jobs.attach_calculation_evidence(scope, {'job_id': uuid4()}, result))
assert attached.evidence_id == 'saved'
assert events == ['commit-reads', 'begin-root-operational', 'commit-evidence']
receipt_repo.save_receipt.assert_awaited_once()
@pytest.mark.parametrize('job_budget', [3600, 7200])
def test_http_timeout_does_not_limit_queued_execution(monkeypatch, job_budget):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service
from app.mcp.catalog import TOOLS
tool = TOOLS['run_simulation']
args = tool.model.model_validate({
'context_handle': 'x' * 32, 'timeout_seconds': 1,
'start_time': '2026-10-08T12:00:00+08:00', 'duration': 3600,
'output_node_ids': ['J1'],
})
repo = SimpleNamespace(enqueue=AsyncMock(return_value={
'job_id': uuid4(), 'status': 'queued', 'tool': tool.name,
}))
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
monkeypatch.setattr(query_service, 'authorize_query', AsyncMock())
scope = SimpleNamespace(connection=SimpleNamespace(commit=AsyncMock()),
project=SimpleNamespace(project_id=uuid4(), user_id=uuid4()))
result = asyncio.run(jobs.submit_calculation(
scope, binding(), tool, args, 'host-call', job_timeout_seconds=job_budget,
))
assert result.data['status'] == 'queued'
assert repo.enqueue.await_args.kwargs['timeout_seconds'] == job_budget
assert 'timeout_seconds' not in repo.enqueue.await_args.kwargs['parameters']
def test_expired_result_is_safe_explicit_error(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs
from app.mcp.calculation_models import CalculationResultInput
original = binding()
row = {'job_id': uuid4(), 'binding': durable_binding(original), 'status': 'succeeded',
'result': None, 'result_expires_at': datetime(2020, 1, 1, tzinfo=timezone.utc)}
repo = SimpleNamespace(get=AsyncMock(return_value=row))
monkeypatch.setattr(jobs, 'CalculationJobRepository', lambda *a: repo)
monkeypatch.setattr(jobs, 'authorize_original', AsyncMock())
attach = AsyncMock()
monkeypatch.setattr(jobs, 'attach_calculation_evidence', attach)
scope = SimpleNamespace(connection=None, project=SimpleNamespace(project_id=original.project_id, user_id=uuid4()))
with pytest.raises(HTTPException) as error:
asyncio.run(jobs.read_calculation_job(scope, original, SimpleNamespace(name='get_calculation_result'),
CalculationResultInput(context_handle='x' * 32, job_id=row['job_id'])))
assert error.value.status_code == 410
assert jobs.public_job(row)['result_available'] is False
assert repo.get.await_args.kwargs['include_result'] is True
attach.assert_not_awaited()
def test_legacy_execution_timeout_does_not_block_owner_authorization(monkeypatch):
from unittest.mock import AsyncMock
from app.mcp import calculation_jobs as jobs, query_service
from app.mcp.catalog import TOOLS
original = binding()
owner = uuid4()
row = {'binding': durable_binding(original), 'tool': 'run_simulation',
'owner_user_id': owner, 'parameters': {'timeout_seconds': 3600,
'start_time': '2026-10-29T00:00:00Z', 'output_node_ids': ['1']}}
authorize = AsyncMock()
monkeypatch.setattr(query_service, 'authorize_query', authorize)
scope = SimpleNamespace(project=SimpleNamespace(project_id=original.project_id, user_id=owner))
_, tool, args = asyncio.run(jobs.authorize_original(scope, row))
assert tool is TOOLS['run_simulation']
assert args.timeout_seconds <= 240
assert row['parameters']['timeout_seconds'] == 3600
authorize.assert_awaited_once()
+8 -2
View File
@@ -23,9 +23,15 @@ def test_explicit_private_development_issuer():
assert local_settings().issuer_uri == ISSUER
def test_calculation_limit_covers_default_without_extending_ordinary_queries():
def test_execution_limit_is_separate_from_http_request_budget():
config = local_settings(max_calculation_timeout_seconds=120)
assert config.query_timeout_seconds == 30
assert config.query_timeout_seconds == 120
assert config.max_request_timeout_seconds == 240
assert local_settings().max_calculation_timeout_seconds == 3600
with pytest.raises(ValidationError):
local_settings(query_timeout_seconds=241)
with pytest.raises(ValidationError):
local_settings(max_request_timeout_seconds=241)
with pytest.raises(ValidationError):
local_settings(max_calculation_timeout_seconds=119)
+1 -1
View File
@@ -292,7 +292,7 @@ def test_artifacts_are_integrity_checked_bounded_binary_envelopes():
def test_no_health_prediction_tool_and_every_write_requests_native_permission():
assert not any("health" in name for name in TOOLS)
assert len(TOOLS) == 67
assert len(TOOLS) == 71
assert sum(t.mutating for t in TOOLS.values()) == 13
for tool in TOOLS.values():
if tool.mutating:
@@ -0,0 +1,36 @@
from datetime import datetime, timezone
from uuid import uuid4
from app.domain.schemas.operational_task import TaskPresentationEventCommand
from app.mcp.models import QueryResult
from app.mcp.presentation_preview import saved_presentation_preview
def test_abbreviated_coverage_keeps_publishable_command_without_copying_geometry():
result = QueryResult(
source="generate_node_coverage", source_ref="mcp:analysis", evidence_id="mcp:analysis",
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
data={"result": {"map_payload": {"type": "FeatureCollection", "features": [{"large": "geometry"}]},
"coverage": {"status": "partial", "groups": {"isolation": {
"node_ids": ["A", "B"], "mapped_node_count": 1, "missing_node_ids": ["B"],
}}}}},
)
hint = saved_presentation_preview(result)
assert hint == saved_presentation_preview(result)
assert hint["groups"]["isolation"] == {"node_count": 2, "mapped_node_count": 1, "missing_node_count": 1}
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=hint["command"],
)
assert command.command.evidence_id == result.evidence_id and command.command.data is None
assert "geometry" not in str(hint["groups"]) and result.data["result"]["map_payload"]["features"]
result.evidence_id = None
assert saved_presentation_preview(result) is None
def test_missing_geometry_does_not_offer_a_broken_overlay_command():
result = QueryResult(
source="generate_node_coverage", source_ref="mcp:analysis", evidence_id="mcp:analysis",
queried_at=datetime.now(timezone.utc), semantics="analysis", origin="system_data", truncated=False,
data={"result": {"coverage": {"status": "unavailable"}}},
)
assert saved_presentation_preview(result) is None
+110 -49
View File
@@ -37,36 +37,28 @@ PERMISSIONS = {
}
def test_calculation_budget_outlives_query_default_and_still_times_out(runtime, monkeypatch):
def test_calculation_submission_returns_receipt_without_running_native_code(runtime, monkeypatch):
from app.mcp import calculation_jobs
app, client, _, _ = runtime
app.settings.query_timeout_seconds = 1
monkeypatch.setattr(application, "authorize_query", AsyncMock())
async def execute(scope, bound, tool, args):
await asyncio.sleep(1.05)
return QueryResult(
source=tool.name, source_ref="test:calculation", semantics="analysis",
queried_at=datetime.now(timezone.utc), data={"completed": True},
)
app.executor = execute
handle = binding().context_handle
arguments = {
"context_handle": handle, "start_time": "2026-10-08T12:00:00+08:00",
receipt = calculation_jobs.envelope("run_simulation", {"job_id": str(uuid4()), "status": "queued"})
submit = AsyncMock(return_value=receipt)
monkeypatch.setattr(calculation_jobs, "submit_calculation", submit)
app.executor = AsyncMock(side_effect=AssertionError("native work entered API process"))
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"], "timeout_seconds": 2,
}
response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json()
assert response["result"].get("isError") is not True
assert response["result"]["structuredContent"]["data"] == {"completed": True}
arguments["timeout_seconds"] = 1
response = request(client, "tools/call", {"name": "run_simulation", "arguments": arguments}).json()
assert response["result"]["isError"] is True
assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_TIMEOUT"
}}).json()
assert response["result"]["structuredContent"]["data"]["status"] == "queued"
assert response["result"]["structuredContent"]["semantics"] == "task"
app.executor.assert_not_awaited()
assert submit.await_args.args[3].timeout_seconds == 2
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
app, client, calls, _ = runtime
app.settings.max_calculation_timeout_seconds = 120
app.settings.max_request_timeout_seconds = 120
tools = request(client).json()["result"]["tools"]
schema = next(tool["inputSchema"] for tool in tools if tool["name"] == "run_simulation")
assert schema["properties"]["timeout_seconds"]["maximum"] == 120
@@ -80,45 +72,47 @@ def test_calculation_limit_is_advertised_and_enforced_before_execution(runtime):
assert calls == []
def test_calculation_capacity_has_an_explicit_mcp_error(runtime, monkeypatch):
from threading import BoundedSemaphore
from app.mcp import analysis_service
app, client, _, _ = runtime
def test_queue_submission_errors_preserve_mcp_error_mapping(runtime, monkeypatch):
from app.mcp import calculation_jobs
_, client, _, _ = runtime
monkeypatch.setattr(application, "authorize_query", AsyncMock())
slots = BoundedSemaphore(2)
monkeypatch.setattr(analysis_service, "_slots", slots)
async def execute(*unused):
return await analysis_service.bounded_calculation(lambda: pytest.fail("occupied calculation entered"))
app.executor = execute
assert slots.acquire(blocking=False) and slots.acquire(blocking=False)
try:
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
finally:
slots.release()
slots.release()
error = response["result"]["structuredContent"]["error"]
assert response["result"]["isError"] is True
assert error["code"] == "CALCULATION_CAPACITY_EXCEEDED"
assert "not started" in error["message"]
monkeypatch.setattr(calculation_jobs, "submit_calculation", AsyncMock(side_effect=HTTPException(409, "Calculation request_key was used with different inputs")))
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
assert response["result"]["structuredContent"]["error"]["code"] == "QUERY_CONTEXT_STALE"
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch):
def test_expired_calculation_result_has_distinct_mcp_error(runtime):
app, client, _, _ = runtime
app.executor = AsyncMock(side_effect=HTTPException(410, "Calculation result retention period has expired"))
response = request(client, "tools/call", {"name": "get_calculation_result", "arguments": {
"context_handle": binding().context_handle, "job_id": str(uuid4()),
}}).json()
assert response["result"]["structuredContent"]["error"]["code"] == "RESULT_EXPIRED"
@pytest.mark.parametrize("maximum,expected", [(240, 120), (120, 120)])
def test_burst_location_default_inputs_pass_protocol_validation(runtime, monkeypatch, maximum, expected):
app, client, calls, _ = runtime
app.settings.max_request_timeout_seconds = maximum
from app.mcp import calculation_jobs
executor = AsyncMock(return_value=calculation_jobs.envelope("run_burst_location", {"job_id": str(uuid4()), "status": "queued"}))
monkeypatch.setattr(calculation_jobs, "submit_calculation", executor)
monkeypatch.setattr(application, "authorize_query", AsyncMock())
schema = next(tool["inputSchema"] for tool in request(client).json()["result"]["tools"]
if tool["name"] == "run_burst_location")
assert "pressure_scada_ids" not in schema["required"]
assert schema["properties"]["timeout_seconds"]["default"] == expected
response = request(client, "tools/call", {"name": "run_burst_location", "arguments": {
"context_handle": binding().context_handle, "burst_leakage": 0.0274,
"scada_burst_start": "2026-10-08T12:00:00+08:00",
"scada_burst_end": "2026-10-08T13:00:00+08:00",
}}).json()
assert response["result"].get("isError") is not True
assert calls == [("session-1", "run_burst_location")]
assert calls == []
assert executor.await_args.args[3].timeout_seconds == expected
def settings():
@@ -306,6 +300,37 @@ def binding(task=False):
return value
@pytest.mark.parametrize("seconds", [30, 120, 240])
def test_all_tools_advertise_the_effective_host_request_budget(runtime, seconds):
app, client, _, _ = runtime
app.settings.query_timeout_seconds = seconds
for tool in request(client).json()["result"]["tools"]:
assert tool["_meta"]["io.tjwater/request-timeout"] == {
"defaultSeconds": seconds, "maxSeconds": 240,
}
timeout = tool["inputSchema"]["properties"].get("timeout_seconds")
if timeout:
assert timeout["default"] == seconds
def test_omitted_calculation_http_budget_uses_server_configuration(runtime, monkeypatch):
from app.mcp import calculation_jobs
app, client, _, _ = runtime
app.settings.query_timeout_seconds = 240
monkeypatch.setattr(application, "authorize_query", AsyncMock())
submit = AsyncMock(return_value=calculation_jobs.envelope(
"run_simulation", {"job_id": str(uuid4()), "status": "queued"},
))
monkeypatch.setattr(calculation_jobs, "submit_calculation", submit)
response = request(client, "tools/call", {"name": "run_simulation", "arguments": {
"context_handle": binding().context_handle, "start_time": "2026-10-08T12:00:00+08:00",
"duration": 3600, "output_node_ids": ["J1"],
}}).json()
assert response["result"].get("isError") is not True
assert submit.await_args.args[3].timeout_seconds == 240
assert submit.await_args.kwargs["job_timeout_seconds"] == 3600
def test_modern_discovery_catalog_and_metadata(runtime):
_, client, _, _ = runtime
result = request(client, "server/discover").json()
@@ -318,7 +343,7 @@ def test_modern_discovery_catalog_and_metadata(runtime):
assert (timeout is not None) == (TOOLS[tool["name"]].computation or TOOLS[tool["name"]].mutating)
if timeout:
assert timeout["default"] == 120
assert timeout["maximum"] == 3600
assert timeout["maximum"] == 240
assert listed["ttlMs"] == 300000 and listed["cacheScope"] == "public"
assert all(
tool["inputSchema"]["additionalProperties"] is False for tool in listed["tools"]
@@ -518,6 +543,42 @@ def test_evidence_is_saved_before_return_and_call_conflicts_fail(runtime, monkey
assert len(saved) == 1
def test_large_node_coverage_retains_publishable_hint_and_saves_complete_geometry(runtime, monkeypatch):
app, client, _, _ = runtime
value = binding(task=True)
saved = []
monkeypatch.setattr(application, "verify_task", AsyncMock())
monkeypatch.setattr(contexts, "verify_task", AsyncMock())
class TaskService:
repo = SimpleNamespace(lock=lambda key: asyncio.sleep(0))
async def record_data_snapshot(self, task_id, snapshot):
saved.append(snapshot)
return SimpleNamespace(evidence_id=snapshot.source_ref), snapshot
monkeypatch.setattr(application, "task_service", lambda scope: TaskService())
geometry = {"type": "FeatureCollection", "features": [{
"type": "Feature", "properties": {"label": "x" * 13000},
"geometry": {"type": "Polygon", "coordinates": [[[117, 32], [118, 32], [118, 33], [117, 32]]]},
}]}
app.executor = AsyncMock(return_value=QueryResult(
source="generate_node_coverage", source_ref="mcp:before-persisting", queried_at=datetime.now(timezone.utc),
semantics="analysis", data={"result": {"map_payload": geometry, "coverage": {
"status": "ready", "groups": {"compare": {"node_ids": ["A"], "mapped_node_count": 1}},
}}},
))
output = request(client, "tools/call", {"name": "generate_node_coverage", "arguments": {
"context_handle": value.context_handle,
"groups": [{"group_id": "compare", "title": "节点比较", "node_ids": ["A"]}],
}}).json()["result"]["structuredContent"]
assert output["truncated"] is True
command = output["data"]["presentation"]["command"]
assert command["action"] == "set_overlay" and command["evidence_id"] == saved[0].source_ref
assert "data" not in command
assert saved[0].data["data"]["result"]["map_payload"] == geometry
def test_business_skill_is_delivered_over_mcp_without_recording_event_evidence(runtime, monkeypatch):
app, client, _, _ = runtime
value = binding(task=True)
+118
View File
@@ -0,0 +1,118 @@
from datetime import datetime, timezone
import pytest
from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError
from shapely.geometry import Point, shape
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.mcp.analysis_models import NodeCoverageInput
from app.services.node_coverage import build_node_coverage
from app.services.operational_presentation import resolve_overlay
def position(node, longitude, latitude=32.75):
return {"id": node, "longitude": longitude, "latitude": latitude}
def group(ids, group_id="selected"):
return {"group_id": group_id, "title": "节点覆盖比较", "node_ids": ids}
def link(link_id, start, end, status=None):
return {"id": link_id, "start_node_id": start, "end_node_id": end, "status": status}
def test_coverage_can_compare_arbitrary_groups_without_assigning_isolation_semantics():
positions = [position("A", 117.24), position("B", 117.241), position("C", 117.26)]
coverage = build_node_coverage([group(["A", "B"], "monitoring"), group(["C"], "candidate")], positions, [])
assert coverage["status"] == "ready"
assert set(coverage["groups"]) == {"monitoring", "candidate"}
assert [f["properties"]["coverage_group"] for f in coverage["map_payload"]["features"]] == ["monitoring", "candidate"]
assert coverage["parameters"]["split_by_connectivity"] is False
def test_connectivity_option_does_not_bridge_disconnected_groups():
positions = [position("A", 117.24), position("B", 117.241),
position("C", 117.26), position("D", 117.261)]
coverage = build_node_coverage([group(["A", "B", "C", "D"])], positions,
[link("P1", "A", "B"), link("P2", "C", "D")],
split_by_connectivity=True)
feature = coverage["map_payload"]["features"][0]
geometry = shape(feature["geometry"])
assert geometry.geom_type == "MultiPolygon" and geometry.is_valid
assert feature["properties"]["component_count"] == 2
assert all(geometry.covers(Point(row["longitude"], row["latitude"])) for row in positions)
assert not geometry.covers(Point(117.25, 32.75))
@pytest.mark.parametrize("status,excluded", [("CLOSED", []), (None, ["V"])])
def test_connectivity_excludes_initially_closed_and_explicitly_excluded_links(status, excluded):
coverage = build_node_coverage([group(["A", "B"])], [position("A", 117.24), position("B", 117.26)],
[link("V", "A", "B", status)], split_by_connectivity=True,
excluded_link_ids=excluded)
assert coverage["groups"]["selected"]["component_count"] == 2
def test_partial_geometries_are_reported_and_single_point_buffer_uses_meters():
coverage = build_node_coverage([group(["A", "A", "missing", "invalid"])],
[position("A", 117.24), position("invalid", float("nan"))], [])
assert coverage["status"] == "partial"
metadata = coverage["groups"]["selected"]
assert metadata["mapped_node_count"] == 1 and metadata["missing_node_ids"] == ["invalid", "missing"]
feature = coverage["map_payload"]["features"][0]
geometry = shape(feature["geometry"])
assert geometry.covers(Point(117.24, 32.75))
assert 0.0004 < geometry.bounds[2] - geometry.bounds[0] < 0.001
assert feature["properties"]["display_only"] is True
def test_duplicate_and_collinear_positions_are_valid_and_parameters_are_preserved():
coverage = build_node_coverage([group(["A", "B", "C"])],
[position("A", 117.24), position("B", 117.24), position("C", 117.241)], [],
concave_ratio=0.5, buffer_m=40)
assert coverage["status"] == "ready"
assert coverage["parameters"]["concave_ratio"] == 0.5 and coverage["parameters"]["buffer_m"] == 40
assert shape(coverage["map_payload"]["features"][0]["geometry"]).is_valid
def test_missing_positions_do_not_fabricate_an_area():
coverage = build_node_coverage([group(["missing"])], [], [])
assert coverage["status"] == "partial" and coverage["map_payload"]["features"] == []
@pytest.mark.parametrize("mcp_envelope", [False, True])
def test_derived_payload_round_trips_through_saved_evidence_overlay(mcp_envelope):
coverage = build_node_coverage([group(["A"])], [position("A", 117.24)], [])
data = jsonable_encoder({"result": {"map_payload": coverage["map_payload"]}})
if mcp_envelope:
data = {"source": "generate_node_coverage", "source_ref": "mcp:coverage", "data": data}
snapshot = TaskDataSnapshot(source="generate_node_coverage", source_ref="mcp:coverage",
queried_at=datetime.now(timezone.utc), origin="system_data",
truncated=False, data=data)
overlay = resolve_overlay(snapshot, "geometry")
assert overlay["features"][0]["properties"]["coverage_group"] == "selected"
assert shape(overlay["features"][0]["geometry"]).covers(Point(117.24, 32.75))
def test_service_resolves_only_selected_project_nodes_and_reads_topology_only_when_requested(monkeypatch):
from app.services import node_coverage
def positions(network, ids):
assert network == "bound-project" and ids == ["A", "B"]
return [position("A", 117.24), position("B", 117.241)]
monkeypatch.setattr(node_coverage, "get_node_geographic_positions", positions)
monkeypatch.setattr(node_coverage, "get_node_coverage_links", lambda *args: pytest.fail("unnecessary topology query"))
result = node_coverage.generate_node_coverage("bound-project", [group(["A", "B"])])
assert result["coverage"]["status"] == "ready"
assert len(result["map_payload"]["features"]) == 1
@pytest.mark.parametrize("extra", [
{"groups": []}, {"groups": [group(["A"]), group(["B"])]},
{"buffer_m": 0}, {"buffer_m": 1001}, {"concave_ratio": float("nan")},
{"excluded_link_ids": ["V"]}, {"groups": [{**group(["A"]), "coordinates": [117, 32]}]},
{"groups": [group([str(i) for i in range(10000)]), group(["extra"], "other")]},
])
def test_tool_rejects_invalid_parameters_and_unbounded_or_arbitrary_coordinate_inputs(extra):
with pytest.raises(ValidationError):
NodeCoverageInput.model_validate({"context_handle": "x" * 43, "groups": [group(["A"])], **extra})
@@ -48,12 +48,13 @@ async def test_create_work_order_confirms_creation_not_field_completion():
calls.append(request)
return httpx.Response(201, json={"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id), "created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic", "status": "open"})
"origin": "system_data", "status": "open"})
adapter = HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler))
result = await adapter.dispatch(**data)
assert result.status == "succeeded"
assert result.receipt.external_status == "open"
assert "现场工作进度" in result.detail
assert result.detail == "外围工单已创建,现场工作进度由外围系统继续同步"
assert calls[0].url.path == "/api/sample/v1/work-orders"
assert calls[0].headers["X-Project-Id"] == str(data["context"].project_id)
@@ -66,7 +67,7 @@ async def test_work_order_receipt_accepts_awaiting_reply_status():
"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic",
"origin": "system_data",
"status": "awaiting_reply",
})
adapter = HttpOperationalActionDispatcher(
@@ -90,7 +91,7 @@ async def test_action_work_order_is_not_reclassified_as_a_new_inbound_event():
"work_order_id": str(uuid4()),
"project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z",
"origin": "synthetic",
"origin": "system_data",
"status": "open",
})
@@ -111,7 +112,7 @@ async def test_lost_create_response_recovers_same_request_without_second_write()
raise httpx.ReadTimeout("lost", request=request)
return httpx.Response(200, json={"request_id": request.url.path.split("/")[-1],
"result": {"work_order_id": str(uuid4()), "project_id": str(data["context"].project_id),
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"}})
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"}})
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
assert result.status == "succeeded"
assert methods == ["POST", "GET"]
@@ -122,7 +123,7 @@ async def test_unconfirmed_receipt_remains_unknown_not_failed_or_succeeded():
data = inputs()
def handler(request):
return httpx.Response(200, json={"work_order_id": str(uuid4()), "project_id": str(uuid4()),
"created_at": "2026-09-23T08:00:00Z", "origin": "synthetic", "status": "open"})
"created_at": "2026-09-23T08:00:00Z", "origin": "system_data", "status": "open"})
result = await HttpOperationalActionDispatcher(settings(), transport=httpx.MockTransport(handler)).dispatch(**data)
assert result.status == "unknown"
assert result.receipt is None
@@ -54,3 +54,35 @@ def test_overlay_rejects_missing_or_invalid_recorded_spatial_data(data):
def test_presentation_input_rejects_free_form_or_invalid_commands(command):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
@pytest.mark.parametrize("command", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 24},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "unrated"}]},
{"action": "clear_scada_analysis"},
])
def test_unified_presentation_commands_preserve_flat_shape(command):
event = TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
assert event.command.model_dump(mode="json") == command
@pytest.mark.parametrize("command", [
{"action": "highlight_features", "layer": "pipes", "feature_ids": ["P-1"]},
{"action": "locate_features", "layer": "pipes", "feature_ids": []},
{"action": "zoom_to_map", "center": [181, 31], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 91], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 31, 0], "zoom": 1},
{"action": "zoom_to_map", "center": [True, 31], "zoom": 1},
{"action": "zoom_to_map", "center": [121, 31], "zoom": 25},
{"action": "zoom_to_map", "center": [121, 31], "zoom": -1},
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "urgent"}]},
{"action": "render_scada_analysis", "items": [{"device_id": "D", "level": "high"}] * 2},
{"action": "render_scada_analysis", "items": [{"device_id": str(i), "level": "low"} for i in range(101)]},
{"action": "open_chart", "chart_id": "X", "evidence_refs": [], "chart": {
"title": "X", "chart_type": "line", "x_data": ["t"], "series": [{"name": "s", "data": [1]}],
}},
])
def test_unified_presentation_rejects_invalid_commands(command):
with pytest.raises(ValidationError):
TaskPresentationEventCommand(request_id=uuid4(), run_id=uuid4(), source_session_id="session", command=command)
@@ -17,12 +17,19 @@ def anyio_backend():
@pytest.mark.anyio
@pytest.mark.parametrize("available", [False, True])
async def test_execution_requires_message_storage(available):
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
@pytest.mark.parametrize("available,agent_ownership_available,ready", [
(False, False, False), (True, False, False), (True, True, True),
])
async def test_execution_requires_message_and_agent_ownership_storage(
available, agent_ownership_available, ready,
):
cursor = SimpleNamespace(fetchone=AsyncMock(return_value={
"available": available,
"agent_ownership_available": agent_ownership_available,
}))
connection = SimpleNamespace(execute=AsyncMock(return_value=cursor))
repository = OperationalTaskRepository(connection, uuid4())
if available:
if ready:
await repository.require_execution_storage()
else:
with pytest.raises(DomainError) as failure:
@@ -30,9 +37,31 @@ async def test_execution_requires_message_storage(available):
assert failure.value.status == 503
assert failure.value.code == "DEPENDENCY_UNAVAILABLE"
assert "024_agent_messages.sql" in failure.value.message
assert "030_operational_run_agent_ownership.sql" in failure.value.message
assert "to_regclass('workbench.agent_messages')" in connection.execute.call_args.args[0]
@pytest.mark.anyio
@pytest.mark.parametrize("available", [False, True])
async def test_run_update_uses_agent_owner_column_only_after_migration(available):
availability = SimpleNamespace(fetchone=AsyncMock(return_value={"available": available}))
updated = SimpleNamespace(rowcount=1)
connection = SimpleNamespace(execute=AsyncMock(side_effect=[availability, updated]))
repository = OperationalTaskRepository(connection, uuid4())
run_id = str(uuid4())
await repository.update_run({
"run_id": run_id, "status": "running", "revision": 2,
"agent_session_id": "session-1", "agent_instance_id": "agent-a",
"lease_expires_at": None, "heartbeat_at": None, "started_at": None,
"finished_at": None, "failure_code": None, "failure_detail": None,
"dispatch_attempts": 0, "next_dispatch_at": None, "updated_at": None,
}, 1)
query, params = connection.execute.call_args.args
assert ("agent_instance_id=%s" in query) is available
assert ("agent-a" in params) is available
@pytest.mark.anyio
async def test_missing_message_storage_rejects_claim_before_state_changes():
@asynccontextmanager
@@ -56,3 +85,16 @@ async def test_missing_message_storage_rejects_claim_before_state_changes():
repository.lock.assert_not_awaited()
repository.update_run.assert_not_awaited()
repository.update_task.assert_not_awaited()
@pytest.mark.anyio
async def test_presentation_history_storage_filters_project_task_and_committed_cursor():
project_id, task_id = uuid4(), uuid4()
connection = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(fetchall=AsyncMock(return_value=[]))))
repository = OperationalTaskRepository(connection, project_id)
assert await repository.list_presentation_events(str(task_id), 7, 30, 21) == []
query, params = connection.execute.call_args.args
assert params == (str(project_id), str(task_id), 7, 30, 21)
assert "project_id=%s" in query and "aggregate_id=%s" in query
assert "operational.presentation.requested" in query and "event_seq<=%s" in query
assert "ORDER BY event_seq LIMIT %s" in query
+247 -2
View File
@@ -402,6 +402,9 @@ class RunTaskRepository(TaskRepository):
async def get_run(self, run_id):
return self.run if run_id == self.run["run_id"] else None
async def require_execution_storage(self):
return None
async def update_run(self, run, expected_revision):
assert run["revision"] == expected_revision + 1
self.run = run
@@ -416,6 +419,11 @@ class RunTaskRepository(TaskRepository):
async def current_event_cursor(self):
return len(self.events)
async def list_presentation_events(self, task_id, after, through, limit):
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
if after < index <= through and event["topic"] == "operational.presentation.requested"
and event["aggregate_id"] == task_id][:limit]
def _active_run(task, now):
return OperationalTaskRun(
@@ -427,6 +435,30 @@ def _active_run(task, now):
).model_dump(mode="json")
@pytest.mark.anyio
async def test_run_claim_persists_agent_instance_ownership():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=None)
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
run = _active_run(task, now)
run.update(status="queued", revision=1, agent_session_id=None,
lease_expires_at=None, heartbeat_at=None, started_at=None)
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
claimed = await service.claim_run(
task["task_id"], run["run_id"],
AgentRunCommand(
request_id=run["execution_request_id"], expected_revision=1,
source_session_id="session-1", agent_instance_id="agent-a",
),
)
assert claimed.agent_session_id == "session-1"
assert claimed.agent_instance_id == "agent-a"
@pytest.mark.anyio
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
@@ -544,6 +576,64 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
await service.presentation_cursor(uuid4())
@pytest.mark.anyio
@pytest.mark.parametrize("payload", [
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 15},
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "high"}]},
{"action": "clear_scada_analysis"},
])
async def test_unified_commands_record_canonical_events_without_mutating_task(payload):
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
)
result = await service.publish_presentation_event(task["task_id"], command)
assert result.recorded and repository.events[0]["metadata"]["command"] == payload
assert await service.publish_presentation_event(task["task_id"], command) == result
assert len(repository.events) == 1 and repository.task["revision"] == task["revision"]
@pytest.mark.anyio
async def test_saved_mcp_coverage_envelope_publishes_groups_without_agent_geometry():
from fastapi.encoders import jsonable_encoder
from app.services.node_coverage import build_node_coverage
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
coverage = build_node_coverage(
[{"group_id": "one", "title": "第一组", "node_ids": ["A"]},
{"group_id": "two", "title": "第二组", "node_ids": ["A", "B"]}],
[{"id": "A", "longitude": 117.24, "latitude": 32.75},
{"id": "B", "longitude": 117.241, "latitude": 32.75}],
[],
)
snapshot = TaskDataSnapshot(
source="generate_node_coverage", source_ref="mcp:node-coverage", queried_at=now,
origin="system_data", truncated=False,
data=jsonable_encoder({"source": "generate_node_coverage", "data": {
"result": {"map_payload": coverage["map_payload"]},
}}),
)
evidence, _ = await service.record_data_snapshot(task["task_id"], snapshot)
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": "set_overlay", "overlay_id": "isolation-coverage", "title": "节点覆盖示意",
"mode": "geometry", "evidence_id": evidence.evidence_id},
))
features = repository.events[-1]["metadata"]["command"]["data"]["features"]
assert {feature["properties"]["coverage_group"] for feature in features} == {"one", "two"}
assert all(feature["properties"]["display_only"] for feature in features)
assert [feature["properties"]["node_count"] for feature in features] == [1, 2]
@pytest.mark.anyio
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
@@ -566,6 +656,16 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
emitted = repository.events[-1]["metadata"]["command"]["data"]
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
assert emitted["features"][0]["properties"]["weight"] == 1
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": emitted},
))
spoofed = {**emitted, "features": [{**emitted["features"][0],
"geometry": {"type": "Point", "coordinates": [120, 30]}}]}
with pytest.raises(DomainError) as spoof:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": spoofed},
))
assert spoof.value.code == "OVERLAY_DATA_MISMATCH"
with pytest.raises(DomainError) as missing:
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
@@ -580,7 +680,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
))
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
assert len(repository.events) == 1
assert len(repository.events) == 2
@pytest.mark.anyio
@@ -699,6 +799,28 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
assert repository.events == []
@pytest.mark.anyio
async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_session_id():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
run = _active_run(task, now)
run["agent_instance_id"] = "agent-a"
repository = RunTaskRepository(task, run)
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as mismatch:
await service.heartbeat_run(
task["task_id"], repository.run["run_id"],
AgentRunCommand(
request_id=uuid4(), expected_revision=2,
source_session_id="session-1", agent_instance_id="agent-b",
),
)
assert mismatch.value.code == "AGENT_INSTANCE_MISMATCH"
@pytest.mark.anyio
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
@@ -1537,7 +1659,7 @@ def _work_order_snapshot(work_order_id, now):
"feedback_requirements": ["上传检测结果"],
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
"origin": "synthetic",
"origin": "system_data",
"updated_at": now.isoformat(),
}
@@ -1842,3 +1964,126 @@ async def test_interactive_report_publication_is_idempotent_and_versioned_with_e
request_id=uuid4(), source_session_id="session-1", report=report,
))
assert error.value.code == "EVIDENCE_NOT_FOUND"
@pytest.mark.anyio
async def test_presentation_history_is_task_scoped_paginated_and_readable_after_run_finishes():
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now)
repository = RunTaskRepository(task, _active_run(task, now))
repository.run["status"] = "succeeded"
for index in range(4):
await repository.append_event(
topic="operational.presentation.requested", aggregate_type="operational_task_presentation",
aggregate_id=task["task_id"] if index != 1 else str(uuid4()), aggregate_revision=1,
occurred_at=now, metadata={"run_id": repository.run["run_id"],
"execution_generation": 1 if index < 2 else 2,
"command": {"action": "clear_highlights"}},
)
service = OperationalTaskService(repository, context, {OPERATIONAL_TASK_VIEW}, clock=lambda: now)
first = await service.presentation_history(task["task_id"], limit=1)
assert [item.event_seq for item in first.items] == [1]
assert first.event_cursor == 4 and first.next_after == 1
# An append during pagination is outside the captured boundary.
repository.events.append(repository.events[-1])
second = await service.presentation_history(task["task_id"], after=first.next_after, through=first.event_cursor)
assert [item.event_seq for item in second.items] == [3, 4]
assert [item.execution_generation for item in second.items] == [2, 2]
assert second.next_after is None and second.event_cursor == 4
assert repository.run["status"] == "succeeded" and repository.task["revision"] == task["revision"]
with pytest.raises(DomainError):
await service.presentation_history(uuid4())
assert len(repository.events) == 5
@pytest.mark.anyio
@pytest.mark.parametrize("report_factory", [_current_report, _insufficient_report])
async def test_final_report_rejected_while_original_run_has_pending_calculations(report_factory):
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
pending_job = str(uuid4())
repository.pending_calculation_jobs = AsyncMock(return_value=[pending_job])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
with pytest.raises(DomainError) as error:
await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
request_id=uuid4(), source_session_id='session-1', run_id=repository.run['run_id'], report=report_factory(),
))
assert error.value.code == 'PENDING_CALCULATIONS'
assert error.value.status == 409
assert error.value.recovery == 'retry_same_request'
assert error.value.refs == [pending_job]
repository.pending_calculation_jobs.assert_awaited_once_with(
str(task['task_id']), str(repository.run['run_id']), str(repository.run['execution_request_id']),
)
assert not repository.reports
assert repository.run['status'] in {'claimed', 'running'}
@pytest.mark.anyio
async def test_completion_rejected_until_calculations_reach_terminal_state():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
command = AgentRunCommand(request_id=uuid4(), expected_revision=repository.run['revision'], source_session_id='session-1')
with pytest.raises(DomainError) as error:
await service.complete_run(task['task_id'], repository.run['run_id'], command)
assert error.value.code == 'PENDING_CALCULATIONS'
assert repository.run['status'] in {'claimed', 'running'}
repository.pending_calculation_jobs.return_value = []
# Once terminal, the normal final-artifact requirement runs again.
completed = await service.complete_run(task['task_id'], repository.run['run_id'], command)
assert repository.run['failure_code'] == 'ARTIFACT_INCOMPLETE'
assert completed.lifecycle_status == 'failed'
@pytest.mark.anyio
async def test_pending_calculation_query_matches_exact_original_execution_binding():
job_id = uuid4()
connection = SimpleNamespace(execute=AsyncMock())
connection.execute.return_value.fetchall = AsyncMock(return_value=[{'job_id': job_id}])
project_id, task_id, run_id, request_id = (uuid4() for _ in range(4))
repo = OperationalTaskRepository(connection, project_id)
assert await repo.pending_calculation_jobs(str(task_id), str(run_id), str(request_id)) == [str(job_id)]
sql, parameters = connection.execute.await_args.args
assert parameters == (str(project_id), str(task_id), str(run_id), str(request_id))
assert "status IN ('queued','running','cancel_requested')" in sql
assert "binding->>'execution_request_id'=%s" in sql
@pytest.mark.anyio
async def test_interim_report_can_explain_progress_while_calculation_is_pending():
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
context = _context()
task = _task_document(context, now, lease=now + timedelta(minutes=1))
repository = RunTaskRepository(task, _active_run(task, now))
repository.pending_calculation_jobs = AsyncMock(return_value=[str(uuid4())])
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
result = await service.publish_artifacts(task['task_id'], AgentArtifactsCommand(
request_id=uuid4(), source_session_id='session-1', run_id=repository.run['run_id'],
report={**_current_report(), 'stage': 'interim'},
))
assert result.report_revision == 1
repository.pending_calculation_jobs.assert_not_awaited()
@pytest.mark.anyio
async def test_missing_calculation_migration_fails_finalization_closed(monkeypatch):
from psycopg.errors import UndefinedTable
from app.infra.db.postgresql import operational_tasks as storage
@asynccontextmanager
async def transaction():
yield
connection = SimpleNamespace(transaction=transaction, execute=AsyncMock(side_effect=UndefinedTable('calculation_jobs missing')))
monkeypatch.setattr(storage, 'lock_event_transaction', AsyncMock())
repo = OperationalTaskRepository(connection, uuid4())
with pytest.raises(DomainError) as error:
async with repo.transaction():
await repo.pending_calculation_jobs(str(uuid4()), str(uuid4()), str(uuid4()))
assert error.value.code == 'DEPENDENCY_UNAVAILABLE'
assert error.value.status == 503
+52 -5
View File
@@ -90,8 +90,7 @@ async def test_worker_reconciles_messages_at_capacity_and_preserves_transport_re
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert submitted == [row["message_id"] for row in pending]
assert len(failures) == 1 and pending[1]["message_id"] in failures[0][0]
assert failures[0][1]["status"] == "failed"
assert failures == []
@pytest.mark.anyio
@@ -126,6 +125,8 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
task_id, run_id, execution_id = str(uuid4()), str(uuid4()), str(uuid4())
submitted = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
assert request.headers["Authorization"] == "Bearer TEST_ONLY"
assert not request.url.path.endswith("schedules/dispatch-due")
if request.url.path.endswith("leases/recover"):
@@ -140,7 +141,10 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
body = json.loads(request.content)
assert str(request.url) == "http://agent.test/api/v1/task-executions"
submitted.append(body)
return httpx.Response(202, json={"task_id": task_id, "run_id": run_id, "session_id": "execution-session"})
return httpx.Response(202, json={
"task_id": task_id, "run_id": run_id, "session_id": "execution-session",
"agent_instance_id": "agent-a",
})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
worker = OperationalWorker(settings, client)
@@ -149,6 +153,7 @@ async def test_persisted_queue_retries_with_backend_execution_identity():
assert submitted[0] == submitted[1]
assert submitted[0] == {
"task_id": task_id, "run_id": run_id, "execution_request_id": execution_id,
"agent_instance_id": "agent-a",
}
assert "credential" not in submitted[0]
@@ -178,6 +183,8 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
limits, submitted, failures = [], [], []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.method == "GET":
if request.url.params.get("status") != "queued":
return httpx.Response(200, json={"items": []})
@@ -192,7 +199,10 @@ async def test_bounded_queue_continues_after_one_agent_handoff_fails():
submitted.append(body)
if body["task_id"] == runs[0]["task_id"]:
return httpx.Response(503, json={"detail": "temporary failure"})
return httpx.Response(202, json={"task_id": body["task_id"], "run_id": body["run_id"]})
return httpx.Response(202, json={
"task_id": body["task_id"], "run_id": body["run_id"],
"agent_instance_id": "agent-a",
})
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
@@ -257,10 +267,16 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
renewed = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.url.path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
items = [{"run_id": run_id, "agent_session_id": "existing-session"}] if request.url.params.get("status") == "running" else []
items = [{
"run_id": run_id,
"agent_session_id": "existing-session",
"agent_instance_id": "agent-a",
}] if request.url.params.get("status") == "running" else []
return httpx.Response(200, json={"items": items})
assert request.url.host == "agent.test"
assert str(request.url) == "http://agent.test/api/v1/session/existing-session/credentials"
@@ -274,3 +290,34 @@ async def test_worker_refreshes_running_agent_credentials_after_restart():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(renewed) == 1
@pytest.mark.anyio
async def test_worker_only_restores_sessions_owned_by_target_agent_instance():
own_run_id, foreign_run_id, project_id = str(uuid4()), str(uuid4()), str(uuid4())
renewed = []
def handler(request):
if request.url.path == "/health":
return httpx.Response(200, json={"ok": True, "agent_instance_id": "agent-a"})
if request.url.path.endswith("leases/recover"):
return httpx.Response(200, json={"items": []})
if request.method == "GET":
items = [{
"run_id": own_run_id,
"agent_session_id": "own-session",
"agent_instance_id": "agent-a",
}, {
"run_id": foreign_run_id,
"agent_session_id": "foreign-session",
"agent_instance_id": "agent-b",
}] if request.url.params.get("status") == "running" else []
return httpx.Response(200, json={"items": items})
assert str(request.url) == "http://agent.test/api/v1/session/own-session/credentials"
renewed.append(request)
return httpx.Response(202, json={"accepted": True})
settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
assert await OperationalWorker(settings, client).tick() == 0
assert len(renewed) == 1
@@ -86,7 +86,7 @@ def test_active_work_order_projection_removes_only_tombstone_fields():
assert "deleted_at" not in result
assert "deleted_by" not in result
assert stored_document == document
WorkOrder.model_validate(result)
assert WorkOrder.model_validate(result).origin == "system_data"
@pytest.mark.parametrize(
@@ -24,7 +24,7 @@ class ManagedStore:
now = "2026-09-18T00:00:00Z"
return command.model_dump(mode="json", exclude={"request_id"}) | {
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
"origin": "synthetic", "created_at": now, "updated_at": now,
"origin": getattr(command, "origin", "system_data"), "created_at": now, "updated_at": now,
"created_by": str(USER_ID), "updated_by": str(USER_ID), "activity": [],
}
@@ -88,12 +88,17 @@ def test_managed_work_order_and_data_routes_are_typed_crud():
"scenario_code": "WQ-SECONDARY-SUPPLY-001",
"feedback_requirements": ["上传检测结果"],
})
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
assert created.status_code == 201 and created.json()["origin"] == "system_data"
assert created.json()["work_order_type"] == "water_quality_complaint"
assert created.json()["location"] == "演示小区"
assert created.json()["scenario_code"] == "WQ-SECONDARY-SUPPLY-001"
assert created.json()["feedback_requirements"] == ["上传检测结果"]
assert "subtasks" not in created.json()
rejected = client.post("/api/sample/v1/work-orders", json={
"request_id": str(uuid4()), "title": "非法来源", "summary": "不接受演练来源",
"origin": "synthetic",
})
assert rejected.status_code == 422
work_order_id = created.json()["work_order_id"]
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
"request_id": str(uuid4()), "expected_revision": 1, "status": "awaiting_reply",
+42
View File
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from uuid import uuid4
import httpx
import pytest
from app.auth.project_dependencies import ProjectContext
from app.domain.schemas.operational_data import TaskDataQuery
from app.infra.integrations.operational_data import TaskDataSettings, read_task_data
from app.mcp import peripheral
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("origins", [
[], ["system_data"], ["synthetic"], ["system_data", "synthetic"], [None], ["unverified"],
])
async def test_http_and_mcp_normalize_work_orders_as_system_data(monkeypatch, origins):
project_id = uuid4()
items = [{"work_order_id": str(uuid4()), "project_id": str(project_id), "origin": origin} for origin in origins]
async def rows(*_args, **_kwargs):
return [{"document": item} for item in items]
monkeypatch.setattr(peripheral, "read_rows", rows)
query = TaskDataQuery(source="peripheral_work_orders")
context = ProjectContext(project_id=project_id, user_id=uuid4(), project_code="test", project_role="member")
kwargs = dict(context=context, credential="Bearer TEST_ONLY", query=query,
settings=TaskDataSettings(_env_file=None, peripheral_url="http://peripheral.test"),
transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"items": items})))
scope = SimpleNamespace(connection=object(), project=SimpleNamespace(project_id=project_id))
args = SimpleNamespace(source=query.source, limit=20, offset=0)
snapshots = [await read_task_data(**kwargs), await peripheral.read_peripheral_query(scope, args)]
for snapshot in snapshots:
assert snapshot.origin == "system_data"
assert [item["origin"] for item in snapshot.data["items"]] == ["system_data"] * len(origins)
assert "provenance_scope" not in snapshot.data
assert "source_origins" not in snapshot.data

Some files were not shown because too many files have changed in this diff Show More