5 Commits
Author SHA1 Message Date
jiang 72ebf4d6c1 feat(agent): 完善分析编排与结果传输
Generic Container CI/CD / test-build-publish (push) Failing after 1m20s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m20s
2026-08-26 18:04:51 +08:00
jiang a438433068 refactor(cli)!: align commands with analysis run APIs
Replace legacy scheme commands with run-id based analysis and timeseries queries. Update sensor placement and SCADA routes, and refresh CLI help, tests, and usage guidance.

BREAKING CHANGE: legacy scheme, risk, and valve-close CLI command paths are removed.
2026-08-26 17:28:41 +08:00
jiang 4ec010455b fix(agent): stabilize sandboxed streaming workflows 2026-08-26 12:26:29 +08:00
jiang 80cfc1f2ab feat(agent): sandbox conversation analysis
Generic Container CI/CD / test-build-publish (push) Successful in 2m44s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m44s
2026-08-25 16:08:33 +08:00
jiang ce04704af2 fix(agent): isolate conversation workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 2m2s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m2s
2026-08-25 13:18:39 +08:00
62 changed files with 4842 additions and 794 deletions
-1
View File
@@ -1,7 +1,6 @@
node_modules/
__pycache__/
.opencode/node_modules/
.opencode/skills/
.local.env
.vscode
docker-compose.yml
+17 -24
View File
@@ -8,7 +8,13 @@ model: deepseek/deepseek-v4-flash
## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划;简单问答不创建计划
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
- 开始工作及每次进入新的业务阶段时调用一次 `activity_update`,用 `title` 概括当前阶段、用 `reason` 说明该阶段为何必要;已有计划时必须通过 `todos` 提交完整计划状态快照,使阶段与任务状态同时更新,不再单独调用 `todowrite` 更新里程碑
- `activity_update` 是过程分组,不是任务清单:活动描述当前正在做的一组动作,`todowrite` 描述整个任务的业务目标与完成状态
## 工作流生命周期
@@ -41,14 +47,19 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
`tjwater_cli.command` 虽然是字符串,但命令空间不是可类推的层级语法。当前会话尚未验证某个完整命令路径和参数时,先调用 `help <命令族或前缀>`;已加载工作流中明确记录且已验证的固定命令可直接使用。禁止根据 `analysis runs` 等已有路径创造其他命令族的同名子路径。收到 `COMMAND_NOT_FOUND` 后只执行返回的 `next_commands` 做命令发现,不得继续猜测近似命令。
## 执行约束
1. 每次工具调用必须在 `reason` 字段填写具体理由
1. 普通工具不填写重复的调用理由,具体动作自动归入当前 `activity_update` 活动;切换业务阶段前先更新活动
2. `tjwater-cli` 输出为 JSON`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果
6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程
7. Bash 始终运行在当前对话专属沙箱中:只能读写当前对话目录、读取 skills 和 Python 环境,不能联网,也不能访问 `/app` 源码、密钥、其他对话或全局 `tool-output`
8. 需要文件输入时,调用 `tjwater_cli(..., store_result=true)`,使用返回的 `data_file.file_path`;不要把 JSON 手工写到 `/tmp`
9. `store_render_ref` 的输入必须是 `{metadata, location: {file_path}, data}` 包装 JSON;若旧脚本输出裸数据,先在当前对话目录内包装,且 `location.file_path` 必须等于包装文件的绝对路径
## 工作流沉淀(skill_manager
@@ -63,30 +74,12 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`
**脚本编写要求——优先用 pipe 串联**
**脚本编写要求——数据获取与本地分析分离**
workflow skill 脚本应尽量用 shell pipe 在一次 subprocess 调用中串联多个 CLI 命令。减少 tool calling 次数,提升执行效率。
```python
import subprocess, os
# env dict 仅用于当前子进程,不污染 os.environ,多用户安全
env = {**os.environ,
"TJWATER_SERVER": auth["server"],
"TJWATER_ACCESS_TOKEN": auth["access_token"], ...}
# 好:一次 shell 调用,pipe 串联
cmd = "tjwater-cli net list-pipes | jq '...' | xargs tjwater-cli analysis calc"
result = subprocess.run(cmd, shell=True, env=env, capture_output=True, text=True)
# 差:多次 subprocess.run
step1 = subprocess.run(["tjwater-cli", "net", "list-pipes"], ...)
step2 = subprocess.run(["tjwater-cli", "analysis", "calc"], ...)
```
管道场景下用子进程隔离的 env dict 传认证,释放 stdin 给管道数据流。不修改全局 `os.environ`。认证 JSON 由内部桥接注入,脚本不硬编码。
CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell 管道中完成。
- 后端数据只能由 `tjwater_cli` 工具获取;认证与网络请求留在 Agent 主进程
- 分析脚本接收 `data_file.file_path`,只处理当前对话目录内的本地文件
- 多份互不依赖的数据可并行调用 `tjwater_cli(..., store_result=true)`,随后在一次沙箱 Bash 中运行 Python 分析
- 脚本输出文件必须写入当前工作目录;禁止使用 `/tmp`、全局 `tool-output` 或硬编码认证环境变量
**触发时机**
- 用户明确说"保存/沉淀/记录工作流"
+181
View File
@@ -0,0 +1,181 @@
---
name: tjwater-cli
description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输出格式、命令族、错误处理及最佳实践。
---
# tjwater-cli 使用说明
## 概述
`tjwater-cli` 是 TJWater 供水管网系统的命令行工具,用于与后端服务交互,支持数据查询、分析和工程操作。所有输出统一为 JSON 格式。
## 工具调用
通过 `tjwater_cli` 工具执行 CLI 命令:
```json
{
"command": "project list",
"timeout": 120,
"store_result": false
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` |
| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
| `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true |
认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。
## 命令发现
Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。
**重要:命令分为三类——触发动作、运行/结果查询与时序数据获取。**
- **触发动作**`simulation`、各类 `analysis`):向服务端发起计算请求。
- **运行/结果查询**`analysis runs`):按 `run_id` 查询运行元数据和非时序结果。
- **时序数据获取**`data timeseries`):实时结果或按 `run_id` 查询节点、管道时序。
```
analysis → 触发计算 → analysis runs list/get/results
↓ run_id
data timeseries analysis → 获取元素时序
simulation → 触发实时模拟 → data timeseries realtime → 获取实时结果
```
通过 `help` 发现命令:
```
tjwater-cli help → 一级命令清单(含 commands 数组和 summary
tjwater-cli help data timeseries → data timeseries 的子命令与参数详情
tjwater-cli help simulation → simulation 的子命令与参数详情
tjwater-cli help COMMAND → 子命令与参数详情
```
`help` 返回 JSON 格式,Agent 可直接解析 `commands` 数组识别可用能力。
**严禁猜测命令或参数!** 所有命令路径、子命令和参数(名称、类型、必填/可选)均以 `help` 输出为准。执行任何命令前,必须先通过 `help` 确认其存在及参数签名,禁止凭经验拼写。
### 已知命令族
| 命令族 | 典型子命令 | 用途 |
|------|-----------|------|
| `network` | `get-pipe-properties`, `get-all-pipes-properties` | 管网元素查询 |
| `component` | `option get`, `option schema` | 模型选项和结构查询 |
| `data` | `timeseries realtime`, `timeseries analysis`, `timeseries scada`, `scada` | 实时、分析时序和 SCADA 查询 |
| `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) |
| `analysis` | `runs`, `sensor-placement` 及各类分析命令 | 触发分析,并按运行 ID 查询元数据与结果 |
| `help` | (无子命令) | 命令发现入口 |
> 完整命令清单始终以 `tjwater-cli help` 实时输出为准。
## 输出格式
所有命令返回统一 JSON 结构:
```json
{
"schema_version": "tjwater-cli/v1",
"ok": true,
"data": { ... },
"error": {
"code": "COMMAND_NOT_FOUND",
"message": "详细错误描述"
}
}
```
- `ok: true` — 成功,数据在 `data` 字段
- `ok: false` — 失败,检查 `error.code``error.message`
### 大结果集处理
超过内联阈值的结果不会终止 CLI,而是保存到当前对话的 `tool-data/` 目录并返回:
```json
{
"ok": true,
"data_file": {
"file_path": "/app/data/conversation-workspaces/conversation-.../tool-data/cli-....json",
"bytes": 38700000,
"content_type": "application/json"
}
}
```
禁止完整读取超大结果集。优先使用:
- 采样/截断参数(如 `--limit``--offset`
- `--field` 按字段过滤
- `store_result=true` 后用沙箱 Python 脚本按字段读取
## 错误码速查
| error.code | 含义 | 来源 | 处理建议 |
|------|------|------|------|
| `UNAUTHENTICATED` | 缺少 access token | CLI `core.py:162` | 检查认证上下文注入 |
| `SERVER_ERROR` | 后端返回 error 状态 | CLI `core.py:400` | 记录 `request_id`,结合后端日志排查 |
| `REQUEST_TIMEOUT` | CLI 请求后端超时 | CLI `core.py:445` | 增大 `timeout` 参数或检查后端负载 |
| `TIMEOUT` | bridge 层进程超时 | Agent `server.ts:199` | 增大 `tjwater_cli``timeout` 参数 |
| `COMMAND_NOT_FOUND` | 命令/子命令不存在 | CLI `helping.py:300` | 执行 `help` 确认命令拼写 |
| `INPUT_NOT_FOUND` | `--input` 文件不存在 | CLI `core.py:243` | 检查文件路径 |
| `REQUEST_FAILED` | 网络连接失败 | CLI `core.py:453` | 检查服务端可达性 |
| `AUTH_CONTEXT_INVALID` | 认证上下文格式错误 | CLI `core.py:111` | 检查 auth headers 格式 |
## 最佳实践
1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写
2. **阶段分组** — 调用 CLI 前确认当前业务阶段已通过 `activity_update` 建立,同一阶段的多个查询无需重复说明理由
3. **按运行 ID 取结果** — 分析完成后先用 `analysis runs list/get/results` 获取 `run_id` 和非时序结果;元素时序再用 `data timeseries analysis` 查询
4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI
5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试
6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据
7. **模拟时长控制** — 实时模拟或分析运行的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大
## 示例
### 查询所有实时节点数据
```json
{
"command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
}
```
> `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。
### 按节点查询分析运行时序字段
```json
{
"command": "data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
}
```
### 查询 SCADA 时序数据
```json
{
"command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00"
}
```
### 触发仿真并获取结果
通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真:
```json
// step 1: 先尝试获取仿真结果
{
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
}
// step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真
{
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60"
}
// step 3: 仿真完成后,再次获取结果(同 step 1)
{
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
}
```
`simulation run` 仅接受 `--start-time`RFC3339,必填)和 `--duration`(整数分钟,必填)。
@@ -0,0 +1,154 @@
---
name: hydraulic-bottleneck-analysis
description: 基于实时水力数据的管网水力瓶颈识别与改造建议。复合评分法(流速×水头损失)定位瓶颈管段,输出分级改造方案。
---
# 水力瓶颈分析工作流
## 概述
本工作流通过复合评分法(流速分级 × 水头损失百分位)从全管网管道中识别水力瓶颈管段,并结合节点压力、管径、粗糙系数给出分级改造建议。
适用场景:管网运行评估、管网改造优先级排序、泵站阀站运行诊断。
## 评分方法论
### 双维度复合评分
| 维度 | 判定标准 | 分值 |
|------|----------|------|
| **流速** | >3.0 m/s = 极危 | 3 |
| | 2.03.0 m/s = 严重 | 2 |
| | 1.52.0 m/s = 偏高 | 1 |
| | <1.5 m/s = 正常 | 0 |
| **水头损失** | >P90 = 严重 | 2 |
| | P80P90 = 中度 | 1 |
| | <P80 = 正常 | 0 |
**瓶颈判定**`(流速≥1 且 水损≥1)``(流速≥2)` —— 即双侧超标或单侧流速严重。
**复合评分** = 流速分值 + 水损分值(最高 5 分),按降序排列。
### 辅助指标
| 指标 | 阈值 | 含义 |
|------|------|------|
| 节点压力 < 20m | — | 低压区域,需增压 |
| 节点压力 20–25m | — | 压力偏低 |
| roughness > 130 | — | 管壁粗糙,建议内衬修复 |
> ⚠️ **setting 字段无效**`data timeseries realtime links` 返回的 `setting` 字段为无效值,不可用于阀门节流或水泵出口判定。若需确定阀门/泵状态,应通过 `network get-link-properties` 逐条查询。
## 数据依赖
| 步骤 | 命令 | 数据量 | 超时 | 关键字段 |
|------|------|--------|------|----------|
| ① 管道属性 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2, length, diameter, roughness |
| ② 管道水力 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, velocity, headloss, time |
| ③ 节点压力 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, time |
> **时间窗口说明**:模拟步长 15 分钟,查询窗口取 `T~T+15min` 可覆盖 12 个时间步。脚本内部按 `--target-time` 精确筛选目标时刻的记录。
> **大结果集处理**:三份调用都使用 `store_result=true`,结果保存到当前对话的 `tool-data/` 目录。脚本直接读取每次返回的 `data_file.file_path`,不得访问全局 `tool-output/`。
## 执行步骤
### 第 1 步:拉取三份数据
并行发起 3 个 `tjwater_cli` 调用(互不依赖):
```bash
# ① 管道静态属性
tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true)
# ② 目标时刻管道水力数据
tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
# ③ 目标时刻节点压力数据
tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
```
### 第 2 步:运行分析脚本
```bash
python3 <skill_dir>/scripts/bottleneck_analysis.py \
--pipe-props <data_file.file_path-①> \
--realtime <data_file.file_path-②> \
--node-pressures <data_file.file_path-③> \
--target-time '2026-04-01T08:00:00+08:00' \
--top 50 \
2>./bottleneck_report.txt
```
**脚本参数**
- `--pipe-props`:管道属性 JSON 文件路径(必填)
- `--realtime`:实时管道水力 JSON 文件路径(必填)
- `--node-pressures`:实时节点压力 JSON 文件路径(必填)
- `--target-time`:目标时刻 ISO8601(必填,如 `2026-04-01T08:00:00+08:00`
- `--top`:输出 Top N 瓶颈管段(默认 100)
**输出**
- **stderr**:文本摘要 + Top 30 表格(可重定向到文件查看)
- **stdout**:完整 JSON 结果,包含 `summary``top_bottlenecks`(含每条的建议 `suggestions`
### 第 3 步:结果解读与分类
按瓶颈严重程度和根因分类:
| 类别 | 判定条件 | 优先处理方案 |
|------|----------|--------------|
| 🔴 极危 | 流速>3.0m/s 且 水损>P90 | 检查模型/扩容/分流 |
| 🔵 串联瓶颈 | 连续多根瓶颈管段共享节点 | 统一规划扩径,一次性解除 |
| 🟣 低压区 | 端节点压力<20m | 扩径 + 评估中途加压 |
| 🟡 高粗糙度 | roughness>130 的瓶颈管段 | 内衬修复降阻 |
| 🟢 短管异常 | length<10m 且 headloss>P95 | 检查模型是否存在局部阻塞 |
### 第 4 步:可视化(可选)
1. **地图定位**:将 Top N 瓶颈管段用 `locate_features` 高亮到地图
2. **统计图表**:用 `show_chart` 展示管径分布柱状图 + 流速等级柱状图
3. **样式渲染**:可对 pipes 图层按 velocity 或 headloss 属性做分层设色
## 改造建议生成逻辑
脚本自动为每条瓶颈管段生成建议,规则如下:
```
if velocity > 2.0 → "流速X.XXm/s过高,需扩容或分流"
elif velocity > 1.5 → "流速X.XXm/s偏高"
if headloss > P95 → "水头损失X.XXm(>P95)严重超标"
elif headloss > P90 → "水头损失X.XXm(>P90)"
if diameter < 100 → "管径Xmm偏小,建议扩径至≥150mm"
elif diameter < 200 → "管径Xmm,评估扩容至250-300mm"
if roughness > 130 → "粗糙系数X偏高,建议内衬修复"
if min_pressure < 20 → "端节点压力X.Xm(<20m),低压区域需增压"
elif min_pressure < 25 → "端节点压力X.Xm偏低"
if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模型异常或局部阻塞"
```
## 参考数据规模(实测)
基于 91,000 管段 / 88,000 节点规模的管网模型:
| 指标 | 实测值 |
|------|--------|
| 管道属性数据量 | 91,052 条 / ~11.7MB |
| 实时管道数据量 | 182,108 条(2步)/ ~38.7MB |
| 实时节点数据量 | 175,814 条(2步)/ ~28.2MB |
| 分析脚本处理时间 | 约 10-20 秒 |
| 典型瓶颈数量 | 50-100 条(占总管数 0.05%-0.1% |
## 已知限制
- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。
- **setting 字段不可用**`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
## Learned Patterns
- [5cbdaa6bcf4e01c22eb2e544] [2026-08 复验] **schema 适配已固化进脚本**bottleneck_analysis.py 现已直接使用 link_id/node_id 主键与 UTC target-time,无需再手工改码。本次 91,052 管段管网识别出 56 条瓶颈(0.06%),典型特征:100-110mm 小管径串联瓶颈(多条水损值近等差递减、逐段累计,如 7 段链 478128→460635→479635→508436→506699→484919→406224),宜按整链统一扩径;另有 1.4m 短管水损 67m 的模型异常信号(399832/399820),需核查局部阻塞或模型设置。分析后若用户需要改造建议落地,可按"极危...
- [5ba58cd24c9cea84b6ab5861] **数据 schema 实测适配(2026-04 验证)**`data timeseries realtime links` 返回记录的管道主键为 `link_id`(不是 `id`),`data timeseries realtime nodes` 返回记录的节点主键为 `node_id`(不是 `id`),且 `time` 字段为 UTC 格式(如 `2026-04-01T00:00:00+00:00`)。运行 `bottleneck_analysis.py` 前需:① 脚本内将 `r['id']` 改为 `r['link_id']``n['id']` 改为 `n['node_id']`;② `--target-tim...
@@ -0,0 +1,80 @@
# 数据源与字段映射
## CLI 命令清单
### ① 管道静态属性
```bash
tjwater-cli network get-all-pipes-properties
```
返回字段:
| 字段 | 类型 | 说明 | 分析用途 |
|------|------|------|----------|
| id | string | 管段 ID | 主键,关联水力数据 |
| node1 | string | 起始节点 ID | 拓扑,定位压力 |
| node2 | string | 终止节点 ID | 拓扑,定位压力 |
| length | float | 管长 (m) | 短管高水损检测 |
| diameter | int | 管径 (mm) | 管径分级,扩容建议 |
| roughness | int | 粗糙系数 | 内衬修复判定 |
| minor_loss | float | 局部水头损失系数 | 暂未使用 |
| status | string | OPEN/CLOSED | 管道状态 |
### ② 管道实时水力
```bash
tjwater-cli data timeseries realtime links --start-time <T> --end-time <T+15min>
```
返回字段:
| 字段 | 类型 | 说明 | 分析用途 |
|------|------|------|----------|
| time | string | 时间戳 ISO8601 | 筛选目标时刻 |
| id | string | 管段 ID | 关联静态属性 |
| flow | float | 流量 | 辅助参考 |
| velocity | float | 流速 (m/s) | **核心评分指标** |
| headloss | float | 水头损失 (m) | **核心评分指标** |
| setting | float | ⚠️ 无效值 | 时序 API 返回的 setting 为无效值,不可用于判定 |
| friction | float | 摩擦系数 | 暂未使用 |
| quality | float | 水质 | 暂未使用 |
| reaction | float | 反应速率 | 暂未使用 |
| status | string | OPEN/CLOSED | 管道状态 |
### ③ 节点实时压力
```bash
tjwater-cli data timeseries realtime nodes --start-time <T> --end-time <T+15min>
```
返回字段:
| 字段 | 类型 | 说明 | 分析用途 |
|------|------|------|----------|
| time | string | 时间戳 ISO8601 | 筛选目标时刻 |
| id | string | 节点 ID | 关联管段端点 |
| pressure | float | 压力 (m) | **低压判定** |
| total_head | float | 总水头 (m) | 含高程信息 |
| actual_demand | float | 实际需水量 | 暂未使用 |
| quality | float | 水质 | 暂未使用 |
## 数据合并逻辑
```
管道属性 (pipe_map[id]) ←─id─→ 实时水力 (rt[time==TT])
node1, node2
节点压力 (node_pressure[id])
```
合并时以**实时水力数据为主表**,左联管道属性,再通过 node1/node2 查找两端压力。
## 时间处理
- 模拟步长:15 分钟
- 查询窗口建议:T 到 T+15min(覆盖 1-2 步)
- 脚本内精确筛选:`r.get('time') == TT` 严格匹配字符串
- 若目标时刻(如 08:00)无数据,需先触发 `simulation run --start-time T --duration 15`
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
水力瓶颈管道综合分析
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
schema 适配(2026-08 实测):realtime links 主键为 link_idrealtime nodes 主键为 node_id
time 为 UTC 格式,--target-time 需传 UTC 时刻(如北京时间 08:00 对应 00:00+00:00)。
"""
import json, sys, math, argparse
from collections import defaultdict
VELOCITY_THRESHOLDS = {"critical": 3.0, "severe": 2.0, "high": 1.5}
def pct(d, v):
if not d: return 0
k = (v/100)*(len(d)-1); f=math.floor(k); c=math.ceil(k)
return d[f] if f==c else d[f]*(c-k)+d[c]*(k-f)
def load_json(path):
with open(path, encoding='utf-8') as f: raw = f.read()
return json.loads(raw[raw.find('{'):])
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--pipe-props', required=True)
ap.add_argument('--realtime', required=True)
ap.add_argument('--node-pressures', required=True)
ap.add_argument('--target-time', required=True)
ap.add_argument('--top', type=int, default=100)
args = ap.parse_args()
TT = args.target_time
# 1. Load
print("[1/5] Loading data...", file=sys.stderr)
props = load_json(args.pipe_props).get('data', [])
pipe_map = {p['id']: p for p in props}
rt = load_json(args.realtime).get('data', [])
rt = [r for r in rt if r.get('time') == TT]
np = load_json(args.node_pressures).get('data', [])
np = [n for n in np if n.get('time') == TT]
node_pressure = {n['node_id']: n.get('pressure', n.get('value',0)) for n in np}
print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr)
# 2. Merge
print("[2/5] Merging...", file=sys.stderr)
merged = []
for r in rt:
pid = r['link_id']; prop = pipe_map.get(pid)
if prop:
merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['link_id']})
print(f" Merged: {len(merged)}", file=sys.stderr)
# 3. Score
print("[3/5] Scoring...", file=sys.stderr)
hl_vals = sorted([abs(m['headloss']) for m in merged])
p80 = pct(hl_vals, 80); p90 = pct(hl_vals, 90); p95 = pct(hl_vals, 95)
scored = []
for m in merged:
vel = abs(m['velocity']); hl = abs(m['headloss'])
diam = m.get('diameter', 0)
length = m.get('length', 0); roughness = m.get('roughness', 0)
n1, n2 = m['node1'], m['node2']
pid = m['id']
vs = 3 if vel>=3 else (2 if vel>=2 else (1 if vel>=1.5 else 0))
vg = "极危" if vs==3 else ("严重" if vs==2 else ("偏高" if vs==1 else "正常"))
hs = 2 if hl>p90 else (1 if hl>p80 else 0)
hg = "严重" if hs==2 else ("中度" if hs==1 else "正常")
composite = vs + hs
is_bn = (vs>=1 and hs>=1) or (vs>=2)
# Node pressures
p1 = node_pressure.get(n1); p2 = node_pressure.get(n2)
min_p = min(p1, p2) if (p1 is not None and p2 is not None) else None
# Pipe category
if diam <= 50: dcat = "微型(≤50mm)"
elif diam <= 100: dcat = "小型(51-100mm)"
elif diam <= 200: dcat = "中型(101-200mm)"
elif diam <= 400: dcat = "大型(201-400mm)"
elif diam <= 800: dcat = "主干(401-800mm)"
else: dcat = "干管(>800mm)"
scored.append({
'id': pid, 'node1': n1, 'node2': n2,
'velocity': round(vel, 4), 'flow': round(m.get('flow',0), 4),
'headloss': round(hl, 4), 'length': round(length, 4),
'diameter': int(diam), 'roughness': int(roughness),
'vel_grade': vg, 'hl_grade': hg, 'composite_score': composite,
'is_bottleneck': is_bn,
'n1_pressure': round(p1, 2) if p1 is not None else None,
'n2_pressure': round(p2, 2) if p2 is not None else None,
'min_pressure': round(min_p, 2) if min_p is not None else None,
'diam_cat': dcat,
})
# 4. Filter & sort
print("[4/5] Filtering bottlenecks...", file=sys.stderr)
bn = [s for s in scored if s['is_bottleneck']]
bn.sort(key=lambda x: (-x['composite_score'], -x['velocity']))
critical = [b for b in bn if b['vel_grade']=='极危']
severe = [b for b in bn if b['vel_grade']=='严重']
high_vel = [b for b in bn if b['vel_grade']=='偏高']
low_p = sum(1 for b in bn if b['min_pressure'] and b['min_pressure'] < 20)
lowish_p = sum(1 for b in bn if b['min_pressure'] and 20 <= b['min_pressure'] < 25)
high_rough = sum(1 for b in bn if b['roughness'] > 130)
# Diam distribution
dd = defaultdict(int)
for b in bn:
d = b['diameter']
if d <= 50: dd['≤50mm']+=1
elif d <= 100: dd['51-100mm']+=1
elif d <= 200: dd['101-200mm']+=1
elif d <= 400: dd['201-400mm']+=1
elif d <= 800: dd['401-800mm']+=1
else: dd['>800mm']+=1
# 5. Output
print("[5/5] Generating report...", file=sys.stderr)
# Print text summary to stderr
print(f"\n{'='*70}", file=sys.stderr)
print(f" 水力瓶颈分析报告 - {TT}", file=sys.stderr)
print(f"{'='*70}", file=sys.stderr)
print(f" 总管道数: {len(scored)}", file=sys.stderr)
print(f" 瓶颈管道: {len(bn)} ({len(bn)/len(scored)*100:.1f}%)", file=sys.stderr)
print(f" 极危(>3.0m/s): {len(critical)}", file=sys.stderr)
print(f" 严重(2.0-3.0): {len(severe)}", file=sys.stderr)
print(f" 偏高(1.5-2.0): {len(high_vel)}", file=sys.stderr)
print(f"\n 水头损失阈值: P80={p80:.4f}m P90={p90:.4f}m P95={p95:.4f}m", file=sys.stderr)
print(f" 平均={sum(hl_vals)/len(hl_vals):.4f}m 最大={max(hl_vals):.4f}m", file=sys.stderr)
print(f"\n 低压节点(<20m): {low_p} 条, 偏低(20-25m): {lowish_p}", file=sys.stderr)
print(f" 高粗糙度(>130): {high_rough}", file=sys.stderr)
print(f"\n 瓶颈管径分布:", file=sys.stderr)
for cat in ['≤50mm','51-100mm','101-200mm','201-400mm','401-800mm','>800mm']:
print(f" {cat}: {dd.get(cat,0)}", file=sys.stderr)
# Text table (Top 30) to stderr
print(f"\n{'='*120}", file=sys.stderr)
print(f"{'排名':<5} {'管道ID':<10} {'流速(m/s)':<10} {'水损(m)':<10} {'管径(mm)':<9} {'管长(km)':<10} {'评分':<4} {'压力1':<8} {'压力2':<8} {'管径类别':<18}", file=sys.stderr)
print('-'*120, file=sys.stderr)
for i, b in enumerate(bn[:30]):
p1s = f"{b['n1_pressure']:.1f}" if b['n1_pressure'] is not None else "-"
p2s = f"{b['n2_pressure']:.1f}" if b['n2_pressure'] is not None else "-"
print(f"{i+1:<5} {b['id']:<10} {b['velocity']:<10.4f} {b['headloss']:<10.2f} {b['diameter']:<9} {b['length']:<10.4f} {b['composite_score']:<4} {p1s:<8} {p2s:<8} {b['diam_cat']:<18}", file=sys.stderr)
# Generate suggestions for each bottleneck
for b in bn:
sug = []
if b['velocity'] > 2.0:
sug.append(f"流速{b['velocity']:.2f}m/s过高,需扩容或分流")
elif b['velocity'] > 1.5:
sug.append(f"流速{b['velocity']:.2f}m/s偏高")
hl = b['headloss']
if hl > p95:
sug.append(f"水头损失{hl:.2f}m(>P95)严重超标")
elif hl > p90:
sug.append(f"水头损失{hl:.2f}m(>P90)")
if b['diameter'] < 100:
sug.append(f"管径{b['diameter']}mm偏小,建议扩径至≥150mm")
elif b['diameter'] < 200:
sug.append(f"管径{b['diameter']}mm,评估扩容至250-300mm")
if b['roughness'] > 130:
sug.append(f"粗糙系数{b['roughness']}偏高,建议内衬修复")
if b['min_pressure'] is not None and b['min_pressure'] < 20:
sug.append(f"端节点压力{b['min_pressure']:.1f}m(<20m),低压区域需增压")
elif b['min_pressure'] is not None and b['min_pressure'] < 25:
sug.append(f"端节点压力{b['min_pressure']:.1f}m偏低")
if b['length'] < 0.01 and b['headloss'] > 0.5:
sug.append("短管高水损,检查是否存在模型异常或局部阻塞")
b['suggestions'] = sug
result = {
'target_time': TT,
'summary': {
'total_pipes': len(scored),
'bottleneck_count': len(bn),
'critical': len(critical), 'severe': len(severe), 'high_vel': len(high_vel),
'low_pressure_nodes': low_p, 'lowish_pressure_nodes': lowish_p,
'high_roughness_pipes': high_rough,
'headloss_p80': round(p80,4), 'headloss_p90': round(p90,4),
'headloss_p95': round(p95,4),
'diameter_distribution': dict(dd),
},
'top_bottlenecks': bn[:args.top],
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,149 @@
---
name: service-area-analysis
description: 基于实时水力模拟数据的水源追溯供水服务范围分区。通过管段流量确定水流方向,从水库BFS追溯服务节点,环网/零流量节点用无向拓扑补充分配,输出分区可视化。
---
# 供水服务范围分区工作流
## 概述
本工作流基于指定时刻的水力模拟结果,通过**流向追溯法**将全部管网节点分配到各水库的服务范围。核心思路:利用管段流量符号判定水流方向,构建有向图从水库逐级追溯,对环网和零流量节点用无向拓扑修正。
适用场景:供水服务范围评估、DMA分区规划、多水源供水格局分析、管网调度策略评估。
## 分区方法
### 第一步:水流方向判定
对于每条管段,根据实时流量 `flow` 判定水流方向:
| flow 值 | 水流方向 | 说明 |
|---------|----------|------|
| `flow > 1e-6` | node1 → node2 | 正向流量 |
| `flow < -1e-6` | node2 → node1 | 反向流量 |
| `|flow| ≤ 1e-6` | 无方向 | 零流量,不参与有向追溯 |
### 第二步:多源有向BFS
1. 以每个水库为根节点,沿水流方向执行 BFS
2. 遍历到的节点归属该水库的服务范围
3. **先到先得**:一个节点首次被访问到的水库即为归属
4. 预期覆盖 **8590%** 节点
### 第三步:无向拓扑修正
有向BFS不可达节点(通常 10–15%)通过无向图邻近性补充分配:
| 不可达原因 | 说明 |
|------------|------|
| 环状管网 | 水流回路中下游节点反向连回上游,有向遍历被阻断 |
| 零流量管段 | `flow≈0` 的管段无方向,其下游节点断开 |
| 多水源交汇 | 交汇区流向往复,非树状拓扑 |
### 输出统计
每个分区输出:
- `node_count`:分区内节点总数
- `total_demand`:总需水量(负数=净供水区)
- `avg_pressure`/`min_pressure`/`max_pressure`:压力统计
## 数据依赖
| 步骤 | 命令 | 数据量 | 超时 | 关键字段 |
|------|------|--------|------|----------|
| ① 管道拓扑 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2 |
| ② 水库属性 | `network get-all-reservoirs-properties` | ~小 | 120s | id, links |
| ③ 管段流量 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, time |
| ④ 节点数据 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, actual_demand, time |
> **时间窗口**:模拟步长 15 分钟,查询 T~T+15min 覆盖 12 个时间步。脚本按 `--target-time` 精确筛选。
> **文件输入**:四份调用都使用 `store_result=true`,包括结果较小的水库属性。脚本读取每次返回的 `data_file.file_path`;文件都属于当前对话,禁止使用 `/tmp` 或全局 `tool-output/`。
## 执行步骤
### 第 1 步:并行拉取数据
4 个 `tjwater_cli` 调用(互不依赖),可一次发起:
```bash
# ① 管道静态拓扑
tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true)
# ② 水库属性
tjwater_cli(command="network get-all-reservoirs-properties", timeout=120, store_result=true)
# ③ 目标时刻管段流量
tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
# ④ 目标时刻节点数据
tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true)
```
### 第 2 步:运行分区脚本
```bash
python3 <skill_dir>/scripts/service_area_partition.py \
--pipe-props <data_file.file_path-①> \
--reservoirs <data_file.file_path-②> \
--links <data_file.file_path-③> \
--nodes <data_file.file_path-④> \
--target-time '2026-04-01T08:00:00+08:00' \
--output ./service_area_partition_wrapper.json
```
**脚本参数**
| 参数 | 说明 | 必填 |
|------|------|------|
| `--pipe-props` | 管道属性 JSON 文件路径 | 是 |
| `--reservoirs` | 水库属性 JSON 文件路径 | 是 |
| `--links` | 实时管段数据 JSON 文件路径 | 是 |
| `--nodes` | 实时节点数据 JSON 文件路径 | 是 |
| `--target-time` | 目标时刻 ISO8601 | 是 |
| `--output` | 分区结果输出路径 | 是 |
**输出**
- **stderr**:处理日志 + 各分区统计表格
- **stdout**:紧凑 JSON 摘要(total_nodes, reservoirs, areas
- **文件**:符合 `store_render_ref` 要求的 `{metadata, location, data}` 包装 JSON,其中 `data` 包含 `node_area_map``area_ids``area_colors` 和分析元数据
### 第 3 步:前端可视化
```bash
# 持久化分区结果
store_render_ref(file_path=<output-file>)
# 渲染节点分区
render_junctions(render_ref="res-xxxxxxxx-xxxx-xx")
# 定位水库
locate_features(ids=[...], feature_type="reservoir")
# 展示统计图表
show_chart(title="各水源分区节点数/压力对比", chart_type="bar", ...)
```
## 参考数据规模
基于 91,000 管段 / 88,000 节点规模的管网模型:
| 指标 | 实测值 |
|------|--------|
| 管道拓扑数据量 | 91,052 条 |
| 水库数量 | 13 个 |
| 总节点数 | 87,907 |
| 有向BFS分配节点 | ~76,900 (87.5%) |
| 无向修正节点 | ~11,000 (12.5%) |
| 分区覆盖率 | 100% |
| 脚本处理时间 | ~15-30 秒 |
| 峰值内存 | ~400-500MB |
## 已知限制
- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果
- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化
- **零流量阈值**`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
## Learned Patterns
- [6614a914a8f7dcc2fc34c1ba] 实时数据时间匹配必须用 norm_time 归一化为 UTC 再比较(数据 time 为 UTC 格式如 2026-06-03T00:00:00+00:00target-time 传 +08:00 会因字符串不等而筛出 0 条);links/nodes 记录主键字段为 link_id/node_id(不是 id)。执行分区前先确认目标时刻存在实时数据,可用 `data timeseries realtime simulation-by-id-time` 探测;数据可能只覆盖某几天(本模型覆盖 2026-06-03 附近,2026-04 与 2026-08 均无数据)。
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
供水服务范围分析与分区 — 可复用脚本
基于实时水力数据,从水库沿水流方向追溯服务范围,自动发现水库并分区。
用法:
python3 service_area_partition.py \
--pipe-props pipes.json \
--reservoirs reservoirs.json \
--links realtime_links.json \
--nodes realtime_nodes.json \
--target-time '2026-04-01T08:00:00+08:00' \
--output ./service_area_partition_wrapper.json
"""
import argparse
import json
import os
import sys
from collections import deque, defaultdict
COLORS = [
"rgba(31,119,180,0.7)", "rgba(255,127,14,0.7)", "rgba(44,160,44,0.7)",
"rgba(148,103,189,0.7)", "rgba(140,86,75,0.7)", "rgba(227,119,194,0.7)",
"rgba(127,127,127,0.7)", "rgba(188,189,34,0.7)", "rgba(23,190,207,0.7)",
"rgba(174,199,232,0.7)", "rgba(255,152,150,0.7)", "rgba(196,156,148,0.7)",
"rgba(219,64,82,0.7)", "rgba(153,204,153,0.7)", "rgba(255,204,102,0.7)",
"rgba(102,102,204,0.7)", "rgba(204,102,102,0.7)", "rgba(102,204,204,0.7)",
"rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)"
]
def norm_time(t):
"""把 ISO8601 字符串归一化为 UTC 的 ISO 字符串,用于跨时区比较"""
from datetime import datetime
return datetime.fromisoformat(t.replace("Z", "+00:00")).astimezone(
__import__("datetime").timezone.utc).isoformat()
def load_json(path, label):
print(f"Loading {label}...", file=sys.stderr)
with open(path) as f:
return json.load(f)
def main():
parser = argparse.ArgumentParser(description="供水服务范围分区分析")
parser.add_argument("--pipe-props", required=True, help="管道属性 JSON 文件")
parser.add_argument("--reservoirs", required=True, help="水库属性 JSON 文件")
parser.add_argument("--links", required=True, help="实时管段数据 JSON 文件")
parser.add_argument("--nodes", required=True, help="实时节点数据 JSON 文件")
parser.add_argument("--target-time", required=True, help="目标时刻 ISO8601")
parser.add_argument("--output", required=True, help="分区结果输出 JSON 路径")
args = parser.parse_args()
# --- Step 1: Load pipe topology ---
pdata = load_json(args.pipe_props, "pipe topology")["data"]
pipe_topology = {}
node_neighbors = defaultdict(set)
for p in pdata:
pid = p["id"]
n1, n2 = p["node1"], p["node2"]
pipe_topology[pid] = (n1, n2)
node_neighbors[n1].add(n2)
node_neighbors[n2].add(n1)
print(f" {len(pdata)} pipes, {len(node_neighbors)} unique nodes", file=sys.stderr)
# --- Step 2: Discover reservoirs ---
rdata = load_json(args.reservoirs, "reservoirs")["data"]
reservoirs = [r["id"] for r in rdata]
print(f" {len(reservoirs)} reservoirs: {reservoirs}", file=sys.stderr)
# --- Step 3: Load link flow at target time ---
ldata = load_json(args.links, "link flows")["data"]
target_ts = norm_time(args.target_time)
target_links = [l for l in ldata if norm_time(l["time"]) == target_ts]
flow_direction = {}
pipe_flow = {}
for l in target_links:
lid = l.get("link_id") or l.get("id")
flow_val = l["flow"]
pipe_flow[lid] = abs(flow_val)
if lid in pipe_topology:
n1, n2 = pipe_topology[lid]
if flow_val > 1e-6:
flow_direction[lid] = (n1, n2)
elif flow_val < -1e-6:
flow_direction[lid] = (n2, n1)
nonzero = len(flow_direction)
print(f" {len(target_links)} link records, {nonzero} with non-zero flow", file=sys.stderr)
# --- Step 4: Load node data at target time ---
ndata = load_json(args.nodes, "node data")["data"]
target_nodes = [n for n in ndata if norm_time(n["time"]) == target_ts]
node_pressure = {}
node_demand = {}
for n in target_nodes:
nid = n.get("node_id") or n.get("id")
node_pressure[nid] = n.get("pressure", 0)
node_demand[nid] = n.get("actual_demand", 0)
print(f" {len(target_nodes)} nodes", file=sys.stderr)
# --- Step 5: Build downstream graph ---
downstream = defaultdict(set)
for _lid, (up, dn) in flow_direction.items():
downstream[up].add(dn)
print(f" downstream graph: {len(downstream)} source nodes", file=sys.stderr)
# --- Step 6: Multi-source BFS along flow direction ---
reservoir_area = {}
node_served_by = {}
queue = deque()
for rid in reservoirs:
reservoir_area[rid] = {rid}
node_served_by[rid] = rid
queue.append((rid, rid, 0))
while queue:
node, source, dist = queue.popleft()
for neighbor in downstream.get(node, set()):
if neighbor not in node_served_by:
node_served_by[neighbor] = source
reservoir_area[source].add(neighbor)
queue.append((neighbor, source, dist + 1))
directed_count = len(node_served_by)
unassigned = set(node_pressure.keys()) - set(node_served_by.keys())
print(f" flow-tracing assigned: {directed_count}, unassigned: {len(unassigned)}", file=sys.stderr)
# --- Step 7: Undirected proximity fallback ---
if unassigned:
print(" running proximity fallback...", file=sys.stderr)
ua_queue = deque()
ua_visited = {}
for nid, src in node_served_by.items():
ua_visited[nid] = src
ua_queue.append((nid, src, 0))
while ua_queue:
node, source, dist = ua_queue.popleft()
for neighbor in node_neighbors.get(node, set()):
if neighbor not in ua_visited:
ua_visited[neighbor] = source
reservoir_area[source].add(neighbor)
ua_queue.append((neighbor, source, dist + 1))
still = set(node_pressure.keys()) - set(ua_visited.keys())
if still:
print(f" WARNING: {len(still)} nodes still unassigned", file=sys.stderr)
node_served_by = ua_visited
# --- Step 8: Compute statistics ---
print(f"\n=== 供水服务范围分区统计 ===\n", file=sys.stderr)
area_stats = []
for rid in reservoirs:
nodes_in = reservoir_area.get(rid, set())
pressures = [node_pressure[n] for n in nodes_in if n in node_pressure]
demands = [node_demand[n] for n in nodes_in if n in node_demand]
area_stats.append({
"reservoir": rid,
"node_count": len(nodes_in),
"total_demand": round(sum(demands), 4),
"avg_pressure": round(sum(pressures)/len(pressures), 2) if pressures else 0,
"min_pressure": round(min(pressures), 2) if pressures else 0,
"max_pressure": round(max(pressures), 2) if pressures else 0,
})
area_stats.sort(key=lambda x: x["node_count"], reverse=True)
for s in area_stats:
print(f" 水源 {s['reservoir']:>8s}: {s['node_count']:>6d} 节点 | "
f"总需水={s['total_demand']:.2f} | "
f"压力 avg={s['avg_pressure']:.1f}m [{s['min_pressure']:.1f}{s['max_pressure']:.1f}m]",
file=sys.stderr)
# --- Step 9: Assign colors and write output ---
area_colors = {}
for i, rid in enumerate(reservoirs):
area_colors[rid] = COLORS[i % len(COLORS)]
output = {
"node_area_map": node_served_by,
"area_ids": reservoirs,
"area_colors": area_colors,
"metadata": {
"analysis_time": args.target_time,
"total_nodes": len(node_served_by),
"reservoir_count": len(reservoirs),
"directed_assigned": directed_count,
"proximity_assigned": len(node_served_by) - directed_count,
"method": "flow-direction-source-tracing"
}
}
absolute_output = os.path.abspath(args.output)
wrapper = {
"metadata": {
"generated_by": "service_area_partition.py",
"schema_version": 1,
},
"location": {"file_path": absolute_output},
"data": output,
}
with open(absolute_output, "w", encoding="utf-8") as f:
json.dump(wrapper, f, ensure_ascii=False)
summary = {
"total_nodes": len(node_served_by),
"reservoirs": len(reservoirs),
"areas": area_stats,
"output_file": absolute_output
}
print(json.dumps(summary, ensure_ascii=False))
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"开始一个新的业务活动阶段。仅在语义阶段发生变化时调用一次,用 title 概括阶段,用 reason 说明本阶段为何必要;同一阶段内的多个工具动作不要重复调用。该工具只更新用户可见的过程信息,不执行外部操作。",
args: {
title: tool.schema
.string()
.min(1)
.describe("面向用户的简短业务阶段标题,不包含工具名、函数名、文件名或命令。"),
reason: tool.schema
.string()
.min(1)
.describe("本阶段对完成用户目标的必要性,使用一句简洁的自然语言。"),
todos: tool.schema
.array(
tool.schema.object({
id: tool.schema.string().optional(),
content: tool.schema.string().min(1),
status: tool.schema.enum([
"pending",
"in_progress",
"completed",
"cancelled",
]),
priority: tool.schema.enum(["low", "medium", "high"]).optional(),
}),
)
.optional()
.describe(
"已有任务计划时提交完整状态快照,使本阶段与任务状态在同一次更新中生效。",
),
},
async execute() {
return "活动阶段已更新。";
},
});
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema
.enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
+53
View File
@@ -0,0 +1,53 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"在当前对话专属的 Landlock 沙箱中运行 Shell 命令。只能读写当前对话工作区,不能访问网络、密钥、其他对话或应用源码。",
args: {
command: tool.schema.string().describe("需要在沙箱中执行的 Shell 命令。"),
description: tool.schema
.string()
.optional()
.describe("面向用户的简短命令说明。"),
timeout: tool.schema
.number()
.optional()
.describe("超时秒数,默认 120,最大 1800。"),
},
async execute(args, context) {
await context.ask({
permission: "bash",
patterns: [args.command],
always: [args.command],
metadata: {
command: args.command,
...(args.description ? { description: args.description } : {}),
},
});
const response = await fetch(
`${internalBaseUrl}/internal/tools/sandbox-shell`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
command: args.command,
description: args.description,
timeout: args.timeout,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+15
View File
@@ -0,0 +1,15 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"提交直接展示给用户的最终回答。只能在全部业务动作和其他工具调用完成后调用一次;调用后不得继续调用任何工具或输出额外文本。",
args: {
answer: tool.schema
.string()
.min(1)
.describe("直接展示给用户的完整最终回答,使用简体中文和 Markdown。"),
},
async execute() {
return "最终回答已提交。";
},
});
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: {
reason: tool.schema
.string()
.describe("Why geocoding is required for the current user request."),
keyword: tool.schema
.string()
.describe("Address or place name to geocode, such as 北京市人民政府."),
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "在前端地图上定位并高亮指定的管网要素。",
args: {
reason: tool.schema
.string()
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema
.array(tool.schema.string())
.describe("Feature ids to locate."),
-3
View File
@@ -11,9 +11,6 @@ export default tool({
action: tool.schema
.enum(["add", "list", "replace", "remove"])
.describe("Memory operation to perform."),
reason: tool.schema
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema
.string()
.describe(
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description:
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: {
reason: tool.schema
.string()
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema
.string()
.describe(
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description:
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
args: {
reason: tool.schema
.string()
.describe("Why prior session history is needed for the current request."),
query: tool.schema
.string()
.describe("What to search for in prior session history."),
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: {
reason: tool.schema
.string()
.describe("Why this chart should be rendered for the user request."),
title: tool.schema.string().optional().describe("Chart title."),
chart_type: tool.schema
.enum(["line", "bar", "pie"])
-3
View File
@@ -21,9 +21,6 @@ export default tool({
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe("Why this skill maintenance action is justified for future reuse."),
skill_path: tool.schema
.string()
.describe(
+24 -10
View File
@@ -3,25 +3,39 @@ import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const importDirectory =
process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports";
type StoreRenderRefArgs = {
file_path?: unknown;
filePath?: unknown;
};
export function resolveStoreRenderFilePath(args: StoreRenderRefArgs): string {
if (typeof args.file_path === "string" && args.file_path.trim() !== "") {
return args.file_path;
}
if (typeof args.filePath === "string" && args.filePath.trim() !== "") {
return args.filePath;
}
throw new Error("file_path is required");
}
export default tool({
description:
`导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`,
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: {
reason: tool.schema
.string()
.describe(
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
),
file_path: tool.schema
.string()
.optional()
.describe(
`位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`,
"位于当前对话工作目录内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。",
),
filePath: tool.schema
.string()
.optional()
.describe("兼容旧调用的参数名;新调用应优先使用 file_path。"),
},
async execute(args, context) {
const filePath = resolveStoreRenderFilePath(args);
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
@@ -32,7 +46,7 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
file_path: filePath,
}),
},
);
+9 -6
View File
@@ -6,20 +6,23 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。",
"通过本地 Agent 桥接调用 tjwater-cli命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。",
args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
command: tool.schema
.string()
.describe(
"tjwater-cli 子命令,不含二进制路径。示例:'data scheme list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'",
"不含二进制路径。只可使用 help 响应或已验证工作流中出现的完整命令路径和参数,禁止类推不同命令族的层级,例如 analysis runs list 存在不代表 simulation runs list 存在。无法确认时调用 'help'、'help simulation' 或相应前缀的 help。",
),
timeout: tool.schema
.number()
.optional()
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
store_result: tool.schema
.boolean()
.optional()
.describe(
"是否强制把结果保存到当前对话工作区并返回 data_file。分析脚本需要文件输入时设为 true。",
),
},
async execute(args, context) {
const response = await fetch(
@@ -32,8 +35,8 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
reason: args.reason,
command: args.command,
store_result: args.store_result,
timeout: args.timeout,
}),
},
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
args: {
reason: tool.schema
.string()
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."),
-3
View File
@@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "打开前端的 SCADA 监测数据历史面板。",
args: {
reason: tool.schema
.string()
.describe("Why SCADA panel interaction is required for this request."),
device_ids: tool.schema
.array(tool.schema.string())
.optional()
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: {
reason: tool.schema
.string()
.describe("Why this map zoom action is needed for the current request."),
x: tool.schema
.number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
+1 -1
View File
@@ -38,4 +38,4 @@ PRs should describe runtime behavior changes, list `bun run check` and any test
Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets.
Automatic approval for `glob` and `grep` must remain limited to canonical paths inside an explicit safe workspace subtree. Broad workspace-root searches, symlink escapes, external paths, and `.env`, `data/`, or `logs/` targets must stay interactive.
Automatic approval for `glob` and `grep` must remain limited to canonical paths inside an explicit safe workspace subtree. Broad source-workspace searches, symlink escapes, external paths, and `.env`, ordinary `data/`, or `logs/` targets must stay interactive. The current session's canonical `data/conversation-workspaces/<conversation-id>/` directory is the only `data/` exception; access must still reject symlinks and every other conversation directory.
+34 -1
View File
@@ -18,6 +18,7 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
apt-get update && apt-get install -y --no-install-recommends \
curl \
jq \
libseccomp2 \
unzip \
python3 \
python3-venv && \
@@ -43,8 +44,35 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
rich \
ipython \
pytest && \
(getent group 10001 >/dev/null || groupadd --gid 10001 tjwater-sandbox) && \
(getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \
rm -rf /var/lib/apt/lists/*
FROM base AS opencode-builder
ARG OPENCODE_SOURCE_COMMIT=a105350812f05f914c768e468559dbd6bd508d8e
ARG OPENCODE_PATCH_VERSION=1.18.13-tjwater.1
WORKDIR /tmp/opencode-src
RUN git init . && \
git remote add origin https://github.com/anomalyco/opencode.git && \
git fetch --depth 1 origin "$OPENCODE_SOURCE_COMMIT" && \
git checkout --detach FETCH_HEAD
COPY patches/opencode-1.18.13-message-phase.patch /tmp/opencode-message-phase.patch
RUN git apply --check /tmp/opencode-message-phase.patch && \
git apply /tmp/opencode-message-phase.patch && \
bun install --frozen-lockfile --ignore-scripts && \
bun test --cwd packages/llm test/provider/openai-responses.test.ts && \
OPENCODE_VERSION="$OPENCODE_PATCH_VERSION" bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui && \
case "$(uname -m)" in \
x86_64) binary=packages/opencode/dist/opencode-linux-x64/bin/opencode ;; \
aarch64|arm64) binary=packages/opencode/dist/opencode-linux-arm64/bin/opencode ;; \
*) echo "unsupported OpenCode build architecture: $(uname -m)" >&2; exit 1 ;; \
esac && \
install -D -m 0755 "$binary" /out/opencode
FROM base AS deps
WORKDIR /app
@@ -59,13 +87,15 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json README.md .gitignore ./
COPY tsconfig.json opencode.json README.md .gitignore Dockerfile ./
COPY src ./src
COPY cli ./cli
COPY scripts ./scripts
COPY .opencode ./.opencode
RUN bun run check
FROM build AS test
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
COPY contracts ./contracts
@@ -77,6 +107,9 @@ RUN bun run test:ci
FROM build AS runner
WORKDIR /app
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN opencode --version
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8787
+20 -4
View File
@@ -29,6 +29,8 @@ data/ 本地运行时数据,禁止提交
logs/ 本地日志,禁止提交
```
仓库跟踪 `.opencode/skills/` 中经过评审的默认工作流基线;部署环境仍可通过持久化卷保留 `skill_manager` 在运行中沉淀的增量内容。默认基线不得包含真实客户数据、认证信息或本地执行产物。
## 本地开发
项目使用 Bun
@@ -74,6 +76,18 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
生产镜像会从固定的 OpenCode `v1.18.13` 源码提交构建 CLI,并应用仓库内的
`patches/opencode-1.18.13-message-phase.patch`。该补丁只透传 OpenAI Responses
输出项已有的 `commentary` / `final_answer` phase,不改变模型行为:过程文本继续写入
可折叠的 Agent 过程卡,`final_answer` 到达后立即按增量写入正式回答。未提供 phase 的
DeepSeek 模型启用 OpenCode 1.18.13 内置的 JSON Schema 最终回答工具
`StructuredOutput`:模型必须先完成全部分析和工具调用,再把完整回答写入 `answer`
该工具成功后 OpenCode 会直接结束运行循环,不再进入下一轮模型或工具调用。Agent 将
`answer` 映射为正式文本推送;若模型未按协议调用该工具,仍保留会话 idle 后提取最终
文本的兼容兜底。
本地直接运行 `bun --watch src/server.ts` 时,`PATH` 中也需要放置应用了同一补丁的
`opencode` CLI,才能启用 phase 驱动的正式文本流式输出。
## 认证续期与学习工具
后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
@@ -86,13 +100,15 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
`opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。
前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill,以及真实路径位于工作区安全子树且不涉及 `.env``data/``logs/` 的 glob/grep;工作区根目录的宽泛搜索仍需确认。“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,Shell 和写操作逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill、沙箱 Shell,以及真实路径位于当前 conversation workspace 内且通过 realpath/symlink 校验的 read/edit/glob/grep“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env``data/``logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env`普通 `data/``logs/` 和其他对话目录保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录。普通 `rm <文件>``rmdir` 和非强制递归删除可在沙箱内执行,`rm -rf`/`rm -fr` 及等价的递归强制删除形式会在执行前拒绝
`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata``location.file_path``data`,且真实路径不能越出导入目录;单文件默认上限为 128 MiB,成功导入后源包装文件会被删除
OpenCode 的内置 Bash 由同名自定义工具覆盖。命令经内部鉴权路由进入独立子进程,切换到专用非 root UID 后应用 Landlock 文件规则和 seccomp 网络规则:当前 conversation workspace 可读写,系统/Python/skills 只读,其他应用文件、其他对话和全局 `tool-output` 不可见;IPv4/IPv6 TCP 与 UDP socket 均被拒绝。启动时会探测 Landlock ABI(要求 ≥4)和 libseccomp,失败时 Agent 直接启动失败,不会回退到未沙箱化 Shell。Shell 环境不包含模型 key、内部 token 或用户 access token`HOME``TMPDIR` 和 Python 缓存均位于当前对话目录
CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)只控制 OpenCode 的内联阈值,较大结果由 OpenCode 写入标准 `tool-output` 目录。Agent 启动时及后续定期清理其中超过 `RESULT_REF_TTL_HOURS`(默认 7 天)的 `tool_*` 文件
`store_render_ref` 只会从当前对话绑定的工作目录导入包装格式 JSON;工作区根目录固定为项目内的 `./data/conversation-workspaces`,以确保 OpenCode 能继续发现项目配置和工具。文件必须包含 `metadata``location.file_path``data`,且真实路径不能越出当前对话目录;单文件默认上限为 128 MiB,成功导入后只删除这一份源包装文件。升级前已经存在的会话没有独立工作目录,需要新建对话后才能使用该导入能力
CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)仅决定内联还是落盘:较大结果写入当前对话的 `tool-data/` 并返回 `data_file.file_path`,不会因为超过 12000 字节杀掉 CLI;分析脚本需要文件输入时可由 `tjwater_cli(store_result=true)` 强制落盘小结果。会话暂存数据不自动删除。OpenCode 自身为其他工具生成的全局 `tool-output` 仍按 `RESULT_REF_TTL_HOURS`(默认 7 天)清理,但沙箱命令不能访问该目录。
## 配置与安全
+38 -55
View File
@@ -1,10 +1,9 @@
import { CliError } from "../core/errors.js";
import { emitApi, requestJson } from "../core/http.js";
import { emitApi } from "../core/http.js";
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js";
import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -22,30 +21,13 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
scheme_name: schemeName,
},
requireProject: true,
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
}, ["tjwater-cli analysis runs list"]);
}
function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" });
const mode = validateChoice(requiredString(values, "mode"), ["close", "isolation"] as const, "--mode");
if (mode === "close") {
const valves = optionalStringArray(values, "valve");
const startTime = optionalString(values, "start-time");
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
return emitApi(ctx, "阀门关闭分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
params: {
start_time: parseTime(startTime, "--start-time"),
valves,
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
function analysisValveIsolation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { element: "repeat", "disabled-valve": "repeat" });
const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "at least one --element is required", 2);
return emitApi(ctx, "阀门隔离分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
@@ -97,14 +79,16 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
}
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
function sensorPlacementRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", {
method: "POST",
path: "/pressure-sensor-placement-kmeans",
path: "/sensor-placement-runs",
body: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
sensor_number: requiredNumber(values, "count"),
run_name: requiredString(values, "run-name"),
sensor_type: "pressure",
method: validateChoice(requiredString(values, "method"), ["sensitivity", "kmeans"] as const, "--method"),
sensor_count: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0,
},
requireProject: true,
@@ -125,14 +109,25 @@ function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, pa
});
}
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
function analysisRunGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
return emitApi(ctx, "读取分析运行成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}`,
requireProject: true,
});
}
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> {
const { positionals } = parseOptions(argv);
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true });
function analysisRunResults(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
return emitApi(ctx, "读取分析结果成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}/results`,
params: { result_type: optionalString(values, "result-type") },
requireProject: true,
});
}
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -156,34 +151,22 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
}
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
}
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
}
export const analysisHandlers: HandlerMap = {
"analysis burst": analysisBurst,
"analysis valve": analysisValve,
"analysis valve isolation": analysisValveIsolation,
"analysis flushing": analysisFlushing,
"analysis age": analysisAge,
"analysis contaminant": analysisContaminant,
"analysis sensor-placement kmeans": sensorKmeans,
"analysis sensor-placement run": sensorPlacementRun,
"analysis sensor-placement list": (ctx) => emitApi(ctx, "读取传感器选址运行列表成功", { method: "GET", path: "/sensor-placement-runs", requireProject: true }),
"analysis sensor-placement get": (ctx, argv) => {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取传感器选址运行成功", { method: "GET", path: `/sensor-placement-runs/${encodeURIComponent(requiredString(values, "run-id"))}`, requireProject: true });
},
"analysis runs list": (ctx) => emitApi(ctx, "读取分析运行列表成功", { method: "GET", path: "/analysis/runs", requireProject: true }),
"analysis runs get": analysisRunGet,
"analysis runs results": analysisRunResults,
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
"analysis burst-location locate": burstLocation,
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
"analysis risk network": riskNetwork,
};
+36 -57
View File
@@ -1,8 +1,8 @@
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js";
import { emitApi, requestAllPages } from "../core/http.js";
import { fieldsFor, optionalNumber, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { success } from "../core/output.js";
import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -42,14 +42,17 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
});
}
function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
function analysisSeries(ctx: RuntimeContext, argv: string[], type: ElementType): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案管道数据成功", {
const runId = requiredString(values, "run-id");
const idOption = type === "pipe" ? "link" : "node";
const elementId = requiredString(values, idOption);
const elementPath = type === "pipe" ? "links" : "nodes";
return emitApi(ctx, type === "pipe" ? "读取分析管道字段成功" : "读取分析节点字段成功", {
method: "GET",
path: "/timeseries/schemes/links",
path: `/timeseries/analysis/runs/${encodeURIComponent(runId)}/${elementPath}/${encodeURIComponent(elementId)}`,
params: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
},
@@ -57,40 +60,21 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
});
}
function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
function analysisValues(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案节点字段成功", {
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取分析运行指定时刻结果成功", {
method: "GET",
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
path: `/timeseries/analysis/runs/${encodeURIComponent(requiredString(values, "run-id"))}/values`,
params: {
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
result_time: parseTime(requiredString(values, "time"), "--time"),
element_type: backendElementType(type),
field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
},
requireProject: true,
});
}
function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const query = validateChoice(requiredString(values, "query"), ["by-id-time", "by-scheme-time-property"] as const, "--query");
const type = validateChoice(optionalString(values, "type") || "pipe", ["pipe", "junction"] as const, "--type") as ElementType;
const params: Record<string, unknown> = {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
query_time: parseTime(requiredString(values, "time"), "--time"),
type: backendElementType(type),
};
if (query === "by-id-time") {
params.id = requiredString(values, "id");
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
}
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
}
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "device-id": "repeat" });
const params: Record<string, unknown> = {
@@ -110,8 +94,8 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
};
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"));
if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" });
const runId = optionalString(values, "run-id");
if (runId && kind !== "element-scada") params.run_id = runId;
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
else {
@@ -130,31 +114,28 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
requiredString(values, "pipe");
requiredString(values, "start-time");
return emitApi(ctx, "读取管道健康预测成功", {
method: "GET",
path: "/pipeline-health-predictions",
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
params: { query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true,
});
}
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
return emitApi(ctx, "读取 SCADA 设备成功", { method: "GET", path: "/scada-devices/detail", params: { device_id: requiredString(values, "device-id") }, requireProject: true });
}
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
}
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
async function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "page-size": "integer" });
const pageSize = Math.min(1000, Math.max(1, optionalNumber(values, "page-size") ?? 1000));
const [data, durationMs] = await requestAllPages(
ctx,
{ method: "GET", path: "/scada-devices", requireProject: true },
pageSize,
);
success("读取 SCADA 设备列表成功", data, ctx, durationMs);
}
export const dataHandlers: HandlerMap = {
@@ -162,15 +143,13 @@ export const dataHandlers: HandlerMap = {
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
"data timeseries scheme links": schemeLinks,
"data timeseries scheme node-field": schemeNodeField,
"data timeseries scheme simulation": schemeSimulation,
"data timeseries analysis link-field": (ctx, argv) => analysisSeries(ctx, argv, "pipe"),
"data timeseries analysis node-field": (ctx, argv) => analysisSeries(ctx, argv, "junction"),
"data timeseries analysis values": analysisValues,
"data timeseries scada query": scadaQuery,
"data timeseries composite": composite,
"data timeseries composite pipeline-health": pipelineHealth,
"data pipeline-health": pipelineHealth,
"data scada get": dataScadaGet,
"data scada list": dataScadaList,
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
"data scheme get": dataSchemeGet,
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
"data scada schema": (ctx) => emitApi(ctx, "读取 SCADA 设备 schema 成功", { method: "GET", path: "/network-schemas/scada-device", requireProject: true }),
};
+21 -31
View File
@@ -7,25 +7,21 @@ export const GROUP_SUMMARIES: Record<string, string> = {
"component option": "组件选项查询命令。",
simulation: "模拟运行与调度相关命令。",
analysis: "分析计算与诊断相关命令。",
"analysis runs": "分析运行及结果查询命令。",
"analysis leakage": "漏损分析相关命令。",
"analysis leakage schemes": "漏损方案查询命令。",
"analysis burst-detection": "爆管检测相关命令。",
"analysis burst-detection schemes": "爆管检测方案查询命令。",
"analysis burst-location": "爆管定位相关命令。",
"analysis burst-location schemes": "爆管定位方案查询命令。",
"analysis risk": "风险分析相关命令。",
"analysis sensor-placement": "传感器选址相关命令。",
data: "时序、SCADA 和方案数据查询命令。",
data: "时序、SCADA 和分析数据查询命令。",
"data timeseries": "时序数据查询命令。",
"data timeseries realtime": "实时模拟时序查询命令。",
"data timeseries scheme": "方案时序查询命令。",
"data timeseries analysis": "按分析运行 ID 查询历史时序命令。",
"data timeseries scada": "SCADA 时序查询命令。",
"data timeseries composite": "复合时序查询命令。",
"data scada": "SCADA 元数据查询命令。",
"data scheme": "方案数据查询命令。",
};
export const HIDDEN_PATH_PREFIXES = ["analysis burst-location", "analysis risk"];
export const HIDDEN_PATH_PREFIXES: string[] = [];
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
@@ -44,39 +40,33 @@ const commandSpecs: readonly CommandSpec[] = [
["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]],
["component option get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]],
["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]],
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list"]],
["analysis valve", "阀门工况分析", ["--mode <MODE>", "[--start-time <START_TIME>]", "[--valve <VALVE>]", "[--element <ELEMENT>]", "[--disabled-valve <DISABLED_VALVE>]", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", "tjwater-cli analysis valve --mode isolation --element E1 --element E2", "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3"]],
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli analysis runs list"]],
["analysis valve isolation", "执行阀门隔离分析", ["--element <ELEMENT>", "[--disabled-valve <DISABLED_VALVE>]"], ["tjwater-cli analysis valve isolation --element E1 --element E2", "tjwater-cli analysis valve isolation --element E1 --disabled-valve V3"]],
["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]],
["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]],
["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]],
["analysis sensor-placement kmeans", "执行 KMeans 传感器选址", ["--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01"]],
["analysis sensor-placement run", "执行传感器选址", ["--run-name <RUN_NAME>", "--method <METHOD>", "--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]"], ["tjwater-cli analysis sensor-placement run --run-name placement_case_01 --method kmeans --count 5 --min-diameter 100", "tjwater-cli analysis sensor-placement run --run-name placement_case_02 --method sensitivity --count 5"]],
["analysis sensor-placement list", "列出传感器选址运行", [], ["tjwater-cli analysis sensor-placement list"]],
["analysis sensor-placement get", "读取传感器选址运行", ["--run-id <RUN_ID>"], ["tjwater-cli analysis sensor-placement get --run-id 00000000-0000-0000-0000-000000000001"]],
["analysis runs list", "列出分析运行", [], ["tjwater-cli analysis runs list"]],
["analysis runs get", "读取分析运行", ["--run-id <RUN_ID>"], ["tjwater-cli analysis runs get --run-id 00000000-0000-0000-0000-000000000001"]],
["analysis runs results", "读取分析运行结果", ["--run-id <RUN_ID>", "[--result-type <RESULT_TYPE>]"], ["tjwater-cli analysis runs results --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli analysis runs results --run-id 00000000-0000-0000-0000-000000000001 --result-type leakage_identification"]],
["analysis leakage identify", "执行漏损识别", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]],
["analysis leakage schemes list", "列出漏损方案", [], ["tjwater-cli analysis leakage schemes list"]],
["analysis leakage schemes get", "读取漏损方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis leakage schemes get my_scheme"]],
["analysis burst-detection detect", "执行爆管检测", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]],
["analysis burst-detection schemes list", "列出爆管检测方案", [], ["tjwater-cli analysis burst-detection schemes list"]],
["analysis burst-detection schemes get", "读取爆管检测方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-detection schemes get my_scheme"]],
["analysis burst-location locate", "执行爆管定位", ["--start-time <START_TIME>", "--end-time <END_TIME>", "--burst-leakage <BURST_LEAKAGE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]],
["analysis burst-location schemes list", "列出爆管定位方案", [], ["tjwater-cli analysis burst-location schemes list"]],
["analysis burst-location schemes get", "读取爆管定位方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-location schemes get my_scheme"]],
["analysis risk pipe-now", "读取单条管道当前风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-now --pipe P1"]],
["analysis risk pipe-history", "读取单条管道历史风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-history --pipe P1"]],
["analysis risk network", "读取全网风险", [], ["tjwater-cli analysis risk network"]],
["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]],
["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]],
["data timeseries scheme links", "查询方案管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
["data timeseries scheme node-field", "查询方案节点字段时序", ["--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
["data timeseries scheme simulation", "查询方案模拟数据", ["--query <QUERY>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--id <ID>]", "[--time <TIME>]", "[--type <TYPE>]", "[--property <PROPERTY>]"], ["tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme"]],
["data timeseries analysis link-field", "查询分析运行的管道字段时序", ["--run-id <RUN_ID>", "--link <LINK>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis link-field --run-id 00000000-0000-0000-0000-000000000001 --link P1 --field flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries analysis node-field", "查询分析运行的节点字段时序", ["--run-id <RUN_ID>", "--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries analysis values", "查询分析运行指定时刻的全部元素字段", ["--run-id <RUN_ID>", "--type <TYPE>", "--time <TIME>", "--field <FIELD>"], ["tjwater-cli data timeseries analysis values --run-id 00000000-0000-0000-0000-000000000001 --type junction --time 2025-01-02T03:30:00+08:00 --field pressure"]],
["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]],
["data timeseries composite", "执行复合时序查询", ["[--kind <KIND>]", "[--feature <FEATURE>]", "[--start-time <START_TIME>]", "[--end-time <END_TIME>]", "[--pipe <PIPE>]", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
["data timeseries composite pipeline-health", "查询管道健康预测", ["--pipe <PIPE>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data scada get", "读取单条 SCADA 元数据", ["--kind <KIND>", "--id <ID>"], ["tjwater-cli data scada get --kind info --id SCADA-001"]],
["data scada list", "列出 SCADA 元数据", ["--kind <KIND>"], ["tjwater-cli data scada list --kind info"]],
["data scheme schema", "读取方案 schema", [], ["tjwater-cli data scheme schema"]],
["data scheme get", "读取单条方案", ["--name <NAME>"], ["tjwater-cli data scheme get --name my_scheme"]],
["data scheme list", "列出方案", [], ["tjwater-cli data scheme list"]],
["data timeseries composite", "执行复合时序查询", ["--kind <KIND>", "--feature <FEATURE>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--run-id <RUN_ID>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
["data pipeline-health", "查询指定时刻的管道健康预测", ["--time <TIME>"], ["tjwater-cli data pipeline-health --time 2025-01-02T04:00:00+08:00"]],
["data scada get", "读取单条 SCADA 设备", ["--device-id <DEVICE_ID>"], ["tjwater-cli data scada get --device-id SCADA-001"]],
["data scada list", "列出 SCADA 设备", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli data scada list"]],
["data scada schema", "读取 SCADA 设备 schema", [], ["tjwater-cli data scada schema"]],
];
export const commandDocs = new Map<string, CommandDoc>(
@@ -107,7 +97,7 @@ function optionDoc(path: string, token: string): CommandOptionDoc {
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
const name = clean.slice(2).split(/\s+/)[0]!;
const repeatedOptions: Record<string, string[]> = {
"analysis valve": ["valve", "element", "disabled-valve"],
"analysis valve isolation": ["element", "disabled-valve"],
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
"data timeseries scada query": ["device-id"],
"data timeseries composite": ["feature"],
+102 -48
View File
@@ -13,32 +13,32 @@ const visibleCommandPaths = [
"analysis age",
"analysis burst",
"analysis burst-detection detect",
"analysis burst-detection schemes get",
"analysis burst-detection schemes list",
"analysis burst-location locate",
"analysis contaminant",
"analysis flushing",
"analysis leakage identify",
"analysis leakage schemes get",
"analysis leakage schemes list",
"analysis sensor-placement kmeans",
"analysis valve",
"analysis runs get",
"analysis runs list",
"analysis runs results",
"analysis sensor-placement get",
"analysis sensor-placement list",
"analysis sensor-placement run",
"analysis valve isolation",
"component option get",
"component option schema",
"data scada get",
"data scada list",
"data scheme get",
"data scheme list",
"data scheme schema",
"data scada schema",
"data pipeline-health",
"data timeseries analysis link-field",
"data timeseries analysis node-field",
"data timeseries analysis values",
"data timeseries composite",
"data timeseries composite pipeline-health",
"data timeseries realtime links",
"data timeseries realtime nodes",
"data timeseries realtime simulation-by-id-time",
"data timeseries realtime simulation-by-time-property",
"data timeseries scada query",
"data timeseries scheme links",
"data timeseries scheme node-field",
"data timeseries scheme simulation",
"network get-all-pipes-properties",
"network get-all-pumps-properties",
"network get-all-reservoirs-properties",
@@ -53,14 +53,7 @@ const visibleCommandPaths = [
"simulation run",
];
const hiddenCommandPaths = [
"analysis burst-location locate",
"analysis burst-location schemes get",
"analysis burst-location schemes list",
"analysis risk network",
"analysis risk pipe-history",
"analysis risk pipe-now",
];
const hiddenCommandPaths = [];
function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => {
@@ -144,7 +137,7 @@ function normalizeSeenRequest(request) {
function defaultContractResponse(req) {
const url = new URL(req.url, "http://127.0.0.1");
if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves"].includes(url.pathname)) {
if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves", "/api/v1/scada-devices"].includes(url.pathname)) {
return {
items: [],
limit: Number(url.searchParams.get("limit")),
@@ -338,18 +331,86 @@ test("maps CLI pipe and junction types to backend link and node types", async ()
for (const args of [
["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at],
["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"],
["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "P1", "--type", "pipe", "--time", at, "--scheme", "scheme_case"],
["data", "timeseries", "analysis", "values", "--run-id", "00000000-0000-0000-0000-000000000001", "--type", "pipe", "--time", at, "--field", "flow"],
]) {
const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr);
}
const queries = server.seen.map(normalizeSeenRequest).map((request) => request.query.type);
assert.deepEqual(queries, ["node", "link", "link"]);
const requests = server.seen.map(normalizeSeenRequest);
assert.deepEqual(
[requests[0].query.type, requests[1].query.type, requests[2].query.element_type],
["node", "link", "link"],
);
} finally {
await server.close();
}
});
test("uses run-id based analysis, sensor placement, and SCADA contracts", async () => {
const server = await startJsonServer(defaultContractResponse);
const auth = { server: server.url, access_token: "token-4", project_id: "project-1" };
const runId = "00000000-0000-0000-0000-000000000001";
const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00";
try {
for (const args of [
["analysis", "sensor-placement", "run", "--run-name", "placement-1", "--method", "kmeans", "--count", "5", "--min-diameter", "100"],
["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"],
["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end],
["data", "scada", "get", "--device-id", "SCADA-001"],
]) {
const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr);
}
const requests = server.seen.map(normalizeSeenRequest);
assert.deepEqual(requests[0], {
body: {
run_name: "placement-1",
sensor_type: "pressure",
method: "kmeans",
sensor_count: 5,
min_diameter: 100,
},
headers: { authorization: "Bearer token-4", "x-project-id": "project-1" },
method: "POST",
path: "/api/v1/sensor-placement-runs",
query: {},
});
assert.equal(requests[1].path, `/api/v1/analysis/runs/${runId}/results`);
assert.deepEqual(requests[1].query, { result_type: "leakage_identification" });
assert.equal(requests[2].path, `/api/v1/timeseries/analysis/runs/${runId}/nodes/J1`);
assert.deepEqual(requests[2].query, {
end_time: end,
field: "pressure",
start_time: start,
});
assert.equal(requests[3].path, "/api/v1/scada-devices/detail");
assert.deepEqual(requests[3].query, { device_id: "SCADA-001" });
} finally {
await server.close();
}
});
test("does not expose removed scheme, risk, or valve-close commands", async () => {
for (const command of [
"analysis leakage schemes list",
"analysis risk network",
"data scheme list",
"data timeseries scheme simulation",
]) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, result.stderr);
const payload = parseJsonResult(result);
assert.equal(payload.ok, false, command);
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
}
const valveClose = await runCli(["analysis", "valve", "--mode", "close"]);
assert.equal(valveClose.exitCode, 2, valveClose.stderr);
assert.equal(parseJsonResult(valveClose).error.code, "COMMAND_NOT_FOUND");
});
test("executes every command and key variant against the backend contract", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
try {
@@ -372,6 +433,7 @@ test("executes every command and key variant against the backend contract", asyn
const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00";
const at = "2025-01-02T03:30:00+08:00";
const runId = "00000000-0000-0000-0000-000000000001";
const cases = [
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
@@ -388,42 +450,34 @@ test("executes every command and key variant against the backend contract", asyn
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]],
["analysis valve close", ["analysis", "valve", "--mode", "close", "--start-time", start, "--valve", "V1", "--valve", "V2", "--duration", "900", "--scheme", "valve_case"]],
["analysis valve isolation", ["analysis", "valve", "--mode", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis valve isolation", ["analysis", "valve", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]],
["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]],
["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]],
["analysis sensor-placement kmeans", ["analysis", "sensor-placement", "kmeans", "--count", "5", "--min-diameter", "100", "--scheme", "place_case"]],
["analysis sensor-placement run", ["analysis", "sensor-placement", "run", "--run-name", "place_case", "--method", "kmeans", "--count", "5", "--min-diameter", "100"]],
["analysis sensor-placement list", ["analysis", "sensor-placement", "list"]],
["analysis sensor-placement get", ["analysis", "sensor-placement", "get", "--run-id", runId]],
["analysis runs list", ["analysis", "runs", "list"]],
["analysis runs get", ["analysis", "runs", "get", "--run-id", runId]],
["analysis runs results", ["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"]],
["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]],
["analysis leakage schemes list", ["analysis", "leakage", "schemes", "list"]],
["analysis leakage schemes get", ["analysis", "leakage", "schemes", "get", "leak_case"]],
["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]],
["analysis burst-detection schemes list", ["analysis", "burst-detection", "schemes", "list"]],
["analysis burst-detection schemes get", ["analysis", "burst-detection", "schemes", "get", "detect_case"]],
["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]],
["analysis burst-location schemes list", ["analysis", "burst-location", "schemes", "list"]],
["analysis burst-location schemes get", ["analysis", "burst-location", "schemes", "get", "locate_case"]],
["analysis risk pipe-now", ["analysis", "risk", "pipe-now", "--pipe", "P1"]],
["analysis risk pipe-history", ["analysis", "risk", "pipe-history", "--pipe", "P1"]],
["analysis risk network", ["analysis", "risk", "network"]],
["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]],
["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]],
["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]],
["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]],
["data scheme links", ["data", "timeseries", "scheme", "links", "--start-time", start, "--end-time", end, "--scheme", "scheme_case", "--scheme-type", "simulation"]],
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]],
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
["data analysis link-field", ["data", "timeseries", "analysis", "link-field", "--run-id", runId, "--link", "P1", "--field", "flow", "--start-time", start, "--end-time", end]],
["data analysis node-field", ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end]],
["data analysis values", ["data", "timeseries", "analysis", "values", "--run-id", runId, "--type", "pipe", "--time", at, "--field", "flow"]],
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--run-id", runId]],
["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]],
["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]],
["data composite pipeline-health", ["data", "timeseries", "composite", "pipeline-health", "--pipe", "P1", "--start-time", start, "--end-time", end]],
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]],
["data scada list", ["data", "scada", "list", "--kind", "info"]],
["data scheme schema", ["data", "scheme", "schema"]],
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
["data scheme list", ["data", "scheme", "list"]],
["data pipeline-health", ["data", "pipeline-health", "--time", end]],
["data scada get", ["data", "scada", "get", "--device-id", "SCADA-001"]],
["data scada list", ["data", "scada", "list"]],
["data scada schema", ["data", "scada", "schema"]],
];
for (const [name, args] of cases) {
+9 -7
View File
@@ -44,6 +44,12 @@
"bash": {
"*": "ask",
"rm *": "ask",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -r -f *": "deny",
"rm -f -r *": "deny",
"rm --recursive --force *": "deny",
"rm --force --recursive *": "deny",
"rmdir *": "ask",
"mv *": "ask",
"chmod *": "ask",
@@ -51,15 +57,11 @@
"sudo *": "ask",
"curl *": "ask",
"wget *": "ask",
"*.env*": "deny",
"*data/*": "deny",
"* data": "deny",
"*/data": "deny",
"*logs/*": "deny",
"* logs": "deny",
"*/logs": "deny"
"*.env*": "deny"
},
"question": "allow",
"activity_update": "allow",
"final_answer": "allow",
"task": "deny",
"todo": "allow",
"todoread": "allow",
@@ -0,0 +1,186 @@
diff --git a/package.json b/package.json
index 15725c8..4378766 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.14",
+ "packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts
index 4936d31..53138af 100644
--- a/packages/llm/src/protocols/openai-responses.ts
+++ b/packages/llm/src/protocols/openai-responses.ts
@@ -177,6 +177,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
const OpenAIResponsesStreamItem = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
+ phase: optionalNull(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
@@ -238,6 +239,7 @@ interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
+ readonly textMetadata: Readonly<Record<string, ProviderMetadata>>
readonly store: boolean | undefined
}
@@ -614,9 +616,19 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
+ const itemID = event.item_id ?? "text-0"
const events: LLMEvent[] = []
return [
- { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
+ {
+ ...state,
+ lifecycle: Lifecycle.textDelta(
+ state.lifecycle,
+ events,
+ itemID,
+ event.delta,
+ state.textMetadata[itemID],
+ ),
+ },
events,
]
}
@@ -655,6 +667,22 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
+ if (item?.type === "message" && item.id) {
+ const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined
+ return [
+ {
+ ...state,
+ textMetadata: {
+ ...state.textMetadata,
+ [item.id]: openaiMetadata({
+ itemId: item.id,
+ ...(phase ? { phase } : {}),
+ }),
+ },
+ },
+ NO_EVENTS,
+ ]
+ }
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -812,6 +840,20 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
+ if (item.type === "message" && item.id) {
+ const events: LLMEvent[] = []
+ const lifecycle = Lifecycle.textEnd(state.lifecycle, events, item.id, state.textMetadata[item.id])
+ const { [item.id]: _removed, ...textMetadata } = state.textMetadata
+ return [
+ {
+ ...state,
+ lifecycle,
+ textMetadata,
+ },
+ events,
+ ] satisfies StepResult
+ }
+
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
@@ -969,6 +1011,7 @@ export const protocol = Protocol.make({
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
reasoningItems: {},
+ textMetadata: {},
store: OpenAIOptions.store(request),
}),
step,
diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts
index eb6c95d..64248be 100644
--- a/packages/llm/src/protocols/utils/lifecycle.ts
+++ b/packages/llm/src/protocols/utils/lifecycle.ts
@@ -14,13 +14,22 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
-export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
+export const textDelta = (
+ state: State,
+ events: LLMEvent[],
+ id: string,
+ text: string,
+ providerMetadata?: ProviderMetadata,
+): State => {
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
- events.push(LLMEvent.textDelta({ id, text }))
+ events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
return stepped
}
- events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
+ events.push(
+ LLMEvent.textStart({ id, providerMetadata }),
+ LLMEvent.textDelta({ id, text, providerMetadata }),
+ )
return { ...stepped, text: new Set([...stepped.text, id]) }
}
diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts
index cd8bad5..89ae576 100644
--- a/packages/llm/test/provider/openai-responses.test.ts
+++ b/packages/llm/test/provider/openai-responses.test.ts
@@ -754,6 +754,45 @@ describe("OpenAI Responses route", () => {
}),
)
+ it.effect("preserves assistant message phase in text provider metadata", () =>
+ Effect.gen(function* () {
+ const message = {
+ type: "message",
+ id: "msg_final",
+ phase: "final_answer",
+ }
+ const body = sseEvents(
+ { type: "response.output_item.added", item: message },
+ { type: "response.output_text.delta", item_id: "msg_final", delta: "Final" },
+ { type: "response.output_item.done", item: message },
+ { type: "response.completed", response: { id: "resp_1" } },
+ )
+
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
+ const metadata = {
+ openai: {
+ itemId: "msg_final",
+ phase: "final_answer",
+ },
+ }
+
+ expect(response.text).toBe("Final")
+ expect(response.events).toMatchObject([
+ { type: "step-start", index: 0 },
+ { type: "text-start", id: "msg_final", providerMetadata: metadata },
+ {
+ type: "text-delta",
+ id: "msg_final",
+ text: "Final",
+ providerMetadata: metadata,
+ },
+ { type: "text-end", id: "msg_final", providerMetadata: metadata },
+ { type: "step-finish", index: 0, reason: "stop" },
+ { type: "finish", reason: "stop" },
+ ])
+ }),
+ )
+
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Apply a Landlock + seccomp policy, drop privileges, then exec one shell command."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import errno
import json
import os
import platform
import sys
from pathlib import Path
LANDLOCK_CREATE_RULESET_VERSION = 1
LANDLOCK_RULE_PATH_BENEATH = 1
ACCESS_FS_EXECUTE = 1 << 0
ACCESS_FS_WRITE_FILE = 1 << 1
ACCESS_FS_READ_FILE = 1 << 2
ACCESS_FS_READ_DIR = 1 << 3
ACCESS_FS_REMOVE_DIR = 1 << 4
ACCESS_FS_REMOVE_FILE = 1 << 5
ACCESS_FS_MAKE_CHAR = 1 << 6
ACCESS_FS_MAKE_DIR = 1 << 7
ACCESS_FS_MAKE_REG = 1 << 8
ACCESS_FS_MAKE_SOCK = 1 << 9
ACCESS_FS_MAKE_FIFO = 1 << 10
ACCESS_FS_MAKE_BLOCK = 1 << 11
ACCESS_FS_MAKE_SYM = 1 << 12
ACCESS_FS_REFER = 1 << 13
ACCESS_FS_TRUNCATE = 1 << 14
ACCESS_NET_CONNECT_TCP = 1 << 0
ACCESS_NET_BIND_TCP = 1 << 1
READ_ACCESS = ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR
WRITE_ACCESS = (
ACCESS_FS_WRITE_FILE
| ACCESS_FS_REMOVE_DIR
| ACCESS_FS_REMOVE_FILE
| ACCESS_FS_MAKE_CHAR
| ACCESS_FS_MAKE_DIR
| ACCESS_FS_MAKE_REG
| ACCESS_FS_MAKE_SOCK
| ACCESS_FS_MAKE_FIFO
| ACCESS_FS_MAKE_BLOCK
| ACCESS_FS_MAKE_SYM
| ACCESS_FS_REFER
| ACCESS_FS_TRUNCATE
)
HANDLED_FS_ACCESS = READ_ACCESS | WRITE_ACCESS
PR_SET_NO_NEW_PRIVS = 38
AF_INET = 2
AF_INET6 = 10
SCMP_ACT_ALLOW = 0x7FFF0000
SCMP_ACT_ERRNO = 0x00050000 | errno.EPERM
SCMP_CMP_EQ = 4
class RulesetAttr(ctypes.Structure):
_fields_ = [
("handled_access_fs", ctypes.c_uint64),
("handled_access_net", ctypes.c_uint64),
]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [
("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
]
class ScmpArgCmp(ctypes.Structure):
_fields_ = [
("arg", ctypes.c_uint32),
("op", ctypes.c_uint32),
("datum_a", ctypes.c_uint64),
("datum_b", ctypes.c_uint64),
]
def syscall_numbers() -> tuple[int, int, int]:
machine = platform.machine().lower()
if machine not in {"x86_64", "amd64", "aarch64", "arm64"}:
raise RuntimeError(f"unsupported architecture for Landlock syscalls: {machine}")
return 444, 445, 446
def checked_syscall(libc: ctypes.CDLL, number: int, *args: object) -> int:
result = int(libc.syscall(number, *args))
if result < 0:
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
return result
def get_landlock_abi(libc: ctypes.CDLL) -> int:
create_ruleset, _, _ = syscall_numbers()
return checked_syscall(
libc,
create_ruleset,
ctypes.c_void_p(),
ctypes.c_size_t(0),
ctypes.c_uint32(LANDLOCK_CREATE_RULESET_VERSION),
)
def load_seccomp() -> ctypes.CDLL:
library_name = ctypes.util.find_library("seccomp") or "libseccomp.so.2"
library = ctypes.CDLL(library_name, use_errno=True)
library.seccomp_init.argtypes = [ctypes.c_uint32]
library.seccomp_init.restype = ctypes.c_void_p
library.seccomp_release.argtypes = [ctypes.c_void_p]
library.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
library.seccomp_syscall_resolve_name.restype = ctypes.c_int
library.seccomp_rule_add_array.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_int,
ctypes.c_uint,
ctypes.POINTER(ScmpArgCmp),
]
library.seccomp_rule_add_array.restype = ctypes.c_int
library.seccomp_load.argtypes = [ctypes.c_void_p]
library.seccomp_load.restype = ctypes.c_int
return library
def add_path_rule(
libc: ctypes.CDLL,
add_rule_number: int,
ruleset_fd: int,
path: str,
access: int,
) -> None:
resolved_path = os.path.realpath(path)
if not os.path.exists(resolved_path):
return
path_fd = os.open(resolved_path, os.O_PATH | os.O_CLOEXEC)
try:
allowed_access = access
if not Path(resolved_path).is_dir():
allowed_access &= ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE | ACCESS_FS_TRUNCATE
attribute = PathBeneathAttr(
allowed_access=allowed_access,
parent_fd=path_fd,
)
checked_syscall(
libc,
add_rule_number,
ctypes.c_int(ruleset_fd),
ctypes.c_int(LANDLOCK_RULE_PATH_BENEATH),
ctypes.byref(attribute),
ctypes.c_uint32(0),
)
finally:
os.close(path_fd)
def build_landlock_ruleset(
libc: ctypes.CDLL,
abi: int,
workspace: str,
read_only_paths: list[str],
) -> int:
create_ruleset, add_rule, _ = syscall_numbers()
handled_network = (
ACCESS_NET_CONNECT_TCP | ACCESS_NET_BIND_TCP if abi >= 4 else 0
)
ruleset_attribute = RulesetAttr(
handled_access_fs=HANDLED_FS_ACCESS,
handled_access_net=handled_network,
)
ruleset_fd = checked_syscall(
libc,
create_ruleset,
ctypes.byref(ruleset_attribute),
ctypes.sizeof(ruleset_attribute),
ctypes.c_uint32(0),
)
try:
add_path_rule(
libc,
add_rule,
ruleset_fd,
workspace,
HANDLED_FS_ACCESS,
)
for path in read_only_paths:
add_path_rule(libc, add_rule, ruleset_fd, path, READ_ACCESS)
for device_path, access in (
("/dev/null", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE),
("/dev/zero", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE),
("/dev/urandom", ACCESS_FS_READ_FILE),
("/dev/random", ACCESS_FS_READ_FILE),
):
add_path_rule(libc, add_rule, ruleset_fd, device_path, access)
except Exception:
os.close(ruleset_fd)
raise
return ruleset_fd
def install_seccomp_network_filter(library: ctypes.CDLL) -> None:
context = library.seccomp_init(SCMP_ACT_ALLOW)
if not context:
raise RuntimeError("seccomp_init failed")
try:
socket_syscall = library.seccomp_syscall_resolve_name(b"socket")
if socket_syscall < 0:
raise RuntimeError("could not resolve socket syscall")
for domain in (AF_INET, AF_INET6):
comparison = ScmpArgCmp(
arg=0,
op=SCMP_CMP_EQ,
datum_a=domain,
datum_b=0,
)
result = library.seccomp_rule_add_array(
context,
SCMP_ACT_ERRNO,
socket_syscall,
1,
ctypes.byref(comparison),
)
if result != 0:
raise OSError(-result, os.strerror(-result))
result = library.seccomp_load(context)
if result != 0:
raise OSError(-result, os.strerror(-result))
finally:
library.seccomp_release(context)
def set_no_new_privileges(libc: ctypes.CDLL) -> None:
result = libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
if result != 0:
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
def restrict_process(
workspace: str,
read_only_paths: list[str],
uid: int,
gid: int,
) -> int:
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
libc.prctl.restype = ctypes.c_int
seccomp = load_seccomp()
abi = get_landlock_abi(libc)
if abi < 4:
raise RuntimeError(f"Landlock ABI 4 or newer is required; detected ABI {abi}")
ruleset_fd = build_landlock_ruleset(
libc,
abi,
workspace,
read_only_paths,
)
try:
if os.geteuid() == 0:
os.setgroups([])
os.setgid(gid)
os.setuid(uid)
set_no_new_privileges(libc)
_, _, restrict_self = syscall_numbers()
checked_syscall(
libc,
restrict_self,
ctypes.c_int(ruleset_fd),
ctypes.c_uint32(0),
)
install_seccomp_network_filter(seccomp)
finally:
os.close(ruleset_fd)
return abi
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--probe", action="store_true")
parser.add_argument("--workspace")
parser.add_argument("--read-only", action="append", default=[])
parser.add_argument("--uid", type=int, default=10_001)
parser.add_argument("--gid", type=int, default=10_001)
parser.add_argument("--command")
return parser.parse_args()
def main() -> int:
args = parse_args()
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
if args.probe:
abi = get_landlock_abi(libc)
load_seccomp()
print(json.dumps({"ok": True, "landlock_abi": abi, "seccomp": True}))
return 0 if abi >= 4 else 1
if not args.workspace or args.command is None:
raise RuntimeError("workspace and command are required")
workspace = os.path.realpath(args.workspace)
if not os.path.isdir(workspace):
raise RuntimeError("workspace must be an existing directory")
os.chdir(workspace)
restrict_process(workspace, args.read_only, args.uid, args.gid)
os.execve("/bin/bash", ["bash", "-c", args.command], dict(os.environ))
return 127
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
print(
json.dumps(
{
"ok": False,
"error": "SANDBOX_UNAVAILABLE",
"message": str(error),
}
),
file=sys.stderr,
)
raise SystemExit(125)
+4 -3
View File
@@ -4,14 +4,15 @@ import { dirname } from "node:path";
import { config } from "../config.js";
export type LlmRequestAuditEntry = {
kind: "tool" | "skill";
kind: "activity" | "tool" | "skill";
sessionId: string;
clientSessionId: string;
traceId?: string;
projectId?: string;
target: string;
reason: string;
reasonProvided: boolean;
activityId?: string;
activityTitle?: string;
activityReason?: string;
payload?: Record<string, unknown>;
};
+10 -2
View File
@@ -12,6 +12,7 @@ export type SessionBinding = {
clientSessionId: string;
sessionId: string;
startedAt: number;
workspaceDirectory?: string;
};
export type SessionContext = {
@@ -52,20 +53,26 @@ export class ChatSessionBridge {
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
let sessionId = existingSessionId;
let runtimeSession;
let created = false;
if (!sessionId) {
const session = await this.runtime.createSession();
sessionId = session.id;
runtimeSession = await this.runtime.createSession(undefined, {
conversationWorkspace: true,
});
sessionId = runtimeSession.id;
requestContext = {
...requestContext,
clientSessionId: sessionId,
};
created = true;
} else {
runtimeSession = await this.runtime.getSession(sessionId);
}
const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId,
sessionId,
startedAt: Date.now(),
workspaceDirectory: runtimeSession.directory,
};
setRuntimeSessionContext({
accessToken: requestContext.accessToken,
@@ -79,6 +86,7 @@ export class ChatSessionBridge {
sessionId,
tokenExpiresAt: requestContext.tokenExpiresAt,
traceId: requestContext.traceId,
workspaceDirectory: runtimeSession.directory,
});
return { binding, requestContext, created };
+5 -1
View File
@@ -7,6 +7,8 @@ import {
parseAgentModelOptions,
} from "./chat/modelConfig.js";
export const RESULT_REF_IMPORT_DIRECTORY = "./data/conversation-workspaces";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false });
@@ -116,7 +118,9 @@ const envSchema = z
// result_ref 持久化存储目录。
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"),
RESULT_REF_IMPORT_DIR: z
.literal(RESULT_REF_IMPORT_DIRECTORY)
.default(RESULT_REF_IMPORT_DIRECTORY),
// 单个渲染包装 JSON 的最大导入字节数。
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
.number()
+19 -24
View File
@@ -1,6 +1,9 @@
import { realpath, stat } from "node:fs/promises";
import { isAbsolute, relative } from "node:path";
import { stat } from "node:fs/promises";
import {
resolveConversationWorkspace,
resolveExistingPathInsideRoot,
} from "../runtime/conversationWorkspace.js";
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
import {
type ResultReferenceKind,
@@ -68,9 +71,19 @@ export class ResultReferenceResolver {
async registerRenderPayloadFile(
filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion"> & {
workspaceDirectory: string;
},
) {
const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot);
const resolvedWorkspaceDirectory = await resolveConversationWorkspace(
input.workspaceDirectory,
this.importRoot,
);
const resolvedFilePath = await resolveExistingPathInsideRoot(
filePath,
resolvedWorkspaceDirectory,
"render payload file must be inside the current conversation workspace",
);
const fileStat = await stat(resolvedFilePath);
if (!fileStat.isFile()) {
throw new Error("render payload path must point to a regular file");
@@ -95,8 +108,9 @@ export class ResultReferenceResolver {
throw new Error("render payload file does not contain a valid junction render payload");
}
const { workspaceDirectory: _workspaceDirectory, ...registrationInput } = input;
const record = await this.register({
...input,
...registrationInput,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
@@ -186,25 +200,6 @@ export const extractRenderJunctionPayload = (
};
};
const resolvePathInsideRoot = async (filePath: string, rootPath: string) => {
if (!isAbsolute(filePath)) {
throw new Error("render payload file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
const relativePath = relative(resolvedRootPath, resolvedFilePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
isAbsolute(relativePath)
) {
throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR");
}
return resolvedFilePath;
};
const normalizeDataForKind = (
kind: ResultReferenceKind,
data: unknown,
+37 -2
View File
@@ -35,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import {
collectTextContent,
type ActivityUpdatePayload,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
@@ -46,7 +47,9 @@ import {
type StreamSubscriber,
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendActivities,
completeBackendProgress,
completeBackendTodos,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
@@ -54,6 +57,7 @@ import {
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendActivity,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
@@ -155,7 +159,9 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const sessionId =
requestedSessionId ||
(await runtime.createSession(undefined, { conversationWorkspace: true })).id;
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
@@ -451,7 +457,9 @@ export const buildChatRouter = (
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession();
const forkSession = await runtime.createSession(undefined, {
conversationWorkspace: true,
});
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
@@ -683,6 +691,27 @@ export const buildChatRouter = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: typeof data.content === "string" ? data.content : "",
isError: false,
}));
} else if (event === "activity_update") {
const payload = data as ActivityUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
activities: upsertBackendActivity(message.activities, payload.activity),
...(payload.todos
? {
todos: upsertBackendTodoUpdate(message.todos, {
session_id: payload.session_id,
todos: payload.todos,
created_at: payload.todos_created_at ?? Date.now(),
}),
}
: {}),
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
@@ -698,6 +727,8 @@ export const buildChatRouter = (
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities),
todos: completeBackendTodos(message.todos),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
@@ -710,6 +741,7 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "auth_required") {
@@ -723,6 +755,7 @@ export const buildChatRouter = (
: "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
@@ -874,6 +907,7 @@ export const buildChatRouter = (
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
workspaceRoot: binding.workspaceDirectory,
write: (event, data) => {
publish(event, data);
},
@@ -963,6 +997,7 @@ export const buildChatRouter = (
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "cancelled"),
todos: cancelBackendTodos(message.todos),
}));
void queueSessionUiStatePersist().catch((error) => {
+180
View File
@@ -0,0 +1,180 @@
import type { Part } from "@opencode-ai/sdk/v2";
import {
getToolLabel,
normalizeToolStatus,
type ActivityActionPayload,
type ActivityPayload,
type ActivityStatus,
type ActivityUpdatePayload,
type TodoItemPayload,
} from "./chatStreamEvents.js";
type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const createActivityTracker = ({
clientSessionId,
write,
}: {
clientSessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
}) => {
const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now();
const snapshot = activity.status === "running"
? {
...activity,
elapsed_ms: Math.max(0, now - activity.started_at),
actions: activity.actions.map((action) =>
action.status === "running"
? { ...action, elapsed_ms: Math.max(0, now - action.started_at) }
: action,
),
}
: activity;
write("activity_update", {
session_id: clientSessionId,
activity: snapshot,
...(todos
? {
todos,
todos_created_at: now,
}
: {}),
} satisfies ActivityUpdatePayload);
};
const current = (): ActivityPayload | undefined =>
currentActivityId ? activities.get(currentActivityId) : undefined;
const finalize = (status: Exclude<ActivityStatus, "running">) => {
const activity = current();
if (!activity || activity.status !== "running") return;
const endedAt = Date.now();
const nextActivity: ActivityPayload = {
...activity,
status,
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - activity.started_at),
actions: activity.actions.map((action) => {
if (action.status !== "running") return action;
return {
...action,
status: status === "error" ? "error" : "completed",
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - action.started_at),
...(status === "error" ? { error: action.error ?? "活动执行失败" } : {}),
};
}),
};
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => {
finalize("completed");
const activity: ActivityPayload = {
id,
title,
reason,
status: "running",
actions: [],
started_at: Date.now(),
elapsed_ms: 0,
};
activities.set(id, activity);
currentActivityId = id;
emit(activity, todos);
return activity;
};
const ensure = (tool: string) => {
const activity = current();
if (activity?.status === "running") return activity;
return start(
`activity-fallback-${Date.now().toString(36)}`,
"执行分析操作",
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
);
};
const upsertAction = (part: ToolPart, params: Record<string, unknown>) => {
const associatedActivityId = actionActivityIds.get(part.id);
const activity = associatedActivityId
? activities.get(associatedActivityId)
: ensure(part.tool);
if (!activity) return;
if (!associatedActivityId) actionActivityIds.set(part.id, activity.id);
const now = Date.now();
const actionIndex = activity.actions.findIndex((action) => action.id === part.id);
const previous = actionIndex >= 0 ? activity.actions[actionIndex] : undefined;
const status = normalizeToolStatus(part.state.status);
const startedAt = previous?.started_at ?? now;
const endedAt = status === "running" ? undefined : now;
const action: ActivityActionPayload = {
id: part.id,
tool: part.tool,
title: getToolLabel(part.tool),
status,
target: getActionTarget(params),
error: part.state.status === "error" ? part.state.error : undefined,
started_at: startedAt,
ended_at: endedAt,
elapsed_ms: status === "running" ? Math.max(0, now - startedAt) : undefined,
duration_ms: endedAt ? Math.max(0, endedAt - startedAt) : undefined,
};
const actions = [...activity.actions];
if (actionIndex >= 0) actions[actionIndex] = action;
else actions.push(action);
const nextActivity = { ...activity, actions };
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const getActionContext = (actionId: string): ActivityContext | undefined => {
const activityId = actionActivityIds.get(actionId);
const activity = activityId ? activities.get(activityId) : undefined;
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
const getCurrentContext = (): ActivityContext | undefined => {
const activity = current();
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
return {
finalize,
getActionContext,
getCurrentContext,
start,
upsertAction,
};
};
+210 -12
View File
@@ -1,5 +1,5 @@
import { lstatSync, readdirSync, realpathSync } from "node:fs";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
export type ApprovalMode = "request" | "auto" | "always";
@@ -46,6 +46,14 @@ export const canAutoApprovePermission = (
return isSafeWorkspaceSearch(normalized, context);
}
if (normalized === "bash") {
return isConversationWorkspace(context.workspaceRoot);
}
if (normalized === "read" || normalized === "edit" || normalized === "write") {
return isSafeConversationFileAccess(context);
}
if (lowRiskToolPermissions.has(normalized)) {
return true;
}
@@ -62,9 +70,19 @@ export const resolvePermissionApproval = (
permission: string,
context: PermissionApprovalContext = {},
) => {
if (isDirectRecursiveForceRemove(permission, context)) {
return {
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
} as const;
}
if (approvalMode === "always") {
return {
autoApprove: true,
autoReject: false,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
@@ -74,6 +92,7 @@ export const resolvePermissionApproval = (
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
return {
autoApprove: true,
autoReject: false,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
@@ -81,11 +100,119 @@ export const resolvePermissionApproval = (
return {
autoApprove: false,
autoReject: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
const isDirectRecursiveForceRemove = (
permission: string,
context: PermissionApprovalContext,
): boolean => {
if (normalizePermission(permission) !== "bash") {
return false;
}
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
return splitShellCommandSegments(command).some((segment) => {
const words = tokenizeShellSegment(segment);
let commandIndex = 0;
while (commandIndex < words.length) {
const word = words[commandIndex]!;
const executable = word.split("/").at(-1)?.toLowerCase();
if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
commandIndex += 1;
continue;
}
if (executable === "command") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") {
commandIndex += 1;
}
if (words[commandIndex] === "--") commandIndex += 1;
continue;
}
if (executable === "env") {
commandIndex += 1;
while (commandIndex < words.length) {
const envWord = words[commandIndex]!;
if (envWord === "--") {
commandIndex += 1;
break;
}
if (envWord === "-u" || envWord === "--unset") {
commandIndex += 2;
continue;
}
if (
envWord.startsWith("-") ||
/^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord)
) {
commandIndex += 1;
continue;
}
break;
}
continue;
}
if (executable === "sudo" || executable === "doas") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-")) {
commandIndex += 1;
}
continue;
}
if (executable === "busybox" && words[commandIndex + 1] === "rm") {
commandIndex += 1;
}
break;
}
const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase();
if (executable !== "rm") {
return false;
}
let recursive = false;
let force = false;
for (const word of words.slice(commandIndex + 1)) {
if (word === "--") {
break;
}
if (word === "--recursive") {
recursive = true;
} else if (word === "--force") {
force = true;
} else if (/^-[^-]/.test(word)) {
recursive ||= /[rR]/.test(word.slice(1));
force ||= word.slice(1).includes("f");
}
}
return recursive && force;
});
};
const splitShellCommandSegments = (command: string): string[] =>
command.split(/&&|\|\||[;|()\n]/u);
const tokenizeShellSegment = (segment: string): string[] =>
(segment.match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/gu) ?? []).map(
(word) => {
const quoted = word.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u);
return (quoted ? (quoted[1] ?? quoted[2] ?? "") : word).replace(
/(["'])|\\(.)/gu,
"$2",
);
},
);
const isSafeWorkspaceSearch = (
permission: "glob" | "grep",
context: PermissionApprovalContext,
@@ -133,13 +260,14 @@ const isSafeWorkspaceSearch = (
isAbsolute(expression) ||
containsParentTraversal(expression) ||
containsAmbiguousGlobSyntax(expression) ||
containsProtectedPath(expression),
containsProtectedPath(expression, isConversationWorkspace(root)),
)
) {
return false;
}
if (!relativePath) {
const conversationWorkspace = isConversationWorkspace(root);
if (!relativePath && !conversationWorkspace) {
if (permission !== "glob") {
return false;
}
@@ -156,16 +284,19 @@ const isSafeWorkspaceSearch = (
}
return (
relativePath !== "" &&
relativePath !== ".." &&
!relativePath.startsWith(`..${sep}`) &&
!isAbsolute(relativePath) &&
!containsProtectedPath(relativePath) &&
isSafeSearchTarget(searchRoot, relativePath)
!containsProtectedPath(relativePath, conversationWorkspace) &&
isSafeSearchTarget(searchRoot, relativePath, conversationWorkspace)
);
};
const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => {
const isSafeSearchTarget = (
searchRoot: string,
relativePath: string,
conversationWorkspace: boolean,
): boolean => {
try {
const target = lstatSync(searchRoot);
if (target.isFile()) {
@@ -175,7 +306,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean =
return false;
}
const topLevelName = relativePath.split(sep)[0];
if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) {
if (
!conversationWorkspace &&
(!topLevelName || !lowRiskSearchRootNames.has(topLevelName))
) {
return false;
}
@@ -183,7 +317,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean =
while (pending.length > 0) {
const directory = pending.pop()!;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) {
if (
entry.isSymbolicLink() ||
containsProtectedPath(entry.name, conversationWorkspace)
) {
return false;
}
if (entry.isDirectory()) {
@@ -213,10 +350,71 @@ const containsParentTraversal = (value: string): boolean =>
const containsAmbiguousGlobSyntax = (value: string): boolean =>
/[?[\]{}()!+@\\]/.test(value);
const containsProtectedPath = (value: string): boolean => {
const containsProtectedPath = (
value: string,
conversationWorkspace = false,
): boolean => {
const normalized = value.replaceAll("\\", "/").toLowerCase();
return (
normalized.includes(".env") ||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)
(!conversationWorkspace &&
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized))
);
};
const isConversationWorkspace = (workspaceRoot: string | undefined): boolean => {
if (!workspaceRoot?.trim()) return false;
try {
const root = realpathSync.native(resolve(workspaceRoot));
return (
basename(dirname(root)) === "conversation-workspaces" &&
basename(root).startsWith("conversation-") &&
lstatSync(root).isDirectory()
);
} catch {
return false;
}
};
const isSafeConversationFileAccess = (
context: PermissionApprovalContext,
): boolean => {
if (!isConversationWorkspace(context.workspaceRoot)) return false;
const requestedPath = [
context.metadata?.path,
context.metadata?.file_path,
context.metadata?.filePath,
context.metadata?.filepath,
context.metadata?.file,
context.patterns?.[0],
].find((value): value is string => typeof value === "string" && value.trim().length > 0);
if (!requestedPath || /[*?[\]{}]/.test(requestedPath)) return false;
try {
const root = realpathSync.native(resolve(context.workspaceRoot!));
const target = resolve(root, requestedPath);
let existingPath = target;
while (!existsSync(existingPath)) {
const parent = dirname(existingPath);
if (parent === existingPath) return false;
existingPath = parent;
}
const resolvedExistingPath = realpathSync.native(existingPath);
const relativeExisting = relative(root, resolvedExistingPath);
if (
relativeExisting === ".." ||
relativeExisting.startsWith(`..${sep}`) ||
isAbsolute(relativeExisting)
) {
return false;
}
const relativeTarget = relative(root, target);
return (
relativeTarget !== ".." &&
!relativeTarget.startsWith(`..${sep}`) &&
!isAbsolute(relativeTarget) &&
!containsProtectedPath(relativeTarget, true)
);
} catch {
return false;
}
};
+244 -265
View File
@@ -8,16 +8,10 @@ import {
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import {
buildPermissionDetail,
buildPermissionV2Detail,
buildReasoningProgressDetail,
buildSessionStatusDetail,
buildToolProgressDetail,
collectTextContent,
extractRequestReason,
extractSkillAuditInfo,
getErrorMessage,
getToolProgressTitle,
getAssistantMessagePhase,
getUnknownErrorMessage,
hasToolParams,
isPermissionAskedEvent,
@@ -33,6 +27,7 @@ import {
isQuestionV2RepliedEvent,
isSessionEvent,
isSkillEvent,
initialActivity,
logDevelopmentDebug,
normalizeQuestionAnswers,
normalizeQuestionPayload,
@@ -40,12 +35,15 @@ import {
normalizeTodoPriority,
normalizeTodoStatus,
normalizeToolParams,
normalizeToolStatus,
type ActivityPayload,
type ActivityUpdatePayload,
type PermissionRequestPayload,
type QuestionRequestPayload,
type AssistantMessagePhase,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
import { createActivityTracker } from "./chatActivityTracker.js";
import {
resolvePermissionApproval,
type ApprovalMode,
@@ -53,10 +51,13 @@ import {
export {
collectTextContent,
initialActivity,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
type ActivityPayload,
type ActivityUpdatePayload,
} from "./chatStreamEvents.js";
export type { ApprovalMode } from "./chatPermissionPolicy.js";
@@ -71,19 +72,10 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
workspaceRoot?: string;
write: (event: string, data: Record<string, unknown>) => void;
};
type ProgressStatus = "running" | "completed" | "error";
type ProgressPayload = {
id: string;
phase: string;
status: ProgressStatus;
title: string;
detail?: string;
};
const getPermissionTarget = (metadata: unknown) => {
if (!isObjectRecord(metadata)) {
return undefined;
@@ -111,17 +103,41 @@ const toRuntimeModel = (model?: SupportedModel) => {
};
};
const emitFinalMessage = async (
const FINAL_ANSWER_TOOL_NAME = "final_answer";
const ACTIVITY_UPDATE_TOOL_NAME = "activity_update";
const extractFinalAnswer = (value: unknown) =>
isObjectRecord(value) && typeof value.answer === "string"
? value.answer.trim()
: "";
const resolveFinalMessage = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
clientSessionId: string,
currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>,
write: (event: string, data: Record<string, unknown>) => void,
assistantTextPartPhases: Map<string, AssistantMessagePhase>,
) => {
let text = [...currentAssistantMessageIds]
.reverse()
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
.map((messageId) => {
const parts = assistantTextParts.get(messageId);
if (!parts) {
return "";
}
const candidates = [...parts.entries()];
const finalText = candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) === "final_answer")
.map(([, content]) => content)
.join("");
if (finalText) {
return finalText;
}
return candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) !== "commentary")
.map(([, content]) => content)
.join("");
})
.find((content) => content.length > 0) ?? "";
if (!text) {
@@ -134,17 +150,53 @@ const emitFinalMessage = async (
(currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)),
);
text = collectTextContent(assistantMessage?.parts ?? []);
const assistantParts = assistantMessage?.parts ?? [];
text = collectTextContent(
assistantParts.filter(
(part) =>
part.type !== "text" ||
getAssistantMessagePhase(part.metadata) !== "commentary",
),
);
if (!text) {
const finalAnswerPart = [...assistantParts]
.reverse()
.find(
(part) =>
part.type === "tool" && part.tool === FINAL_ANSWER_TOOL_NAME,
);
if (finalAnswerPart?.type === "tool") {
text = extractFinalAnswer(finalAnswerPart.state.input);
}
}
}
if (text) {
write("token", {
session_id: clientSessionId,
content: text,
});
return true;
}
return false;
return text.trim();
};
const normalizeActivityTodos = (value: unknown): TodoItemPayload[] | undefined => {
if (!Array.isArray(value)) return undefined;
const now = Date.now();
return value
.filter(isObjectRecord)
.map((todo, index) => {
const content = typeof todo.content === "string" ? todo.content.trim() : "";
return {
id:
typeof todo.id === "string" && todo.id.trim()
? todo.id.trim()
: `todo-${index}-${content.slice(0, 24)}`,
content,
status: normalizeTodoStatus(
typeof todo.status === "string" ? todo.status : "pending",
),
priority: normalizeTodoPriority(
typeof todo.priority === "string" ? todo.priority : "",
),
updated_at: now,
};
})
.filter((todo) => todo.content.length > 0);
};
export const streamPromptResponse = async ({
@@ -157,23 +209,24 @@ export const streamPromptResponse = async ({
traceId,
projectId,
signal,
workspaceRoot,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
toolCallCount: number;
}> => {
const eventStream = await runtime.subscribeEvents();
const eventStream = await runtime.subscribeEvents(workspaceRoot);
const iterator = eventStream[Symbol.asyncIterator]();
const requestStartedAt = Date.now();
const promptStartedAt = Date.now();
const progressStartedAtMap = new Map<string, number>();
const finalizedProgressIds = new Set<string>();
const emittedToolParts = new Set<string>();
const emittedActivityParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>();
const assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
const partTypes = new Map<string, Part["type"]>();
const pendingTextDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">();
@@ -184,7 +237,7 @@ export const streamPromptResponse = async ({
let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null;
let sawResponseActivity = false;
let emittedText = false;
let finalAnswerText = "";
let toolCallCount = 0;
let done = false;
let promptSettled = false;
@@ -215,57 +268,25 @@ export const streamPromptResponse = async ({
})
: null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
const activityTracker = createActivityTracker({ clientSessionId, write });
const now = Date.now();
const startedAt = progressStartedAtMap.get(id) ?? now;
if (!progressStartedAtMap.has(id)) {
progressStartedAtMap.set(id, startedAt);
}
if (status === "running") {
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
return;
}
const durationMs = Math.max(0, now - startedAt);
finalizedProgressIds.add(id);
progressStartedAtMap.delete(id);
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
});
const captureFinalText = (text: string) => {
const answer = text.trim();
if (answer) finalAnswerText = answer;
};
emitProgress({
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
activityTracker.start(
initialActivity.id,
initialActivity.title,
initialActivity.reason,
);
const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model))
.prompt(
sessionId,
message,
toRuntimeModel(model),
)
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
@@ -287,6 +308,8 @@ export const streamPromptResponse = async ({
...debugContext,
});
let pendingIteratorNext: ReturnType<typeof iterator.next> | undefined;
try {
while (!done) {
if (signal?.aborted) {
@@ -298,8 +321,8 @@ export const streamPromptResponse = async ({
break;
}
const nextEvent = iterator
.next()
pendingIteratorNext ??= iterator.next();
const nextEvent = pendingIteratorNext
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
? null
@@ -325,6 +348,7 @@ export const streamPromptResponse = async ({
if (next.type === "prompt") {
continue;
}
pendingIteratorNext = undefined;
if (next.result.done) {
break;
}
@@ -364,18 +388,6 @@ export const streamPromptResponse = async ({
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
emitProgress({
id: "session-status",
phase: "session",
status: event.properties.status.type === "idle" ? "completed" : "running",
title:
event.properties.status.type === "retry"
? `模型请求重试中:${event.properties.status.message}`
: event.properties.status.type === "busy"
? "Agent 正在处理请求"
: "Agent 已空闲",
detail: buildSessionStatusDetail(event.properties.status),
});
continue;
}
@@ -397,7 +409,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.patterns,
workspaceRoot: process.cwd(),
workspaceRoot,
},
);
logDevelopmentDebug("permission request received", {
@@ -407,32 +419,30 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
directory: workspaceRoot,
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
const activity = activityTracker.getCurrentContext();
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.permission,
patterns: event.properties.patterns,
target: getPermissionTarget(event.properties.metadata),
activity_id: activity?.id,
reason: activity?.reason,
always: event.properties.always,
tool: event.properties.tool,
created_at: Date.now(),
@@ -448,7 +458,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.resources,
workspaceRoot: process.cwd(),
workspaceRoot,
},
);
logDevelopmentDebug("permission v2 request received", {
@@ -458,32 +468,30 @@ export const streamPromptResponse = async ({
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
directory: workspaceRoot,
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
const activity = activityTracker.getCurrentContext();
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.action,
patterns: event.properties.resources,
target: getPermissionTarget(event.properties.metadata),
activity_id: activity?.id,
reason: activity?.reason,
always: event.properties.save ?? [],
tool: undefined,
created_at: Date.now(),
@@ -499,21 +507,6 @@ export const streamPromptResponse = async ({
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
@@ -530,21 +523,6 @@ export const streamPromptResponse = async ({
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
@@ -561,15 +539,6 @@ export const streamPromptResponse = async ({
questionCount: event.properties.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: event.properties.questions
.map((question) => question.question)
.join("\n"),
});
const payload = normalizeQuestionPayload(event, clientSessionId);
emittedQuestionRequestIds.add(payload.request_id);
write("question_request", payload);
@@ -583,16 +552,6 @@ export const streamPromptResponse = async ({
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已收到补充信息",
detail: normalizeQuestionAnswers(event.properties.answers)
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("\n"),
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
@@ -608,13 +567,6 @@ export const streamPromptResponse = async ({
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已跳过补充信息",
detail: "用户选择跳过本次补充信息。",
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
@@ -625,11 +577,11 @@ export const streamPromptResponse = async ({
if (isSkillEvent(event)) {
sawResponseActivity = true;
const { name, reason, payload } = extractSkillAuditInfo(event);
const { name, payload } = extractSkillAuditInfo(event);
const activity = activityTracker.getCurrentContext();
logDevelopmentDebug("skill event received", {
...debugContext,
skill: name,
reason: reason || null,
payloadKeys: Object.keys(payload).slice(0, 8),
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
@@ -640,8 +592,9 @@ export const streamPromptResponse = async ({
traceId,
projectId,
target: name,
reason,
reasonProvided: Boolean(reason),
activityId: activity?.id,
activityTitle: activity?.title,
activityReason: activity?.reason,
payload,
}).catch((error) => {
logger.warn({ err: error }, "failed to write skill audit log");
@@ -662,11 +615,13 @@ export const streamPromptResponse = async ({
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
messageParts.set(
event.properties.partID,
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
);
const text = `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`;
messageParts.set(event.properties.partID, text);
assistantTextParts.set(event.properties.messageID, messageParts);
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
if (phase === "final_answer") {
captureFinalText(text);
}
} else if (!partType) {
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
@@ -683,11 +638,17 @@ export const streamPromptResponse = async ({
currentAssistantMessageIds.add(part.messageID);
}
if (part.type === "text") {
const phase = getAssistantMessagePhase(part.metadata);
assistantTextPartPhases.set(part.id, phase);
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingTextDeltas.delete(part.id);
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
messageParts.set(part.id, part.text || pendingText);
const text = part.text || pendingText;
messageParts.set(part.id, text);
assistantTextParts.set(part.messageID, messageParts);
if (phase === "final_answer") {
captureFinalText(text);
}
} else {
pendingTextDeltas.delete(part.id);
}
@@ -702,14 +663,6 @@ export const streamPromptResponse = async ({
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
const reasoningDetail = buildReasoningProgressDetail(part.time.end);
emitProgress({
id: part.id,
phase: "planning",
status: part.time.end ? "completed" : "running",
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
detail: reasoningDetail,
});
}
if (part.type === "tool") {
if (!firstToolEventLogged) {
@@ -724,7 +677,6 @@ export const streamPromptResponse = async ({
});
}
const toolParams = normalizeToolParams(part.state.input);
const reason = extractRequestReason(toolParams);
const isToolFinalState =
part.state.status === "completed" || part.state.status === "error";
const nextToolStatus = String(part.state.status);
@@ -736,7 +688,6 @@ export const streamPromptResponse = async ({
partId: part.id,
tool: part.tool,
status: nextToolStatus,
reason: reason || null,
inputKeys: Object.keys(toolParams).slice(0, 8),
error:
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
@@ -744,6 +695,80 @@ export const streamPromptResponse = async ({
});
}
if (part.tool === FINAL_ANSWER_TOOL_NAME) {
if (part.state.status === "error") {
logger.warn(
{
sessionId,
clientSessionId,
partId: part.id,
error: part.state.error,
},
"final answer tool failed",
);
continue;
}
if (
(part.state.status !== "running" &&
part.state.status !== "completed")
) {
continue;
}
const answer = extractFinalAnswer(toolParams);
if (!answer) {
logger.warn(
{ sessionId, clientSessionId, partId: part.id },
"final answer tool received without an answer",
);
continue;
}
captureFinalText(answer);
logDevelopmentDebug("final answer submitted through tool", {
...debugContext,
partId: part.id,
tool: part.tool,
answerChars: answer.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
continue;
}
if (part.tool === ACTIVITY_UPDATE_TOOL_NAME) {
const title = typeof toolParams.title === "string"
? toolParams.title.trim()
: "";
const reason = typeof toolParams.reason === "string"
? toolParams.reason.trim()
: "";
const todos = normalizeActivityTodos(toolParams.todos);
if (
title &&
reason &&
!emittedActivityParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedActivityParts.add(part.id);
const activity = activityTracker.start(part.id, title, reason, todos);
void writeLlmRequestAuditLog({
kind: "activity",
sessionId,
clientSessionId,
traceId,
projectId,
target: ACTIVITY_UPDATE_TOOL_NAME,
activityId: activity.id,
activityTitle: activity.title,
activityReason: activity.reason,
payload: toolParams,
}).catch((error) => {
logger.warn({ err: error }, "failed to write activity audit log");
});
}
continue;
}
const questionToolPayload = normalizeQuestionToolPayload(
part,
toolParams,
@@ -760,49 +785,23 @@ export const streamPromptResponse = async ({
questionCount: questionToolPayload.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${questionToolPayload.request_id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: questionToolPayload.questions
.map((question) => question.question)
.join("\n"),
});
write("question_request", questionToolPayload);
}
continue;
}
emitProgress({
id: part.id,
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
detail: buildToolProgressDetail(
part.tool,
part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
),
});
if (part.tool === "todowrite" || part.tool === "todo") {
continue;
}
activityTracker.upsertAction(part, toolParams);
if (
!emittedToolParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedToolParts.add(part.id);
toolCallCount += 1;
if (!reason) {
logger.warn(
{
tool: part.tool,
sessionId: sessionId,
clientSessionId,
},
"llm tool request missing reason",
);
}
const activity = activityTracker.getActionContext(part.id);
void writeLlmRequestAuditLog({
kind: "tool",
sessionId: sessionId,
@@ -810,8 +809,9 @@ export const streamPromptResponse = async ({
traceId,
projectId,
target: part.tool,
reason,
reasonProvided: Boolean(reason),
activityId: activity?.id,
activityTitle: activity?.title,
activityReason: activity?.reason,
payload: toolParams,
}).catch((error) => {
logger.warn({ err: error }, "failed to write tool audit log");
@@ -820,7 +820,6 @@ export const streamPromptResponse = async ({
session_id: clientSessionId,
tool: part.tool,
params: toolParams,
reason,
});
}
}
@@ -841,18 +840,6 @@ export const streamPromptResponse = async ({
priority: normalizeTodoPriority(todo.priority),
updated_at: Date.now(),
}));
const completed = todos.filter(
(todo) => todo.status === "completed",
).length;
emitProgress({
id: "todo-progress",
phase: "planning",
status: completed === todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${todos.length}`,
detail: todos
.map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"),
});
write("todo_update", {
session_id: clientSessionId,
todos: normalizedTodos,
@@ -870,6 +857,7 @@ export const streamPromptResponse = async ({
? getErrorMessage(event.properties.error)
: "opencode session error",
});
activityTracker.finalize("error");
write("error", {
session_id: clientSessionId,
message: event.properties.error
@@ -893,17 +881,10 @@ export const streamPromptResponse = async ({
}
logDevelopmentDebug("session idle received", {
...debugContext,
emittedText,
hasFinalAnswer: Boolean(finalAnswerText),
toolCallCount,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: "session-status",
phase: "session",
status: "completed",
title: "Agent 已完成处理",
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
});
done = true;
}
}
@@ -913,6 +894,7 @@ export const streamPromptResponse = async ({
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
activityTracker.finalize("cancelled");
await runtime.abortSession(sessionId).catch((error) => {
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
});
@@ -930,37 +912,34 @@ export const streamPromptResponse = async ({
}
await promptPromise;
emittedText = await emitFinalMessage(
if (!finalAnswerText) {
finalAnswerText = await resolveFinalMessage(
runtime,
sessionId,
clientSessionId,
currentAssistantMessageIds,
assistantTextParts,
write,
assistantTextPartPhases,
);
emitProgress({
id: "request-received",
phase: "start",
status: "completed",
title: "请求处理完成",
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
}
activityTracker.finalize("completed");
if (finalAnswerText) {
// Keep one compatibility cycle for deployed frontends that only consume token.
write("token", {
session_id: clientSessionId,
content: finalAnswerText,
});
emitProgress({
id: "request-completed",
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
write("final_answer", {
session_id: clientSessionId,
content: finalAnswerText,
});
}
write("done", {
session_id: clientSessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
});
logDevelopmentDebug("chat stream completed", {
...debugContext,
emittedText,
hasFinalAnswer: Boolean(finalAnswerText),
toolCallCount,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
});
+65 -119
View File
@@ -9,6 +9,8 @@ export type PermissionRequestPayload = {
permission: string;
patterns: string[];
target?: string;
activity_id?: string;
reason?: string;
always: string[];
tool?: {
messageID: string;
@@ -17,6 +19,46 @@ export type PermissionRequestPayload = {
created_at: number;
};
export type ActivityStatus = "running" | "completed" | "error" | "cancelled";
export type ActivityActionPayload = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityPayload = {
id: string;
title: string;
reason: string;
status: ActivityStatus;
actions: ActivityActionPayload[];
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityUpdatePayload = {
session_id: string;
activity: ActivityPayload;
todos?: TodoItemPayload[];
todos_created_at?: number;
};
export const initialActivity = {
id: "activity-startup",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
} as const;
type QuestionOptionPayload = {
label: string;
description: string;
@@ -57,9 +99,17 @@ export type TodoUpdatePayload = {
created_at: number;
};
export type AssistantMessagePhase =
| "commentary"
| "final_answer"
| "unknown";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
tjwater_cli: "查询后端数据",
bash: "运行本地分析",
store_render_ref: "保存渲染结果",
memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索",
@@ -71,6 +121,7 @@ const toolLabels: Record<string, string> = {
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
apply_layer_style: "图层样式调整",
};
export const logDevelopmentDebug = (
@@ -110,6 +161,19 @@ export const getUnknownErrorMessage = (error: unknown) => {
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const getAssistantMessagePhase = (
metadata: unknown,
): AssistantMessagePhase => {
if (!isObjectRecord(metadata)) {
return "unknown";
}
const openai = isObjectRecord(metadata.openai) ? metadata.openai : undefined;
const phase = openai?.phase ?? metadata.phase;
return phase === "commentary" || phase === "final_answer"
? phase
: "unknown";
};
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
@@ -125,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
return {};
};
export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill");
@@ -154,10 +204,8 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => {
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
@@ -222,24 +270,6 @@ export const isQuestionV2RejectedEvent = (
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected";
export const buildPermissionDetail = (
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
) => {
const patterns = event.properties.patterns.length
? event.properties.patterns.join(", ")
: event.properties.permission;
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
};
export const buildPermissionV2Detail = (
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
) => {
const resources = event.properties.resources.length
? event.properties.resources.join(", ")
: event.properties.action;
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
};
export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string,
@@ -356,88 +386,4 @@ export const normalizeToolStatus = (status: string) => {
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
ended?: string | number | Date | null,
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
+119 -9
View File
@@ -1,5 +1,7 @@
import { type PermissionReply } from "../runtime/opencode.js";
import {
type ActivityPayload,
initialActivity,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoUpdatePayload,
@@ -28,6 +30,7 @@ type ToolCallPayload = {
session_id?: string;
tool?: string;
params?: unknown;
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
reason?: string;
};
@@ -56,13 +59,13 @@ export const createInitialStreamingMessages = (
id: createFrontendMessageId(),
role: "assistant",
content: "",
progress: [
activities: [
{
id: "request-received",
phase: "start",
id: initialActivity.id,
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
title: initialActivity.title,
reason: initialActivity.reason,
actions: [],
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
@@ -72,6 +75,90 @@ export const createInitialStreamingMessages = (
];
};
const toFrontendActivityAction = (
action: ActivityPayload["actions"][number],
) => ({
id: action.id,
tool: action.tool,
title: action.title,
status: action.status,
target: action.target,
error: action.error,
startedAt: action.started_at,
endedAt: action.ended_at,
elapsedMs: action.elapsed_ms,
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: action.duration_ms,
});
const toFrontendActivity = (activity: ActivityPayload) => ({
id: activity.id,
title: activity.title,
reason: activity.reason,
status: activity.status,
actions: activity.actions.map(toFrontendActivityAction),
startedAt: activity.started_at,
endedAt: activity.ended_at,
elapsedMs: activity.elapsed_ms,
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: activity.duration_ms,
});
export const upsertBackendActivity = (
activities: unknown,
activity: ActivityPayload,
) => {
const next = Array.isArray(activities) ? [...activities] : [];
const index = next.findIndex(
(item) => isObjectRecord(item) && item.id === activity.id,
);
const nextItem = toFrontendActivity(activity);
if (index >= 0) next[index] = nextItem;
else next.push(nextItem);
return next;
};
export const completeBackendActivities = (
activities: unknown,
status: "completed" | "error" | "cancelled" = "completed",
) => Array.isArray(activities)
? activities.map((activity) => {
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
const endedAt = Date.now();
const startedAt = typeof activity.startedAt === "number"
? activity.startedAt
: endedAt;
const actions = Array.isArray(activity.actions)
? activity.actions.map((action) => {
if (!isObjectRecord(action) || action.status !== "running") return action;
const actionStartedAt = typeof action.startedAt === "number"
? action.startedAt
: endedAt;
return {
...action,
status: status === "error" ? "error" : "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - actionStartedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
};
})
: activity.actions;
return {
...activity,
status,
actions,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - startedAt),
};
})
: activities;
export const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
@@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) =>
})
: todos;
export const completeBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "completed",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
@@ -249,10 +361,6 @@ export const appendBackendToolArtifact = (
tool,
kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params,
});
return next;
@@ -267,6 +375,8 @@ export const toFrontendPermission = (
permission: payload.permission,
patterns: payload.patterns,
target: payload.target,
activityId: payload.activity_id,
reason: payload.reason,
always: payload.always,
tool: payload.tool,
createdAt: payload.created_at,
+137
View File
@@ -0,0 +1,137 @@
import { randomUUID } from "node:crypto";
import {
chown,
chmod,
lstat,
mkdir,
realpath,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
export const SANDBOX_UID = 10_001;
export const SANDBOX_GID = 10_001;
const isOutsideRoot = (root: string, candidate: string) => {
const relativePath = relative(root, candidate);
return (
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
);
};
export const setSandboxOwnership = async (path: string) => {
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
return;
}
await chown(path, SANDBOX_UID, SANDBOX_GID);
};
export const resolveConversationWorkspace = async (
workspaceDirectory: string,
importRoot: string,
) => {
const workspaceLinkStat = await lstat(workspaceDirectory);
if (workspaceLinkStat.isSymbolicLink()) {
throw new Error("conversation workspace must not be a symbolic link");
}
const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([
realpath(importRoot),
realpath(workspaceDirectory),
]);
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
throw new Error("conversation workspace must be inside RESULT_REF_IMPORT_DIR");
}
const relativeWorkspace = relative(
resolvedImportRoot,
resolvedWorkspaceDirectory,
);
if (
!relativeWorkspace ||
relativeWorkspace.includes("/") ||
relativeWorkspace.includes("\\")
) {
throw new Error(
"conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR",
);
}
const workspaceStat = await stat(resolvedWorkspaceDirectory);
if (!workspaceStat.isDirectory()) {
throw new Error("conversation workspace must point to a directory");
}
return resolvedWorkspaceDirectory;
};
export const resolveExistingPathInsideRoot = async (
filePath: string,
rootPath: string,
outsideMessage = "path must be inside the current conversation workspace",
) => {
if (!isAbsolute(filePath)) {
throw new Error("file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
if (isOutsideRoot(resolvedRootPath, resolvedFilePath)) {
throw new Error(outsideMessage);
}
return resolvedFilePath;
};
export type StagedConversationFile = {
bytes: number;
contentType: string;
filePath: string;
};
export const stageConversationText = async (
workspaceDirectory: string,
importRoot: string,
content: string,
options: {
contentType: string;
extension: string;
prefix: string;
},
): Promise<StagedConversationFile> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
importRoot,
);
const outputDirectory = resolve(workspace, "tool-data");
if (isOutsideRoot(workspace, outputDirectory)) {
throw new Error("tool data directory escaped the conversation workspace");
}
await mkdir(outputDirectory, { mode: 0o700, recursive: true });
const resolvedOutputDirectory = await realpath(outputDirectory);
if (isOutsideRoot(workspace, resolvedOutputDirectory)) {
throw new Error("tool data directory escaped the conversation workspace");
}
await chmod(resolvedOutputDirectory, 0o700);
await setSandboxOwnership(resolvedOutputDirectory);
const fileName = `${options.prefix}-${randomUUID()}.${options.extension}`;
const filePath = join(resolvedOutputDirectory, fileName);
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
try {
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
await setSandboxOwnership(temporaryPath);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
await chmod(filePath, 0o600);
await setSandboxOwnership(filePath);
return {
bytes: Buffer.byteLength(content),
contentType: options.contentType,
filePath,
};
};
+84 -10
View File
@@ -2,11 +2,15 @@ import {
createOpencode,
type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { chmod, mkdir, rmdir } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { ensureDirectory } from "../utils/fileStore.js";
import { setSandboxOwnership } from "./conversationWorkspace.js";
import {
cleanupExpiredToolOutputs,
resolveOpencodeToolOutputDirectory,
@@ -126,12 +130,47 @@ export class OpencodeRuntimeAdapter {
}
}
async createSession(title?: string) {
async createSession(
title?: string,
options: {
conversationWorkspace?: boolean;
workspaceRoot?: string;
} = {},
) {
const client = await this.ensureClient();
if (!options.conversationWorkspace) {
const response = await client.session.create({ title });
return requireData(response.data, "session.create");
}
const workspaceRoot = resolve(
options.workspaceRoot ?? config.RESULT_REF_IMPORT_DIR,
);
const directory = resolve(workspaceRoot, `conversation-${randomUUID()}`);
await ensureDirectory(workspaceRoot);
await chmod(workspaceRoot, 0o700);
await mkdir(directory, { mode: 0o700 });
await setSandboxOwnership(directory);
try {
const response = await client.session.create({
directory,
title,
permission: [
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
],
});
return requireData(response.data, "session.create");
} catch (error) {
await rmdir(directory).catch(() => undefined);
throw error;
}
}
async getSession(sessionId: string) {
const client = await this.ensureClient();
const response = await client.session.get({ sessionID: sessionId });
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId: string, text: string) {
@@ -141,7 +180,11 @@ export class OpencodeRuntimeAdapter {
return this.messages(sessionId);
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
async prompt(
sessionId: string,
text: string,
model?: RuntimeModelOverride,
) {
const client = await this.ensureClient();
const startedAt = Date.now();
logDevelopmentDebug(
@@ -260,35 +303,41 @@ export class OpencodeRuntimeAdapter {
);
}
async subscribeEvents() {
async subscribeEvents(directory?: string) {
const client = await this.ensureClient();
const response = await client.event.subscribe();
const response = await client.event.subscribe(
directory ? { directory } : undefined,
);
return response.stream;
}
async replyPermission(options: {
requestId: string;
sessionId?: string;
directory?: string;
reply: PermissionReply;
message?: string;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("permission" in client && client.permission?.reply) {
const response = await client.permission.reply({
requestID: options.requestId,
directory,
reply: options.reply,
message: options.message,
});
return response.data;
return requireData(response.data, "permission.reply");
}
if ("permission" in client && client.permission?.respond && options.sessionId) {
const response = await client.permission.respond({
sessionID: options.sessionId,
permissionID: options.requestId,
directory,
response: options.reply,
});
return response.data;
return requireData(response.data, "permission.respond");
}
throw new Error("opencode permission reply API is unavailable");
@@ -297,16 +346,19 @@ export class OpencodeRuntimeAdapter {
async replyQuestion(options: {
requestId: string;
sessionId?: string;
directory?: string;
answers: QuestionAnswers;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reply) {
try {
const response = await client.question.reply({
requestID: options.requestId,
directory,
answers: options.answers,
});
return response.data;
return requireData(response.data, "question.reply");
} catch (error) {
if (!options.sessionId) {
throw error;
@@ -321,6 +373,7 @@ export class OpencodeRuntimeAdapter {
reply?: (parameters: {
sessionID: string;
requestID: string;
directory?: string;
questionV2Reply: { answers: QuestionAnswers };
}) => Promise<{ data: unknown }>;
};
@@ -332,11 +385,12 @@ export class OpencodeRuntimeAdapter {
const response = await v2Question.reply({
sessionID: options.sessionId,
requestID: options.requestId,
directory,
questionV2Reply: {
answers: options.answers,
},
});
return response.data;
return requireData(response.data, "question.v2.reply");
}
throw new Error("opencode question reply API is unavailable");
@@ -345,14 +399,17 @@ export class OpencodeRuntimeAdapter {
async rejectQuestion(options: {
requestId: string;
sessionId?: string;
directory?: string;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reject) {
try {
const response = await client.question.reject({
requestID: options.requestId,
directory,
});
return response.data;
return requireData(response.data, "question.reject");
} catch (error) {
if (!options.sessionId) {
throw error;
@@ -367,6 +424,7 @@ export class OpencodeRuntimeAdapter {
reject?: (parameters: {
sessionID: string;
requestID: string;
directory?: string;
}) => Promise<{ data: unknown }>;
};
};
@@ -377,13 +435,29 @@ export class OpencodeRuntimeAdapter {
const response = await v2Question.reject({
sessionID: options.sessionId,
requestID: options.requestId,
directory,
});
return response.data;
return requireData(response.data, "question.v2.reject");
}
throw new Error("opencode question reject API is unavailable");
}
private async resolveInteractionDirectory(options: {
sessionId?: string;
directory?: string;
}): Promise<string | undefined> {
const directory = options.directory?.trim();
if (directory) {
return directory;
}
if (!options.sessionId) {
return undefined;
}
const session = await this.getSession(options.sessionId);
return session.directory;
}
async dispose(): Promise<void> {
if (this.toolOutputCleanupTimer) {
clearInterval(this.toolOutputCleanupTimer);
+1
View File
@@ -15,6 +15,7 @@ export type RuntimeSessionContext = {
sessionId: string;
tokenExpiresAt?: string;
traceId: string;
workspaceDirectory?: string;
};
const contexts = new Map<string, RuntimeSessionContext>();
+47
View File
@@ -0,0 +1,47 @@
import { config } from "../config.js";
import { type RuntimeSessionContext } from "./sessionContext.js";
import { stageConversationText } from "./conversationWorkspace.js";
export type ToolDataFilePayload = {
bytes: number;
content_type: string;
file_path: string;
};
export const stageLargeToolOutput = async (
context: RuntimeSessionContext,
content: string,
options: {
contentType: string;
extension: string;
force?: boolean;
prefix: string;
},
): Promise<ToolDataFilePayload | null> => {
if (!options.force && Buffer.byteLength(content) <= config.MAX_INLINE_RESULT_BYTES) {
return null;
}
if (!context.workspaceDirectory) {
throw new Error(
"large tool output requires a conversation workspace; create a new conversation",
);
}
const staged = await stageConversationText(
context.workspaceDirectory,
config.RESULT_REF_IMPORT_DIR,
content,
options,
);
return {
bytes: staged.bytes,
content_type: staged.contentType,
file_path: staged.filePath,
};
};
export const buildLargeCliResult = (dataFile: ToolDataFilePayload) => ({
ok: true,
schema_version: "tjwater-cli/v1",
summary: "CLI 结果已保存到当前对话工作区",
data_file: dataFile,
});
+219
View File
@@ -0,0 +1,219 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import {
resolveConversationWorkspace,
SANDBOX_GID,
SANDBOX_UID,
setSandboxOwnership,
} from "../runtime/conversationWorkspace.js";
export type SandboxProbe = {
landlockAbi: number;
seccomp: boolean;
};
export type SandboxExecutionResult = {
exitCode: number | null;
outcome: "completed" | "output_limit" | "timeout";
signal: NodeJS.Signals | null;
stderr: string;
stderrTruncated: boolean;
stdout: string;
};
const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py");
const maxSandboxTimeoutSeconds = 30 * 60;
const resolvePythonPath = () => {
const virtualEnvironment = process.env.VIRTUAL_ENV?.trim();
const candidates = [
virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "",
"/opt/venv/bin/python",
"/usr/bin/python3",
];
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3";
};
const collectProcess = async (
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxStderrBytes: number;
maxStdoutBytes: number;
timeoutMs: number;
},
): Promise<SandboxExecutionResult> => {
const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], {
cwd: options.cwd,
detached: true,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let stderrTruncated = false;
let outcome: SandboxExecutionResult["outcome"] = "completed";
let terminationStarted = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const killGroup = (signal: NodeJS.Signals) => {
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child when process groups are unavailable.
}
}
child.kill(signal);
};
const terminate = (nextOutcome: "output_limit" | "timeout") => {
if (terminationStarted) return;
terminationStarted = true;
outcome = nextOutcome;
killGroup("SIGTERM");
forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500);
};
const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
if (terminationStarted) return;
if (stdoutBytes + chunk.length > options.maxStdoutBytes) {
terminate("output_limit");
return;
}
stdoutChunks.push(chunk);
stdoutBytes += chunk.length;
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderrTruncated) return;
const remaining = options.maxStderrBytes - stderrBytes;
if (chunk.length > remaining) {
if (remaining > 0) {
stderrChunks.push(chunk.subarray(0, remaining));
stderrBytes += remaining;
}
stderrTruncated = true;
return;
}
stderrChunks.push(chunk);
stderrBytes += chunk.length;
});
return await new Promise((resolveResult, reject) => {
child.once("error", (error) => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
reject(error);
});
child.once("close", (exitCode, signal) => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
resolveResult({
exitCode,
outcome,
signal,
stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"),
stderrTruncated,
stdout:
outcome === "output_limit"
? ""
: Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"),
});
});
});
};
export const probeLandlockSandbox = async (): Promise<SandboxProbe> => {
const result = await collectProcess(["--probe"], {
maxStderrBytes: 16 * 1024,
maxStdoutBytes: 16 * 1024,
timeoutMs: 5000,
});
if (result.exitCode !== 0) {
throw new Error(result.stderr || "Landlock sandbox probe failed");
}
const payload = JSON.parse(result.stdout) as {
landlock_abi?: unknown;
seccomp?: unknown;
};
if (
typeof payload.landlock_abi !== "number" ||
payload.landlock_abi < 4 ||
payload.seccomp !== true
) {
throw new Error("Landlock ABI 4 and seccomp are required");
}
return { landlockAbi: payload.landlock_abi, seccomp: true };
};
const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({
HOME: workspace,
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"),
PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONDONTWRITEBYTECODE: "1",
TMPDIR: resolve(workspace, "tmp"),
TZ: process.env.TZ ?? "Asia/Shanghai",
VIRTUAL_ENV: "/opt/venv",
XDG_CACHE_HOME: resolve(workspace, ".cache"),
});
export const executeSandboxCommand = async (
workspaceDirectory: string,
command: string,
timeoutSeconds: number,
): Promise<SandboxExecutionResult> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
config.RESULT_REF_IMPORT_DIR,
);
const normalizedTimeout = Math.min(
maxSandboxTimeoutSeconds,
Math.max(1, Math.trunc(timeoutSeconds)),
);
for (const path of [resolve(workspace, "tmp"), resolve(workspace, ".cache")]) {
await mkdir(path, { mode: 0o700, recursive: true });
await setSandboxOwnership(path);
}
const readOnlyPaths = [
"/usr",
"/bin",
"/lib",
"/lib64",
"/opt/venv",
"/etc/ld.so.cache",
"/etc/localtime",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/passwd",
resolve(config.OPENCODE_SKILLS_ROOT_DIR),
].filter(existsSync);
const args = [
"--workspace",
workspace,
"--uid",
String(SANDBOX_UID),
"--gid",
String(SANDBOX_GID),
...readOnlyPaths.flatMap((path) => ["--read-only", path]),
"--command",
command,
];
return collectProcess(args, {
cwd: workspace,
env: buildSandboxEnvironment(workspace),
maxStderrBytes: config.MAX_CLI_STDERR_BYTES,
maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES,
timeoutMs: normalizedTimeout * 1000,
});
};
+124
View File
@@ -32,11 +32,20 @@ import {
import { buildChatRouter } from "./routes/chat.js";
import { buildAgentPublicRouter } from "./routes/publicApi.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import {
executeSandboxCommand,
probeLandlockSandbox,
type SandboxProbe,
} from "./sandbox/landlockSandbox.js";
import {
getRuntimeSessionContext,
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
import {
buildLargeCliResult,
stageLargeToolOutput,
} from "./runtime/toolOutputStaging.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js";
@@ -63,6 +72,7 @@ const resultReferenceResolver = new ResultReferenceResolver(
);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
let sandboxProbe: SandboxProbe | null = null;
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
@@ -78,6 +88,7 @@ app.get("/health", async (_req, res) => {
ready: true,
warmed_up: true,
runtime,
sandbox: sandboxProbe,
sessions: sessionBridge.count(),
});
} catch (error) {
@@ -215,6 +226,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
const storeResult = req.body?.store_result === true;
if (!context.network) {
res.status(400).json({
@@ -309,6 +321,24 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}
if (result.stdout.trim()) {
try {
const dataFile = await stageLargeToolOutput(context, result.stdout, {
contentType: "application/json",
extension: "json",
force: storeResult,
prefix: "cli",
});
if (dataFile) {
res.status(200).json(buildLargeCliResult(dataFile));
return;
}
} catch (error) {
res.status(409).json({
message: "large CLI result could not be staged",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
res.status(200).type("application/json").send(result.stdout);
return;
}
@@ -321,6 +351,91 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
});
});
app.post("/internal/tools/sandbox-shell", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const command =
typeof req.body?.command === "string" ? req.body.command.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({ message: "session context not found", detail: sessionId });
return;
}
if (!context.workspaceDirectory) {
res.status(400).json({
message: "conversation workspace is required",
detail: "create a new conversation before running shell commands",
});
return;
}
if (!command) {
res.status(400).json({ message: "command is required" });
return;
}
const timeoutSeconds =
typeof req.body?.timeout === "number" && Number.isFinite(req.body.timeout)
? req.body.timeout
: 120;
try {
const result = await executeSandboxCommand(
context.workspaceDirectory,
command,
timeoutSeconds,
);
if (result.outcome === "timeout") {
res.status(504).json({
ok: false,
error: { code: "TIMEOUT", message: "sandbox command timed out" },
});
return;
}
if (result.outcome === "output_limit") {
res.status(502).json({
ok: false,
error: {
code: "OUTPUT_LIMIT_EXCEEDED",
message: `stdout exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`,
},
});
return;
}
if (result.exitCode === 125) {
res.status(503).json({
ok: false,
error: { code: "SANDBOX_UNAVAILABLE", message: result.stderr },
});
return;
}
const dataFile = result.stdout
? await stageLargeToolOutput(context, result.stdout, {
contentType: "text/plain",
extension: "txt",
prefix: "shell",
})
: null;
res.json({
ok: result.exitCode === 0,
exit_code: result.exitCode,
signal: result.signal,
...(dataFile ? { data_file: dataFile } : { stdout: result.stdout }),
stderr: result.stderr || undefined,
stderr_truncated: result.stderrTruncated || undefined,
});
} catch (error) {
res.status(503).json({
ok: false,
error: {
code: "SANDBOX_UNAVAILABLE",
message: error instanceof Error ? error.message : String(error),
},
});
}
});
app.post("/internal/tools/store-render-ref", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
@@ -342,6 +457,13 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
res.status(400).json({ message: "file_path is required" });
return;
}
if (!context.workspaceDirectory) {
res.status(400).json({
message: "conversation workspace is required",
detail: "create a new conversation before importing render data",
});
return;
}
try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
@@ -352,6 +474,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
sessionId: context.clientSessionId,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId,
workspaceDirectory: context.workspaceDirectory,
});
res.json({
ok: true,
@@ -618,6 +741,7 @@ app.use(
);
const bootstrap = async () => {
sandboxProbe = await probeLandlockSandbox();
await Promise.all([
sessionMetadataStore.initialize(),
sessionUiStateStore.initialize(),
+70 -6
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -12,13 +12,18 @@ import {
describe("ResultReferenceResolver", () => {
let tempDir: string;
let importRoot: string;
let conversationWorkspace: string;
let store: ResultReferenceStore;
let resolver: ResultReferenceResolver;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
importRoot = join(tempDir, "conversation-workspaces");
conversationWorkspace = join(importRoot, "conversation-1");
await mkdir(conversationWorkspace, { recursive: true });
store = new ResultReferenceStore(join(tempDir, "refs"), 60_000);
resolver = new ResultReferenceResolver(store, importRoot, 1024 * 1024);
await store.initialize();
});
@@ -127,7 +132,7 @@ describe("ResultReferenceResolver", () => {
});
it("registers render refs from local wrapper files and normalizes payloads", async () => {
const filePath = join(tempDir, "render-wrapper.json");
const filePath = join(conversationWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify(
@@ -166,6 +171,7 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3",
workspaceDirectory: conversationWorkspace,
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
@@ -218,6 +224,7 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-4",
workspaceDirectory: outsideDir,
}),
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
} finally {
@@ -226,9 +233,9 @@ describe("ResultReferenceResolver", () => {
});
it("rejects oversized render payload files before parsing", async () => {
const filePath = join(tempDir, "oversized.json");
const filePath = join(conversationWorkspace, "oversized.json");
await writeFile(filePath, "x".repeat(128), "utf8");
const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64);
const sizeLimitedResolver = new ResultReferenceResolver(store, importRoot, 64);
await expect(
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
@@ -238,8 +245,65 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-5",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-5",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
});
it("rejects render payload files owned by another conversation workspace", async () => {
const otherWorkspace = join(importRoot, "conversation-2");
await mkdir(otherWorkspace);
const filePath = join(otherWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-6",
clientSessionId: "client-6",
projectKey: "project-key-6",
sessionId: "session-6",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-6",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("current conversation workspace");
});
it("rejects a conversation workspace that is itself a symbolic link", async () => {
const targetWorkspace = join(importRoot, "conversation-target");
const linkedWorkspace = join(importRoot, "conversation-linked");
await mkdir(targetWorkspace);
await symlink(targetWorkspace, linkedWorkspace, "dir");
const filePath = join(targetWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-7",
clientSessionId: "client-7",
projectKey: "project-key-7",
sessionId: "session-7",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-7",
workspaceDirectory: linkedWorkspace,
}),
).rejects.toThrow("symbolic link");
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "bun:test";
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
describe("createActivityTracker", () => {
it("groups actions and closes running children with their activity", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
tracker.start("activity-1", "准备分析数据", "需要先获取分析输入。");
tracker.upsertAction(
{
id: "tool-1",
tool: "tjwater_cli",
state: {
status: "running",
input: { command: "data list" },
},
} as never,
{ command: "data list" },
);
tracker.finalize("cancelled");
expect(tracker.getCurrentContext()).toEqual({
id: "activity-1",
title: "准备分析数据",
reason: "需要先获取分析输入。",
});
expect(events.at(-1)).toMatchObject({
event: "activity_update",
data: {
session_id: "client-session-1",
activity: {
id: "activity-1",
status: "cancelled",
actions: [
expect.objectContaining({
id: "tool-1",
status: "completed",
target: "data list",
}),
],
},
},
});
});
});
+68
View File
@@ -129,4 +129,72 @@ describe("permission approval policy", () => {
title: "已按始终允许模式放行",
});
});
it("auto approves sandboxed shell and file access in a conversation workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "permission-conversations-"));
const conversationRoot = join(root, "conversation-workspaces");
const workspaceRoot = join(conversationRoot, "conversation-test");
try {
await mkdir(workspaceRoot, { recursive: true });
expect(
resolvePermissionApproval("auto", "bash", {
workspaceRoot,
metadata: { command: "python3 analysis.py" },
}),
).toMatchObject({ autoApprove: true, autoReject: false });
expect(
canAutoApprovePermission("edit", {
workspaceRoot,
metadata: { filePath: join(workspaceRoot, "result.json") },
}),
).toBe(true);
expect(
canAutoApprovePermission("glob", {
workspaceRoot,
metadata: { path: workspaceRoot, pattern: "**/*.json" },
patterns: ["**/*.json"],
}),
).toBe(true);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it.each([
"rm -rf ./target",
"rm -rf ./target",
"rm -Rf ./target",
"/bin/rm -rf ./target",
"command rm --force --recursive ./target",
"env LANG=C rm -r -f ./target",
"SAFE=1 rm -rf ./target",
"env -u HOME rm -rf ./target",
"r\"\"m -rf ./target",
"(rm -rf ./target)",
"! rm -rf ./target",
"npm test && rm --recursive --force ./target",
])("rejects direct recursive force removal in always mode: %s", (command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
});
});
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
"allows non-force-recursive or non-executed removal text in always mode: %s",
(command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({ autoApprove: true, autoReject: false });
},
);
});
+560 -35
View File
@@ -16,9 +16,11 @@ const createEventStream = (events: unknown[]) => ({
describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
let subscribedDirectory: string | undefined;
const runtime = {
subscribeEvents: async () =>
createEventStream([
subscribeEvents: async (directory?: string) => {
subscribedDirectory = directory;
return createEventStream([
{
type: "message.part.delta",
properties: {
@@ -73,7 +75,8 @@ describe("streamPromptResponse", () => {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
]);
},
prompt: async () => undefined,
messages: async () => [
{
@@ -109,15 +112,166 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
expect(events.filter((item) => item.event === "token")).toEqual([
{
event: "token",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
});
it("buffers final_answer deltas and emits one complete answer", async () => {
let messagesCalls = 0;
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "",
metadata: { openai: { phase: "commentary" } },
time: { start: 1 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
partID: "commentary-part",
field: "text",
delta: "我先检查相关数据。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "我先检查相关数据。",
metadata: { openai: { phase: "commentary" } },
time: { start: 1, end: 2 },
},
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "分析完成,",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "结果正常。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "分析完成,结果正常。",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3, end: 4 },
},
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
messagesCalls += 1;
return [];
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"分析完成,结果正常。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "分析完成,结果正常。",
},
},
]);
expect(messagesCalls).toBe(0);
expect(events.some((item) => item.event === "progress")).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
events.some(
(item) => item.event === "token" && item.data.content === "我先检查相关数据。",
),
).toBe(false);
});
it("uses the final text event cache when the messages lookup fails", async () => {
@@ -160,18 +314,56 @@ describe("streamPromptResponse", () => {
});
expect(result.failed).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"最终分析结果。",
);
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"最终分析结果。",
);
});
it("keeps reasoning generic while preserving tool execution details", async () => {
it("groups concrete tool execution under the current activity", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "activity-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "activity-call-1",
tool: "activity_update",
state: {
status: "completed",
input: {
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
todos: [
{
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
},
],
},
output: "活动阶段已更新。",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{
type: "message.part.updated",
properties: {
@@ -212,7 +404,6 @@ describe("streamPromptResponse", () => {
status: "error",
input: {
command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
},
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 },
@@ -239,16 +430,37 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
const reasoningProgress = events.find(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1",
);
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe(
"tjwater_cli 调用失败;调用原因:尝试突破分页限制;关键参数:command=network get-all-pipes-properties --limit 5000;错误:HTTP_422 raw backend payload with trace_id=secret-trace",
const activityUpdates = events.filter(
(item) => item.event === "activity_update" &&
(item.data.activity as { id?: string } | undefined)?.id === "activity-part-1",
);
expect(activityUpdates.at(-1)?.data.activity).toMatchObject({
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
actions: [
expect.objectContaining({
id: "tool-part-1",
tool: "tjwater_cli",
status: "error",
target: "network get-all-pipes-properties --limit 5000",
}),
],
});
expect(activityUpdates[0]?.data.todos).toEqual([
expect.objectContaining({
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
}),
expect.objectContaining({
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
}),
]);
expect(events.some((item) => item.event === "progress")).toBe(false);
});
it("forwards opencode permission requests as SSE payloads", async () => {
@@ -293,6 +505,8 @@ describe("streamPromptResponse", () => {
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
activity_id: "activity-startup",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
always: ["rm *"],
} satisfies Partial<PermissionRequestPayload>);
});
@@ -334,6 +548,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "auto",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
@@ -341,6 +556,7 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
@@ -422,6 +638,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
@@ -429,12 +646,63 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("rejects recursive force removal even in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-rm-rf",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["/bin/rm -rf ./target"],
metadata: { command: "/bin/rm -rf ./target" },
always: ["/bin/rm -rf ./target"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "delete recursively",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "reject",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-always-rm-rf",
reply: "reject",
});
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
@@ -633,10 +901,35 @@ describe("streamPromptResponse", () => {
).toBe(false);
});
it("forwards todo updates as structured SSE payloads and progress", async () => {
it("forwards todo updates independently from activity progress", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "todo-tool-part",
sessionID: "runtime-session-1",
messageID: "assistant-plan",
type: "tool",
callID: "todo-tool-call",
tool: "todowrite",
state: {
status: "completed",
input: {
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
output: "计划已更新",
time: { start: 1, end: 2 },
},
},
},
},
{
type: "todo.updated",
properties: {
@@ -667,15 +960,7 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(
events.find(
(item) => item.event === "progress" && item.data.id === "todo-progress",
)?.data,
).toMatchObject({
id: "todo-progress",
phase: "planning",
title: "计划进度 1/2",
});
expect(events.some((item) => item.event === "progress")).toBe(false);
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
session_id: "client-session-1",
todos: [
@@ -691,6 +976,246 @@ describe("streamPromptResponse", () => {
}),
],
});
expect(
events.some(
(item) =>
item.event === "activity_update" &&
((item.data.activity as { actions?: Array<{ tool?: string }> } | undefined)
?.actions ?? [])
.some((action) => action.tool === "todowrite"),
),
).toBe(false);
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "todowrite",
),
).toBe(false);
});
it("buffers the voluntary DeepSeek final answer tool without forcing tool choice", async () => {
const promptCalls: unknown[][] = [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区" },
time: { start: 1 },
},
},
},
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区分析已完成。" },
time: { start: 1 },
},
},
},
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "供水服务分区分析已完成。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
};
},
}),
prompt: async (...args: unknown[]) => {
promptCalls.push(args);
},
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析供水服务分区",
model: "deepseek/deepseek-v4-flash",
write: (event, data) => events.push({ event, data }),
});
expect(promptCalls[0]?.[3]).toBeUndefined();
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"供水服务分区分析已完成。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "供水服务分区分析已完成。",
},
},
]);
expect(events.some((item) => item.event === "tool_call")).toBe(false);
expect(events.some((item) => item.event === "done")).toBe(true);
});
it("keeps the pending event read when prompt resolves before final_answer", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
await new Promise((resolve) => setTimeout(resolve, 0));
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "最终答案不会因事件竞争而丢失。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
};
},
}),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"最终答案不会因事件竞争而丢失。",
);
expect(events.at(-1)?.event).toBe("done");
});
it("recovers a persisted final_answer tool result from message history", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.updated",
properties: {
sessionID: "runtime-session-1",
info: { id: "assistant-final", role: "assistant" },
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "已从持久化工具结果恢复最终答案。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"已从持久化工具结果恢复最终答案。",
);
});
});
+52 -2
View File
@@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test";
import {
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendActivities,
completeBackendTodos,
upsertBackendQuestion,
} from "../../src/routes/chatUiState.js";
describe("completeBackendActivities", () => {
it("closes running child actions when the stream terminates", () => {
const activities = completeBackendActivities([
{
id: "activity-1",
status: "running",
startedAt: Date.now() - 100,
actions: [
{
id: "action-1",
status: "running",
startedAt: Date.now() - 50,
},
],
},
], "error") as Array<Record<string, unknown>>;
expect(activities[0]).toMatchObject({
status: "error",
actions: [expect.objectContaining({ status: "error" })],
});
});
});
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
chart_type: "line",
@@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => {
tool: "show_chart",
kind: "chart",
title: "压力曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
x_data: ["00:00", "01:00"],
@@ -160,3 +184,29 @@ describe("cancelBackendTodos", () => {
]);
});
});
describe("completeBackendTodos", () => {
it("marks pending and in-progress todos as completed after a successful run", () => {
const completed = completeBackendTodos([
{
sessionId: "session-1",
todos: [
{ id: "todo-1", content: "分析水位", status: "in_progress" },
{ id: "todo-2", content: "生成建议", status: "pending" },
{ id: "todo-3", content: "完成报告", status: "completed" },
],
createdAt: 123,
},
]);
expect(completed).toEqual([
expect.objectContaining({
todos: [
expect.objectContaining({ id: "todo-1", status: "completed" }),
expect.objectContaining({ id: "todo-2", status: "completed" }),
expect.objectContaining({ id: "todo-3", status: "completed" }),
],
}),
]);
});
});
+160 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
@@ -87,6 +90,157 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
});
});
describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
it("subscribes to the conversation workspace directory", async () => {
const calls: Array<Record<string, unknown> | undefined> = [];
const stream = (async function* () {
return;
})();
const client = {
event: {
subscribe: async (input?: Record<string, unknown>) => {
calls.push(input);
return { stream };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await expect(
runtime.subscribeEvents("/tmp/conversation-workspace-1"),
).resolves.toBe(stream);
expect(calls).toEqual([
{ directory: "/tmp/conversation-workspace-1" },
]);
});
});
describe("OpencodeRuntimeAdapter interaction replies", () => {
it("replies to permissions in the conversation workspace directory", async () => {
const calls: unknown[] = [];
const client = {
permission: {
reply: async (input: unknown) => {
calls.push(input);
return { data: true };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.replyPermission({
requestId: "permission-1",
sessionId: "session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
});
expect(calls).toEqual([
{
requestID: "permission-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
});
it("fails when OpenCode returns no permission reply data", async () => {
const client = {
permission: {
reply: async () => ({ data: undefined }),
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await expect(
runtime.replyPermission({
requestId: "permission-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
}),
).rejects.toThrow("permission.reply returned no data");
});
});
describe("OpencodeRuntimeAdapter.createSession", () => {
it("creates a real chat session inside a dedicated conversation workspace", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const calls: Array<Record<string, unknown>> = [];
const client = {
session: {
create: async (input: Record<string, unknown>) => {
calls.push(input);
return {
data: {
id: "runtime-session-1",
directory: input.directory,
},
};
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
const session = await runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
});
const directory = String(calls[0]?.directory);
expect(relative(workspaceRoot, directory).startsWith("..")).toBe(false);
const workspaceStat = await stat(directory);
expect(workspaceStat.isDirectory()).toBe(true);
expect(workspaceStat.mode & 0o777).toBe(0o700);
expect(session.directory).toBe(directory);
expect(calls[0]?.permission).toEqual([
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
it("removes an empty conversation workspace when session creation fails", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const client = {
session: {
create: async () => {
throw new Error("session creation failed");
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
await expect(
runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
}),
).rejects.toThrow("session creation failed");
expect(await readdir(workspaceRoot)).toEqual([]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
});
describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = [];
@@ -149,11 +303,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
await runtime.replyQuestion({
requestId: "question-1",
sessionId: "session-1",
directory: "/tmp/conversation-workspace-1",
answers: [["继续"]],
});
expect(calls).toEqual([
{ requestID: "question-1", answers: [["继续"]] },
{
requestID: "question-1",
directory: "/tmp/conversation-workspace-1",
answers: [["继续"]],
},
]);
});
});
+188 -3
View File
@@ -1,7 +1,29 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, {
resolveStoreRenderFilePath,
} from "../../.opencode/tools/store_render_ref.js";
describe("internal OpenCode permissions", () => {
it("pins the runtime, SDK, plugin, and image to OpenCode 1.18.13", async () => {
const [rootPackageText, toolPackageText, dockerfile] = await Promise.all([
readFile("package.json", "utf8"),
readFile(".opencode/package.json", "utf8"),
readFile("Dockerfile", "utf8"),
]);
const rootPackage = JSON.parse(rootPackageText) as {
dependencies?: Record<string, string>;
};
const toolPackage = JSON.parse(toolPackageText) as {
dependencies?: Record<string, string>;
};
expect(rootPackage.dependencies?.["@opencode-ai/sdk"]).toBe("1.18.13");
expect(toolPackage.dependencies?.["@opencode-ai/plugin"]).toBe("1.18.13");
expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true);
});
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
@@ -15,6 +37,8 @@ describe("internal OpenCode permissions", () => {
expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow");
expect(permission.activity_update).toBe("allow");
expect(permission.final_answer).toBe("allow");
expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny");
@@ -23,8 +47,169 @@ describe("internal OpenCode permissions", () => {
expect(edit?.["data/**"]).toBe("deny");
expect(edit?.["**/logs/**"]).toBe("deny");
expect(bash?.["*"]).toBe("ask");
expect(bash?.["rm *"]).toBe("ask");
expect(bash?.["rm -rf *"]).toBe("deny");
expect(bash?.["rm -fr *"]).toBe("deny");
expect(bash?.["rm -r -f *"]).toBe("deny");
expect(bash?.["rm -f -r *"]).toBe("deny");
expect(bash?.["rm --recursive --force *"]).toBe("deny");
expect(bash?.["rm --force --recursive *"]).toBe("deny");
expect(bash?.["*.env*"]).toBe("deny");
expect(bash?.["*data/*"]).toBe("deny");
expect(bash?.["*logs/*"]).toBe("deny");
expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined();
});
it("keeps reason only on the activity grouping tool", async () => {
const toolFiles = (await readdir(".opencode/tools"))
.filter((file) => file.endsWith(".ts"));
const sources = await Promise.all(
toolFiles.map(async (file) => ({
file,
source: await readFile(`.opencode/tools/${file}`, "utf8"),
})),
);
const reasonSchemaFiles = sources
.filter(({ source }) => /reason:\s*tool\.schema/u.test(source))
.map(({ file }) => file);
expect(reasonSchemaFiles).toEqual(["activity_update.ts"]);
});
});
describe("store_render_ref arguments", () => {
it("accepts the observed camelCase alias without changing snake_case precedence", () => {
expect(
resolveStoreRenderFilePath({
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/partition.json");
expect(
resolveStoreRenderFilePath({
file_path: "/app/data/conversation-workspaces/chat-1/preferred.json",
filePath: "/app/data/conversation-workspaces/chat-1/compatibility.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/preferred.json");
});
it("forwards a camelCase compatibility argument as file_path", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
globalThis.fetch = (async (
_input: RequestInfo | URL,
init?: RequestInit,
) => {
requestBody = JSON.parse(String(init?.body));
return new Response('{"render_ref":"res-test"}');
}) as unknown as typeof fetch;
try {
const definition = storeRenderRef as unknown as {
args: Record<string, unknown>;
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
expect(definition.args.filePath).toBeDefined();
await definition.execute(
{
reason: "regression test",
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toEqual({
session_id: "session-test",
file_path: "/app/data/conversation-workspaces/chat-1/partition.json",
});
});
});
describe("sandbox bash tool", () => {
it("forwards commands to the authenticated sandbox endpoint", async () => {
const originalFetch = globalThis.fetch;
const permissionRequests: unknown[] = [];
let requestUrl = "";
let requestBody: unknown;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
requestUrl = String(input);
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"stdout":"done"}');
}) as unknown as typeof fetch;
try {
const definition = sandboxBash as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{ command: "python3 analysis.py", timeout: 300 },
{
sessionID: "session-test",
ask: async (input: unknown) => permissionRequests.push(input),
} as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestUrl).toEndWith("/internal/tools/sandbox-shell");
expect(permissionRequests).toEqual([
{
permission: "bash",
patterns: ["python3 analysis.py"],
always: ["python3 analysis.py"],
metadata: {
command: "python3 analysis.py",
},
},
]);
expect(requestBody).toEqual({
session_id: "session-test",
command: "python3 analysis.py",
timeout: 300,
});
});
});
describe("tjwater_cli storage request", () => {
it("keeps command discovery rules in the always-visible tool contract", () => {
const definition = tjwaterCli as unknown as {
description: string;
args: { command: { description?: string } };
};
expect(definition.description).toContain("help");
expect(definition.args.command.description).toContain("禁止类推");
expect(definition.args.command.description).toContain("simulation runs list");
expect(definition.args.command.description).not.toContain(
"示例:'analysis runs list'",
);
});
it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requestBody = JSON.parse(String(init?.body));
return new Response('{"ok":true,"data_file":{"file_path":"/tmp/test"}}');
}) as unknown as typeof fetch;
try {
const definition = tjwaterCli as unknown as {
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
await definition.execute(
{
command: "network get-all-reservoirs-properties",
store_result: true,
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toMatchObject({
session_id: "session-test",
store_result: true,
});
});
});
+4
View File
@@ -19,6 +19,7 @@ describe("runtime session context", () => {
projectKey: "project-1",
sessionId: "runtime-session-1",
traceId: "trace-1",
workspaceDirectory: "/app/data/conversation-workspaces/chat-session-1",
});
const runtimeContext = getRuntimeSessionContext("runtime-session-1");
@@ -27,6 +28,9 @@ describe("runtime session context", () => {
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.network).toBe("fengyang");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
expect(runtimeContext?.workspaceDirectory).toBe(
"/app/data/conversation-workspaces/chat-session-1",
);
removeRuntimeSessionContext("runtime-session-1");
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, readFile, rm, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../../src/config.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import {
buildLargeCliResult,
stageLargeToolOutput,
} from "../../src/runtime/toolOutputStaging.js";
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
const createContext = async (): Promise<RuntimeSessionContext> => {
const importRoot = resolve(config.RESULT_REF_IMPORT_DIR);
await mkdir(importRoot, { recursive: true });
const workspaceDirectory = resolve(
importRoot,
`conversation-staging-${crypto.randomUUID()}`,
);
await mkdir(workspaceDirectory, { mode: 0o700 });
createdPaths.push(workspaceDirectory);
return {
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
workspaceDirectory,
};
};
describe("tool output staging", () => {
it("keeps small output inline unless storage is forced", async () => {
const context = await createContext();
await expect(
stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
prefix: "cli",
}),
).resolves.toBeNull();
const stored = await stageLargeToolOutput(context, '{"ok":true}', {
contentType: "application/json",
extension: "json",
force: true,
prefix: "cli",
});
expect(stored?.file_path).toContain("/tool-data/cli-");
expect(await readFile(stored!.file_path, "utf8")).toBe('{"ok":true}');
expect((await stat(stored!.file_path)).mode & 0o777).toBe(0o600);
});
it("stages output above the inline threshold and returns a compact descriptor", async () => {
const context = await createContext();
const content = JSON.stringify({
ok: true,
schema_version: "tjwater-cli/v1",
data: "x".repeat(config.MAX_INLINE_RESULT_BYTES),
});
const dataFile = await stageLargeToolOutput(context, content, {
contentType: "application/json",
extension: "json",
prefix: "cli",
});
expect(dataFile?.bytes).toBe(Buffer.byteLength(content));
const stored = dataFile!;
expect(buildLargeCliResult(stored)).toEqual({
ok: true,
schema_version: "tjwater-cli/v1",
summary: "CLI 结果已保存到当前对话工作区",
data_file: stored,
});
});
it("does not stage large output for legacy sessions without a workspace", async () => {
await expect(
stageLargeToolOutput(
{
actorKey: "actor-1",
clientSessionId: "client-1",
projectKey: "project-1",
sessionId: "runtime-1",
traceId: "trace-1",
},
"x".repeat(config.MAX_INLINE_RESULT_BYTES + 1),
{ contentType: "text/plain", extension: "txt", prefix: "shell" },
),
).rejects.toThrow("requires a conversation workspace");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../../src/config.js";
import {
resolveConversationWorkspace,
setSandboxOwnership,
} from "../../src/runtime/conversationWorkspace.js";
import {
executeSandboxCommand,
probeLandlockSandbox,
} from "../../src/sandbox/landlockSandbox.js";
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
const createWorkspace = async (name: string = crypto.randomUUID()) => {
const importRoot = resolve(config.RESULT_REF_IMPORT_DIR);
await mkdir(importRoot, { recursive: true });
const workspace = resolve(importRoot, `conversation-sandbox-${name}`);
await mkdir(workspace, { mode: 0o700 });
await setSandboxOwnership(workspace);
createdPaths.push(workspace);
return workspace;
};
describe("Landlock sandbox", () => {
it("requires Landlock ABI 4 and seccomp", async () => {
const probe = await probeLandlockSandbox();
expect(probe.landlockAbi).toBeGreaterThanOrEqual(4);
expect(probe.seccomp).toBe(true);
});
it("allows local Python analysis while denying filesystem escape, secrets, and network", async () => {
const workspace = await createWorkspace("primary");
const sibling = await createWorkspace("sibling");
await resolveConversationWorkspace(workspace, config.RESULT_REF_IMPORT_DIR);
const allowed = await executeSandboxCommand(
workspace,
'python3 -c "import json; open(\'result.json\', \'w\').write(json.__name__)" && cat result.json',
10,
);
expect(allowed).toMatchObject({ exitCode: 0, stdout: "json" });
const escaped = await executeSandboxCommand(
workspace,
`cat ${resolve("package.json")}`,
10,
);
expect(escaped.exitCode).not.toBe(0);
expect(escaped.stderr).toContain("Permission denied");
const siblingRead = await executeSandboxCommand(
workspace,
`ls ${sibling}`,
10,
);
expect(siblingRead.exitCode).not.toBe(0);
const originalApiKey = process.env.DEEPSEEK_API_KEY;
process.env.DEEPSEEK_API_KEY = "must-not-leak";
try {
const environment = await executeSandboxCommand(
workspace,
'test -z "$DEEPSEEK_API_KEY"',
10,
);
expect(environment.exitCode).toBe(0);
} finally {
if (originalApiKey === undefined) {
delete process.env.DEEPSEEK_API_KEY;
} else {
process.env.DEEPSEEK_API_KEY = originalApiKey;
}
}
for (const socketType of ["SOCK_STREAM", "SOCK_DGRAM"]) {
const network = await executeSandboxCommand(
workspace,
`python3 -c "import socket; socket.socket(socket.AF_INET, socket.${socketType})"`,
10,
);
expect(network.exitCode).not.toBe(0);
expect(network.stderr).toContain("Operation not permitted");
}
});
});
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from "bun:test";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const createdPaths: string[] = [];
afterEach(async () => {
await Promise.all(
createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })),
);
});
describe("service area workflow script", () => {
it("writes the wrapped render payload required by store_render_ref", async () => {
const directory = await mkdtemp(join(tmpdir(), "service-area-script-"));
createdPaths.push(directory);
const time = "2026-04-01T08:00:00+08:00";
const inputs = {
pipes: { data: [{ id: "P1", node1: "R1", node2: "N1" }] },
reservoirs: { data: [{ id: "R1" }] },
links: { data: [{ id: "P1", flow: 1, time }] },
nodes: {
data: [
{ id: "R1", pressure: 30, actual_demand: 0, time },
{ id: "N1", pressure: 28, actual_demand: 2, time },
],
},
};
const paths = Object.fromEntries(
await Promise.all(
Object.entries(inputs).map(async ([name, value]) => {
const path = join(directory, `${name}.json`);
await writeFile(path, JSON.stringify(value));
return [name, path] as const;
}),
),
);
const outputPath = join(directory, "service-area-wrapper.json");
await execFileAsync(
"python3",
[
resolve(
".opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py",
),
"--pipe-props",
paths.pipes!,
"--reservoirs",
paths.reservoirs!,
"--links",
paths.links!,
"--nodes",
paths.nodes!,
"--target-time",
time,
"--output",
outputPath,
],
{ cwd: directory },
);
const wrapper = JSON.parse(await readFile(outputPath, "utf8")) as {
data: { node_area_map: Record<string, string> };
location: { file_path: string };
metadata: { schema_version: number };
};
expect(wrapper.location.file_path).toBe(outputPath);
expect(wrapper.metadata.schema_version).toBe(1);
expect(wrapper.data.node_area_map).toMatchObject({ R1: "R1", N1: "R1" });
});
});