Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05183aff93 | ||
|
|
63af998045 | ||
|
|
2865811f1b | ||
|
|
af1dc11681 | ||
|
|
8673429ec4 | ||
|
|
6d31f456e6 | ||
|
|
cddfc8647d |
+35
-8
@@ -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
@@ -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
|
||||
|
||||
@@ -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`。迁移只增加运行归属列和活跃运行索引;旧运行不猜测归属,待其结束或租约恢复后,新一代运行开始记录实例。
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ from pydantic import Field, model_validator
|
||||
from app.mcp.models import Id, StrictInput
|
||||
|
||||
|
||||
MAX_CALCULATION_TIMEOUT_SECONDS = 86400
|
||||
MAX_REQUEST_TIMEOUT_SECONDS = 240
|
||||
|
||||
|
||||
class CalculationInput(StrictInput):
|
||||
timeout_seconds: int = Field(
|
||||
default=120, ge=1, le=MAX_CALCULATION_TIMEOUT_SECONDS, strict=True,
|
||||
description="本次计算等待预算,秒;由Agent按模型规模和任务时长选择,受工具目录中的服务端上限约束;与模拟duration不同,超时不保证已启动的计算停止或回滚",
|
||||
default=120, ge=1, le=MAX_REQUEST_TIMEOUT_SECONDS, strict=True,
|
||||
description="本次HTTP请求响应等待预算,秒,默认120秒;不是后台计算执行时限,受工具目录中的请求上限约束;后台任务通过状态和结果工具查询",
|
||||
)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class BurstDetectionInput(CalculationInput):
|
||||
class BurstLocationInput(CalculationInput):
|
||||
data_source: Literal["monitoring", "simulation"] = "monitoring"
|
||||
simulation_run_id: UUID | None = Field(default=None, description="已有分析运行UUID,不是方案名称;simulation模式只读取已有异常结果及模型基线")
|
||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="有依据的假定漏水流量,WNTR SI单位m³/s;不是已知爆管管段,不能随意猜测")
|
||||
burst_leakage: float = Field(gt=0, allow_inf_nan=False, description="当前爆管定位算法必需的假定漏水流量,WNTR SI单位m³/s;可用有数据和方法依据的估计或明确给定的情景假设,说明来源与不确定性,不要求现场实测确认;不能任填数值")
|
||||
pressure_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="压力监测设备关联的模型节点ID,不是设备查询编号;省略时自动读取当前项目SCADA配置中的压力测点,不需要猜测节点ID")
|
||||
flow_scada_ids: list[Id] | None = Field(default=None, min_length=1, max_length=20, description="流量监测设备关联的模型要素ID,不是设备查询编号")
|
||||
scada_burst_start: datetime
|
||||
@@ -69,6 +69,7 @@ class BurstLocationInput(CalculationInput):
|
||||
|
||||
class ValveIsolationInput(CalculationInput):
|
||||
accident_element: list[Id] = Field(min_length=1, max_length=20, description="经核实或明确作为候选的事故管段/节点ID")
|
||||
accident_element_type: Literal["auto", "link", "node"] = Field(default="auto", description="事故要素类型;auto优先识别管段,再识别节点;节点与管段重号时,节点分析须指定node,纯管段分析可指定link")
|
||||
disabled_valves: list[Id] = Field(default_factory=list, max_length=200, description="已知无法关闭的阀门;未提供不代表现场确认全部可用")
|
||||
|
||||
|
||||
|
||||
+31
-12
@@ -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",
|
||||
}:
|
||||
@@ -458,6 +476,7 @@ class McpQueryApplication:
|
||||
403: "QUERY_CONTEXT_DENIED",
|
||||
404: "NOT_FOUND",
|
||||
409: "QUERY_CONTEXT_STALE",
|
||||
410: "RESULT_EXPIRED",
|
||||
413: "RESULT_TOO_LARGE",
|
||||
422: "INVALID_QUERY",
|
||||
503: "SOURCE_UNAVAILABLE",
|
||||
@@ -466,7 +485,7 @@ class McpQueryApplication:
|
||||
codes.get(exc.status_code, "QUERY_FAILED"),
|
||||
str(exc.detail)
|
||||
if isinstance(exc.detail, str)
|
||||
and exc.status_code in {403, 404, 409, 413, 422}
|
||||
and exc.status_code in {403, 404, 409, 410, 413, 422}
|
||||
else "Query is unavailable",
|
||||
)
|
||||
except (UndefinedTable, InvalidSchemaName):
|
||||
@@ -475,7 +494,7 @@ class McpQueryApplication:
|
||||
"MCP project storage has not been provisioned",
|
||||
)
|
||||
except TimeoutError:
|
||||
return tool_error("QUERY_TIMEOUT", "Query execution timed out")
|
||||
return tool_error("QUERY_TIMEOUT", "HTTP request timed out")
|
||||
except ValueError:
|
||||
return tool_error(
|
||||
"INVALID_QUERY", "Invalid query value or incompatible source data"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+16
-5
@@ -4,6 +4,7 @@ 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 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,8 @@ _specs = [
|
||||
("optimize_sensor_placement", "使用灵敏度或K-means算法推荐压力监测点;仅返回候选节点,不保存正式方案,不保证污染溯源可观测性", SensorPlacementInput, "optimization.run", "analysis", False, True),
|
||||
("run_leakage_identification", "基于SCADA压力与模型进行DMA漏损分配识别,需有依据的总漏损流量和时间对齐;返回区域估计,不是已确认漏点。内部执行遗传算法与水力计算", LeakageIdentificationInput, "burst.run", "analysis", False, True),
|
||||
("run_burst_detection", "爆管异常侦测:使用至少5个完整压力传感器此前15天数据;只提示压力异常,不定位、不证明爆管", BurstDetectionInput, "burst.run", "analysis", False, True),
|
||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;需有依据的漏水量假设。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;不是污染扩散或水力影响范围,不执行关阀,须现场核实", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||
("run_burst_location", "爆管候选定位:对比异常与正常压力,可辅助流量;burst_leakage必需,可使用有依据的估计或明确给定的情景假设,不要求现场确诊;本工具不反演总漏水量。压力节点可省略,自动读取项目SCADA压力测点;monitoring正常窗可成对省略,默认前一天同时间段,须说明该对照假设。simulation须有已有分析运行UUID并读取同窗模型基线,不是实测;定位内部会执行水力计算,不创建新的事件方案", BurstLocationInput, "burst.run", "analysis", False, True),
|
||||
("analyze_valve_isolation", "按事故候选要素分析拓扑隔离边界、建议关闭阀门和受影响节点;候选分析无需等待现场确诊。输出不是污染扩散或水力影响范围;现场操作前须核实阀门映射及可用性,本工具不执行关阀", ValveIsolationInput, "simulation.run", "analysis", False, True),
|
||||
(
|
||||
"get_element_properties",
|
||||
"读取当前项目的单个管网元素属性",
|
||||
@@ -208,7 +213,7 @@ _specs = [
|
||||
),
|
||||
(
|
||||
"query_peripheral_data",
|
||||
"查询有限外围数据源;synthetic是合成证据",
|
||||
"查询外围业务工单及监测数据;外围工单属于系统数据,监测数据按实际来源保留来源信息",
|
||||
m.PeripheralInput,
|
||||
"simulation.view",
|
||||
"peripheral",
|
||||
@@ -256,4 +261,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
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -409,14 +412,14 @@ async def execute_query(
|
||||
data["display_time_zone"] = "Asia/Shanghai"
|
||||
data["approved_data_scopes"] = binding.task_scope_cap
|
||||
data["bound_run_id"] = str(binding.run_id) if binding.run_id else None
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill"]
|
||||
data["available_sections"] = ["task", "report", "evidence", "actions", "progress", "access_requests", "plan", "run", "skill", "questions"]
|
||||
truncated = True
|
||||
gaps.append("Task context is a bounded summary; use section, data_path, limit and offset for detail. Previews are not complete reports.")
|
||||
else:
|
||||
projection = await reads.task_context_page(conn, project_id=binding.project_id, task_id=binding.task_id,
|
||||
section=args.section, report_revision=args.report_revision, data_path=args.data_path, limit=args.limit, offset=args.offset)
|
||||
total = projection["total"]
|
||||
if args.section in {"evidence", "actions", "access_requests"} and not args.data_path:
|
||||
if args.section in {"evidence", "actions", "access_requests", "questions"} and not args.data_path:
|
||||
total = projection["snapshot_metadata"].get("total", total)
|
||||
if projection["value_type"] in {"array", "object", "string"}:
|
||||
page = PageInfo(limit=args.limit, offset=args.offset, total=total, has_more=args.offset + args.limit < total)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Read-only projections backed by the GIS materialized-view query layer."""
|
||||
"""Read-only GIS projections and authoritative network topology snapshots."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -69,6 +69,46 @@ def get_network_link_nodes(name: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def get_valve_isolation_topology(name: str) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Read links, closed statuses and sources from one consistent SQL snapshot.
|
||||
|
||||
Isolation must include model links without published geometries, and use
|
||||
initial statuses rather than treating every link as open. UNION ALL keeps
|
||||
source nodes in the same statement snapshot without repeating them for
|
||||
every link or relying on materialized view refresh timing.
|
||||
"""
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT l.id, l.link_type::text AS element_type,
|
||||
l.start_node_id, l.end_node_id,
|
||||
COALESCE(s.status::text, p.status::text) AS status
|
||||
FROM network.links AS l
|
||||
LEFT JOIN network.pipes AS p ON p.link_id = l.id
|
||||
LEFT JOIN network.link_initial_settings AS s ON s.link_id = l.id
|
||||
UNION ALL
|
||||
SELECT n.id, n.node_type::text AS element_type,
|
||||
NULL, NULL, NULL
|
||||
FROM network.nodes AS n
|
||||
WHERE n.node_type::text IN ('reservoir', 'tank')
|
||||
""",
|
||||
)
|
||||
link_entries: list[str] = []
|
||||
source_nodes: list[str] = []
|
||||
closed_links: list[str] = []
|
||||
for row in rows:
|
||||
element_id = str(row["id"])
|
||||
if row["element_type"] in {"reservoir", "tank"}:
|
||||
source_nodes.append(element_id)
|
||||
else:
|
||||
link_entries.append(
|
||||
f"{element_id}:{row['element_type']}:{row['start_node_id']}:{row['end_node_id']}"
|
||||
)
|
||||
if row["status"] == "CLOSED":
|
||||
closed_links.append(element_id)
|
||||
return link_entries, source_nodes, closed_links
|
||||
|
||||
|
||||
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
|
||||
"""Return large pipes in the established API wire format."""
|
||||
rows = read_all(
|
||||
|
||||
@@ -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)
|
||||
@@ -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]))
|
||||
@@ -88,7 +88,7 @@ def resolve_overlay(snapshot: TaskDataSnapshot, mode: str) -> dict:
|
||||
properties = feature.get("properties") or {}
|
||||
require(isinstance(properties, dict), "OVERLAY_GEOMETRY_INVALID", "Feature properties are invalid", status=422)
|
||||
normalized = {"type": "Feature", "geometry": geometry, "properties": dict(properties)}
|
||||
if isinstance(feature.get("id"), (str, int)) and not isinstance(feature.get("id"), bool):
|
||||
if isinstance(feature.get("id"), str) or _number(feature.get("id")):
|
||||
normalized["id"] = feature["id"]
|
||||
if mode == "heatmap":
|
||||
weight = properties.get("weight", 1)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "66fbf1105aa966a590f93fac176cbc77d22a4a6cf0a94dfcd1d3c27273f73692"
|
||||
"sha256": "fa5b186d0f1e4257352a812e578cb9901baf47beaca33c388e876f1a7b4c42ae"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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#"
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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,17 +55,24 @@ 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。
|
||||
@@ -98,7 +105,7 @@ Server 校验当前 run、绑定会话、有效租约和幂等身份,记录 `o
|
||||
实际 worker 曾成功投递指定调查,模型发布进展和报告,但因 service 账号的任务权限过滤
|
||||
导致 `approved_data_scopes` 为空,误作数据不足结束。这不是业务数据确实不足。
|
||||
用户确认开放规则后,任务 API 已移除该额外过滤,沿用账号已有的项目读取权限。
|
||||
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围合成工单成功;没有新增
|
||||
在线上下文返回七类来源,同一身份实际读取 SCADA 设备和外围系统工单成功;没有新增
|
||||
角色权限或放宽跨项目、审批和设备控制边界。旧委托路径保留兼容,已有权限无需委托凭据。
|
||||
用户级 `tjwater-operational-worker.service` 已重新启用。旧报告保留,未自动重新调查,
|
||||
不能把旧报告视为修复后的业务验收。任务创建时间下界和并发配置见
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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覆盖和结果准确性仍需另行验收。
|
||||
|
||||
|
||||
@@ -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的完整样本,预算内仍需读全样本、核验摘要后筛选;此限制不能通过缩小查询窗口解决。监测点列表沿用设备目录,不把未登记的测试点冒充实测设备。
|
||||
|
||||
## 启用步骤
|
||||
|
||||
|
||||
@@ -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
@@ -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,因此浏览器关闭后流程仍可继续。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,34 @@
|
||||
---
|
||||
name: appointment-follow-up
|
||||
description: 调查关联预约任务的执行状态与反馈,核对审批、派发和现场结果,避免把预约任务当成新的爆管或投诉事件。
|
||||
description: 核查关联预约工单的审批、提交、现场执行和反馈,分别判断预约履约与原事件闭环。
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
version: "2.0.2"
|
||||
---
|
||||
|
||||
# 预约任务跟进
|
||||
|
||||
预约工单承载已安排的人工工作。先读取原任务、已审批动作、预约时间、派发状态和现场反馈;需要时追溯关联的投诉或爆管事件。预约时间、工单派发时间、现场完成时间和事件发生时间分别说明。
|
||||
目标是说明预约安排了什么、流程到哪一步、现场实际做了什么、证据是否满足要求,以及还有哪些责任事项。预约工单承载已安排的人工工作,不自动代表新的爆管或水质事件,也不因存在待办而反复创建跟进工单。
|
||||
|
||||
核对预约工作的内容、执行条件和实际回执。只有现场反馈与证据支持时,才能认定采样、通知、抢修、冲洗或复测完成;工单创建或派发成功仅代表流程状态变化。对尚未完成的工作,报告责任事项、已有进展、缺少的反馈和下一步依赖。
|
||||
## 接续与核查
|
||||
|
||||
预约工单不自动触发隔离停水或污染水排放,也不生成同类型的投诉、爆管事件。确有新增处置需要且已有证据支持时,保存阶段报告,再提议具体的关联预约工单(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.3"
|
||||
---
|
||||
|
||||
# 爆管上报处置
|
||||
|
||||
依据上报位置、时间、现场描述、相关管段台账和同窗压力流量证据,区分上报事实、爆管候选、已确认故障与演练假设。完成初步风险判断不要求抢修、复测和回访已经发生;这些属于后续执行反馈。演练数据可支持明确标注的演练判断,不能证明生产故障。
|
||||
目标是形成有依据的候选位置、隔离方案、影响判断和可审批处置,并在后续轮次根据真实反馈复核事件闭环。区分候选方案、现场确诊和事件闭环;后两者不能提前阻断当前可完成的分析。演练数据可以用于比较,但必须标注 `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.2"
|
||||
---
|
||||
|
||||
# 水质投诉处置
|
||||
|
||||
区分用户感官投诉、监测异常、已确认的水质问题与污染源假设。单个投诉不能直接证明供水管网污染;上游监测正常也不能排除末梢或二次供水问题。不预设异常必然来自二次供水,不把模拟扩散结果当成污染源反演。
|
||||
目标是说明投诉和异常的实际覆盖、可能原因及依据、候选影响范围、采样或处置建议和后续复核条件。区分感官投诉、实测异常、污染源假设和已确认问题;不预设异常来自上游或二次供水。演练结果须标注 `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 控制。
|
||||
|
||||
采样、处置和回访尚未发生不阻止有边界的初步建议。工单创建、审批或派发不证明采样、通知、冲洗或复测完成。
|
||||
|
||||
## 闭环复核
|
||||
|
||||
后续轮次核对实际采样位置、时间、结果和判定依据,以及污染进入控制、隔离与通知、清洗冲洗、复测、恢复供水、用户回访、未决动作和遗留风险。不适用项说明依据,缺少反馈保留待核验。
|
||||
|
||||
证据齐全时报告具备结案条件,由来源系统的有权限人员确认关闭。核对来源新版本后才能认定闭环;误报或未发现异常也须有足够核查和人工确认,不能用有限正常数据、模拟结果或流程状态证明水质安全。
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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())
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,82 @@
|
||||
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")
|
||||
|
||||
|
||||
@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"
|
||||
@@ -218,7 +218,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 +316,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 +350,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 = []
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -402,6 +402,9 @@ class RunTaskRepository(TaskRepository):
|
||||
async def get_run(self, run_id):
|
||||
return self.run if run_id == self.run["run_id"] else None
|
||||
|
||||
async def require_execution_storage(self):
|
||||
return None
|
||||
|
||||
async def update_run(self, run, expected_revision):
|
||||
assert run["revision"] == expected_revision + 1
|
||||
self.run = run
|
||||
@@ -416,6 +419,11 @@ class RunTaskRepository(TaskRepository):
|
||||
async def current_event_cursor(self):
|
||||
return len(self.events)
|
||||
|
||||
async def list_presentation_events(self, task_id, after, through, limit):
|
||||
return [{"event_seq": index, **event} for index, event in enumerate(self.events, 1)
|
||||
if after < index <= through and event["topic"] == "operational.presentation.requested"
|
||||
and event["aggregate_id"] == task_id][:limit]
|
||||
|
||||
|
||||
def _active_run(task, now):
|
||||
return OperationalTaskRun(
|
||||
@@ -427,6 +435,30 @@ def _active_run(task, now):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_claim_persists_agent_instance_ownership():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=None)
|
||||
task.update(status="queued", agent_session_id=None, agent_lease_expires_at=None)
|
||||
run = _active_run(task, now)
|
||||
run.update(status="queued", revision=1, agent_session_id=None,
|
||||
lease_expires_at=None, heartbeat_at=None, started_at=None)
|
||||
repository = RunTaskRepository(task, run)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
claimed = await service.claim_run(
|
||||
task["task_id"], run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=run["execution_request_id"], expected_revision=1,
|
||||
source_session_id="session-1", agent_instance_id="agent-a",
|
||||
),
|
||||
)
|
||||
|
||||
assert claimed.agent_session_id == "session-1"
|
||||
assert claimed.agent_instance_id == "agent-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_progress_can_publish_without_conclusion_and_replaces_the_complete_graph():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
@@ -544,6 +576,28 @@ async def test_presentation_cursor_and_event_are_read_only_and_idempotent():
|
||||
await service.presentation_cursor(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{"action": "locate_features", "layer": "scada", "feature_ids": ["D-1"]},
|
||||
{"action": "zoom_to_map", "center": [121.4, 31.1], "zoom": 15},
|
||||
{"action": "render_scada_analysis", "items": [{"device_id": "D-1", "level": "high"}]},
|
||||
{"action": "clear_scada_analysis"},
|
||||
])
|
||||
async def test_unified_commands_record_canonical_events_without_mutating_task(payload):
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
command = TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1", command=payload,
|
||||
)
|
||||
result = await service.publish_presentation_event(task["task_id"], command)
|
||||
assert result.recorded and repository.events[0]["metadata"]["command"] == payload
|
||||
assert await service.publish_presentation_event(task["task_id"], command) == result
|
||||
assert len(repository.events) == 1 and repository.task["revision"] == task["revision"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_oversized_data():
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
@@ -566,6 +620,16 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
||||
emitted = repository.events[-1]["metadata"]["command"]["data"]
|
||||
assert emitted["features"][0]["geometry"]["coordinates"] == [121.4, 31.1]
|
||||
assert emitted["features"][0]["properties"]["weight"] == 1
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": emitted},
|
||||
))
|
||||
spoofed = {**emitted, "features": [{**emitted["features"][0],
|
||||
"geometry": {"type": "Point", "coordinates": [120, 30]}}]}
|
||||
with pytest.raises(DomainError) as spoof:
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id, "data": spoofed},
|
||||
))
|
||||
assert spoof.value.code == "OVERLAY_DATA_MISMATCH"
|
||||
with pytest.raises(DomainError) as missing:
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": "not-this-task"},
|
||||
@@ -580,7 +644,7 @@ async def test_overlay_event_uses_stored_coordinates_and_rejects_untrusted_or_ov
|
||||
request_id=uuid4(), **identity, command={**overlay, "evidence_id": evidence.evidence_id},
|
||||
))
|
||||
assert oversized.value.code == "PRESENTATION_TOO_LARGE"
|
||||
assert len(repository.events) == 1
|
||||
assert len(repository.events) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -699,6 +763,28 @@ async def test_run_heartbeat_does_not_change_task_business_revision():
|
||||
assert repository.events == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_heartbeat_rejects_a_different_agent_instance_with_same_session_id():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
run = _active_run(task, now)
|
||||
run["agent_instance_id"] = "agent-a"
|
||||
repository = RunTaskRepository(task, run)
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
|
||||
with pytest.raises(DomainError) as mismatch:
|
||||
await service.heartbeat_run(
|
||||
task["task_id"], repository.run["run_id"],
|
||||
AgentRunCommand(
|
||||
request_id=uuid4(), expected_revision=2,
|
||||
source_session_id="session-1", agent_instance_id="agent-b",
|
||||
),
|
||||
)
|
||||
|
||||
assert mismatch.value.code == "AGENT_INSTANCE_MISMATCH"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_complete_without_same_generation_artifacts_fails_run_explicitly():
|
||||
now = datetime(2026, 9, 23, 8, tzinfo=timezone.utc)
|
||||
@@ -1537,7 +1623,7 @@ def _work_order_snapshot(work_order_id, now):
|
||||
"feedback_requirements": ["上传检测结果"],
|
||||
"activity": [{"sequence": 1, "occurred_at": now.isoformat(), "actor": "水质班组",
|
||||
"kind": "feedback", "title": "现场反馈", "detail": "上传复测结果"}],
|
||||
"origin": "synthetic",
|
||||
"origin": "system_data",
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
|
||||
@@ -1842,3 +1928,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -100,7 +100,7 @@ async def test_source_push_synchronizes_new_and_updated_orders_without_waiting_f
|
||||
try:
|
||||
await asyncio.wait_for(initial_scan.wait(), timeout=3)
|
||||
orders.append({
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"revision": 1, "title": "预约维修", "summary": "等待现场处理", "priority": "normal",
|
||||
"status": "open", "created_at": "2026-10-08T00:00:00Z",
|
||||
"updated_at": "2026-10-08T00:00:00Z", "scheduled_at": None,
|
||||
|
||||
@@ -14,7 +14,6 @@ from scripts.sync_peripheral_work_orders import (
|
||||
sync_once,
|
||||
work_order_snapshot,
|
||||
)
|
||||
from samples.peripheral.seed_identity import SEED_ACTOR_ID
|
||||
from app.domain.schemas.operational_task import ExternalFeedbackCommand
|
||||
|
||||
|
||||
@@ -36,27 +35,21 @@ def test_documented_sync_command_can_start_from_repository_root():
|
||||
assert "--once" in result.stdout
|
||||
|
||||
|
||||
def test_unlinked_synthetic_checks_are_not_operational_tasks():
|
||||
def test_all_active_work_orders_are_operational_tasks():
|
||||
order_id = str(uuid4())
|
||||
order = {
|
||||
"work_order_id": order_id,
|
||||
"origin": "synthetic",
|
||||
"created_by": str(SEED_ACTOR_ID),
|
||||
"title": "合成压力偏差复核",
|
||||
"origin": "system_data",
|
||||
"title": "压力偏差复核",
|
||||
"work_order_type": "general",
|
||||
}
|
||||
|
||||
assert not should_project_work_order(order, set())
|
||||
assert should_project_work_order(order, set())
|
||||
assert should_project_work_order(order, {order_id})
|
||||
assert should_project_work_order(
|
||||
{**order, "work_order_type": "water_quality_complaint"}, set()
|
||||
)
|
||||
assert should_project_work_order(
|
||||
{**order, "title": "现场采样:二次供水出水及末端用户水样"}, set()
|
||||
) is False
|
||||
assert should_project_work_order(
|
||||
{**order, "created_by": str(uuid4()), "title": "合成管材接驳预约"}, set()
|
||||
)
|
||||
assert should_project_work_order({**order, "title": "现场采样"}, set())
|
||||
|
||||
|
||||
def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
|
||||
@@ -71,7 +64,7 @@ def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces():
|
||||
async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||
project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4()))
|
||||
tasks, writes = [], []
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal",
|
||||
"status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": None, "work_order_type": "general"}
|
||||
@@ -116,16 +109,15 @@ async def test_sync_creates_one_task_and_only_applies_new_external_revisions():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
async def test_sync_rejects_non_system_work_order_origin():
|
||||
project_id, order_id = str(uuid4()), str(uuid4())
|
||||
order = {
|
||||
"project_id": project_id,
|
||||
"work_order_id": order_id,
|
||||
"origin": "synthetic",
|
||||
"created_by": str(SEED_ACTOR_ID),
|
||||
"revision": 1,
|
||||
"title": "合成通信核查",
|
||||
"summary": "仅用于外围样板联调",
|
||||
"title": "来源异常工单",
|
||||
"summary": "验证来源边界",
|
||||
"priority": "normal",
|
||||
"status": "open",
|
||||
"created_at": "2026-09-23T08:00:00Z",
|
||||
@@ -135,7 +127,6 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
}
|
||||
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [order], "next_cursor": None})
|
||||
return httpx.Response(200, json={"items": [], "next_offset": None})
|
||||
@@ -144,14 +135,8 @@ async def test_sync_skips_unlinked_synthetic_check_before_writing():
|
||||
"http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY"
|
||||
)
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
synchronized = await sync_once(
|
||||
client,
|
||||
settings,
|
||||
"http://sample.test",
|
||||
CredentialProvider(settings),
|
||||
)
|
||||
|
||||
assert synchronized == 0
|
||||
with pytest.raises(ValueError, match="origin mismatch"):
|
||||
await sync_once(client, settings, "http://sample.test", CredentialProvider(settings))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -231,7 +216,7 @@ async def test_sync_does_not_withdraw_when_missing_order_is_not_confirmed_delete
|
||||
async def test_sample_sync_rejects_cross_project_data_before_writing():
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}]
|
||||
return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "system_data"}]
|
||||
if request.url.host == "sample.test" else []), "next_offset": None})
|
||||
settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY")
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
@@ -263,11 +248,11 @@ async def test_created_work_order_receipt_preserves_projection_and_routes_active
|
||||
def handler(request):
|
||||
if request.url.host == "sample.test":
|
||||
order = {
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"created_by": str(uuid4()),
|
||||
"revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open",
|
||||
"updated_at": "2026-09-23T09:00:00Z",
|
||||
"title": "合成采样工单", "priority": "normal",
|
||||
"title": "采样工单", "priority": "normal",
|
||||
"work_order_type": "general", "created_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": None,
|
||||
"activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员",
|
||||
@@ -320,13 +305,15 @@ async def test_external_events_start_investigations_without_browser(event_kind):
|
||||
return httpx.Response(200, json={"items": [], "next_offset": None})
|
||||
if request.url.host == "sample.test":
|
||||
return httpx.Response(200, json={"items": [{
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1,
|
||||
"project_id": project_id, "work_order_id": order_id, "origin": "system_data", "revision": 1,
|
||||
"title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open",
|
||||
"created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z",
|
||||
"scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置",
|
||||
}], "next_cursor": None})
|
||||
payload = json.loads(request.content)
|
||||
if request.url.path.endswith("external-feedback"):
|
||||
assert payload["evidence"]["kind"] == "external_feedback"
|
||||
assert payload["work_order_snapshot"]["origin"] == "system_data"
|
||||
return httpx.Response(200, json={})
|
||||
task = {**payload, "task_id": str(uuid4())}
|
||||
created.append(task)
|
||||
@@ -339,6 +326,7 @@ async def test_external_events_start_investigations_without_browser(event_kind):
|
||||
assert investigation["workflow"] == event_kind
|
||||
assert investigation["source"]["external_id"] == order_id
|
||||
assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z"
|
||||
assert all("合成" not in item["summary"] for item in created)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -349,7 +337,7 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
|
||||
projection = {"task_id": projection_id, "task_type": "external_work_order", "source": {
|
||||
"external_id": f"peripheral_sample:{order_id}", "external_revision": 1,
|
||||
}}
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
|
||||
order = {"project_id": project_id, "work_order_id": order_id, "origin": "system_data",
|
||||
"revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal",
|
||||
"created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None,
|
||||
"work_order_type": "general"}
|
||||
@@ -398,8 +386,8 @@ async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_de
|
||||
|
||||
def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
order = {
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "synthetic",
|
||||
"work_order_id": str(uuid4()), "revision": 4, "title": "预约工单", "summary": "仅测试过程同步",
|
||||
"status": "in_progress", "priority": "normal", "origin": "system_data",
|
||||
"updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general",
|
||||
"feedback_requirements": ["回填现场检查结果"],
|
||||
"activity": [{
|
||||
@@ -411,7 +399,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
"detail": "已创建现场检查工单。",
|
||||
}],
|
||||
"subtasks": [{
|
||||
"id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行",
|
||||
"id": "visit", "title": "现场检查", "summary": "测试数据,未现场执行",
|
||||
"status": "pending", "scheduled_at": "2026-09-24T08:00:00Z",
|
||||
"location": "测试位置", "assignee": "测试班组", "duration_minutes": 30,
|
||||
"reply_window_minutes": 15, "reply_requirements": ["检查记录"],
|
||||
@@ -420,6 +408,7 @@ def test_snapshot_preserves_recorded_activity_without_legacy_subtasks():
|
||||
}],
|
||||
}
|
||||
snapshot = work_order_snapshot(order)
|
||||
assert snapshot["origin"] == "system_data"
|
||||
assert snapshot["feedback_requirements"] == ["回填现场检查结果"]
|
||||
assert snapshot["activity"] == order["activity"]
|
||||
assert "subtasks" not in snapshot
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
@@ -29,6 +30,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
|
||||
feedback_requirements=["回传开度照片"],
|
||||
))
|
||||
assert created["feedback_requirements"] == ["回传开度照片"]
|
||||
assert created["origin"] == "system_data"
|
||||
assert created["activity"][0]["kind"] == "created"
|
||||
assert "subtasks" not in created
|
||||
|
||||
@@ -37,6 +39,7 @@ def test_create_and_update_record_flat_order_activity(monkeypatch):
|
||||
progress_note="已复核开度,等待回执确认",
|
||||
))
|
||||
assert updated["status"] == "awaiting_reply"
|
||||
assert updated["origin"] == "system_data"
|
||||
assert [entry["sequence"] for entry in updated["activity"]] == [1, 2]
|
||||
assert updated["activity"][1]["kind"] == "feedback"
|
||||
assert updated["activity"][1]["detail"] == "已复核开度,等待回执确认"
|
||||
@@ -68,3 +71,21 @@ def test_legacy_order_reads_flat_without_claiming_unrecorded_progress():
|
||||
assert "subtasks" not in projected
|
||||
assert projected["feedback_requirements"] == []
|
||||
assert projected["activity"] == []
|
||||
|
||||
|
||||
def test_work_order_contract_rejects_synthetic_origin():
|
||||
with pytest.raises(ValidationError):
|
||||
CreateWorkOrder(
|
||||
request_id=uuid4(), title="来源校验", summary="只接受系统数据", origin="synthetic",
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_work_order_origin_is_normalized_for_reads():
|
||||
legacy = {
|
||||
"work_order_id": str(uuid4()), "project_id": str(uuid4()), "revision": 1,
|
||||
"title": "历史工单", "summary": "迁移前记录", "status": "open", "priority": "normal",
|
||||
"assignee": None, "scheduled_at": None, "origin": "synthetic",
|
||||
"created_at": "2026-09-18T00:00:00Z", "updated_at": "2026-09-18T00:00:00Z",
|
||||
"created_by": str(uuid4()), "updated_by": str(uuid4()),
|
||||
}
|
||||
assert WorkOrder.model_validate(legacy).origin == "system_data"
|
||||
|
||||
@@ -40,6 +40,26 @@ def test_report_ui_round_trips_with_a_version_and_keeps_legacy_reports_readable(
|
||||
assert InvestigationReportRevision.model_validate(old).report.ui_blocks == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", [None, "final"])
|
||||
def test_report_ui_accepts_final_and_legacy_phases(phase):
|
||||
data = rich_report()
|
||||
if phase is not None:
|
||||
data["ui_blocks"][0]["block"]["phase"] = phase
|
||||
report = InvestigationReportInput.model_validate(data)
|
||||
assert report.ui_blocks[0].block.phase == phase
|
||||
serialized = report.model_dump()["ui_blocks"][0]["block"]
|
||||
assert ("phase" in serialized) == (phase is not None)
|
||||
assert InvestigationReportInput.model_validate(report.model_dump()).ui_blocks[0].block.phase == phase
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["process", "unknown", None])
|
||||
def test_report_ui_rejects_process_and_unknown_phases(phase):
|
||||
data = rich_report()
|
||||
data["ui_blocks"][0]["block"]["phase"] = phase
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("defect", ["unknown", "script", "unaligned", "nan", "cycle", "unreachable", "duplicate", "foreign", "many", "oversized"])
|
||||
def test_report_ui_rejects_invalid_or_unbound_content(defect):
|
||||
data = rich_report()
|
||||
@@ -72,3 +92,26 @@ def test_report_ui_rejects_invalid_or_unbound_content(defect):
|
||||
data["ui_blocks"] = [entry, {**deepcopy(entry), "block": {**deepcopy(entry["block"]), "block_id": "second"}}]
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(data)
|
||||
|
||||
|
||||
def test_report_ui_accepts_semantic_results_without_allowing_raw_presentation_code():
|
||||
data = rich_report()
|
||||
spec = data["ui_blocks"][0]["block"]["spec"]
|
||||
spec["root"] = "layout"
|
||||
spec["elements"] = {
|
||||
"layout": {"type": "Stack", "props": {"columns": "one", "gap": "normal", "intent": "report", "density": "compact"}, "children": ["section"]},
|
||||
"section": {"type": "Section", "props": {"title": "演练结果", "description": None, "role": "result"}, "children": ["result", "note"]},
|
||||
"result": {"type": "Result", "props": {"title": "演练记录已保存", "summary": "现场处置仍待核验。", "status": "partial", "source": "演练回执"}, "children": []},
|
||||
"note": {"type": "Text", "props": {"title": "核验说明", "text": "结果仅限演练。", "tone": "neutral"}, "children": []},
|
||||
}
|
||||
restored = InvestigationReportInput.model_validate(data)
|
||||
assert restored.ui_blocks[0].block.spec.elements["result"].props["status"] == "partial"
|
||||
for extra in ({"style": {"color": "red"}}, {"className": "text-red-500"}, {"css": "display:none"}, {"action": "dispatch"}):
|
||||
invalid = deepcopy(data)
|
||||
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["props"].update(extra)
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(invalid)
|
||||
invalid = deepcopy(data)
|
||||
invalid["ui_blocks"][0]["block"]["spec"]["elements"]["result"]["children"] = ["note"]
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationReportInput.model_validate(invalid)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.permissions import OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import AgentRunCommand, AgentRunFailureCommand, OperationalTaskRetryCommand
|
||||
from app.domain.schemas.task_question import TaskQuestionCreate, TaskQuestionReply
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from app.services.task_questions import create_question, answer_question
|
||||
from tests.unit.test_operational_tasks import RunTaskRepository, _context, _task_document, _active_run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class QuestionRepository(RunTaskRepository):
|
||||
def __init__(self, task, run):
|
||||
super().__init__(task, run)
|
||||
self.questions = {}
|
||||
|
||||
async def list_questions(self, task_id):
|
||||
return [deepcopy(q) for q in self.questions.values() if q["task_id"] == task_id]
|
||||
|
||||
async def get_question(self, task_id, question_id):
|
||||
q = self.questions.get(question_id)
|
||||
return deepcopy(q) if q and q["task_id"] == task_id else None
|
||||
|
||||
async def save_question(self, task_id, document):
|
||||
assert document["task_id"] == task_id
|
||||
self.questions[document["question_request_id"]] = deepcopy(document)
|
||||
|
||||
|
||||
def setup():
|
||||
now = datetime(2026, 10, 30, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repo = QuestionRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repo, context, {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}, clock=lambda: now)
|
||||
command = TaskQuestionCreate(request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"],
|
||||
questions=[dict(question_id="flow", title="漏水量依据", body="是否有计量记录?", options=[{"id": "unknown", "label": "目前未知"}],
|
||||
mode="single", allow_custom=True, required=True)])
|
||||
return service, repo, UUID(task["task_id"]), command
|
||||
|
||||
|
||||
async def finish(service, repo, task_id):
|
||||
return await service.complete_run(task_id, UUID(repo.run["run_id"]),
|
||||
AgentRunCommand(request_id=uuid4(), expected_revision=repo.run["revision"], source_session_id="session-1"))
|
||||
|
||||
|
||||
def reply(**overrides):
|
||||
return TaskQuestionReply(request_id=uuid4(), expected_version=1,
|
||||
answers=[{"question_id": "flow", "selected_option_ids": ["unknown"], "custom_text": "需要采用情景假设"}], **overrides)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_question_waits_without_final_report_and_answer_requeues_once_without_fabricating_evidence():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
assert (await create_question(service, task_id, command)).question_request_id == question.question_request_id
|
||||
assert len(repo.notifications) == 1
|
||||
with pytest.raises(DomainError, match="waiting_human"):
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
waiting = await finish(service, repo, task_id)
|
||||
assert waiting.lifecycle_status == "waiting_human" and waiting.active_run is None
|
||||
assert repo.run["status"] == "succeeded" and repo.run["failure_code"] is None
|
||||
assert len(repo.notifications) == 1 and repo.notifications[0]["subject_type"] == "question"
|
||||
answer = reply()
|
||||
resumed = await answer_question(service, task_id, question.question_request_id, answer)
|
||||
replay = await answer_question(service, task_id, question.question_request_id, answer)
|
||||
assert resumed.execution_generation == replay.execution_generation == 2
|
||||
assert resumed.active_run.status == "queued"
|
||||
assert resumed.questions[0].answers[0].custom_text == "需要采用情景假设"
|
||||
assert resumed.questions[0].answered_by == service._actor()
|
||||
assert not repo.evidence and not resumed.actions
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("answers", [[],
|
||||
[{"question_id": "foreign", "selected_option_ids": [], "custom_text": "x"}],
|
||||
[{"question_id": "flow", "selected_option_ids": ["bogus"], "custom_text": ""}],
|
||||
[{"question_id": "flow", "selected_option_ids": ["unknown", "unknown"], "custom_text": ""}],
|
||||
])
|
||||
async def test_invalid_answers_do_not_resume_or_resolve_notification(answers):
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id,
|
||||
TaskQuestionReply(request_id=uuid4(), expected_version=1, answers=answers))
|
||||
assert caught.value.code == "ANSWER_INVALID"
|
||||
assert repo.task["execution_generation"] == 1
|
||||
assert repo.notifications[0]["lifecycle_status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_permissions_foreign_session_duplicate_pending_and_stale_generation():
|
||||
service, repo, task_id, command = setup()
|
||||
service.permissions = frozenset()
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command)
|
||||
assert caught.value.code == "PERMISSION_DENIED"
|
||||
service.permissions = {OPERATIONAL_TASK_EXECUTE, OPERATIONAL_TASK_WRITE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command.model_copy(update={"source_session_id": "foreign"}))
|
||||
assert caught.value.code == "SESSION_MISMATCH"
|
||||
question = await create_question(service, task_id, command)
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command.model_copy(update={"request_id": uuid4()}))
|
||||
assert caught.value.code == "QUESTION_PENDING"
|
||||
await finish(service, repo, task_id)
|
||||
await service.retry_task(task_id, OperationalTaskRetryCommand(request_id=uuid4(), reason="重新调查"))
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
assert caught.value.code == "VERSION_CONFLICT"
|
||||
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pending_calculations_must_finish_before_asking():
|
||||
service, repo, task_id, command = setup()
|
||||
async def pending(*_args):
|
||||
return [str(uuid4())]
|
||||
repo.pending_calculation_jobs = pending
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await create_question(service, task_id, command)
|
||||
assert caught.value.code == "PENDING_CALCULATIONS"
|
||||
assert not repo.questions and not repo.notifications
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_answer_requires_write_permission_and_matching_version():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
service.permissions = {OPERATIONAL_TASK_EXECUTE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply())
|
||||
assert caught.value.code == "PERMISSION_DENIED"
|
||||
service.permissions = {OPERATIONAL_TASK_WRITE}
|
||||
with pytest.raises(DomainError) as caught:
|
||||
await answer_question(service, task_id, question.question_request_id, reply().model_copy(update={"expected_version": 9}))
|
||||
assert caught.value.code == "VERSION_CONFLICT"
|
||||
assert repo.task["execution_generation"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_real_execution_failure_cancels_question_instead_of_disguising_failure():
|
||||
service, repo, task_id, command = setup()
|
||||
question = await create_question(service, task_id, command)
|
||||
result = await service.fail_run(task_id, UUID(repo.run["run_id"]), AgentRunFailureCommand(
|
||||
request_id=uuid4(), expected_revision=2, source_session_id="session-1",
|
||||
failure_code="SOURCE_UNAVAILABLE", failure_detail="Required tool failed"))
|
||||
assert result.lifecycle_status == "failed"
|
||||
assert repo.questions[str(question.question_request_id)]["status"] == "cancelled"
|
||||
assert repo.notifications[0]["lifecycle_status"] == "resolved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_selection_and_optional_question_are_preserved():
|
||||
service, repo, task_id, command = setup()
|
||||
question_data = command.questions[0].model_dump()
|
||||
question_data.update(mode="multiple", allow_custom=False,
|
||||
options=[{"id": "in", "label": "入口计量"}, {"id": "out", "label": "出口计量"}])
|
||||
command = TaskQuestionCreate(**{**command.model_dump(), "questions": [question_data,
|
||||
{**question_data, "question_id": "optional", "required": False}]})
|
||||
question = await create_question(service, task_id, command)
|
||||
await finish(service, repo, task_id)
|
||||
result = await answer_question(service, task_id, question.question_request_id, TaskQuestionReply(
|
||||
request_id=uuid4(), expected_version=1,
|
||||
answers=[{"question_id": "flow", "selected_option_ids": ["in", "out"], "custom_text": ""}]))
|
||||
assert result.execution_generation == 2
|
||||
assert result.questions[0].answers[0].selected_option_ids == ["in", "out"]
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from app.algorithms.valve_isolation import topology_search as valve
|
||||
|
||||
|
||||
@@ -23,6 +25,183 @@ def test_isolatable_keeps_affected_node_ids_and_count():
|
||||
assert result["must_close_valves"] == ["V-close"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def colliding_topology():
|
||||
# The reported adjacent pipes and their same-numbered nodes are in
|
||||
# different valve-bounded regions. Keep this fixture independent of a DB.
|
||||
return [
|
||||
"19025:pipe:17038:17037",
|
||||
"19026:pipe:17038:17039",
|
||||
"AUTO_TCV_4:valve:17037:D",
|
||||
"AUTO_TCV_5:valve:17039:E",
|
||||
"AUTO_TCV_546:valve:17038:R",
|
||||
"32252:pipe:19025:X",
|
||||
"15383:pipe:19026:Y",
|
||||
"V-wrong-25:valve:X:R",
|
||||
"V-wrong-26:valve:Y:R",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["19025", "19026", ["19025", "19026"]])
|
||||
def test_adjacent_pipes_take_priority_over_same_numbered_nodes(colliding_topology, target):
|
||||
result = valve.valve_isolation_analysis(colliding_topology, target)
|
||||
|
||||
assert result["affected_nodes"] == ["17037", "17038", "17039"]
|
||||
assert result["must_close_valves"] == ["AUTO_TCV_4", "AUTO_TCV_5", "AUTO_TCV_546"]
|
||||
assert result["optional_valves"] == []
|
||||
|
||||
|
||||
def test_explicit_node_type_resolves_the_other_side_of_an_id_collision(colliding_topology):
|
||||
result = valve.valve_isolation_analysis(
|
||||
colliding_topology, "19025", accident_element_type="node",
|
||||
)
|
||||
|
||||
assert result["affected_nodes"] == ["19025", "X"]
|
||||
assert result["must_close_valves"] == ["V-wrong-25"]
|
||||
|
||||
|
||||
def test_unambiguous_legacy_node_input_still_works():
|
||||
result = valve.valve_isolation_analysis(["P:pipe:A:B", "V:valve:B:R"], "A")
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_explicit_link_type_does_not_fall_back_to_a_node():
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B"], "A", accident_element_type="link",
|
||||
)
|
||||
|
||||
|
||||
def test_a_boundary_valve_does_not_prove_source_isolation():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:R:A", "V:valve:A:B"], "P", water_source_nodes=["R"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is False
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
assert result["affected_nodes"] == []
|
||||
assert result["affected_node_count"] == 2
|
||||
assert result["supply_affected_nodes"] is None
|
||||
|
||||
|
||||
def test_supply_impact_includes_downstream_regions_but_not_preexisting_outages():
|
||||
links = [
|
||||
"P:pipe:A:B", "V-source:valve:A:R", "V-downstream:valve:B:C",
|
||||
"P-downstream:pipe:C:D", "P-already-off:pipe:X:Y",
|
||||
]
|
||||
result = valve.valve_isolation_analysis(links, "P", water_source_nodes=["R"])
|
||||
|
||||
assert result["isolatable"] is True
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
assert result["affected_node_count"] == 2
|
||||
assert result["supply_affected_nodes"] == ["A", "B", "C", "D"]
|
||||
assert result["supply_affected_node_count"] == 4
|
||||
|
||||
|
||||
def test_closed_links_are_not_traversed_or_recommended_for_closure():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R", "V-closed:valve:B:C"],
|
||||
"P", water_source_nodes=["R"], closed_links=["P-closed", "V-closed"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is True
|
||||
assert result["affected_nodes"] == ["A", "B"]
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
assert result["supply_affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_disabled_valve_cannot_cut_the_source_path():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V-disabled:valve:A:R", "V-other:valve:B:C"],
|
||||
"P", disabled_valves=["V-disabled"], water_source_nodes=["R"],
|
||||
)
|
||||
|
||||
assert result["isolatable"] is False
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
|
||||
|
||||
def test_joint_accidents_fail_if_any_region_remains_connected_to_a_source():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P1:pipe:A:B", "V1:valve:B:R", "P2:pipe:R:C", "V2:valve:C:D"],
|
||||
["P1", "P2"], water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
|
||||
|
||||
def test_missing_sources_do_not_claim_verified_isolation():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:C"], "P", water_source_nodes=[],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
assert result["source_check_performed"] is False
|
||||
assert result["isolation_failure_reason"] == "water_sources_unavailable"
|
||||
assert result["supply_affected_node_count"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sources", [["R1"], ["R1", "R2"]])
|
||||
def test_all_water_sources_are_considered(sources):
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:A:R1", "P2:pipe:B:R2"],
|
||||
"P", water_source_nodes=sources,
|
||||
)
|
||||
assert result["isolatable"] is (len(sources) == 1)
|
||||
|
||||
|
||||
def test_empty_targets_are_rejected():
|
||||
with pytest.raises(ValueError, match="At least one"):
|
||||
valve.valve_isolation_analysis(["P:pipe:A:B"], [])
|
||||
|
||||
|
||||
def test_unknown_disabled_valve_is_rejected():
|
||||
with pytest.raises(ValueError, match="Disabled valve"):
|
||||
valve.valve_isolation_analysis(["P:pipe:A:B"], "P", disabled_valves=["typo"])
|
||||
|
||||
|
||||
def test_service_passes_authoritative_sources_statuses_and_element_type(monkeypatch):
|
||||
from app.services import valve_isolation as service
|
||||
|
||||
projects = []
|
||||
|
||||
def snapshot(network):
|
||||
projects.append(network)
|
||||
return (
|
||||
["P:pipe:A:B", "P-closed:pipe:B:R", "V:valve:A:R"],
|
||||
["R"], ["P-closed"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "get_valve_isolation_topology", snapshot)
|
||||
result = service.analyze_valve_isolation(
|
||||
"trusted-project", ["A"], accident_element_type="node",
|
||||
)
|
||||
|
||||
assert projects == ["trusted-project"]
|
||||
assert result["isolatable"] is True
|
||||
assert result["source_check_performed"] is True
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
assert result["supply_affected_nodes"] == ["A", "B"]
|
||||
|
||||
|
||||
def test_parallel_links_preserve_an_open_bypass():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:R", "P-bypass:pipe:B:R"],
|
||||
"P", water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is False
|
||||
assert result["isolation_failure_reason"] == "connected_to_water_source"
|
||||
|
||||
|
||||
def test_closed_disabled_valve_does_not_reopen_a_known_closed_link():
|
||||
result = valve.valve_isolation_analysis(
|
||||
["P:pipe:A:B", "V:valve:B:R", "V-disabled:valve:A:R"],
|
||||
"P", disabled_valves=["V-disabled"], closed_links=["V-disabled"],
|
||||
water_source_nodes=["R"],
|
||||
)
|
||||
assert result["isolatable"] is True
|
||||
assert result["must_close_valves"] == ["V"]
|
||||
|
||||
|
||||
def test_disabled_valve_expands_affected_area_before_counting():
|
||||
links = [
|
||||
"P-1:pipe:A:B",
|
||||
|
||||
@@ -52,6 +52,36 @@ def test_network_link_nodes_use_one_unified_view_query(monkeypatch) -> None:
|
||||
assert "FROM gis.network_links" in calls[0][1]
|
||||
|
||||
|
||||
def test_isolation_snapshot_reads_authoritative_links_statuses_and_sources_once(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_read_all(name, statement, params=None):
|
||||
calls.append((name, statement, params))
|
||||
return [
|
||||
{"id": "P", "element_type": "pipe", "start_node_id": "A", "end_node_id": "B", "status": None},
|
||||
{"id": "P-closed", "element_type": "pipe", "start_node_id": "A", "end_node_id": "R", "status": "CLOSED"},
|
||||
{"id": "V", "element_type": "valve", "start_node_id": "B", "end_node_id": "R", "status": "CLOSED"},
|
||||
{"id": "PU", "element_type": "pump", "start_node_id": "T", "end_node_id": "A", "status": "CLOSED"},
|
||||
{"id": "R", "element_type": "reservoir", "start_node_id": None, "end_node_id": None, "status": None},
|
||||
{"id": "T", "element_type": "tank", "start_node_id": None, "end_node_id": None, "status": None},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(network_views, "read_all", fake_read_all)
|
||||
|
||||
assert network_views.get_valve_isolation_topology("project_a") == (
|
||||
["P:pipe:A:B", "P-closed:pipe:A:R", "V:valve:B:R", "PU:pump:T:A"],
|
||||
["R", "T"], ["P-closed", "V", "PU"],
|
||||
)
|
||||
assert len(calls) == 1
|
||||
name, statement, _params = calls[0]
|
||||
assert name == "project_a"
|
||||
assert "FROM network.links" in statement
|
||||
assert "network.link_initial_settings" in statement
|
||||
assert "COALESCE(s.status::text, p.status::text)" in statement
|
||||
assert "FROM network.nodes" in statement
|
||||
assert "UNION ALL" in statement
|
||||
|
||||
|
||||
def test_topology_rows_are_loaded_in_two_batch_queries(monkeypatch) -> None:
|
||||
calls = []
|
||||
responses = [
|
||||
|
||||
Reference in New Issue
Block a user