Compare commits
35
Commits
latest
...
72ebf4d6c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ebf4d6c1 | ||
|
|
a438433068 | ||
|
|
4ec010455b | ||
|
|
80cfc1f2ab | ||
|
|
ce04704af2 | ||
|
|
004c9bb72d | ||
|
|
774f39cbbe | ||
|
|
c6efccb88a | ||
|
|
11ebf428bb | ||
|
|
18e8b25f48 | ||
|
|
9aa5a96e60 | ||
|
|
a5f6474be5 | ||
|
|
b49290bd91 | ||
|
|
31fdb36e48 | ||
|
|
0a64de89bb | ||
|
|
2f267af7a3 | ||
|
|
649af949c5 | ||
|
|
c565e89d60 | ||
|
|
9c9e31c570 | ||
|
|
c0c54e238d | ||
|
|
99f5a0b823 | ||
|
|
4a9681c148 | ||
|
|
8530793882 | ||
|
|
cb3aa3a150 | ||
|
|
4cbeca4e09 | ||
|
|
f66b9c3e9d | ||
|
|
b19af8846a | ||
|
|
a5e91ac2b8 | ||
|
|
258f4996eb | ||
|
|
2dc37e3fd8 | ||
|
|
a53839e157 | ||
|
|
1407dd3bbe | ||
|
|
764a1f4e82 | ||
|
|
07016451d6 | ||
|
|
5ac50bfeaa |
@@ -1,7 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
node_modules
|
node_modules
|
||||||
.opencode/node_modules
|
.opencode/node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
.local.env
|
.local.env
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
dist
|
dist
|
||||||
.vscode
|
.vscode
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+13
-211
@@ -1,221 +1,23 @@
|
|||||||
name: Agent CI/CD
|
name: Agent CI/CD v2
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "latest"
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-image:
|
build-test-publish-and-deploy:
|
||||||
runs-on: ubuntu-22.04
|
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
with:
|
||||||
permissions:
|
image_name: gitea.waternetwork.cn/orgtjwater/tjwateragent
|
||||||
contents: read
|
dockerfile: Dockerfile
|
||||||
defaults:
|
build_context: .
|
||||||
run:
|
cache_image: gitea.waternetwork.cn/orgtjwater/tjwateragent:ci-cache
|
||||||
shell: bash
|
test_target: test
|
||||||
|
deploy_service: agent
|
||||||
steps:
|
deploy_host: 192.168.1.114
|
||||||
- name: Setup tools
|
secrets:
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq jq
|
|
||||||
jq --version
|
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
env:
|
|
||||||
SERVER_URL: ${{ github.server_url }}
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
COMMIT_SHA: ${{ github.sha }}
|
|
||||||
GIT_USERNAME: ${{ github.actor }}
|
|
||||||
GIT_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
case "$SERVER_URL" in
|
|
||||||
http://*)
|
|
||||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
|
||||||
;;
|
|
||||||
https://*)
|
|
||||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
AUTH_SERVER_URL="$SERVER_URL"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ! -d .git ]; then
|
|
||||||
git init .
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git remote get-url origin >/dev/null 2>&1; then
|
|
||||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
|
||||||
else
|
|
||||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
|
||||||
git checkout --force --detach FETCH_HEAD
|
|
||||||
git clean -ffdx
|
|
||||||
|
|
||||||
- name: Normalize image metadata
|
|
||||||
env:
|
|
||||||
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
|
||||||
RAW_REPOSITORY: ${{ github.repository }}
|
|
||||||
RAW_REF: ${{ github.ref }}
|
|
||||||
RAW_REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
|
||||||
|
|
||||||
if [ -z "${RAW_REGISTRY_HOST}" ]; then
|
|
||||||
echo "Missing required repository variable: REGISTRY_HOST"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
|
||||||
|
|
||||||
if [ -z "${REGISTRY_HOST}" ]; then
|
|
||||||
echo "Repository variable REGISTRY_HOST resolves to an empty host"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
|
||||||
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
|
||||||
IMAGE_TAG="${RAW_REF_NAME}"
|
|
||||||
{
|
|
||||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
|
||||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
|
||||||
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
|
||||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
|
||||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
|
||||||
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
|
||||||
env:
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
run: |
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
if [ -z "${REGISTRY_HOST:-}" ]; then
|
|
||||||
echo "Missing resolved environment value: REGISTRY_HOST"
|
|
||||||
echo "The previous step should write REGISTRY_HOST into GITHUB_ENV."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${REGISTRY_USERNAME}" ]; then
|
|
||||||
echo "Missing required repository secret: REGISTRY_USERNAME"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${REGISTRY_PASSWORD}" ]; then
|
|
||||||
echo "Missing required repository secret: REGISTRY_PASSWORD"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Logging into registry host: ${REGISTRY_HOST}"
|
|
||||||
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
|
|
||||||
--username "${REGISTRY_USERNAME}" \
|
|
||||||
--password-stdin
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
run: |
|
|
||||||
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
|
|
||||||
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
push_with_retry() {
|
|
||||||
image_ref="$1"
|
|
||||||
attempt=1
|
|
||||||
max_attempts=3
|
|
||||||
|
|
||||||
while [ "$attempt" -le "$max_attempts" ]; do
|
|
||||||
if docker push "$image_ref"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${IMAGE_TAG}" = "latest" ]; then
|
|
||||||
docker build \
|
|
||||||
--network=host \
|
|
||||||
-f ./Dockerfile \
|
|
||||||
-t "${IMAGE_NAME}:latest" \
|
|
||||||
.
|
|
||||||
push_with_retry "${IMAGE_NAME}:latest"
|
|
||||||
else
|
|
||||||
docker build \
|
|
||||||
--network=host \
|
|
||||||
-f ./Dockerfile \
|
|
||||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
|
||||||
-t "${IMAGE_NAME}:latest" \
|
|
||||||
.
|
|
||||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
push_with_retry "${IMAGE_NAME}:latest"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify Deploy Server
|
|
||||||
run: |
|
|
||||||
post_deploy_webhook() {
|
|
||||||
label="$1"
|
|
||||||
payload="$2"
|
|
||||||
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
|
|
||||||
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
|
|
||||||
|
|
||||||
# Trim whitespace
|
|
||||||
webhook_url=$(echo "$webhook_url" | xargs)
|
|
||||||
|
|
||||||
echo "[$label] Calling webhook: $webhook_url"
|
|
||||||
|
|
||||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-d "$payload")
|
|
||||||
|
|
||||||
echo "[$label] webhook HTTP status: ${http_code}"
|
|
||||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[$label] response headers:"
|
|
||||||
cat /tmp/deploy_headers.txt
|
|
||||||
echo "[$label] response body:"
|
|
||||||
cat /tmp/deploy_response.txt
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
|
||||||
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
|
|
||||||
|
|
||||||
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
|
|
||||||
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
|
|
||||||
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Primary webhook request failed, retrying with lowercase repo path..."
|
|
||||||
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
|
|
||||||
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Deploy webhook failed after primary and fallback attempts."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
deploy-fallback-log:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs: docker-image
|
|
||||||
if: failure()
|
|
||||||
steps:
|
|
||||||
- name: Deployment not triggered
|
|
||||||
run: echo "Image build/push failed, deployment webhook was not called."
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.opencode/node_modules/
|
.opencode/node_modules/
|
||||||
.opencode/skills/
|
|
||||||
.local.env
|
.local.env
|
||||||
.vscode
|
.vscode
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|||||||
@@ -2,10 +2,20 @@
|
|||||||
description: TJWater Agent,用于供水网络分析和操作员工作流
|
description: TJWater Agent,用于供水网络分析和操作员工作流
|
||||||
mode: primary
|
mode: primary
|
||||||
model: deepseek/deepseek-v4-flash
|
model: deepseek/deepseek-v4-flash
|
||||||
temperature: 0.2
|
|
||||||
---
|
---
|
||||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||||
|
|
||||||
|
## 回复要求
|
||||||
|
|
||||||
|
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
|
||||||
|
- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本
|
||||||
|
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
|
||||||
|
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划;简单问答不创建计划
|
||||||
|
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
|
||||||
|
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
|
||||||
|
- 开始工作及每次进入新的业务阶段时调用一次 `activity_update`,用 `title` 概括当前阶段、用 `reason` 说明该阶段为何必要;已有计划时必须通过 `todos` 提交完整计划状态快照,使阶段与任务状态同时更新,不再单独调用 `todowrite` 更新里程碑
|
||||||
|
- `activity_update` 是过程分组,不是任务清单:活动描述当前正在做的一组动作,`todowrite` 描述整个任务的业务目标与完成状态
|
||||||
|
|
||||||
## 工作流生命周期
|
## 工作流生命周期
|
||||||
|
|
||||||
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
|
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
|
||||||
@@ -37,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`
|
2. `tjwater-cli` 输出为 JSON(`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
|
||||||
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
|
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
|
||||||
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
|
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
|
||||||
5. 无可用数据时不得编造结果
|
5. 无可用数据时不得编造结果
|
||||||
6. 尽量不使用 `task` 子代理,避免无法观测过程进行人为干预
|
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)
|
## 工作流沉淀(skill_manager)
|
||||||
|
|
||||||
@@ -59,30 +74,12 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|
|||||||
|
|
||||||
目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`。
|
目录入口也通过 `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 次数,提升执行效率。
|
- 后端数据只能由 `tjwater_cli` 工具获取;认证与网络请求留在 Agent 主进程
|
||||||
|
- 分析脚本接收 `data_file.file_path`,只处理当前对话目录内的本地文件
|
||||||
```python
|
- 多份互不依赖的数据可并行调用 `tjwater_cli(..., store_result=true)`,随后在一次沙箱 Bash 中运行 Python 分析
|
||||||
import subprocess, os
|
- 脚本输出文件必须写入当前工作目录;禁止使用 `/tmp`、全局 `tool-output` 或硬编码认证环境变量
|
||||||
|
|
||||||
# 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 管道中完成。
|
|
||||||
|
|
||||||
**触发时机**:
|
**触发时机**:
|
||||||
- 用户明确说"保存/沉淀/记录工作流"
|
- 用户明确说"保存/沉淀/记录工作流"
|
||||||
|
|||||||
+8
-4
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.16.2",
|
"@opencode-ai/plugin": "1.18.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||||
@@ -25,9 +27,9 @@
|
|||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="],
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2H9YT80M1PYElpG+lmd/9kGqsNouiJIBCUhLblmgFwoSrB4wyahgkCS6NcFQR/AYXNH4I1Yd3lmQcVaEPu1qNg=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
||||||
|
|
||||||
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||||
|
|
||||||
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.16.2"
|
"@opencode-ai/plugin": "1.18.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
|||||||
@@ -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.0–3.0 m/s = 严重 | 2 |
|
||||||
|
| | 1.5–2.0 m/s = 偏高 | 1 |
|
||||||
|
| | <1.5 m/s = 正常 | 0 |
|
||||||
|
| **水头损失** | >P90 = 严重 | 2 |
|
||||||
|
| | P80–P90 = 中度 | 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` 可覆盖 1–2 个时间步。脚本内部按 `--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`
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
水力瓶颈管道综合分析
|
||||||
|
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
|
||||||
|
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
|
||||||
|
schema 适配(2026-08 实测):realtime links 主键为 link_id,realtime 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. 预期覆盖 **85–90%** 节点
|
||||||
|
|
||||||
|
### 第三步:无向拓扑修正
|
||||||
|
|
||||||
|
有向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 覆盖 1–2 个时间步。脚本按 `--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:00,target-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()
|
||||||
@@ -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 "活动阶段已更新。";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,11 +4,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
|
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this style action is needed for the current user request.",
|
|
||||||
),
|
|
||||||
layer_id: tool.schema
|
layer_id: tool.schema
|
||||||
.enum(["junctions", "pipes"])
|
.enum(["junctions", "pipes"])
|
||||||
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
|
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 "最终回答已提交。";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,9 +8,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
|
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why geocoding is required for the current user request."),
|
|
||||||
keyword: tool.schema
|
keyword: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe("Address or place name to geocode, such as 北京市人民政府."),
|
.describe("Address or place name to geocode, such as 北京市人民政府."),
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "在前端地图上定位并高亮指定的管网要素。",
|
description: "在前端地图上定位并高亮指定的管网要素。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this map positioning action is needed for the user request.",
|
|
||||||
),
|
|
||||||
ids: tool.schema
|
ids: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.describe("Feature ids to locate."),
|
.describe("Feature ids to locate."),
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
import { MemoryStore } from "../../src/memory/store.js";
|
|
||||||
import {
|
|
||||||
getRuntimeSessionContext,
|
|
||||||
setRuntimeSessionContext,
|
|
||||||
} from "../../src/runtime/sessionContext.js";
|
|
||||||
|
|
||||||
const memoryStore = new MemoryStore();
|
const internalBaseUrl =
|
||||||
const initializePromise = memoryStore.initialize();
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -15,9 +11,6 @@ export default tool({
|
|||||||
action: tool.schema
|
action: tool.schema
|
||||||
.enum(["add", "list", "replace", "remove"])
|
.enum(["add", "list", "replace", "remove"])
|
||||||
.describe("Memory operation to perform."),
|
.describe("Memory operation to perform."),
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why this memory should be persisted for future requests."),
|
|
||||||
scope: tool.schema
|
scope: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -26,130 +19,31 @@ export default tool({
|
|||||||
content: tool.schema
|
content: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("The durable fact or preference to remember, written as one concise sentence."),
|
||||||
"The durable fact or preference to remember, written as one concise sentence.",
|
|
||||||
),
|
|
||||||
target_id: tool.schema
|
target_id: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Stable memory entry id used by replace/remove."),
|
.describe("Stable memory entry id used by replace/remove."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
const response = await fetch(
|
||||||
const sessionContext = getRuntimeSessionContext(context.sessionID);
|
`${internalBaseUrl}/internal/tools/memory-manager`,
|
||||||
if (!sessionContext) {
|
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
|
||||||
}
|
|
||||||
const scope =
|
|
||||||
args.scope === "user"
|
|
||||||
? "user"
|
|
||||||
: args.scope === "workspace"
|
|
||||||
? "workspace"
|
|
||||||
: null;
|
|
||||||
if (!scope) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: "rejected",
|
|
||||||
detail: `unsupported scope: ${args.scope}; use exact keyword 'user' or 'workspace'`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: "rejected",
|
|
||||||
detail: "memory writes are disabled for this session",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeKey =
|
|
||||||
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
|
|
||||||
if (args.action === "list") {
|
|
||||||
const readScopes = {
|
|
||||||
...(sessionContext.memoryListReadScopes ?? {}),
|
|
||||||
[scope]: true,
|
|
||||||
};
|
|
||||||
setRuntimeSessionContext({
|
|
||||||
...sessionContext,
|
|
||||||
memoryListReadScopes: readScopes,
|
|
||||||
});
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: "accepted",
|
|
||||||
detail: "memory listed",
|
|
||||||
items: await memoryStore.list(scope, scopeKey),
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "add") {
|
|
||||||
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: "rejected",
|
|
||||||
detail: `must list ${scope} memory and review existing entries before add`,
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
|
||||||
content: args.content ?? "",
|
|
||||||
sessionId: sessionContext.clientSessionId,
|
|
||||||
source: "tool",
|
|
||||||
traceId: sessionContext.traceId,
|
|
||||||
});
|
|
||||||
if (!result.entry) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: "rejected",
|
|
||||||
detail: "content rejected by persistence policy",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: result.changed ? "accepted" : "deduped",
|
|
||||||
detail: result.detail,
|
|
||||||
entry: result.entry,
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.action === "replace") {
|
|
||||||
const result = await memoryStore.replace(
|
|
||||||
scope,
|
|
||||||
scopeKey,
|
|
||||||
args.target_id ?? "",
|
|
||||||
{
|
{
|
||||||
content: args.content ?? "",
|
method: "POST",
|
||||||
sessionId: sessionContext.clientSessionId,
|
headers: {
|
||||||
source: "tool",
|
"Content-Type": "application/json",
|
||||||
traceId: sessionContext.traceId,
|
"x-agent-internal-token": internalToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...args,
|
||||||
|
session_id: context.sessionID,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return JSON.stringify({
|
const text = await response.text();
|
||||||
ok: true,
|
if (!response.ok) {
|
||||||
kind: "memory",
|
throw new Error(text);
|
||||||
decision: result.changed ? "accepted" : "rejected",
|
|
||||||
detail: result.detail,
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return text;
|
||||||
const result = await memoryStore.remove(
|
|
||||||
scope,
|
|
||||||
scopeKey,
|
|
||||||
args.target_id ?? "",
|
|
||||||
);
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "memory",
|
|
||||||
decision: result.changed ? "accepted" : "rejected",
|
|
||||||
detail: result.detail,
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
|
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this junction rendering action is needed for the user request.",
|
|
||||||
),
|
|
||||||
render_ref: tool.schema
|
render_ref: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
|
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why prior session history is needed for the current request."),
|
|
||||||
query: tool.schema
|
query: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe("What to search for in prior session history."),
|
.describe("What to search for in prior session history."),
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
|
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why this chart should be rendered for the user request."),
|
|
||||||
title: tool.schema.string().optional().describe("Chart title."),
|
title: tool.schema.string().optional().describe("Chart title."),
|
||||||
chart_type: tool.schema
|
chart_type: tool.schema
|
||||||
.enum(["line", "bar", "pie"])
|
.enum(["line", "bar", "pie"])
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { SkillStore } from "../../src/skills/store.js";
|
const internalBaseUrl =
|
||||||
import {
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
getRuntimeSessionContext,
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
type RuntimeSessionContext,
|
|
||||||
} from "../../src/runtime/sessionContext.js";
|
|
||||||
|
|
||||||
type ToolContextReader = {
|
export default tool({
|
||||||
read(sessionId: string): RuntimeSessionContext | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtimeContextReader: ToolContextReader = {
|
|
||||||
read: getRuntimeSessionContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createSkillManagerTool = (
|
|
||||||
skillStore = new SkillStore(),
|
|
||||||
toolContextStore: ToolContextReader = runtimeContextReader,
|
|
||||||
initializePromise: Promise<unknown> = Promise.resolve(),
|
|
||||||
) =>
|
|
||||||
tool({
|
|
||||||
description:
|
description:
|
||||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
||||||
args: {
|
args: {
|
||||||
@@ -36,20 +21,12 @@ export const createSkillManagerTool = (
|
|||||||
"remove_script",
|
"remove_script",
|
||||||
])
|
])
|
||||||
.describe("Skill maintenance operation."),
|
.describe("Skill maintenance operation."),
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this skill maintenance action is justified for future reuse.",
|
|
||||||
),
|
|
||||||
skill_path: tool.schema
|
skill_path: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
|
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
|
||||||
),
|
),
|
||||||
pattern: tool.schema
|
pattern: tool.schema.string().optional().describe("Pattern text used by append_pattern."),
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Pattern text used by append_pattern."),
|
|
||||||
target_id: tool.schema
|
target_id: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -57,102 +34,31 @@ export const createSkillManagerTool = (
|
|||||||
file_path: tool.schema
|
file_path: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
|
||||||
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
|
|
||||||
),
|
|
||||||
content: tool.schema
|
content: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("Content used by write_skill, write_reference, or write_script."),
|
||||||
"Content used by write_skill, write_reference, or write_script.",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
const response = await fetch(
|
||||||
const sessionContext = toolContextStore.read(context.sessionID);
|
`${internalBaseUrl}/internal/tools/skill-manager`,
|
||||||
if (!sessionContext) {
|
{
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
if (
|
"Content-Type": "application/json",
|
||||||
sessionContext.allowLearningWrite === false &&
|
"x-agent-internal-token": internalToken,
|
||||||
args.action !== "list"
|
|
||||||
) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "skill",
|
|
||||||
decision: "rejected",
|
|
||||||
detail: "skill writes are disabled for this session",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (args.action === "list") {
|
|
||||||
const result = await skillStore.list(args.skill_path);
|
|
||||||
if (!result) {
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "skill",
|
|
||||||
decision: "rejected",
|
|
||||||
detail:
|
|
||||||
"invalid skill_path; expected a relative path under .opencode/skills",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "skill",
|
|
||||||
decision: "accepted",
|
|
||||||
detail: "skill listed",
|
|
||||||
references: result.references,
|
|
||||||
scripts: result.scripts,
|
|
||||||
skill_path: result.skillPath,
|
|
||||||
target: result.target,
|
|
||||||
patterns: result.patterns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result =
|
|
||||||
args.action === "write_skill"
|
|
||||||
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
|
|
||||||
: args.action === "remove_skill"
|
|
||||||
? await skillStore.removeSkill(args.skill_path)
|
|
||||||
: args.action === "append_pattern"
|
|
||||||
? await skillStore.appendPattern(
|
|
||||||
args.skill_path,
|
|
||||||
args.pattern ?? "",
|
|
||||||
)
|
|
||||||
: args.action === "remove_pattern"
|
|
||||||
? await skillStore.removePattern(
|
|
||||||
args.skill_path,
|
|
||||||
args.target_id ?? "",
|
|
||||||
)
|
|
||||||
: args.action === "write_reference"
|
|
||||||
? await skillStore.writeReference(
|
|
||||||
args.skill_path,
|
|
||||||
args.file_path ?? "",
|
|
||||||
args.content ?? "",
|
|
||||||
)
|
|
||||||
: args.action === "remove_reference"
|
|
||||||
? await skillStore.removeReference(
|
|
||||||
args.skill_path,
|
|
||||||
args.file_path ?? "",
|
|
||||||
)
|
|
||||||
: args.action === "write_script"
|
|
||||||
? await skillStore.writeScript(
|
|
||||||
args.skill_path,
|
|
||||||
args.file_path ?? "",
|
|
||||||
args.content ?? "",
|
|
||||||
)
|
|
||||||
: await skillStore.removeScript(
|
|
||||||
args.skill_path,
|
|
||||||
args.file_path ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
kind: "skill",
|
|
||||||
decision: result.changed ? "accepted" : "rejected",
|
|
||||||
detail: result.detail,
|
|
||||||
target: result.target,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
body: JSON.stringify({
|
||||||
|
...args,
|
||||||
export default createSkillManagerTool();
|
session_id: context.sessionID,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,22 +4,38 @@ const internalBaseUrl =
|
|||||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
|
||||||
|
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({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_ref(res-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
|
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
|
|
||||||
),
|
|
||||||
file_path: tool.schema
|
file_path: tool.schema
|
||||||
.string()
|
.string()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
|
"位于当前对话工作目录内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。",
|
||||||
),
|
),
|
||||||
|
filePath: tool.schema
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("兼容旧调用的参数名;新调用应优先使用 file_path。"),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
|
const filePath = resolveStoreRenderFilePath(args);
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${internalBaseUrl}/internal/tools/store-render-ref`,
|
`${internalBaseUrl}/internal/tools/store-render-ref`,
|
||||||
{
|
{
|
||||||
@@ -30,7 +46,7 @@ export default tool({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: context.sessionID,
|
session_id: context.sessionID,
|
||||||
file_path: args.file_path,
|
file_path: filePath,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,20 +6,23 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。",
|
"通过本地 Agent 桥接调用 tjwater-cli。命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why this tool call is required for the current user request."),
|
|
||||||
command: tool.schema
|
command: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"tjwater-cli 子命令,不含二进制路径。示例:'project 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
|
timeout: tool.schema
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
|
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
|
||||||
|
store_result: tool.schema
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"是否强制把结果保存到当前对话工作区并返回 data_file。分析脚本需要文件输入时设为 true。",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
@@ -32,8 +35,8 @@ export default tool({
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
session_id: context.sessionID,
|
session_id: context.sessionID,
|
||||||
reason: args.reason,
|
|
||||||
command: args.command,
|
command: args.command,
|
||||||
|
store_result: args.store_result,
|
||||||
timeout: args.timeout,
|
timeout: args.timeout,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Why this history panel should be opened for the current task.",
|
|
||||||
),
|
|
||||||
feature_infos: tool.schema
|
feature_infos: tool.schema
|
||||||
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
|
||||||
.describe("List of [id, type] pairs."),
|
.describe("List of [id, type] pairs."),
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
export default tool({
|
export default tool({
|
||||||
description: "打开前端的 SCADA 监测数据历史面板。",
|
description: "打开前端的 SCADA 监测数据历史面板。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why SCADA panel interaction is required for this request."),
|
|
||||||
device_ids: tool.schema
|
device_ids: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
|
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why web search is required for the current user request."),
|
|
||||||
query: tool.schema.string().describe("Search query text."),
|
query: tool.schema.string().describe("Search query text."),
|
||||||
freshness: tool.schema
|
freshness: tool.schema
|
||||||
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
|
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ export default tool({
|
|||||||
description:
|
description:
|
||||||
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
|
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
|
||||||
.string()
|
|
||||||
.describe("Why this map zoom action is needed for the current request."),
|
|
||||||
x: tool.schema
|
x: tool.schema
|
||||||
.number()
|
.number()
|
||||||
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
|
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ PRs should describe runtime behavior changes, list `bun run check` and any test
|
|||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
|
|
||||||
Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets.
|
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 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.
|
||||||
|
|||||||
+50
-11
@@ -1,4 +1,4 @@
|
|||||||
FROM smanx/opencode:latest AS base
|
FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
|
||||||
USER root
|
USER root
|
||||||
ARG UBUNTU_APT_MIRROR=
|
ARG UBUNTU_APT_MIRROR=
|
||||||
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
@@ -18,6 +18,7 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
|
|||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
jq \
|
jq \
|
||||||
|
libseccomp2 \
|
||||||
unzip \
|
unzip \
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv && \
|
python3-venv && \
|
||||||
@@ -43,8 +44,35 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
|
|||||||
rich \
|
rich \
|
||||||
ipython \
|
ipython \
|
||||||
pytest && \
|
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/*
|
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
|
FROM base AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -58,32 +86,43 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||||
COPY tsconfig.json opencode.json README.md .gitignore ./
|
COPY package.json bun.lock ./
|
||||||
|
COPY tsconfig.json opencode.json README.md .gitignore Dockerfile ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY cli ./cli
|
COPY cli ./cli
|
||||||
|
COPY scripts ./scripts
|
||||||
COPY .opencode ./.opencode
|
COPY .opencode ./.opencode
|
||||||
RUN bun run check
|
RUN bun run check
|
||||||
|
|
||||||
FROM base AS runner
|
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
|
||||||
|
COPY node-tests ./node-tests
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY tests ./tests
|
||||||
|
RUN bun run test:ci
|
||||||
|
|
||||||
|
FROM build AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
|
||||||
|
RUN opencode --version
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8787
|
ENV PORT=8787
|
||||||
|
ENV OPENCODE_HOST=127.0.0.1
|
||||||
|
ENV OPENCODE_HOSTNAME=127.0.0.1
|
||||||
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
|
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
|
||||||
|
|
||||||
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 .gitignore ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY .opencode ./.opencode
|
|
||||||
COPY cli ./cli
|
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
|
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/health" >/dev/null || exit 1
|
||||||
CMD ["bun", "src/server.ts"]
|
CMD ["bun", "src/server.ts"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 主要能力
|
## 主要能力
|
||||||
|
|
||||||
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
|
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
|
||||||
- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。
|
- 以内嵌模式启动并预热 OpenCode 运行时。
|
||||||
- 管理前端 `session_id` 与 OpenCode session 的映射。
|
- 管理前端 `session_id` 与 OpenCode session 的映射。
|
||||||
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
|
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
|
||||||
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
|
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
|
||||||
@@ -20,6 +20,7 @@ src/chat/ 聊天流和 SSE 事件适配
|
|||||||
src/runtime/ OpenCode 运行时管理
|
src/runtime/ OpenCode 运行时管理
|
||||||
src/session/ 会话映射和运行上下文
|
src/session/ 会话映射和运行上下文
|
||||||
src/mcp/ MCP 服务与工具桥接
|
src/mcp/ MCP 服务与工具桥接
|
||||||
|
cli/ Agent 使用的 TypeScript 后端 API CLI
|
||||||
.opencode/agents/ Agent prompt 和模型行为配置
|
.opencode/agents/ Agent prompt 和模型行为配置
|
||||||
.opencode/tools/ OpenCode 自定义工具
|
.opencode/tools/ OpenCode 自定义工具
|
||||||
.opencode/skills/ 可复用分析工作流
|
.opencode/skills/ 可复用分析工作流
|
||||||
@@ -28,6 +29,8 @@ data/ 本地运行时数据,禁止提交
|
|||||||
logs/ 本地日志,禁止提交
|
logs/ 本地日志,禁止提交
|
||||||
```
|
```
|
||||||
|
|
||||||
|
仓库跟踪 `.opencode/skills/` 中经过评审的默认工作流基线;部署环境仍可通过持久化卷保留 `skill_manager` 在运行中沉淀的增量内容。默认基线不得包含真实客户数据、认证信息或本地执行产物。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
项目使用 Bun:
|
项目使用 Bun:
|
||||||
@@ -39,6 +42,10 @@ bun run dev
|
|||||||
|
|
||||||
`bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**`、`.opencode/**`、`opencode.json` 或 `.local.env` 后会自动重启。
|
`bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**`、`.opencode/**`、`opencode.json` 或 `.local.env` 后会自动重启。
|
||||||
|
|
||||||
|
`cli/tjwater-cli` 是当前唯一的 TJWater 业务 CLI 入口,由 Bun 直接执行
|
||||||
|
`cli/tjwater-cli.ts` 及 `cli/src/` 源码,并随 Agent 镜像一起交付,不需要
|
||||||
|
Python 或 PyInstaller 构建步骤。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -60,23 +67,49 @@ docker build -t tjwater-agent:local .
|
|||||||
|
|
||||||
## 运行模式
|
## 运行模式
|
||||||
|
|
||||||
Embedded 模式由服务进程拉起本机 OpenCode:
|
当前运行时使用 OpenCode 稳定版 1.x CLI,并通过稳定版 SDK 的 `@opencode-ai/sdk/v2` HTTP 客户端访问运行时;这与 `opencode2` 及 `@opencode-ai/client` 的 2.0 beta 运行时不同。Embedded 模式由服务进程拉起本机 OpenCode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OPENCODE_MODE=embedded
|
OPENCODE_MODE=embedded
|
||||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Client 模式连接外部 OpenCode server:
|
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
|
||||||
|
|
||||||
```bash
|
生产镜像会从固定的 OpenCode `v1.18.13` 源码提交构建 CLI,并应用仓库内的
|
||||||
OPENCODE_MODE=client
|
`patches/opencode-1.18.13-message-phase.patch`。该补丁只透传 OpenAI Responses
|
||||||
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
|
输出项已有的 `commentary` / `final_answer` phase,不改变模型行为:过程文本继续写入
|
||||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
可折叠的 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` 不触发续期。
|
||||||
|
|
||||||
|
`memory_manager` 和 `skill_manager` 在 OpenCode 侧只保留内部 HTTP 桥,读取会话上下文和持久化数据的逻辑统一在 Agent 主进程中执行。长期记忆、自动学习和显式工具写入因此共享同一组 `MemoryStore`、`SkillStore` 和运行时会话上下文。
|
||||||
|
|
||||||
本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。
|
本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。
|
||||||
|
|
||||||
|
服务会在 HTTP 端口开始监听前完成 OpenCode 健康检查、临时会话创建和工具目录加载。`GET /health` 返回 `ready: true` 与 `warmed_up: true` 时,表示冷启动预热已经完成。开发环境会输出各预热阶段的耗时。
|
||||||
|
|
||||||
|
`opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。
|
||||||
|
|
||||||
|
前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,Shell 和写操作逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill、沙箱 Shell,以及真实路径位于当前 conversation workspace 内且通过 realpath/symlink 校验的 read/edit/glob/grep;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
|
||||||
|
|
||||||
|
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env`、普通 `data/`、`logs/` 和其他对话目录保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录。普通 `rm <文件>`、`rmdir` 和非强制递归删除可在沙箱内执行,`rm -rf`/`rm -fr` 及等价的递归强制删除形式会在执行前拒绝。
|
||||||
|
|
||||||
|
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 缓存均位于当前对话目录。
|
||||||
|
|
||||||
|
`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 天)清理,但沙箱命令不能访问该目录。
|
||||||
|
|
||||||
## 配置与安全
|
## 配置与安全
|
||||||
|
|
||||||
不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
|
不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "tjwater-agent",
|
"name": "tjwater-agent",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.16.2",
|
"@opencode-ai/sdk": "1.18.13",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
|
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
|
||||||
|
|
||||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { CliError } from "../core/errors.js";
|
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 { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
|
||||||
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
||||||
import { resolveScheme } from "../core/runtime.js";
|
import { resolveScheme } from "../core/runtime.js";
|
||||||
import { parseTime } from "../core/time.js";
|
import { parseTime } from "../core/time.js";
|
||||||
import { success } from "../core/output.js";
|
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
@@ -22,30 +21,13 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
},
|
},
|
||||||
requireProject: true,
|
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> {
|
function analysisValveIsolation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" });
|
const { values } = parseOptions(argv, { element: "repeat", "disabled-valve": "repeat" });
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const elements = optionalStringArray(values, "element");
|
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, "阀门隔离分析执行成功", {
|
return emitApi(ctx, "阀门隔离分析执行成功", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/valve-isolation-analyses",
|
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 });
|
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" });
|
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
|
||||||
return emitApi(ctx, "传感器选址执行成功", {
|
return emitApi(ctx, "传感器选址执行成功", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/pressure-sensor-placement-kmeans",
|
path: "/sensor-placement-runs",
|
||||||
body: {
|
body: {
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
run_name: requiredString(values, "run-name"),
|
||||||
sensor_number: requiredNumber(values, "count"),
|
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,
|
min_diameter: optionalNumber(values, "min-diameter") || 0,
|
||||||
},
|
},
|
||||||
requireProject: true,
|
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> {
|
function analysisRunGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
|
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> {
|
function analysisRunResults(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { positionals } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
|
const runId = requiredString(values, "run-id");
|
||||||
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true });
|
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> {
|
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 });
|
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 = {
|
export const analysisHandlers: HandlerMap = {
|
||||||
"analysis burst": analysisBurst,
|
"analysis burst": analysisBurst,
|
||||||
"analysis valve": analysisValve,
|
"analysis valve isolation": analysisValveIsolation,
|
||||||
"analysis flushing": analysisFlushing,
|
"analysis flushing": analysisFlushing,
|
||||||
"analysis age": analysisAge,
|
"analysis age": analysisAge,
|
||||||
"analysis contaminant": analysisContaminant,
|
"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 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 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 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,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+43
-59
@@ -1,11 +1,15 @@
|
|||||||
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
|
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
|
||||||
import { CliError } from "../core/errors.js";
|
import { CliError } from "../core/errors.js";
|
||||||
import { emitApi } from "../core/http.js";
|
import { emitApi, requestAllPages } from "../core/http.js";
|
||||||
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
|
import { fieldsFor, optionalNumber, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
|
||||||
import { resolveScheme } from "../core/runtime.js";
|
import { success } from "../core/output.js";
|
||||||
import { parseTime } from "../core/time.js";
|
import { parseTime } from "../core/time.js";
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function backendElementType(type: ElementType): "link" | "node" {
|
||||||
|
return type === "pipe" ? "link" : "node";
|
||||||
|
}
|
||||||
|
|
||||||
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, summary, {
|
return emitApi(ctx, summary, {
|
||||||
@@ -18,10 +22,11 @@ function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: st
|
|||||||
|
|
||||||
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
|
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||||
return emitApi(ctx, "读取实时模拟数据成功", {
|
return emitApi(ctx, "读取实时模拟数据成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/timeseries/realtime/simulation-results",
|
path: "/timeseries/realtime/simulation-results",
|
||||||
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
|
params: { id: requiredString(values, "id"), type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -32,19 +37,22 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
|
|||||||
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/timeseries/realtime/records",
|
path: "/timeseries/realtime/records",
|
||||||
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
params: { type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function analysisSeries(ctx: RuntimeContext, argv: string[], type: ElementType): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
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",
|
method: "GET",
|
||||||
path: "/timeseries/schemes/links",
|
path: `/timeseries/analysis/runs/${encodeURIComponent(runId)}/${elementPath}/${encodeURIComponent(elementId)}`,
|
||||||
params: {
|
params: {
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
|
||||||
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
|
||||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
},
|
},
|
||||||
@@ -52,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);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, "读取方案节点字段成功", {
|
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||||
|
return emitApi(ctx, "读取分析运行指定时刻结果成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
|
path: `/timeseries/analysis/runs/${encodeURIComponent(requiredString(values, "run-id"))}/values`,
|
||||||
params: {
|
params: {
|
||||||
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
|
result_time: parseTime(requiredString(values, "time"), "--time"),
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
element_type: backendElementType(type),
|
||||||
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"),
|
|
||||||
},
|
},
|
||||||
requireProject: true,
|
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,
|
|
||||||
};
|
|
||||||
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> {
|
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv, { "device-id": "repeat" });
|
const { values } = parseOptions(argv, { "device-id": "repeat" });
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
@@ -105,8 +94,8 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
};
|
};
|
||||||
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"));
|
const runId = optionalString(values, "run-id");
|
||||||
if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" });
|
if (runId && kind !== "element-scada") params.run_id = runId;
|
||||||
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
|
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
|
||||||
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
|
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
|
||||||
else {
|
else {
|
||||||
@@ -125,31 +114,28 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
|
|
||||||
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
requiredString(values, "pipe");
|
|
||||||
requiredString(values, "start-time");
|
|
||||||
return emitApi(ctx, "读取管道健康预测成功", {
|
return emitApi(ctx, "读取管道健康预测成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/pipeline-health-predictions",
|
path: "/pipeline-health-predictions",
|
||||||
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
params: { query_time: parseTime(requiredString(values, "time"), "--time") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
return emitApi(ctx, "读取 SCADA 设备成功", { method: "GET", path: "/scada-devices/detail", params: { device_id: requiredString(values, "device-id") }, requireProject: true });
|
||||||
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
async function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv, { "page-size": "integer" });
|
||||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
const pageSize = Math.min(1000, Math.max(1, optionalNumber(values, "page-size") ?? 1000));
|
||||||
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
|
const [data, durationMs] = await requestAllPages(
|
||||||
}
|
ctx,
|
||||||
|
{ method: "GET", path: "/scada-devices", requireProject: true },
|
||||||
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
pageSize,
|
||||||
const { values } = parseOptions(argv);
|
);
|
||||||
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
|
success("读取 SCADA 设备列表成功", data, ctx, durationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dataHandlers: HandlerMap = {
|
export const dataHandlers: HandlerMap = {
|
||||||
@@ -157,15 +143,13 @@ export const dataHandlers: HandlerMap = {
|
|||||||
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
|
"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-id-time": realtimeByIdTime,
|
||||||
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
||||||
"data timeseries scheme links": schemeLinks,
|
"data timeseries analysis link-field": (ctx, argv) => analysisSeries(ctx, argv, "pipe"),
|
||||||
"data timeseries scheme node-field": schemeNodeField,
|
"data timeseries analysis node-field": (ctx, argv) => analysisSeries(ctx, argv, "junction"),
|
||||||
"data timeseries scheme simulation": schemeSimulation,
|
"data timeseries analysis values": analysisValues,
|
||||||
"data timeseries scada query": scadaQuery,
|
"data timeseries scada query": scadaQuery,
|
||||||
"data timeseries composite": composite,
|
"data timeseries composite": composite,
|
||||||
"data timeseries composite pipeline-health": pipelineHealth,
|
"data pipeline-health": pipelineHealth,
|
||||||
"data scada get": dataScadaGet,
|
"data scada get": dataScadaGet,
|
||||||
"data scada list": dataScadaList,
|
"data scada list": dataScadaList,
|
||||||
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
|
"data scada schema": (ctx) => emitApi(ctx, "读取 SCADA 设备 schema 成功", { method: "GET", path: "/network-schemas/scada-device", requireProject: true }),
|
||||||
"data scheme get": dataSchemeGet,
|
|
||||||
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { emitApi } from "../core/http.js";
|
import { emitApi, requestAllPages } from "../core/http.js";
|
||||||
import { parseOptions, requiredString } from "../core/options.js";
|
import { optionalNumber, parseOptions, requiredString } from "../core/options.js";
|
||||||
|
import { success } from "../core/output.js";
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
|
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
|
||||||
@@ -7,20 +8,28 @@ function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: stri
|
|||||||
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
|
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
async function apiGetAll(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
return emitApi(ctx, summary, { method: "GET", path, requireProject: true });
|
const { values } = parseOptions(argv, { limit: "integer", "page-size": "integer" });
|
||||||
|
const requestedPageSize = optionalNumber(values, "page-size") ?? optionalNumber(values, "limit") ?? 1000;
|
||||||
|
const pageSize = Math.min(1000, Math.max(1, requestedPageSize));
|
||||||
|
const [data, durationMs] = await requestAllPages(
|
||||||
|
ctx,
|
||||||
|
{ method: "GET", path, requireProject: true },
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
success(summary, data, ctx, durationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const networkHandlers: HandlerMap = {
|
export const networkHandlers: HandlerMap = {
|
||||||
"network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
|
"network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
|
||||||
"network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
|
"network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
|
||||||
"network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"),
|
"network get-all-pipes-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部管道属性成功", "/pipes"),
|
||||||
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
|
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
|
||||||
"network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"),
|
"network get-all-reservoirs-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水库属性成功", "/reservoirs"),
|
||||||
"network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
|
"network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
|
||||||
"network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"),
|
"network get-all-tanks-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水箱属性成功", "/tanks"),
|
||||||
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
|
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
|
||||||
"network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"),
|
"network get-all-pumps-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水泵属性成功", "/pumps"),
|
||||||
"network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
|
"network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
|
||||||
"network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"),
|
"network get-all-valves-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部阀门属性成功", "/valves"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,6 +76,100 @@ export async function requestJson(ctx: RuntimeContext, request: RequestOptions):
|
|||||||
return [payload, durationMs];
|
return [payload, durationMs];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestAllPages(
|
||||||
|
ctx: RuntimeContext,
|
||||||
|
request: RequestOptions,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<[unknown[], number]> {
|
||||||
|
const items: unknown[] = [];
|
||||||
|
let durationMs = 0;
|
||||||
|
let offset = 0;
|
||||||
|
let expectedTotal: number | null = null;
|
||||||
|
|
||||||
|
while (expectedTotal === null || offset < expectedTotal) {
|
||||||
|
const [payload, pageDurationMs] = await requestJson(ctx, {
|
||||||
|
...request,
|
||||||
|
params: {
|
||||||
|
...request.params,
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
durationMs += pageDurationMs;
|
||||||
|
const page = normalizePage(payload);
|
||||||
|
if (!page) {
|
||||||
|
throw new CliError(
|
||||||
|
"服务端错误",
|
||||||
|
"INVALID_PAGINATION_RESPONSE",
|
||||||
|
"backend collection response must contain items, total, limit, and offset",
|
||||||
|
7,
|
||||||
|
false,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expectedTotal === null) {
|
||||||
|
expectedTotal = page.total;
|
||||||
|
} else if (page.total !== expectedTotal) {
|
||||||
|
throw new CliError(
|
||||||
|
"服务端错误",
|
||||||
|
"PAGINATION_TOTAL_CHANGED",
|
||||||
|
`backend collection total changed from ${expectedTotal} to ${page.total}`,
|
||||||
|
7,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (page.offset !== offset) {
|
||||||
|
throw new CliError(
|
||||||
|
"服务端错误",
|
||||||
|
"PAGINATION_OFFSET_MISMATCH",
|
||||||
|
`backend collection returned offset ${page.offset}, expected ${offset}`,
|
||||||
|
7,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (page.items.length === 0 && offset < expectedTotal) {
|
||||||
|
throw new CliError(
|
||||||
|
"服务端错误",
|
||||||
|
"PAGINATION_STALLED",
|
||||||
|
`backend collection returned an empty page at offset ${offset} before total ${expectedTotal}`,
|
||||||
|
7,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.push(...page.items);
|
||||||
|
offset += page.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [items.slice(0, expectedTotal ?? 0), durationMs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage(
|
||||||
|
payload: unknown,
|
||||||
|
): { items: unknown[]; limit: number; offset: number; total: number } | null {
|
||||||
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
||||||
|
const page = payload as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
!Array.isArray(page.items) ||
|
||||||
|
typeof page.limit !== "number" ||
|
||||||
|
!Number.isInteger(page.limit) ||
|
||||||
|
typeof page.offset !== "number" ||
|
||||||
|
!Number.isInteger(page.offset) ||
|
||||||
|
typeof page.total !== "number" ||
|
||||||
|
!Number.isInteger(page.total) ||
|
||||||
|
page.limit <= 0 ||
|
||||||
|
page.offset < 0 ||
|
||||||
|
page.total < 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: page.items,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
total: page.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapStatus(status: number): number {
|
function mapStatus(status: number): number {
|
||||||
if (status === 400 || status === 422) return 2;
|
if (status === 400 || status === 422) return 2;
|
||||||
if (status === 401) return 3;
|
if (status === 401) return 3;
|
||||||
|
|||||||
+26
-36
@@ -7,76 +7,66 @@ export const GROUP_SUMMARIES: Record<string, string> = {
|
|||||||
"component option": "组件选项查询命令。",
|
"component option": "组件选项查询命令。",
|
||||||
simulation: "模拟运行与调度相关命令。",
|
simulation: "模拟运行与调度相关命令。",
|
||||||
analysis: "分析计算与诊断相关命令。",
|
analysis: "分析计算与诊断相关命令。",
|
||||||
|
"analysis runs": "分析运行及结果查询命令。",
|
||||||
"analysis leakage": "漏损分析相关命令。",
|
"analysis leakage": "漏损分析相关命令。",
|
||||||
"analysis leakage schemes": "漏损方案查询命令。",
|
|
||||||
"analysis burst-detection": "爆管检测相关命令。",
|
"analysis burst-detection": "爆管检测相关命令。",
|
||||||
"analysis burst-detection schemes": "爆管检测方案查询命令。",
|
|
||||||
"analysis burst-location": "爆管定位相关命令。",
|
"analysis burst-location": "爆管定位相关命令。",
|
||||||
"analysis burst-location schemes": "爆管定位方案查询命令。",
|
|
||||||
"analysis risk": "风险分析相关命令。",
|
|
||||||
"analysis sensor-placement": "传感器选址相关命令。",
|
"analysis sensor-placement": "传感器选址相关命令。",
|
||||||
data: "时序、SCADA 和方案数据查询命令。",
|
data: "时序、SCADA 和分析数据查询命令。",
|
||||||
"data timeseries": "时序数据查询命令。",
|
"data timeseries": "时序数据查询命令。",
|
||||||
"data timeseries realtime": "实时模拟时序查询命令。",
|
"data timeseries realtime": "实时模拟时序查询命令。",
|
||||||
"data timeseries scheme": "方案时序查询命令。",
|
"data timeseries analysis": "按分析运行 ID 查询历史时序命令。",
|
||||||
"data timeseries scada": "SCADA 时序查询命令。",
|
"data timeseries scada": "SCADA 时序查询命令。",
|
||||||
"data timeseries composite": "复合时序查询命令。",
|
"data timeseries composite": "复合时序查询命令。",
|
||||||
"data scada": "SCADA 元数据查询命令。",
|
"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[]];
|
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
|
||||||
|
|
||||||
const commandSpecs: readonly CommandSpec[] = [
|
const commandSpecs: readonly CommandSpec[] = [
|
||||||
["network get-junction-properties", "读取节点属性", ["--junction <JUNCTION>"], ["tjwater-cli network get-junction-properties --junction J1"]],
|
["network get-junction-properties", "读取节点属性", ["--junction <JUNCTION>"], ["tjwater-cli network get-junction-properties --junction J1"]],
|
||||||
["network get-pipe-properties", "读取管道属性", ["--pipe <PIPE>"], ["tjwater-cli network get-pipe-properties --pipe P1"]],
|
["network get-pipe-properties", "读取管道属性", ["--pipe <PIPE>"], ["tjwater-cli network get-pipe-properties --pipe P1"]],
|
||||||
["network get-all-pipes-properties", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]],
|
["network get-all-pipes-properties", "读取全部管道属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-pipes-properties"]],
|
||||||
["network get-reservoir-properties", "读取水库属性", ["--reservoir <RESERVOIR>"], ["tjwater-cli network get-reservoir-properties --reservoir R1"]],
|
["network get-reservoir-properties", "读取水库属性", ["--reservoir <RESERVOIR>"], ["tjwater-cli network get-reservoir-properties --reservoir R1"]],
|
||||||
["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]],
|
["network get-all-reservoirs-properties", "读取全部水库属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-reservoirs-properties"]],
|
||||||
["network get-tank-properties", "读取水箱属性", ["--tank <TANK>"], ["tjwater-cli network get-tank-properties --tank T1"]],
|
["network get-tank-properties", "读取水箱属性", ["--tank <TANK>"], ["tjwater-cli network get-tank-properties --tank T1"]],
|
||||||
["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]],
|
["network get-all-tanks-properties", "读取全部水箱属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-tanks-properties"]],
|
||||||
["network get-pump-properties", "读取水泵属性", ["--pump <PUMP>"], ["tjwater-cli network get-pump-properties --pump PU1"]],
|
["network get-pump-properties", "读取水泵属性", ["--pump <PUMP>"], ["tjwater-cli network get-pump-properties --pump PU1"]],
|
||||||
["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]],
|
["network get-all-pumps-properties", "读取全部水泵属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-pumps-properties"]],
|
||||||
["network get-valve-properties", "读取阀门属性", ["--valve <VALVE>"], ["tjwater-cli network get-valve-properties --valve V1"]],
|
["network get-valve-properties", "读取阀门属性", ["--valve <VALVE>"], ["tjwater-cli network get-valve-properties --valve V1"]],
|
||||||
["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]],
|
["network get-all-valves-properties", "读取全部阀门属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-valves-properties"]],
|
||||||
["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 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"]],
|
["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"]],
|
["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 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", "阀门工况分析。", ["--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 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 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 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 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 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 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 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 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 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-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 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 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 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 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 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 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 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", "执行复合时序查询", ["--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 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 pipeline-health", "查询指定时刻的管道健康预测", ["--time <TIME>"], ["tjwater-cli data pipeline-health --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 get", "读取单条 SCADA 设备", ["--device-id <DEVICE_ID>"], ["tjwater-cli data scada get --device-id SCADA-001"]],
|
||||||
["data scada list", "列出 SCADA 元数据", ["--kind <KIND>"], ["tjwater-cli data scada list --kind info"]],
|
["data scada list", "列出 SCADA 设备", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli data scada list"]],
|
||||||
["data scheme schema", "读取方案 schema", [], ["tjwater-cli data scheme schema"]],
|
["data scada schema", "读取 SCADA 设备 schema", [], ["tjwater-cli data scada schema"]],
|
||||||
["data scheme get", "读取单条方案", ["--name <NAME>"], ["tjwater-cli data scheme get --name my_scheme"]],
|
|
||||||
["data scheme list", "列出方案", [], ["tjwater-cli data scheme list"]],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const commandDocs = new Map<string, CommandDoc>(
|
export const commandDocs = new Map<string, CommandDoc>(
|
||||||
@@ -107,7 +97,7 @@ function optionDoc(path: string, token: string): CommandOptionDoc {
|
|||||||
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
|
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
|
||||||
const name = clean.slice(2).split(/\s+/)[0]!;
|
const name = clean.slice(2).split(/\s+/)[0]!;
|
||||||
const repeatedOptions: Record<string, string[]> = {
|
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"],
|
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
|
||||||
"data timeseries scada query": ["device-id"],
|
"data timeseries scada query": ["device-id"],
|
||||||
"data timeseries composite": ["feature"],
|
"data timeseries composite": ["feature"],
|
||||||
|
|||||||
@@ -1011,8 +1011,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"request",
|
"request",
|
||||||
|
"auto",
|
||||||
"always"
|
"always"
|
||||||
]
|
],
|
||||||
|
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1386,6 +1388,155 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_sessions_session_id_credential_refreshes",
|
||||||
|
"tags": [
|
||||||
|
"Agent"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Resume a waiting agent tool call with refreshed credentials",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "path"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"request_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Insufficient permission",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Resource not found",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Resource conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Upstream dependency error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Dependency unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sessions_session_id_permission_responses",
|
"operationId": "post_sessions_session_id_permission_responses",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"file": "agent-v1.openapi.json",
|
"file": "agent-v1.openapi.json",
|
||||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-103
@@ -8,7 +8,52 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
|
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
|
||||||
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
|
|
||||||
|
const visibleCommandPaths = [
|
||||||
|
"analysis age",
|
||||||
|
"analysis burst",
|
||||||
|
"analysis burst-detection detect",
|
||||||
|
"analysis burst-location locate",
|
||||||
|
"analysis contaminant",
|
||||||
|
"analysis flushing",
|
||||||
|
"analysis leakage identify",
|
||||||
|
"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 scada schema",
|
||||||
|
"data pipeline-health",
|
||||||
|
"data timeseries analysis link-field",
|
||||||
|
"data timeseries analysis node-field",
|
||||||
|
"data timeseries analysis values",
|
||||||
|
"data timeseries composite",
|
||||||
|
"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",
|
||||||
|
"network get-all-pipes-properties",
|
||||||
|
"network get-all-pumps-properties",
|
||||||
|
"network get-all-reservoirs-properties",
|
||||||
|
"network get-all-tanks-properties",
|
||||||
|
"network get-all-valves-properties",
|
||||||
|
"network get-junction-properties",
|
||||||
|
"network get-pipe-properties",
|
||||||
|
"network get-pump-properties",
|
||||||
|
"network get-reservoir-properties",
|
||||||
|
"network get-tank-properties",
|
||||||
|
"network get-valve-properties",
|
||||||
|
"simulation run",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hiddenCommandPaths = [];
|
||||||
|
|
||||||
function runCommand(command, args, input, options = {}) {
|
function runCommand(command, args, input, options = {}) {
|
||||||
return new Promise((resolveRun, reject) => {
|
return new Promise((resolveRun, reject) => {
|
||||||
@@ -35,10 +80,6 @@ function runCli(args, input) {
|
|||||||
return runCommand(cliPath, args, input);
|
return runCommand(cliPath, args, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPythonCli(args, input) {
|
|
||||||
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonResult(result) {
|
function parseJsonResult(result) {
|
||||||
return JSON.parse(result.stdout);
|
return JSON.parse(result.stdout);
|
||||||
}
|
}
|
||||||
@@ -56,7 +97,11 @@ async function startJsonServer(responseData) {
|
|||||||
url: req.url,
|
url: req.url,
|
||||||
});
|
});
|
||||||
res.setHeader("content-type", "application/json");
|
res.setHeader("content-type", "application/json");
|
||||||
res.end(JSON.stringify(responseData));
|
res.end(
|
||||||
|
JSON.stringify(
|
||||||
|
typeof responseData === "function" ? responseData(req) : responseData,
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolveListen, reject) => {
|
await new Promise((resolveListen, reject) => {
|
||||||
@@ -90,7 +135,20 @@ function normalizeSeenRequest(request) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) {
|
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", "/api/v1/scada-devices"].includes(url.pathname)) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
limit: Number(url.searchParams.get("limit")),
|
||||||
|
offset: Number(url.searchParams.get("offset")),
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgainstServer(name, runner, args, auth, responseData = defaultContractResponse) {
|
||||||
const server = await startJsonServer(responseData);
|
const server = await startJsonServer(responseData);
|
||||||
try {
|
try {
|
||||||
const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url });
|
const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url });
|
||||||
@@ -118,74 +176,31 @@ test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
|
|||||||
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
|
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches Python CLI help discovery and hidden command behavior", async () => {
|
test("discovers every visible command and keeps internal commands hidden", async () => {
|
||||||
for (const args of [["help"], ["help", "analysis"]]) {
|
const rootResult = await runCli(["help"]);
|
||||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
assert.equal(rootResult.exitCode, 0, rootResult.stderr);
|
||||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
|
assert.deepEqual(
|
||||||
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
|
parseJsonResult(rootResult).commands.map(({ command }) => command),
|
||||||
|
["analysis", "component", "data", "network", "simulation"],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const command of visibleCommandPaths) {
|
||||||
|
const result = await runCli(["help", ...command.split(" ")]);
|
||||||
|
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
|
||||||
|
const payload = parseJsonResult(result);
|
||||||
|
assert.equal(payload.ok, true, command);
|
||||||
|
assert.equal(payload.command, command, command);
|
||||||
|
assert.equal(payload.schema_version, "tjwater-cli/v1", command);
|
||||||
|
assert.ok(payload.usage, `${command}: missing usage`);
|
||||||
|
assert.ok(payload.examples.length > 0, `${command}: missing examples`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [nodeLeaf, pythonLeaf] = await Promise.all([
|
for (const command of hiddenCommandPaths) {
|
||||||
runCli(["help", "simulation", "run"]),
|
const result = await runCli(["help", ...command.split(" ")]);
|
||||||
runPythonCli(["help", "simulation", "run"]),
|
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
|
||||||
]);
|
const payload = parseJsonResult(result);
|
||||||
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
|
assert.equal(payload.ok, false, command);
|
||||||
const nodePayload = parseJsonResult(nodeLeaf);
|
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
|
||||||
const pythonPayload = parseJsonResult(pythonLeaf);
|
|
||||||
assert.equal(nodePayload.ok, pythonPayload.ok);
|
|
||||||
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
|
|
||||||
assert.equal(nodePayload.command, pythonPayload.command);
|
|
||||||
assert.equal(nodePayload.summary, pythonPayload.summary);
|
|
||||||
assert.equal(nodePayload.usage, pythonPayload.usage);
|
|
||||||
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
|
|
||||||
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
|
|
||||||
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
|
|
||||||
|
|
||||||
const [nodeHidden, pythonHidden] = await Promise.all([
|
|
||||||
runCli(["help", "analysis", "risk"]),
|
|
||||||
runPythonCli(["help", "analysis", "risk"]),
|
|
||||||
]);
|
|
||||||
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
|
|
||||||
const nodeError = parseJsonResult(nodeHidden);
|
|
||||||
const pythonError = parseJsonResult(pythonHidden);
|
|
||||||
delete nodeError.metadata.generated_at;
|
|
||||||
delete pythonError.metadata.generated_at;
|
|
||||||
assert.deepEqual(nodeError, pythonError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches Python CLI leaf help for every visible command", async () => {
|
|
||||||
const listResult = await runCommand(
|
|
||||||
"python",
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
{ cwd: pythonCliCwd },
|
|
||||||
);
|
|
||||||
assert.equal(listResult.exitCode, 0, listResult.stderr);
|
|
||||||
const commands = JSON.parse(listResult.stdout);
|
|
||||||
|
|
||||||
for (const command of commands) {
|
|
||||||
const args = ["help", ...command.split(" ")];
|
|
||||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
|
||||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
|
|
||||||
|
|
||||||
const nodePayload = parseJsonResult(nodeResult);
|
|
||||||
const pythonPayload = parseJsonResult(pythonResult);
|
|
||||||
const comparable = (payload) => ({
|
|
||||||
command: payload.command,
|
|
||||||
summary: payload.summary,
|
|
||||||
usage: payload.usage,
|
|
||||||
examples: payload.examples,
|
|
||||||
next_commands: payload.next_commands,
|
|
||||||
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
|
|
||||||
name,
|
|
||||||
required,
|
|
||||||
repeated,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,6 +242,52 @@ test("sends auth headers and simulation body through the backend API contract",
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("get-all network commands collect every backend page", async () => {
|
||||||
|
const items = Array.from({ length: 2_005 }, (_, index) => ({
|
||||||
|
id: `P${index + 1}`,
|
||||||
|
node1: `N${index + 1}`,
|
||||||
|
node2: `N${index + 2}`,
|
||||||
|
}));
|
||||||
|
const server = await startJsonServer((req) => {
|
||||||
|
const url = new URL(req.url, "http://127.0.0.1");
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 100);
|
||||||
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||||
|
return {
|
||||||
|
items: items.slice(offset, offset + limit),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: items.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
["--auth-stdin", "network", "get-all-pipes-properties"],
|
||||||
|
{
|
||||||
|
server: server.url,
|
||||||
|
access_token: "token-1",
|
||||||
|
project_id: "project-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0, result.stderr);
|
||||||
|
const payload = parseJsonResult(result);
|
||||||
|
assert.equal(payload.data.length, items.length);
|
||||||
|
assert.deepEqual(payload.data[0], items[0]);
|
||||||
|
assert.deepEqual(payload.data.at(-1), items.at(-1));
|
||||||
|
assert.deepEqual(
|
||||||
|
server.seen.map((request) => normalizeSeenRequest(request).query),
|
||||||
|
[
|
||||||
|
{ limit: "1000", offset: "0" },
|
||||||
|
{ limit: "1000", offset: "1000" },
|
||||||
|
{ limit: "1000", offset: "2000" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("uses project scoped headers for realtime data commands", async () => {
|
test("uses project scoped headers for realtime data commands", async () => {
|
||||||
const server = await startJsonServer([{ id: "P1" }]);
|
const server = await startJsonServer([{ id: "P1" }]);
|
||||||
try {
|
try {
|
||||||
@@ -262,7 +323,95 @@ test("uses project scoped headers for realtime data commands", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches Python CLI backend request shape for every command and key variants", async () => {
|
test("maps CLI pipe and junction types to backend link and node types", async () => {
|
||||||
|
const server = await startJsonServer({ accepted: true });
|
||||||
|
const auth = { server: server.url, access_token: "token-3", project_id: "project-1" };
|
||||||
|
const at = "2025-01-02T03:30:00+08:00";
|
||||||
|
try {
|
||||||
|
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", "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 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-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
|
||||||
try {
|
try {
|
||||||
const burstFile = join(tempDir, "burst.json");
|
const burstFile = join(tempDir, "burst.json");
|
||||||
@@ -284,6 +433,7 @@ test("matches Python CLI backend request shape for every command and key variant
|
|||||||
const start = "2025-01-02T03:00:00+08:00";
|
const start = "2025-01-02T03:00:00+08:00";
|
||||||
const end = "2025-01-02T04:00:00+08:00";
|
const end = "2025-01-02T04:00:00+08:00";
|
||||||
const at = "2025-01-02T03:30:00+08:00";
|
const at = "2025-01-02T03:30:00+08:00";
|
||||||
|
const runId = "00000000-0000-0000-0000-000000000001";
|
||||||
const cases = [
|
const cases = [
|
||||||
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
|
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
|
||||||
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
|
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
|
||||||
@@ -300,51 +450,47 @@ test("matches Python CLI backend request shape for every command and key variant
|
|||||||
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
|
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
|
||||||
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
|
["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 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", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
|
||||||
["analysis valve isolation", ["analysis", "valve", "--mode", "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 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 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 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 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 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 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 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 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-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 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 analysis link-field", ["data", "timeseries", "analysis", "link-field", "--run-id", runId, "--link", "P1", "--field", "flow", "--start-time", start, "--end-time", end]],
|
||||||
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
|
["data analysis node-field", ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end]],
|
||||||
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]],
|
["data analysis values", ["data", "timeseries", "analysis", "values", "--run-id", runId, "--type", "pipe", "--time", at, "--field", "flow"]],
|
||||||
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
|
|
||||||
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
|
["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-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 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 pipeline-health", ["data", "pipeline-health", "--time", end]],
|
||||||
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]],
|
["data scada get", ["data", "scada", "get", "--device-id", "SCADA-001"]],
|
||||||
["data scada list", ["data", "scada", "list", "--kind", "info"]],
|
["data scada list", ["data", "scada", "list"]],
|
||||||
["data scheme schema", ["data", "scheme", "schema"]],
|
["data scada schema", ["data", "scada", "schema"]],
|
||||||
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
|
|
||||||
["data scheme list", ["data", "scheme", "list"]],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [name, args] of cases) {
|
for (const [name, args] of cases) {
|
||||||
const [nodeRun, pythonRun] = await Promise.all([
|
const run = await runAgainstServer(name, runCli, args, auth);
|
||||||
runAgainstServer(`${name} node`, runCli, args, auth),
|
assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`);
|
||||||
runAgainstServer(`${name} python`, runPythonCli, args, auth),
|
assert.equal(run.payload.ok, true, name);
|
||||||
]);
|
assert.equal(run.payload.schema_version, "tjwater-cli/v1", name);
|
||||||
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
|
assert.ok(run.requests.length > 0, `${name}: no backend request`);
|
||||||
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
|
for (const request of run.requests) {
|
||||||
|
assert.match(request.path, /^\/api\/v1\//, name);
|
||||||
|
assert.equal(request.headers.authorization, "Bearer token", name);
|
||||||
|
assert.equal(request.headers["x-project-id"], "project-1", name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tempDir, { force: true, recursive: true });
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
|||||||
+46
-4
@@ -13,20 +13,62 @@
|
|||||||
"port": 4096
|
"port": 4096
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
|
"*": "ask",
|
||||||
|
"external_directory": "deny",
|
||||||
|
"read": {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
"external_directory": "ask",
|
".env": "deny",
|
||||||
|
".env.*": "deny",
|
||||||
|
"*.env": "deny",
|
||||||
|
"**/.env": "deny",
|
||||||
|
"**/.env.*": "deny",
|
||||||
|
"**/*.env": "deny",
|
||||||
|
"data/**": "deny",
|
||||||
|
"**/data/**": "deny",
|
||||||
|
"logs/**": "deny",
|
||||||
|
"**/logs/**": "deny"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"*": "ask",
|
||||||
|
".env": "deny",
|
||||||
|
".env.*": "deny",
|
||||||
|
"*.env": "deny",
|
||||||
|
"**/.env": "deny",
|
||||||
|
"**/.env.*": "deny",
|
||||||
|
"**/*.env": "deny",
|
||||||
|
"data/**": "deny",
|
||||||
|
"**/data/**": "deny",
|
||||||
|
"logs/**": "deny",
|
||||||
|
"**/logs/**": "deny"
|
||||||
|
},
|
||||||
"bash": {
|
"bash": {
|
||||||
"*": "allow",
|
"*": "ask",
|
||||||
"rm *": "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",
|
"rmdir *": "ask",
|
||||||
"mv *": "ask",
|
"mv *": "ask",
|
||||||
"chmod *": "ask",
|
"chmod *": "ask",
|
||||||
"chown *": "ask",
|
"chown *": "ask",
|
||||||
"sudo *": "ask",
|
"sudo *": "ask",
|
||||||
"curl *": "ask",
|
"curl *": "ask",
|
||||||
"wget *": "ask"
|
"wget *": "ask",
|
||||||
|
"*.env*": "deny"
|
||||||
},
|
},
|
||||||
"edit": "ask"
|
"question": "allow",
|
||||||
|
"activity_update": "allow",
|
||||||
|
"final_answer": "allow",
|
||||||
|
"task": "deny",
|
||||||
|
"todo": "allow",
|
||||||
|
"todoread": "allow",
|
||||||
|
"todowrite": "allow"
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"continue_loop_on_deny": true
|
||||||
},
|
},
|
||||||
"default_agent": "instruction"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@
|
|||||||
"install:opencode": "bun install --cwd .opencode",
|
"install:opencode": "bun install --cwd .opencode",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
||||||
|
"test": "bun test tests",
|
||||||
"test:cli": "node --test node-tests/cli/*.node.mjs",
|
"test:cli": "node --test node-tests/cli/*.node.mjs",
|
||||||
|
"test:ci": "bun run contract:check && /usr/bin/node --test node-tests/cli/*.node.mjs && bun test tests",
|
||||||
"dev": "bun --watch src/server.ts",
|
"dev": "bun --watch src/server.ts",
|
||||||
"build": "bun run check",
|
"build": "bun run check",
|
||||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
"start:prod": "bun run check && bun src/server.ts"
|
"start:prod": "bun run check && bun src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.16.2",
|
"@opencode-ai/sdk": "1.18.13",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -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,14 +4,15 @@ import { dirname } from "node:path";
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
|
|
||||||
export type LlmRequestAuditEntry = {
|
export type LlmRequestAuditEntry = {
|
||||||
kind: "tool" | "skill";
|
kind: "activity" | "tool" | "skill";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
target: string;
|
target: string;
|
||||||
reason: string;
|
activityId?: string;
|
||||||
reasonProvided: boolean;
|
activityTitle?: string;
|
||||||
|
activityReason?: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
|
|
||||||
|
type BackendContext = Pick<
|
||||||
|
RuntimeSessionContext,
|
||||||
|
"accessToken" | "projectId" | "traceId"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const buildBackendContextHeaders = (
|
||||||
|
context: BackendContext,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Trace-Id": context.traceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${context.accessToken}`;
|
||||||
|
}
|
||||||
|
if (context.projectId) {
|
||||||
|
headers["X-Project-Id"] = context.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { type RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
|
|
||||||
|
export type CredentialRefreshReason =
|
||||||
|
| "access_token_expired"
|
||||||
|
| "access_token_rejected";
|
||||||
|
|
||||||
|
export type CredentialRefreshEvent =
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_required";
|
||||||
|
requestId: string;
|
||||||
|
reason: CredentialRefreshReason;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refreshed";
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_failed";
|
||||||
|
requestId: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRefresh = {
|
||||||
|
deadlineAt: number;
|
||||||
|
promise: Promise<RuntimeSessionContext>;
|
||||||
|
reason: CredentialRefreshReason;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
requestId: string;
|
||||||
|
resolve: (context: RuntimeSessionContext) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
|
||||||
|
|
||||||
|
export class CredentialRefreshError extends Error {
|
||||||
|
override readonly name = "CredentialRefreshError";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
||||||
|
|
||||||
|
export const isRuntimeCredentialExpired = (
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
now = Date.now(),
|
||||||
|
) => {
|
||||||
|
if (!context.tokenExpiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(context.tokenExpiresAt);
|
||||||
|
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CredentialRefreshCoordinator {
|
||||||
|
private readonly listeners = new Map<
|
||||||
|
string,
|
||||||
|
Set<CredentialRefreshListener>
|
||||||
|
>();
|
||||||
|
private readonly pending = new Map<string, PendingRefresh>();
|
||||||
|
|
||||||
|
constructor(private readonly timeoutMs = 30_000) {}
|
||||||
|
|
||||||
|
subscribe(sessionId: string, listener: CredentialRefreshListener) {
|
||||||
|
const listeners =
|
||||||
|
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
|
||||||
|
listeners.add(listener);
|
||||||
|
this.listeners.set(sessionId, listeners);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
this.listeners.delete(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(sessionId: string, reason: CredentialRefreshReason) {
|
||||||
|
const existing = this.pending.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
if (!this.listeners.get(sessionId)?.size) {
|
||||||
|
return Promise.reject(
|
||||||
|
new CredentialRefreshError(
|
||||||
|
"credential refresh channel is unavailable",
|
||||||
|
"unavailable",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `credential-${randomUUID()}`;
|
||||||
|
let resolvePromise!: (context: RuntimeSessionContext) => void;
|
||||||
|
let rejectPromise!: (error: Error) => void;
|
||||||
|
const promise = new Promise<RuntimeSessionContext>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
|
||||||
|
}, this.timeoutMs);
|
||||||
|
this.pending.set(sessionId, {
|
||||||
|
deadlineAt: Date.now() + this.timeoutMs,
|
||||||
|
promise,
|
||||||
|
reason,
|
||||||
|
reject: rejectPromise,
|
||||||
|
requestId,
|
||||||
|
resolve: resolvePromise,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId,
|
||||||
|
reason,
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
) {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending || pending.requestId !== requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(sessionId);
|
||||||
|
pending.resolve(context);
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refreshed",
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
message: string,
|
||||||
|
code: CredentialRefreshError["code"] = "failed",
|
||||||
|
emitFailureEvent = true,
|
||||||
|
) {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending || pending.requestId !== requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(sessionId);
|
||||||
|
pending.reject(new CredentialRefreshError(message, code));
|
||||||
|
if (emitFailureEvent) {
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refresh_failed",
|
||||||
|
requestId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSession(sessionId: string, message = "credential refresh cancelled") {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.fail(
|
||||||
|
sessionId,
|
||||||
|
pending.requestId,
|
||||||
|
message,
|
||||||
|
"cancelled",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequestId(sessionId: string) {
|
||||||
|
return this.pending.get(sessionId)?.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingEvent(
|
||||||
|
sessionId: string,
|
||||||
|
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending) return null;
|
||||||
|
return {
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId: pending.requestId,
|
||||||
|
reason: pending.reason,
|
||||||
|
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(sessionId: string, event: CredentialRefreshEvent) {
|
||||||
|
for (const listener of this.listeners.get(sessionId) ?? []) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runWithCredentialRefresh = async <T extends { status: number }>(
|
||||||
|
coordinator: CredentialRefreshCoordinator,
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
execute: (context: RuntimeSessionContext) => Promise<T>,
|
||||||
|
) => {
|
||||||
|
let activeContext = context;
|
||||||
|
let refreshed = false;
|
||||||
|
if (isRuntimeCredentialExpired(activeContext)) {
|
||||||
|
activeContext = await coordinator.request(
|
||||||
|
activeContext.sessionId,
|
||||||
|
"access_token_expired",
|
||||||
|
);
|
||||||
|
refreshed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await execute(activeContext);
|
||||||
|
if (result.status !== 401 || refreshed) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
activeContext = await coordinator.request(
|
||||||
|
activeContext.sessionId,
|
||||||
|
"access_token_rejected",
|
||||||
|
);
|
||||||
|
result = await execute(activeContext);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ export type SessionBinding = {
|
|||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
|
workspaceDirectory?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionContext = {
|
export type SessionContext = {
|
||||||
@@ -52,20 +53,26 @@ export class ChatSessionBridge {
|
|||||||
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
|
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
|
||||||
|
|
||||||
let sessionId = existingSessionId;
|
let sessionId = existingSessionId;
|
||||||
|
let runtimeSession;
|
||||||
let created = false;
|
let created = false;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
const session = await this.runtime.createSession();
|
runtimeSession = await this.runtime.createSession(undefined, {
|
||||||
sessionId = session.id;
|
conversationWorkspace: true,
|
||||||
|
});
|
||||||
|
sessionId = runtimeSession.id;
|
||||||
requestContext = {
|
requestContext = {
|
||||||
...requestContext,
|
...requestContext,
|
||||||
clientSessionId: sessionId,
|
clientSessionId: sessionId,
|
||||||
};
|
};
|
||||||
created = true;
|
created = true;
|
||||||
|
} else {
|
||||||
|
runtimeSession = await this.runtime.getSession(sessionId);
|
||||||
}
|
}
|
||||||
const binding: SessionBinding = {
|
const binding: SessionBinding = {
|
||||||
clientSessionId: requestContext.clientSessionId,
|
clientSessionId: requestContext.clientSessionId,
|
||||||
sessionId,
|
sessionId,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
|
workspaceDirectory: runtimeSession.directory,
|
||||||
};
|
};
|
||||||
setRuntimeSessionContext({
|
setRuntimeSessionContext({
|
||||||
accessToken: requestContext.accessToken,
|
accessToken: requestContext.accessToken,
|
||||||
@@ -79,6 +86,7 @@ export class ChatSessionBridge {
|
|||||||
sessionId,
|
sessionId,
|
||||||
tokenExpiresAt: requestContext.tokenExpiresAt,
|
tokenExpiresAt: requestContext.tokenExpiresAt,
|
||||||
traceId: requestContext.traceId,
|
traceId: requestContext.traceId,
|
||||||
|
workspaceDirectory: runtimeSession.directory,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { binding, requestContext, created };
|
return { binding, requestContext, created };
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { type RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
|
|
||||||
|
type OutputStream = "stdout" | "stderr";
|
||||||
|
|
||||||
|
export type CliExecutionResult = {
|
||||||
|
outcome: "completed" | "timeout" | "output_limit";
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
status: number;
|
||||||
|
stderr: string;
|
||||||
|
stderrTruncated: boolean;
|
||||||
|
stdout: string;
|
||||||
|
exceededStream?: OutputStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteCliCommandOptions = {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
cliPath: string;
|
||||||
|
maxStderrBytes: number;
|
||||||
|
maxStdoutBytes: number;
|
||||||
|
terminationGraceMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCompletedStatus = (exitCode: number | null, stdout: string) => {
|
||||||
|
let errorCode = "";
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(stdout) as { error?: { code?: unknown } };
|
||||||
|
errorCode =
|
||||||
|
typeof payload.error?.code === "string" ? payload.error.code : "";
|
||||||
|
} catch {
|
||||||
|
errorCode = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorCode === "HTTP_401" || errorCode === "UNAUTHENTICATED") {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
|
if (errorCode === "HTTP_403") {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
return exitCode === 0 ? 200 : 502;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeCliCommand = async (
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
command: string,
|
||||||
|
timeoutSec: number,
|
||||||
|
options: ExecuteCliCommandOptions,
|
||||||
|
): Promise<CliExecutionResult> => {
|
||||||
|
if (!Number.isSafeInteger(options.maxStdoutBytes) || options.maxStdoutBytes <= 0) {
|
||||||
|
throw new Error("maxStdoutBytes must be a positive safe integer");
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(options.maxStderrBytes) || options.maxStderrBytes <= 0) {
|
||||||
|
throw new Error("maxStderrBytes must be a positive safe integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
options.cliPath,
|
||||||
|
["--auth-stdin", ...command.split(/\s+/).filter(Boolean)],
|
||||||
|
{ stdio: ["pipe", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
const stdoutChunks: Buffer[] = [];
|
||||||
|
const stderrChunks: Buffer[] = [];
|
||||||
|
let stdoutBytes = 0;
|
||||||
|
let stderrBytes = 0;
|
||||||
|
let stderrTruncated = false;
|
||||||
|
let terminationReason:
|
||||||
|
| "timeout"
|
||||||
|
| "output_limit"
|
||||||
|
| "execution_error"
|
||||||
|
| null = null;
|
||||||
|
let exceededStream: OutputStream | undefined;
|
||||||
|
let terminationStarted = false;
|
||||||
|
let settled = false;
|
||||||
|
let executionError: Error | null = null;
|
||||||
|
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const result = await new Promise<CliExecutionResult>((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeoutTimer);
|
||||||
|
if (forceKillTimer) {
|
||||||
|
clearTimeout(forceKillTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminate = (
|
||||||
|
reason: "timeout" | "output_limit" | "execution_error",
|
||||||
|
) => {
|
||||||
|
if (terminationStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminationStarted = true;
|
||||||
|
terminationReason = reason;
|
||||||
|
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
forceKillTimer = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, options.terminationGraceMs ?? 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureStdout = (data: Buffer) => {
|
||||||
|
if (terminationReason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stdoutBytes + data.length > options.maxStdoutBytes) {
|
||||||
|
exceededStream = "stdout";
|
||||||
|
terminate("output_limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stdoutChunks.push(data);
|
||||||
|
stdoutBytes += data.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const captureStderr = (data: Buffer) => {
|
||||||
|
if (terminationReason || stderrTruncated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remainingBytes = options.maxStderrBytes - stderrBytes;
|
||||||
|
if (data.length > remainingBytes) {
|
||||||
|
if (remainingBytes > 0) {
|
||||||
|
stderrChunks.push(data.subarray(0, remainingBytes));
|
||||||
|
stderrBytes += remainingBytes;
|
||||||
|
}
|
||||||
|
stderrTruncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stderrChunks.push(data);
|
||||||
|
stderrBytes += data.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutTimer = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
terminate("timeout");
|
||||||
|
}
|
||||||
|
}, timeoutSec * 1000);
|
||||||
|
|
||||||
|
child.stdout.on("data", captureStdout);
|
||||||
|
child.stderr.on("data", captureStderr);
|
||||||
|
child.stdin.on("error", (error) => {
|
||||||
|
if (terminationReason === null) {
|
||||||
|
executionError = error;
|
||||||
|
terminate("execution_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
if (terminationReason === null) {
|
||||||
|
executionError = error;
|
||||||
|
terminate("execution_error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.on("close", (exitCode, signal) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (terminationReason === "timeout") {
|
||||||
|
resolve({
|
||||||
|
outcome: "timeout",
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
status: 504,
|
||||||
|
stderr: "",
|
||||||
|
stderrTruncated,
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (executionError) {
|
||||||
|
reject(executionError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (terminationReason === "output_limit") {
|
||||||
|
resolve({
|
||||||
|
outcome: "output_limit",
|
||||||
|
exceededStream,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
status: 502,
|
||||||
|
stderr: "",
|
||||||
|
stderrTruncated,
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = Buffer.concat(stdoutChunks, stdoutBytes).toString("utf-8");
|
||||||
|
const stderr = Buffer.concat(stderrChunks, stderrBytes).toString("utf-8");
|
||||||
|
resolve({
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
status: getCompletedStatus(exitCode, stdout),
|
||||||
|
stderr,
|
||||||
|
stderrTruncated,
|
||||||
|
stdout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.end(
|
||||||
|
JSON.stringify({
|
||||||
|
server: options.apiBaseUrl,
|
||||||
|
access_token: context.accessToken,
|
||||||
|
project_id: context.projectId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
+28
-26
@@ -7,6 +7,8 @@ import {
|
|||||||
parseAgentModelOptions,
|
parseAgentModelOptions,
|
||||||
} from "./chat/modelConfig.js";
|
} from "./chat/modelConfig.js";
|
||||||
|
|
||||||
|
export const RESULT_REF_IMPORT_DIRECTORY = "./data/conversation-workspaces";
|
||||||
|
|
||||||
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
||||||
dotenv.config({ path: ".local.env", override: false });
|
dotenv.config({ path: ".local.env", override: false });
|
||||||
|
|
||||||
@@ -41,8 +43,8 @@ const envSchema = z
|
|||||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
AGENT_INTERNAL_TOKEN: optionalString(),
|
||||||
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
|
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
|
||||||
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
// 当前仅支持 embedded;保留字段用于让旧 client 配置在启动时明确失败。
|
||||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
OPENCODE_MODE: z.literal("embedded").default("embedded"),
|
||||||
// embedded opencode server 的监听地址。
|
// embedded opencode server 的监听地址。
|
||||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||||
// embedded opencode server 的监听端口。
|
// embedded opencode server 的监听端口。
|
||||||
@@ -55,18 +57,26 @@ const envSchema = z
|
|||||||
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
|
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
|
||||||
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
|
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
|
||||||
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
||||||
// client 模式下,目标 opencode server 的基础地址。
|
|
||||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
|
||||||
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
|
|
||||||
OPENCODE_BASE_URL: z.string().url().optional(),
|
|
||||||
// tjwater-cli 可执行文件路径。
|
// tjwater-cli 可执行文件路径。
|
||||||
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
|
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
|
||||||
// TJWater 后端 API 的基础地址。
|
// TJWater 后端 API 的基础地址。
|
||||||
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
|
||||||
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
|
||||||
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
|
||||||
// 后端结果在直接内联返回给模型前允许的最大字节数。
|
// OpenCode 工具结果以内联形式返回给模型的阈值;更大的结果由 OpenCode 落盘。
|
||||||
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
|
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
|
||||||
|
// 单次 tjwater-cli stdout 的硬上限;超过后终止子进程。
|
||||||
|
MAX_CLI_OUTPUT_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(128 * 1024 * 1024),
|
||||||
|
// 单次 tjwater-cli stderr 最多保留的字节数;超过后截断但不终止进程。
|
||||||
|
MAX_CLI_STDERR_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(256 * 1024),
|
||||||
// 生成结果 preview 时最多抽样的条目数。
|
// 生成结果 preview 时最多抽样的条目数。
|
||||||
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
|
||||||
// memory 持久化存储目录。
|
// memory 持久化存储目录。
|
||||||
@@ -107,6 +117,16 @@ const envSchema = z
|
|||||||
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||||
// result_ref 持久化存储目录。
|
// result_ref 持久化存储目录。
|
||||||
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
||||||
|
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
|
||||||
|
RESULT_REF_IMPORT_DIR: z
|
||||||
|
.literal(RESULT_REF_IMPORT_DIRECTORY)
|
||||||
|
.default(RESULT_REF_IMPORT_DIRECTORY),
|
||||||
|
// 单个渲染包装 JSON 的最大导入字节数。
|
||||||
|
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(128 * 1024 * 1024),
|
||||||
// result_ref 保留时长(小时)。
|
// result_ref 保留时长(小时)。
|
||||||
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
||||||
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
||||||
@@ -117,13 +137,6 @@ const envSchema = z
|
|||||||
.default(3600000),
|
.default(3600000),
|
||||||
})
|
})
|
||||||
.superRefine((env, ctx) => {
|
.superRefine((env, ctx) => {
|
||||||
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["OPENCODE_CLIENT_BASE_URL"],
|
|
||||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let modelOptions;
|
let modelOptions;
|
||||||
try {
|
try {
|
||||||
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
|
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
|
||||||
@@ -154,15 +167,4 @@ const envSchema = z
|
|||||||
|
|
||||||
export type AppConfig = z.infer<typeof envSchema>;
|
export type AppConfig = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
const normalizedEnv = {
|
export const config: AppConfig = envSchema.parse(process.env);
|
||||||
...process.env,
|
|
||||||
OPENCODE_MODE:
|
|
||||||
process.env.OPENCODE_MODE ??
|
|
||||||
(process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL
|
|
||||||
? "client"
|
|
||||||
: "embedded"),
|
|
||||||
OPENCODE_CLIENT_BASE_URL:
|
|
||||||
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config: AppConfig = envSchema.parse(normalizedEnv);
|
|
||||||
|
|||||||
@@ -214,7 +214,12 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", {
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
message: z.string().min(1).max(10000),
|
message: z.string().min(1).max(10000),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
approval_mode: z.enum(["request", "always"]).optional(),
|
approval_mode: z
|
||||||
|
.enum(["request", "auto", "always"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -246,6 +251,24 @@ register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
|
|||||||
request: { params: SessionId },
|
request: { params: SessionId },
|
||||||
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
|
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
|
||||||
});
|
});
|
||||||
|
register(
|
||||||
|
"/api/v1/agent/sessions/{session_id}/credential-refreshes",
|
||||||
|
"post",
|
||||||
|
{
|
||||||
|
summary: "Resume a waiting agent tool call with refreshed credentials",
|
||||||
|
request: {
|
||||||
|
params: SessionId,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({ request_id: z.string().min(1).max(128) }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { 202: jsonResponse(JsonObject) },
|
||||||
|
},
|
||||||
|
);
|
||||||
register(
|
register(
|
||||||
"/api/v1/agent/sessions/{session_id}/permission-responses",
|
"/api/v1/agent/sessions/{session_id}/permission-responses",
|
||||||
"post",
|
"post",
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ type TurnReviewInput = {
|
|||||||
export class LearningOrchestrator {
|
export class LearningOrchestrator {
|
||||||
private readonly activeReviews = new Set<string>();
|
private readonly activeReviews = new Set<string>();
|
||||||
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
||||||
private readonly skillStore = new SkillStore();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runtime: OpencodeRuntimeAdapter,
|
private readonly runtime: OpencodeRuntimeAdapter,
|
||||||
private readonly memoryStore: MemoryStore,
|
private readonly memoryStore: MemoryStore,
|
||||||
private readonly transcriptStore: SessionTranscriptStore,
|
private readonly transcriptStore: SessionTranscriptStore,
|
||||||
|
private readonly skillStore: SkillStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { type MemoryScope, MemoryStore } from "../memory/store.js";
|
||||||
|
import {
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
type RuntimeSessionContext,
|
||||||
|
} from "../runtime/sessionContext.js";
|
||||||
|
import { SkillStore } from "../skills/store.js";
|
||||||
|
|
||||||
|
export type MemoryManagerInput = {
|
||||||
|
action: "add" | "list" | "replace" | "remove";
|
||||||
|
content?: string;
|
||||||
|
scope: string;
|
||||||
|
target_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillManagerInput = {
|
||||||
|
action:
|
||||||
|
| "list"
|
||||||
|
| "write_skill"
|
||||||
|
| "remove_skill"
|
||||||
|
| "append_pattern"
|
||||||
|
| "remove_pattern"
|
||||||
|
| "write_reference"
|
||||||
|
| "remove_reference"
|
||||||
|
| "write_script"
|
||||||
|
| "remove_script";
|
||||||
|
content?: string;
|
||||||
|
file_path?: string;
|
||||||
|
pattern?: string;
|
||||||
|
skill_path: string;
|
||||||
|
target_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeMemoryManager = async (
|
||||||
|
memoryStore: MemoryStore,
|
||||||
|
sessionContext: RuntimeSessionContext,
|
||||||
|
input: MemoryManagerInput,
|
||||||
|
) => {
|
||||||
|
const scope: MemoryScope | null =
|
||||||
|
input.scope === "user"
|
||||||
|
? "user"
|
||||||
|
: input.scope === "workspace"
|
||||||
|
? "workspace"
|
||||||
|
: null;
|
||||||
|
if (!scope) {
|
||||||
|
return rejected(
|
||||||
|
"memory",
|
||||||
|
`unsupported scope: ${input.scope}; use exact keyword 'user' or 'workspace'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
|
||||||
|
return rejected("memory", "memory writes are disabled for this session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey =
|
||||||
|
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
|
||||||
|
if (input.action === "list") {
|
||||||
|
setRuntimeSessionContext({
|
||||||
|
...sessionContext,
|
||||||
|
memoryListReadScopes: {
|
||||||
|
...(sessionContext.memoryListReadScopes ?? {}),
|
||||||
|
[scope]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: "accepted",
|
||||||
|
detail: "memory listed",
|
||||||
|
items: await memoryStore.list(scope, scopeKey),
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === "add") {
|
||||||
|
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
|
||||||
|
return {
|
||||||
|
...rejected(
|
||||||
|
"memory",
|
||||||
|
`must list ${scope} memory and review existing entries before add`,
|
||||||
|
),
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||||
|
content: input.content ?? "",
|
||||||
|
sessionId: sessionContext.clientSessionId,
|
||||||
|
source: "tool",
|
||||||
|
traceId: sessionContext.traceId,
|
||||||
|
});
|
||||||
|
if (!result.entry) {
|
||||||
|
return rejected("memory", "content rejected by persistence policy");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: result.changed ? "accepted" : "deduped",
|
||||||
|
detail: result.detail,
|
||||||
|
entry: result.entry,
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
input.action === "replace"
|
||||||
|
? await memoryStore.replace(scope, scopeKey, input.target_id ?? "", {
|
||||||
|
content: input.content ?? "",
|
||||||
|
sessionId: sessionContext.clientSessionId,
|
||||||
|
source: "tool",
|
||||||
|
traceId: sessionContext.traceId,
|
||||||
|
})
|
||||||
|
: await memoryStore.remove(scope, scopeKey, input.target_id ?? "");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: result.changed ? "accepted" : "rejected",
|
||||||
|
detail: result.detail,
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeSkillManager = async (
|
||||||
|
skillStore: SkillStore,
|
||||||
|
sessionContext: RuntimeSessionContext,
|
||||||
|
input: SkillManagerInput,
|
||||||
|
) => {
|
||||||
|
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
|
||||||
|
return rejected("skill", "skill writes are disabled for this session");
|
||||||
|
}
|
||||||
|
if (input.action === "list") {
|
||||||
|
const result = await skillStore.list(input.skill_path);
|
||||||
|
if (!result) {
|
||||||
|
return rejected(
|
||||||
|
"skill",
|
||||||
|
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "skill",
|
||||||
|
decision: "accepted",
|
||||||
|
detail: "skill listed",
|
||||||
|
references: result.references,
|
||||||
|
scripts: result.scripts,
|
||||||
|
skill_path: result.skillPath,
|
||||||
|
target: result.target,
|
||||||
|
patterns: result.patterns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
input.action === "write_skill"
|
||||||
|
? await skillStore.writeSkill(input.skill_path, input.content ?? "")
|
||||||
|
: input.action === "remove_skill"
|
||||||
|
? await skillStore.removeSkill(input.skill_path)
|
||||||
|
: input.action === "append_pattern"
|
||||||
|
? await skillStore.appendPattern(input.skill_path, input.pattern ?? "")
|
||||||
|
: input.action === "remove_pattern"
|
||||||
|
? await skillStore.removePattern(input.skill_path, input.target_id ?? "")
|
||||||
|
: input.action === "write_reference"
|
||||||
|
? await skillStore.writeReference(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
input.content ?? "",
|
||||||
|
)
|
||||||
|
: input.action === "remove_reference"
|
||||||
|
? await skillStore.removeReference(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
)
|
||||||
|
: input.action === "write_script"
|
||||||
|
? await skillStore.writeScript(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
input.content ?? "",
|
||||||
|
)
|
||||||
|
: await skillStore.removeScript(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "skill",
|
||||||
|
decision: result.changed ? "accepted" : "rejected",
|
||||||
|
detail: result.detail,
|
||||||
|
target: result.target,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejected = (kind: "memory" | "skill", detail: string) => ({
|
||||||
|
ok: true,
|
||||||
|
kind,
|
||||||
|
decision: "rejected",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
+39
-6
@@ -1,4 +1,10 @@
|
|||||||
import { readJsonFile } from "../utils/fileStore.js";
|
import { stat } from "node:fs/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveConversationWorkspace,
|
||||||
|
resolveExistingPathInsideRoot,
|
||||||
|
} from "../runtime/conversationWorkspace.js";
|
||||||
|
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
type ResultReferenceKind,
|
type ResultReferenceKind,
|
||||||
type ResultReferenceRecord,
|
type ResultReferenceRecord,
|
||||||
@@ -33,7 +39,11 @@ export type RenderJunctionPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ResultReferenceResolver {
|
export class ResultReferenceResolver {
|
||||||
constructor(private readonly store: ResultReferenceStore) {}
|
constructor(
|
||||||
|
private readonly store: ResultReferenceStore,
|
||||||
|
private readonly importRoot: string,
|
||||||
|
private readonly importMaxBytes: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
||||||
async register(input: RegisterResultReferenceInput) {
|
async register(input: RegisterResultReferenceInput) {
|
||||||
@@ -61,9 +71,29 @@ export class ResultReferenceResolver {
|
|||||||
|
|
||||||
async registerRenderPayloadFile(
|
async registerRenderPayloadFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion"> & {
|
||||||
|
workspaceDirectory: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const raw = await readJsonFile<unknown>(filePath);
|
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");
|
||||||
|
}
|
||||||
|
if (fileStat.size > this.importMaxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`render payload file exceeds RESULT_REF_IMPORT_MAX_BYTES (${this.importMaxBytes})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = await readJsonFile<unknown>(resolvedFilePath);
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
throw new Error(`render payload file not found: ${filePath}`);
|
throw new Error(`render payload file not found: ${filePath}`);
|
||||||
}
|
}
|
||||||
@@ -78,13 +108,16 @@ export class ResultReferenceResolver {
|
|||||||
throw new Error("render payload file does not contain a valid junction render payload");
|
throw new Error("render payload file does not contain a valid junction render payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.register({
|
const { workspaceDirectory: _workspaceDirectory, ...registrationInput } = input;
|
||||||
...input,
|
const record = await this.register({
|
||||||
|
...registrationInput,
|
||||||
data: payload,
|
data: payload,
|
||||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
});
|
});
|
||||||
|
await removeFileIfExists(resolvedFilePath);
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFullAuthorized(
|
async getFullAuthorized(
|
||||||
|
|||||||
+92
-3
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||||
|
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
|
||||||
import {
|
import {
|
||||||
agentModelOptions,
|
agentModelOptions,
|
||||||
isSupportedModel,
|
isSupportedModel,
|
||||||
@@ -34,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
|
|||||||
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
|
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
|
||||||
import {
|
import {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
|
type ActivityUpdatePayload,
|
||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
streamPromptResponse,
|
streamPromptResponse,
|
||||||
@@ -45,7 +47,9 @@ import {
|
|||||||
type StreamSubscriber,
|
type StreamSubscriber,
|
||||||
appendBackendToolArtifact,
|
appendBackendToolArtifact,
|
||||||
cancelBackendTodos,
|
cancelBackendTodos,
|
||||||
|
completeBackendActivities,
|
||||||
completeBackendProgress,
|
completeBackendProgress,
|
||||||
|
completeBackendTodos,
|
||||||
createInitialStreamingMessages,
|
createInitialStreamingMessages,
|
||||||
isObjectRecord,
|
isObjectRecord,
|
||||||
toFrontendPermission,
|
toFrontendPermission,
|
||||||
@@ -53,6 +57,7 @@ import {
|
|||||||
updateLastAssistantMessage,
|
updateLastAssistantMessage,
|
||||||
updateLastAssistantPermission,
|
updateLastAssistantPermission,
|
||||||
updateLastAssistantQuestion,
|
updateLastAssistantQuestion,
|
||||||
|
upsertBackendActivity,
|
||||||
upsertBackendProgress,
|
upsertBackendProgress,
|
||||||
upsertBackendQuestion,
|
upsertBackendQuestion,
|
||||||
upsertBackendTodoUpdate,
|
upsertBackendTodoUpdate,
|
||||||
@@ -64,7 +69,13 @@ const payloadSchema = z.object({
|
|||||||
model: z.string().refine(isSupportedModel, {
|
model: z.string().refine(isSupportedModel, {
|
||||||
message: "unsupported model",
|
message: "unsupported model",
|
||||||
}).optional(),
|
}).optional(),
|
||||||
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
approval_mode: z
|
||||||
|
.enum(["request", "auto", "always"])
|
||||||
|
.optional()
|
||||||
|
.default("request")
|
||||||
|
.describe(
|
||||||
|
"request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSessionPayloadSchema = z.object({
|
const createSessionPayloadSchema = z.object({
|
||||||
@@ -121,6 +132,7 @@ export const buildChatRouter = (
|
|||||||
sessionTranscriptStore: SessionTranscriptStore,
|
sessionTranscriptStore: SessionTranscriptStore,
|
||||||
learningOrchestrator: LearningOrchestrator,
|
learningOrchestrator: LearningOrchestrator,
|
||||||
resultReferenceResolver: ResultReferenceResolver,
|
resultReferenceResolver: ResultReferenceResolver,
|
||||||
|
credentialRefreshCoordinator: CredentialRefreshCoordinator,
|
||||||
) => {
|
) => {
|
||||||
const chatRouter = Router();
|
const chatRouter = Router();
|
||||||
|
|
||||||
@@ -147,7 +159,9 @@ export const buildChatRouter = (
|
|||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
const requestedSessionId = parsed.data.session_id?.trim();
|
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({
|
const { record, created } = await sessionMetadataStore.ensure({
|
||||||
actorKey,
|
actorKey,
|
||||||
@@ -295,6 +309,16 @@ export const buildChatRouter = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
run.subscribers.add(subscriber);
|
run.subscribers.add(subscriber);
|
||||||
|
const pendingCredentialRefresh =
|
||||||
|
credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
|
||||||
|
if (pendingCredentialRefresh) {
|
||||||
|
subscriber.write(pendingCredentialRefresh.type, {
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
request_id: pendingCredentialRefresh.requestId,
|
||||||
|
reason: pendingCredentialRefresh.reason,
|
||||||
|
timeout_ms: pendingCredentialRefresh.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
run.subscribers.delete(subscriber);
|
run.subscribers.delete(subscriber);
|
||||||
@@ -390,6 +414,7 @@ export const buildChatRouter = (
|
|||||||
|
|
||||||
registerChatInteractionRoutes(chatRouter, {
|
registerChatInteractionRoutes(chatRouter, {
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator,
|
||||||
runtime,
|
runtime,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
@@ -432,7 +457,9 @@ export const buildChatRouter = (
|
|||||||
res.status(404).json({ message: "source session not found" });
|
res.status(404).json({ message: "source session not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const forkSession = await runtime.createSession();
|
const forkSession = await runtime.createSession(undefined, {
|
||||||
|
conversationWorkspace: true,
|
||||||
|
});
|
||||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||||
actorKey,
|
actorKey,
|
||||||
parentSessionId: sourceSessionId,
|
parentSessionId: sourceSessionId,
|
||||||
@@ -664,6 +691,27 @@ export const buildChatRouter = (
|
|||||||
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
||||||
isError: false,
|
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") {
|
} else if (event === "progress") {
|
||||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
...message,
|
...message,
|
||||||
@@ -679,6 +727,8 @@ export const buildChatRouter = (
|
|||||||
? message.content
|
? message.content
|
||||||
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
|
activities: completeBackendActivities(message.activities),
|
||||||
|
todos: completeBackendTodos(message.todos),
|
||||||
}));
|
}));
|
||||||
} else if (event === "error") {
|
} else if (event === "error") {
|
||||||
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
|
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
|
||||||
@@ -691,6 +741,7 @@ export const buildChatRouter = (
|
|||||||
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
|
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
|
activities: completeBackendActivities(message.activities, "error"),
|
||||||
todos: cancelBackendTodos(message.todos),
|
todos: cancelBackendTodos(message.todos),
|
||||||
}));
|
}));
|
||||||
} else if (event === "auth_required") {
|
} else if (event === "auth_required") {
|
||||||
@@ -704,6 +755,7 @@ export const buildChatRouter = (
|
|||||||
: "⚠️ **登录态已过期,请刷新登录后重试**",
|
: "⚠️ **登录态已过期,请刷新登录后重试**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
|
activities: completeBackendActivities(message.activities, "error"),
|
||||||
todos: cancelBackendTodos(message.todos),
|
todos: cancelBackendTodos(message.todos),
|
||||||
}));
|
}));
|
||||||
} else if (event === "permission_request") {
|
} else if (event === "permission_request") {
|
||||||
@@ -803,6 +855,35 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(
|
||||||
|
binding.sessionId,
|
||||||
|
(event) => {
|
||||||
|
publish(event.type, {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.requestId,
|
||||||
|
...(event.type === "credential_refresh_required"
|
||||||
|
? {
|
||||||
|
reason: event.reason,
|
||||||
|
timeout_ms: event.timeoutMs,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(event.type === "credential_refresh_failed"
|
||||||
|
? { message: event.message }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cancelCredentialRefreshOnAbort = () => {
|
||||||
|
credentialRefreshCoordinator.cancelSession(
|
||||||
|
binding.sessionId,
|
||||||
|
"credential refresh cancelled because the agent run was aborted",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
abortController.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
cancelCredentialRefreshOnAbort,
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preparedMessage = await buildPromptWithLearningContext(
|
const preparedMessage = await buildPromptWithLearningContext(
|
||||||
@@ -826,6 +907,7 @@ export const buildChatRouter = (
|
|||||||
traceId: requestContext.traceId,
|
traceId: requestContext.traceId,
|
||||||
projectId: requestContext.projectId,
|
projectId: requestContext.projectId,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
workspaceRoot: binding.workspaceDirectory,
|
||||||
write: (event, data) => {
|
write: (event, data) => {
|
||||||
publish(event, data);
|
publish(event, data);
|
||||||
},
|
},
|
||||||
@@ -915,6 +997,7 @@ export const buildChatRouter = (
|
|||||||
: "⚠️ **请求已中断**",
|
: "⚠️ **请求已中断**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
|
activities: completeBackendActivities(message.activities, "cancelled"),
|
||||||
todos: cancelBackendTodos(message.todos),
|
todos: cancelBackendTodos(message.todos),
|
||||||
}));
|
}));
|
||||||
void queueSessionUiStatePersist().catch((error) => {
|
void queueSessionUiStatePersist().catch((error) => {
|
||||||
@@ -925,6 +1008,12 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
sessionBridge.finalizeRequest(clientSessionId);
|
sessionBridge.finalizeRequest(clientSessionId);
|
||||||
|
abortController.signal.removeEventListener(
|
||||||
|
"abort",
|
||||||
|
cancelCredentialRefreshOnAbort,
|
||||||
|
);
|
||||||
|
credentialRefreshCoordinator.cancelSession(binding.sessionId);
|
||||||
|
unsubscribeCredentialRefresh();
|
||||||
activeRun.status = abortController.signal.aborted
|
activeRun.status = abortController.signal.aborted
|
||||||
? activeRun.status === "aborted"
|
? activeRun.status === "aborted"
|
||||||
? "aborted"
|
? "aborted"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,8 +2,13 @@ import { type Router } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||||
|
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
import {
|
||||||
|
getRuntimeSessionContext,
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
} from "../runtime/sessionContext.js";
|
||||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
@@ -26,8 +31,13 @@ const questionReplyPayloadSchema = z.object({
|
|||||||
answers: z.array(z.array(z.string().max(2000))).default([]),
|
answers: z.array(z.array(z.string().max(2000))).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const credentialRefreshPayloadSchema = z.object({
|
||||||
|
request_id: z.string().min(1).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
type RegisterInteractionRoutesOptions = {
|
type RegisterInteractionRoutesOptions = {
|
||||||
activeRuns: Map<string, ActiveRun>;
|
activeRuns: Map<string, ActiveRun>;
|
||||||
|
credentialRefreshCoordinator: CredentialRefreshCoordinator;
|
||||||
runtime: OpencodeRuntimeAdapter;
|
runtime: OpencodeRuntimeAdapter;
|
||||||
sessionMetadataStore: SessionMetadataStore;
|
sessionMetadataStore: SessionMetadataStore;
|
||||||
sessionUiStateStore: SessionUiStateStore;
|
sessionUiStateStore: SessionUiStateStore;
|
||||||
@@ -41,11 +51,73 @@ export const registerChatInteractionRoutes = (
|
|||||||
chatRouter: Router,
|
chatRouter: Router,
|
||||||
{
|
{
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator,
|
||||||
runtime,
|
runtime,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
}: RegisterInteractionRoutesOptions,
|
}: RegisterInteractionRoutesOptions,
|
||||||
) => {
|
) => {
|
||||||
|
chatRouter.post("/sessions/:session_id/credential-refreshes", async (req, res) => {
|
||||||
|
const parsed = credentialRefreshPayloadSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authContext = getAgentAuthContext(req);
|
||||||
|
const actorKey = toActorKey(authContext.userId);
|
||||||
|
const projectKey = toProjectKey(authContext.projectId);
|
||||||
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
projectId: authContext.projectId,
|
||||||
|
projectKey,
|
||||||
|
userId: authContext.userId,
|
||||||
|
},
|
||||||
|
req.params.session_id,
|
||||||
|
);
|
||||||
|
if (!sessionRecord) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getRuntimeSessionContext(sessionRecord.sessionId);
|
||||||
|
if (!current || current.actorKey !== actorKey || current.projectKey !== projectKey) {
|
||||||
|
res.status(409).json({ message: "runtime session context unavailable" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
credentialRefreshCoordinator.getPendingRequestId(sessionRecord.sessionId) !==
|
||||||
|
parsed.data.request_id
|
||||||
|
) {
|
||||||
|
res.status(409).json({ message: "credential refresh request is no longer pending" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refreshedContext = {
|
||||||
|
...current,
|
||||||
|
accessToken: authContext.accessToken,
|
||||||
|
authExpired: undefined,
|
||||||
|
network: authContext.network,
|
||||||
|
projectId: authContext.projectId,
|
||||||
|
tokenExpiresAt: authContext.tokenExpiresAt,
|
||||||
|
traceId: req.header("x-trace-id")?.trim() || current.traceId,
|
||||||
|
};
|
||||||
|
setRuntimeSessionContext(refreshedContext);
|
||||||
|
credentialRefreshCoordinator.resolve(
|
||||||
|
sessionRecord.sessionId,
|
||||||
|
parsed.data.request_id,
|
||||||
|
refreshedContext,
|
||||||
|
);
|
||||||
|
res.status(202).json({
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
request_id: parsed.data.request_id,
|
||||||
|
status: "accepted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
|
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
|
||||||
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||||
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
export type ApprovalMode = "request" | "auto" | "always";
|
||||||
|
|
||||||
|
export type PermissionApprovalContext = {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
patterns?: readonly string[];
|
||||||
|
workspaceRoot?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowRiskToolPermissions = new Set([
|
||||||
|
"apply_layer_style",
|
||||||
|
"geocode",
|
||||||
|
"locate_features",
|
||||||
|
"render_junctions",
|
||||||
|
"show_chart",
|
||||||
|
"view_history",
|
||||||
|
"view_scada",
|
||||||
|
"web_search",
|
||||||
|
"zoom_to_map",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lowRiskSearchRootNames = new Set([
|
||||||
|
".opencode",
|
||||||
|
"cli",
|
||||||
|
"contracts",
|
||||||
|
"node-tests",
|
||||||
|
"scripts",
|
||||||
|
"src",
|
||||||
|
"tests",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const canAutoApprovePermission = (
|
||||||
|
permission: string,
|
||||||
|
context: PermissionApprovalContext = {},
|
||||||
|
): boolean => {
|
||||||
|
const normalized = normalizePermission(permission);
|
||||||
|
if (normalized === "skill") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "glob" || normalized === "grep") {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("tjwater_")) {
|
||||||
|
return lowRiskToolPermissions.has(normalized.slice("tjwater_".length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolvePermissionApproval = (
|
||||||
|
approvalMode: ApprovalMode,
|
||||||
|
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 拒绝。",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
|
||||||
|
return {
|
||||||
|
autoApprove: true,
|
||||||
|
autoReject: false,
|
||||||
|
title: "已自动批准低风险权限",
|
||||||
|
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
): boolean => {
|
||||||
|
const workspaceRoot = context.workspaceRoot?.trim();
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPath =
|
||||||
|
typeof context.metadata?.path === "string" && context.metadata.path.trim()
|
||||||
|
? context.metadata.path
|
||||||
|
: workspaceRoot;
|
||||||
|
let root: string;
|
||||||
|
let searchRoot: string;
|
||||||
|
try {
|
||||||
|
root = realpathSync.native(resolve(workspaceRoot));
|
||||||
|
searchRoot = realpathSync.native(resolve(root, requestedPath));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let relativePath = relative(root, searchRoot);
|
||||||
|
if (
|
||||||
|
relativePath === ".." ||
|
||||||
|
relativePath.startsWith(`..${sep}`) ||
|
||||||
|
isAbsolute(relativePath)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressions: string[] = [];
|
||||||
|
if (permission === "glob" && typeof context.metadata?.pattern === "string") {
|
||||||
|
expressions.push(context.metadata.pattern);
|
||||||
|
}
|
||||||
|
if (permission === "glob") {
|
||||||
|
expressions.push(...(context.patterns ?? []));
|
||||||
|
}
|
||||||
|
if (typeof context.metadata?.include === "string") {
|
||||||
|
expressions.push(context.metadata.include);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expressions.some(
|
||||||
|
(expression) =>
|
||||||
|
isAbsolute(expression) ||
|
||||||
|
containsParentTraversal(expression) ||
|
||||||
|
containsAmbiguousGlobSyntax(expression) ||
|
||||||
|
containsProtectedPath(expression, isConversationWorkspace(root)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationWorkspace = isConversationWorkspace(root);
|
||||||
|
if (!relativePath && !conversationWorkspace) {
|
||||||
|
if (permission !== "glob") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const literalPrefix = getLiteralGlobPrefix(expressions[0]);
|
||||||
|
if (!literalPrefix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
searchRoot = realpathSync.native(resolve(root, literalPrefix));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
relativePath = relative(root, searchRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
relativePath !== ".." &&
|
||||||
|
!relativePath.startsWith(`..${sep}`) &&
|
||||||
|
!isAbsolute(relativePath) &&
|
||||||
|
!containsProtectedPath(relativePath, conversationWorkspace) &&
|
||||||
|
isSafeSearchTarget(searchRoot, relativePath, conversationWorkspace)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSafeSearchTarget = (
|
||||||
|
searchRoot: string,
|
||||||
|
relativePath: string,
|
||||||
|
conversationWorkspace: boolean,
|
||||||
|
): boolean => {
|
||||||
|
try {
|
||||||
|
const target = lstatSync(searchRoot);
|
||||||
|
if (target.isFile()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!target.isDirectory()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const topLevelName = relativePath.split(sep)[0];
|
||||||
|
if (
|
||||||
|
!conversationWorkspace &&
|
||||||
|
(!topLevelName || !lowRiskSearchRootNames.has(topLevelName))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = [searchRoot];
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const directory = pending.pop()!;
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (
|
||||||
|
entry.isSymbolicLink() ||
|
||||||
|
containsProtectedPath(entry.name, conversationWorkspace)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
pending.push(resolve(directory, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiteralGlobPrefix = (expression: string | undefined): string | null => {
|
||||||
|
const firstSegment = expression
|
||||||
|
?.replaceAll("\\", "/")
|
||||||
|
.replace(/^\.\//, "")
|
||||||
|
.split("/")[0];
|
||||||
|
return firstSegment && !/[*?[\]{}()!+@]/.test(firstSegment)
|
||||||
|
? firstSegment
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const containsParentTraversal = (value: string): boolean =>
|
||||||
|
value.replaceAll("\\", "/").split("/").includes("..");
|
||||||
|
|
||||||
|
const containsAmbiguousGlobSyntax = (value: string): boolean =>
|
||||||
|
/[?[\]{}()!+@\\]/.test(value);
|
||||||
|
|
||||||
|
const containsProtectedPath = (
|
||||||
|
value: string,
|
||||||
|
conversationWorkspace = false,
|
||||||
|
): boolean => {
|
||||||
|
const normalized = value.replaceAll("\\", "/").toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes(".env") ||
|
||||||
|
(!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;
|
||||||
|
}
|
||||||
|
};
|
||||||
+300
-316
@@ -8,16 +8,10 @@ import {
|
|||||||
type OpencodeRuntimeAdapter,
|
type OpencodeRuntimeAdapter,
|
||||||
} from "../runtime/opencode.js";
|
} from "../runtime/opencode.js";
|
||||||
import {
|
import {
|
||||||
buildPermissionDetail,
|
|
||||||
buildPermissionV2Detail,
|
|
||||||
buildReasoningProgressDetail,
|
|
||||||
buildSessionStatusDetail,
|
|
||||||
buildToolProgressDetail,
|
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
extractRequestReason,
|
|
||||||
extractSkillAuditInfo,
|
extractSkillAuditInfo,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getToolProgressTitle,
|
getAssistantMessagePhase,
|
||||||
getUnknownErrorMessage,
|
getUnknownErrorMessage,
|
||||||
hasToolParams,
|
hasToolParams,
|
||||||
isPermissionAskedEvent,
|
isPermissionAskedEvent,
|
||||||
@@ -33,6 +27,7 @@ import {
|
|||||||
isQuestionV2RepliedEvent,
|
isQuestionV2RepliedEvent,
|
||||||
isSessionEvent,
|
isSessionEvent,
|
||||||
isSkillEvent,
|
isSkillEvent,
|
||||||
|
initialActivity,
|
||||||
logDevelopmentDebug,
|
logDevelopmentDebug,
|
||||||
normalizeQuestionAnswers,
|
normalizeQuestionAnswers,
|
||||||
normalizeQuestionPayload,
|
normalizeQuestionPayload,
|
||||||
@@ -40,22 +35,32 @@ import {
|
|||||||
normalizeTodoPriority,
|
normalizeTodoPriority,
|
||||||
normalizeTodoStatus,
|
normalizeTodoStatus,
|
||||||
normalizeToolParams,
|
normalizeToolParams,
|
||||||
normalizeToolStatus,
|
type ActivityPayload,
|
||||||
|
type ActivityUpdatePayload,
|
||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
|
type AssistantMessagePhase,
|
||||||
type TodoItemPayload,
|
type TodoItemPayload,
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
} from "./chatStreamEvents.js";
|
} from "./chatStreamEvents.js";
|
||||||
|
import { createActivityTracker } from "./chatActivityTracker.js";
|
||||||
|
import {
|
||||||
|
resolvePermissionApproval,
|
||||||
|
type ApprovalMode,
|
||||||
|
} from "./chatPermissionPolicy.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
|
initialActivity,
|
||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
type TodoItemPayload,
|
type TodoItemPayload,
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
|
type ActivityPayload,
|
||||||
|
type ActivityUpdatePayload,
|
||||||
} from "./chatStreamEvents.js";
|
} from "./chatStreamEvents.js";
|
||||||
|
|
||||||
export type ApprovalMode = "request" | "always";
|
export type { ApprovalMode } from "./chatPermissionPolicy.js";
|
||||||
|
|
||||||
type StreamPromptOptions = {
|
type StreamPromptOptions = {
|
||||||
runtime: OpencodeRuntimeAdapter;
|
runtime: OpencodeRuntimeAdapter;
|
||||||
@@ -67,19 +72,10 @@ type StreamPromptOptions = {
|
|||||||
traceId?: string;
|
traceId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
workspaceRoot?: string;
|
||||||
write: (event: string, data: Record<string, unknown>) => void;
|
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) => {
|
const getPermissionTarget = (metadata: unknown) => {
|
||||||
if (!isObjectRecord(metadata)) {
|
if (!isObjectRecord(metadata)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -107,24 +103,100 @@ const toRuntimeModel = (model?: SupportedModel) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitFallbackMessage = 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,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
clientSessionId: string,
|
currentAssistantMessageIds: Set<string>,
|
||||||
write: (event: string, data: Record<string, unknown>) => void,
|
assistantTextParts: Map<string, Map<string, string>>,
|
||||||
|
assistantTextPartPhases: Map<string, AssistantMessagePhase>,
|
||||||
) => {
|
) => {
|
||||||
|
let text = [...currentAssistantMessageIds]
|
||||||
|
.reverse()
|
||||||
|
.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) {
|
||||||
const messages = await runtime.messages(sessionId);
|
const messages = await runtime.messages(sessionId);
|
||||||
const assistantMessage = [...messages]
|
const assistantMessage = [...messages]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.info.role === "assistant");
|
.find(
|
||||||
const parts = assistantMessage?.parts ?? [];
|
(message) =>
|
||||||
const text = collectTextContent(parts);
|
message.info.role === "assistant" &&
|
||||||
if (text) {
|
(currentAssistantMessageIds.size === 0 ||
|
||||||
write("token", {
|
currentAssistantMessageIds.has(message.info.id)),
|
||||||
session_id: clientSessionId,
|
);
|
||||||
content: text,
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ({
|
export const streamPromptResponse = async ({
|
||||||
@@ -137,35 +209,35 @@ export const streamPromptResponse = async ({
|
|||||||
traceId,
|
traceId,
|
||||||
projectId,
|
projectId,
|
||||||
signal,
|
signal,
|
||||||
|
workspaceRoot,
|
||||||
write,
|
write,
|
||||||
}: StreamPromptOptions): Promise<{
|
}: StreamPromptOptions): Promise<{
|
||||||
aborted: boolean;
|
aborted: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
toolCallCount: number;
|
toolCallCount: number;
|
||||||
}> => {
|
}> => {
|
||||||
const eventStream = await runtime.subscribeEvents();
|
const eventStream = await runtime.subscribeEvents(workspaceRoot);
|
||||||
const iterator = eventStream[Symbol.asyncIterator]();
|
const iterator = eventStream[Symbol.asyncIterator]();
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
const promptStartedAt = Date.now();
|
const promptStartedAt = Date.now();
|
||||||
const progressStartedAtMap = new Map<string, number>();
|
|
||||||
const finalizedProgressIds = new Set<string>();
|
|
||||||
const emittedToolParts = new Set<string>();
|
const emittedToolParts = new Set<string>();
|
||||||
|
const emittedActivityParts = new Set<string>();
|
||||||
const emittedQuestionToolParts = new Set<string>();
|
const emittedQuestionToolParts = new Set<string>();
|
||||||
const emittedQuestionRequestIds = 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 partTypes = new Map<string, Part["type"]>();
|
||||||
const pendingPartTextDeltas = new Map<string, string[]>();
|
const pendingTextDeltas = new Map<string, string[]>();
|
||||||
const reasoningDeltas = new Map<string, string[]>();
|
|
||||||
const reasoningStatuses = new Map<string, "running" | "completed">();
|
const reasoningStatuses = new Map<string, "running" | "completed">();
|
||||||
const toolStatuses = new Map<string, string>();
|
const toolStatuses = new Map<string, string>();
|
||||||
let firstSessionEventLogged = false;
|
let firstSessionEventLogged = false;
|
||||||
let firstNonStatusEventLogged = false;
|
let firstNonStatusEventLogged = false;
|
||||||
let firstTokenLogged = false;
|
|
||||||
let firstReasoningLogged = false;
|
|
||||||
let firstToolEventLogged = false;
|
let firstToolEventLogged = false;
|
||||||
let lastSessionStatus: string | null = null;
|
let lastSessionStatus: string | null = null;
|
||||||
let lastSessionStatusMessage: string | null = null;
|
let lastSessionStatusMessage: string | null = null;
|
||||||
let sawResponseActivity = false;
|
let sawResponseActivity = false;
|
||||||
let emittedText = false;
|
let finalAnswerText = "";
|
||||||
let toolCallCount = 0;
|
let toolCallCount = 0;
|
||||||
let done = false;
|
let done = false;
|
||||||
let promptSettled = false;
|
let promptSettled = false;
|
||||||
@@ -196,57 +268,25 @@ export const streamPromptResponse = async ({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
const activityTracker = createActivityTracker({ clientSessionId, write });
|
||||||
if (status === "running" && finalizedProgressIds.has(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const captureFinalText = (text: string) => {
|
||||||
const startedAt = progressStartedAtMap.get(id) ?? now;
|
const answer = text.trim();
|
||||||
if (!progressStartedAtMap.has(id)) {
|
if (answer) finalAnswerText = answer;
|
||||||
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,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
emitProgress({
|
activityTracker.start(
|
||||||
id: "request-received",
|
initialActivity.id,
|
||||||
phase: "start",
|
initialActivity.title,
|
||||||
status: "running",
|
initialActivity.reason,
|
||||||
title: "已收到请求,正在启动 Agent 分析",
|
);
|
||||||
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = runtime
|
const promptPromise = runtime
|
||||||
.prompt(sessionId, message, toRuntimeModel(model))
|
.prompt(
|
||||||
|
sessionId,
|
||||||
|
message,
|
||||||
|
toRuntimeModel(model),
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
promptSettled = true;
|
promptSettled = true;
|
||||||
logDevelopmentDebug("runtime.prompt resolved", {
|
logDevelopmentDebug("runtime.prompt resolved", {
|
||||||
@@ -268,6 +308,8 @@ export const streamPromptResponse = async ({
|
|||||||
...debugContext,
|
...debugContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let pendingIteratorNext: ReturnType<typeof iterator.next> | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!done) {
|
while (!done) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -279,8 +321,8 @@ export const streamPromptResponse = async ({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEvent = iterator
|
pendingIteratorNext ??= iterator.next();
|
||||||
.next()
|
const nextEvent = pendingIteratorNext
|
||||||
.then((result) => ({ type: "event" as const, result }));
|
.then((result) => ({ type: "event" as const, result }));
|
||||||
const nextPrompt = promptSettled
|
const nextPrompt = promptSettled
|
||||||
? null
|
? null
|
||||||
@@ -306,6 +348,7 @@ export const streamPromptResponse = async ({
|
|||||||
if (next.type === "prompt") {
|
if (next.type === "prompt") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
pendingIteratorNext = undefined;
|
||||||
if (next.result.done) {
|
if (next.result.done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -345,18 +388,6 @@ export const streamPromptResponse = async ({
|
|||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +403,15 @@ export const streamPromptResponse = async ({
|
|||||||
|
|
||||||
if (isPermissionAskedEvent(event)) {
|
if (isPermissionAskedEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
const permissionApproval = resolvePermissionApproval(
|
||||||
|
approvalMode,
|
||||||
|
event.properties.permission,
|
||||||
|
{
|
||||||
|
metadata: event.properties.metadata,
|
||||||
|
patterns: event.properties.patterns,
|
||||||
|
workspaceRoot,
|
||||||
|
},
|
||||||
|
);
|
||||||
logDevelopmentDebug("permission request received", {
|
logDevelopmentDebug("permission request received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
@@ -379,35 +419,30 @@ export const streamPromptResponse = async ({
|
|||||||
patterns: event.properties.patterns,
|
patterns: event.properties.patterns,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
emitProgress({
|
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
|
||||||
id: `permission-${event.properties.id}`,
|
const reply = permissionApproval.autoReject ? "reject" : "once";
|
||||||
phase: "permission",
|
|
||||||
status: approvalMode === "always" ? "completed" : "running",
|
|
||||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
|
||||||
detail:
|
|
||||||
approvalMode === "always"
|
|
||||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
||||||
: buildPermissionDetail(event),
|
|
||||||
});
|
|
||||||
if (approvalMode === "always") {
|
|
||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
reply: "always",
|
directory: workspaceRoot,
|
||||||
|
reply,
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
reply: "always" satisfies PermissionReply,
|
reply: reply satisfies PermissionReply,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const activity = activityTracker.getCurrentContext();
|
||||||
write("permission_request", {
|
write("permission_request", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
permission: event.properties.permission,
|
permission: event.properties.permission,
|
||||||
patterns: event.properties.patterns,
|
patterns: event.properties.patterns,
|
||||||
target: getPermissionTarget(event.properties.metadata),
|
target: getPermissionTarget(event.properties.metadata),
|
||||||
|
activity_id: activity?.id,
|
||||||
|
reason: activity?.reason,
|
||||||
always: event.properties.always,
|
always: event.properties.always,
|
||||||
tool: event.properties.tool,
|
tool: event.properties.tool,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
@@ -417,6 +452,15 @@ export const streamPromptResponse = async ({
|
|||||||
|
|
||||||
if (isPermissionV2AskedEvent(event)) {
|
if (isPermissionV2AskedEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
const permissionApproval = resolvePermissionApproval(
|
||||||
|
approvalMode,
|
||||||
|
event.properties.action,
|
||||||
|
{
|
||||||
|
metadata: event.properties.metadata,
|
||||||
|
patterns: event.properties.resources,
|
||||||
|
workspaceRoot,
|
||||||
|
},
|
||||||
|
);
|
||||||
logDevelopmentDebug("permission v2 request received", {
|
logDevelopmentDebug("permission v2 request received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
@@ -424,35 +468,30 @@ export const streamPromptResponse = async ({
|
|||||||
resources: event.properties.resources,
|
resources: event.properties.resources,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
emitProgress({
|
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
|
||||||
id: `permission-${event.properties.id}`,
|
const reply = permissionApproval.autoReject ? "reject" : "once";
|
||||||
phase: "permission",
|
|
||||||
status: approvalMode === "always" ? "completed" : "running",
|
|
||||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
|
||||||
detail:
|
|
||||||
approvalMode === "always"
|
|
||||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
||||||
: buildPermissionV2Detail(event),
|
|
||||||
});
|
|
||||||
if (approvalMode === "always") {
|
|
||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
reply: "always",
|
directory: workspaceRoot,
|
||||||
|
reply,
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
reply: "always" satisfies PermissionReply,
|
reply: reply satisfies PermissionReply,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const activity = activityTracker.getCurrentContext();
|
||||||
write("permission_request", {
|
write("permission_request", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
permission: event.properties.action,
|
permission: event.properties.action,
|
||||||
patterns: event.properties.resources,
|
patterns: event.properties.resources,
|
||||||
target: getPermissionTarget(event.properties.metadata),
|
target: getPermissionTarget(event.properties.metadata),
|
||||||
|
activity_id: activity?.id,
|
||||||
|
reason: activity?.reason,
|
||||||
always: event.properties.save ?? [],
|
always: event.properties.save ?? [],
|
||||||
tool: undefined,
|
tool: undefined,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
@@ -468,21 +507,6 @@ export const streamPromptResponse = async ({
|
|||||||
reply: event.properties.reply,
|
reply: event.properties.reply,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.requestID,
|
request_id: event.properties.requestID,
|
||||||
@@ -499,21 +523,6 @@ export const streamPromptResponse = async ({
|
|||||||
reply: event.properties.reply,
|
reply: event.properties.reply,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.requestID,
|
request_id: event.properties.requestID,
|
||||||
@@ -530,15 +539,6 @@ export const streamPromptResponse = async ({
|
|||||||
questionCount: event.properties.questions.length,
|
questionCount: event.properties.questions.length,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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);
|
const payload = normalizeQuestionPayload(event, clientSessionId);
|
||||||
emittedQuestionRequestIds.add(payload.request_id);
|
emittedQuestionRequestIds.add(payload.request_id);
|
||||||
write("question_request", payload);
|
write("question_request", payload);
|
||||||
@@ -552,16 +552,6 @@ export const streamPromptResponse = async ({
|
|||||||
requestId: event.properties.requestID,
|
requestId: event.properties.requestID,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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", {
|
write("question_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.requestID,
|
request_id: event.properties.requestID,
|
||||||
@@ -577,13 +567,6 @@ export const streamPromptResponse = async ({
|
|||||||
requestId: event.properties.requestID,
|
requestId: event.properties.requestID,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
emitProgress({
|
|
||||||
id: `question-${event.properties.requestID}`,
|
|
||||||
phase: "question",
|
|
||||||
status: "completed",
|
|
||||||
title: "已跳过补充信息",
|
|
||||||
detail: "用户选择跳过本次补充信息。",
|
|
||||||
});
|
|
||||||
write("question_response", {
|
write("question_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.requestID,
|
request_id: event.properties.requestID,
|
||||||
@@ -594,11 +577,11 @@ export const streamPromptResponse = async ({
|
|||||||
|
|
||||||
if (isSkillEvent(event)) {
|
if (isSkillEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
const { name, payload } = extractSkillAuditInfo(event);
|
||||||
|
const activity = activityTracker.getCurrentContext();
|
||||||
logDevelopmentDebug("skill event received", {
|
logDevelopmentDebug("skill event received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
skill: name,
|
skill: name,
|
||||||
reason: reason || null,
|
|
||||||
payloadKeys: Object.keys(payload).slice(0, 8),
|
payloadKeys: Object.keys(payload).slice(0, 8),
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
@@ -609,8 +592,9 @@ export const streamPromptResponse = async ({
|
|||||||
traceId,
|
traceId,
|
||||||
projectId,
|
projectId,
|
||||||
target: name,
|
target: name,
|
||||||
reason,
|
activityId: activity?.id,
|
||||||
reasonProvided: Boolean(reason),
|
activityTitle: activity?.title,
|
||||||
|
activityReason: activity?.reason,
|
||||||
payload,
|
payload,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.warn({ err: error }, "failed to write skill audit log");
|
logger.warn({ err: error }, "failed to write skill audit log");
|
||||||
@@ -620,45 +604,28 @@ export const streamPromptResponse = async ({
|
|||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
if (event.properties.info.role === "assistant") {
|
if (event.properties.info.role === "assistant") {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
currentAssistantMessageIds.add(event.properties.info.id);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
currentAssistantMessageIds.add(event.properties.messageID);
|
||||||
const partType = partTypes.get(event.properties.partID);
|
const partType = partTypes.get(event.properties.partID);
|
||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
if (!firstTokenLogged) {
|
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
|
||||||
firstTokenLogged = true;
|
const text = `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`;
|
||||||
logDevelopmentDebug("first response token emitted", {
|
messageParts.set(event.properties.partID, text);
|
||||||
...debugContext,
|
assistantTextParts.set(event.properties.messageID, messageParts);
|
||||||
partId: event.properties.partID,
|
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
if (phase === "final_answer") {
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
captureFinalText(text);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
emittedText = true;
|
|
||||||
write("token", {
|
|
||||||
session_id: clientSessionId,
|
|
||||||
content: event.properties.delta,
|
|
||||||
});
|
|
||||||
} else if (partType === "reasoning") {
|
|
||||||
if (!firstReasoningLogged) {
|
|
||||||
firstReasoningLogged = true;
|
|
||||||
logDevelopmentDebug("first reasoning delta received", {
|
|
||||||
...debugContext,
|
|
||||||
partId: event.properties.partID,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
||||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
|
|
||||||
pending.push(event.properties.delta);
|
|
||||||
reasoningDeltas.set(event.properties.partID, pending);
|
|
||||||
} else if (!partType) {
|
} else if (!partType) {
|
||||||
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
|
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
|
||||||
pending.push(event.properties.delta);
|
pending.push(event.properties.delta);
|
||||||
pendingPartTextDeltas.set(event.properties.partID, pending);
|
pendingTextDeltas.set(event.properties.partID, pending);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -667,23 +634,25 @@ export const streamPromptResponse = async ({
|
|||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
const part = event.properties.part;
|
const part = event.properties.part;
|
||||||
partTypes.set(part.id, part.type);
|
partTypes.set(part.id, part.type);
|
||||||
|
if (part.type === "text" || part.type === "reasoning" || part.type === "tool") {
|
||||||
|
currentAssistantMessageIds.add(part.messageID);
|
||||||
|
}
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
const phase = getAssistantMessagePhase(part.metadata);
|
||||||
pendingPartTextDeltas.delete(part.id);
|
assistantTextPartPhases.set(part.id, phase);
|
||||||
for (const content of pending) {
|
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
|
||||||
emittedText = true;
|
pendingTextDeltas.delete(part.id);
|
||||||
write("token", {
|
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
|
||||||
session_id: clientSessionId,
|
const text = part.text || pendingText;
|
||||||
content,
|
messageParts.set(part.id, text);
|
||||||
});
|
assistantTextParts.set(part.messageID, messageParts);
|
||||||
|
if (phase === "final_answer") {
|
||||||
|
captureFinalText(text);
|
||||||
}
|
}
|
||||||
} else if (part.type === "reasoning") {
|
} else {
|
||||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
pendingTextDeltas.delete(part.id);
|
||||||
if (pending.length > 0) {
|
|
||||||
const existing = reasoningDeltas.get(part.id) ?? [];
|
|
||||||
reasoningDeltas.set(part.id, existing.concat(pending));
|
|
||||||
}
|
}
|
||||||
pendingPartTextDeltas.delete(part.id);
|
if (part.type === "reasoning") {
|
||||||
const reasoningStatus = part.time.end ? "completed" : "running";
|
const reasoningStatus = part.time.end ? "completed" : "running";
|
||||||
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
||||||
reasoningStatuses.set(part.id, reasoningStatus);
|
reasoningStatuses.set(part.id, reasoningStatus);
|
||||||
@@ -691,21 +660,9 @@ export const streamPromptResponse = async ({
|
|||||||
...debugContext,
|
...debugContext,
|
||||||
partId: part.id,
|
partId: part.id,
|
||||||
status: reasoningStatus,
|
status: reasoningStatus,
|
||||||
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const reasoningDetail = buildReasoningProgressDetail(
|
|
||||||
reasoningDeltas.get(part.id) ?? [],
|
|
||||||
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 (part.type === "tool") {
|
||||||
if (!firstToolEventLogged) {
|
if (!firstToolEventLogged) {
|
||||||
@@ -720,7 +677,6 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const toolParams = normalizeToolParams(part.state.input);
|
const toolParams = normalizeToolParams(part.state.input);
|
||||||
const reason = extractRequestReason(toolParams);
|
|
||||||
const isToolFinalState =
|
const isToolFinalState =
|
||||||
part.state.status === "completed" || part.state.status === "error";
|
part.state.status === "completed" || part.state.status === "error";
|
||||||
const nextToolStatus = String(part.state.status);
|
const nextToolStatus = String(part.state.status);
|
||||||
@@ -732,7 +688,6 @@ export const streamPromptResponse = async ({
|
|||||||
partId: part.id,
|
partId: part.id,
|
||||||
tool: part.tool,
|
tool: part.tool,
|
||||||
status: nextToolStatus,
|
status: nextToolStatus,
|
||||||
reason: reason || null,
|
|
||||||
inputKeys: Object.keys(toolParams).slice(0, 8),
|
inputKeys: Object.keys(toolParams).slice(0, 8),
|
||||||
error:
|
error:
|
||||||
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
||||||
@@ -740,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(
|
const questionToolPayload = normalizeQuestionToolPayload(
|
||||||
part,
|
part,
|
||||||
toolParams,
|
toolParams,
|
||||||
@@ -756,49 +785,23 @@ export const streamPromptResponse = async ({
|
|||||||
questionCount: questionToolPayload.questions.length,
|
questionCount: questionToolPayload.questions.length,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
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);
|
write("question_request", questionToolPayload);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitProgress({
|
if (part.tool === "todowrite" || part.tool === "todo") {
|
||||||
id: part.id,
|
continue;
|
||||||
phase: "tool",
|
}
|
||||||
status: normalizeToolStatus(part.state.status),
|
|
||||||
title: getToolProgressTitle(part.tool, part.state.status),
|
activityTracker.upsertAction(part, toolParams);
|
||||||
detail: buildToolProgressDetail(
|
|
||||||
part.tool,
|
|
||||||
part.state.status,
|
|
||||||
toolParams,
|
|
||||||
reason,
|
|
||||||
part.state.status === "error" ? part.state.error : undefined,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
!emittedToolParts.has(part.id) &&
|
!emittedToolParts.has(part.id) &&
|
||||||
(hasToolParams(toolParams) || isToolFinalState)
|
(hasToolParams(toolParams) || isToolFinalState)
|
||||||
) {
|
) {
|
||||||
emittedToolParts.add(part.id);
|
emittedToolParts.add(part.id);
|
||||||
toolCallCount += 1;
|
toolCallCount += 1;
|
||||||
if (!reason) {
|
const activity = activityTracker.getActionContext(part.id);
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
tool: part.tool,
|
|
||||||
sessionId: sessionId,
|
|
||||||
clientSessionId,
|
|
||||||
},
|
|
||||||
"llm tool request missing reason",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
void writeLlmRequestAuditLog({
|
void writeLlmRequestAuditLog({
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
@@ -806,8 +809,9 @@ export const streamPromptResponse = async ({
|
|||||||
traceId,
|
traceId,
|
||||||
projectId,
|
projectId,
|
||||||
target: part.tool,
|
target: part.tool,
|
||||||
reason,
|
activityId: activity?.id,
|
||||||
reasonProvided: Boolean(reason),
|
activityTitle: activity?.title,
|
||||||
|
activityReason: activity?.reason,
|
||||||
payload: toolParams,
|
payload: toolParams,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.warn({ err: error }, "failed to write tool audit log");
|
logger.warn({ err: error }, "failed to write tool audit log");
|
||||||
@@ -816,7 +820,6 @@ export const streamPromptResponse = async ({
|
|||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
tool: part.tool,
|
tool: part.tool,
|
||||||
params: toolParams,
|
params: toolParams,
|
||||||
reason,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -837,18 +840,6 @@ export const streamPromptResponse = async ({
|
|||||||
priority: normalizeTodoPriority(todo.priority),
|
priority: normalizeTodoPriority(todo.priority),
|
||||||
updated_at: Date.now(),
|
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", {
|
write("todo_update", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
todos: normalizedTodos,
|
todos: normalizedTodos,
|
||||||
@@ -866,6 +857,7 @@ export const streamPromptResponse = async ({
|
|||||||
? getErrorMessage(event.properties.error)
|
? getErrorMessage(event.properties.error)
|
||||||
: "opencode session error",
|
: "opencode session error",
|
||||||
});
|
});
|
||||||
|
activityTracker.finalize("error");
|
||||||
write("error", {
|
write("error", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
message: event.properties.error
|
message: event.properties.error
|
||||||
@@ -889,17 +881,10 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
logDevelopmentDebug("session idle received", {
|
logDevelopmentDebug("session idle received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
emittedText,
|
hasFinalAnswer: Boolean(finalAnswerText),
|
||||||
toolCallCount,
|
toolCallCount,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
emitProgress({
|
|
||||||
id: "session-status",
|
|
||||||
phase: "session",
|
|
||||||
status: "completed",
|
|
||||||
title: "Agent 已完成处理",
|
|
||||||
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
|
|
||||||
});
|
|
||||||
done = true;
|
done = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,6 +894,7 @@ export const streamPromptResponse = async ({
|
|||||||
...debugContext,
|
...debugContext,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
|
activityTracker.finalize("cancelled");
|
||||||
await runtime.abortSession(sessionId).catch((error) => {
|
await runtime.abortSession(sessionId).catch((error) => {
|
||||||
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
|
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
|
||||||
});
|
});
|
||||||
@@ -926,36 +912,34 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
if (!emittedText) {
|
if (!finalAnswerText) {
|
||||||
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
finalAnswerText = await resolveFinalMessage(
|
||||||
...debugContext,
|
runtime,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
sessionId,
|
||||||
});
|
currentAssistantMessageIds,
|
||||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
|
assistantTextParts,
|
||||||
|
assistantTextPartPhases,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
emitProgress({
|
activityTracker.finalize("completed");
|
||||||
id: "request-received",
|
if (finalAnswerText) {
|
||||||
phase: "start",
|
// Keep one compatibility cycle for deployed frontends that only consume token.
|
||||||
status: "completed",
|
write("token", {
|
||||||
title: "请求处理完成",
|
session_id: clientSessionId,
|
||||||
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
content: finalAnswerText,
|
||||||
});
|
});
|
||||||
emitProgress({
|
write("final_answer", {
|
||||||
id: "request-completed",
|
session_id: clientSessionId,
|
||||||
phase: "complete",
|
content: finalAnswerText,
|
||||||
status: "completed",
|
|
||||||
title: "分析完成",
|
|
||||||
detail: emittedText
|
|
||||||
? "最终回答已生成并推送到前端。"
|
|
||||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
write("done", {
|
write("done", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
logDevelopmentDebug("chat stream completed", {
|
logDevelopmentDebug("chat stream completed", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
emittedText,
|
hasFinalAnswer: Boolean(finalAnswerText),
|
||||||
toolCallCount,
|
toolCallCount,
|
||||||
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-136
@@ -9,6 +9,8 @@ export type PermissionRequestPayload = {
|
|||||||
permission: string;
|
permission: string;
|
||||||
patterns: string[];
|
patterns: string[];
|
||||||
target?: string;
|
target?: string;
|
||||||
|
activity_id?: string;
|
||||||
|
reason?: string;
|
||||||
always: string[];
|
always: string[];
|
||||||
tool?: {
|
tool?: {
|
||||||
messageID: string;
|
messageID: string;
|
||||||
@@ -17,6 +19,46 @@ export type PermissionRequestPayload = {
|
|||||||
created_at: number;
|
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 = {
|
type QuestionOptionPayload = {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -57,9 +99,17 @@ export type TodoUpdatePayload = {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssistantMessagePhase =
|
||||||
|
| "commentary"
|
||||||
|
| "final_answer"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
const toolLabels: Record<string, string> = {
|
||||||
|
tjwater_cli: "查询后端数据",
|
||||||
|
bash: "运行本地分析",
|
||||||
|
store_render_ref: "保存渲染结果",
|
||||||
memory_manager: "记忆写入",
|
memory_manager: "记忆写入",
|
||||||
geocode: "地理编码",
|
geocode: "地理编码",
|
||||||
session_search: "历史会话检索",
|
session_search: "历史会话检索",
|
||||||
@@ -71,6 +121,7 @@ const toolLabels: Record<string, string> = {
|
|||||||
view_scada: "SCADA 面板",
|
view_scada: "SCADA 面板",
|
||||||
show_chart: "图表渲染",
|
show_chart: "图表渲染",
|
||||||
render_junctions: "节点渲染",
|
render_junctions: "节点渲染",
|
||||||
|
apply_layer_style: "图层样式调整",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logDevelopmentDebug = (
|
export const logDevelopmentDebug = (
|
||||||
@@ -110,6 +161,19 @@ export const getUnknownErrorMessage = (error: unknown) => {
|
|||||||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
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> => {
|
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||||
if (isObjectRecord(value)) {
|
if (isObjectRecord(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -125,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
|
|||||||
return {};
|
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) =>
|
export const isSkillEvent = (event: OpencodeEvent) =>
|
||||||
event.type.toLowerCase().includes("skill");
|
event.type.toLowerCase().includes("skill");
|
||||||
|
|
||||||
@@ -154,10 +204,8 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
|||||||
: typeof payload.name === "string"
|
: typeof payload.name === "string"
|
||||||
? payload.name
|
? payload.name
|
||||||
: event.type;
|
: event.type;
|
||||||
const reason = extractRequestReason(payload);
|
|
||||||
return {
|
return {
|
||||||
name: candidateName,
|
name: candidateName,
|
||||||
reason,
|
|
||||||
payload,
|
payload,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -222,24 +270,6 @@ export const isQuestionV2RejectedEvent = (
|
|||||||
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
|
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
|
||||||
event.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 = (
|
export const normalizeQuestionPayload = (
|
||||||
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
|
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
|
||||||
clientSessionId: string,
|
clientSessionId: string,
|
||||||
@@ -356,105 +386,4 @@ export const normalizeToolStatus = (status: string) => {
|
|||||||
return "running";
|
return "running";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatProgressValue = (value: unknown): string => {
|
export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
|
||||||
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 normalizeProgressText = (chunks: string[]) =>
|
|
||||||
chunks.join("").replace(/\s+/g, " ").trim();
|
|
||||||
|
|
||||||
const truncateProgressText = (text: string, maxLength: number) =>
|
|
||||||
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
|
||||||
|
|
||||||
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 = (
|
|
||||||
chunks: string[],
|
|
||||||
ended?: string | number | Date | null,
|
|
||||||
) => {
|
|
||||||
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
|
||||||
if (ended) {
|
|
||||||
return reasoningText
|
|
||||||
? `推理过程:${reasoningText}`
|
|
||||||
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
|
||||||
}
|
|
||||||
return reasoningText
|
|
||||||
? `正在推理:${reasoningText}`
|
|
||||||
: "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}`;
|
|
||||||
};
|
|
||||||
|
|||||||
+119
-9
@@ -1,5 +1,7 @@
|
|||||||
import { type PermissionReply } from "../runtime/opencode.js";
|
import { type PermissionReply } from "../runtime/opencode.js";
|
||||||
import {
|
import {
|
||||||
|
type ActivityPayload,
|
||||||
|
initialActivity,
|
||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
@@ -28,6 +30,7 @@ type ToolCallPayload = {
|
|||||||
session_id?: string;
|
session_id?: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
params?: unknown;
|
params?: unknown;
|
||||||
|
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,13 +59,13 @@ export const createInitialStreamingMessages = (
|
|||||||
id: createFrontendMessageId(),
|
id: createFrontendMessageId(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
progress: [
|
activities: [
|
||||||
{
|
{
|
||||||
id: "request-received",
|
id: initialActivity.id,
|
||||||
phase: "start",
|
|
||||||
status: "running",
|
status: "running",
|
||||||
title: "已收到请求,正在启动 Agent 分析",
|
title: initialActivity.title,
|
||||||
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
reason: initialActivity.reason,
|
||||||
|
actions: [],
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
elapsedSnapshotAt: Date.now(),
|
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 = (
|
export const upsertBackendProgress = (
|
||||||
progress: unknown,
|
progress: unknown,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
@@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) =>
|
|||||||
})
|
})
|
||||||
: todos;
|
: 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 = (
|
export const updateLastAssistantMessage = (
|
||||||
messages: unknown[],
|
messages: unknown[],
|
||||||
updater: (message: Record<string, unknown>) => Record<string, unknown>,
|
updater: (message: Record<string, unknown>) => Record<string, unknown>,
|
||||||
@@ -249,10 +361,6 @@ export const appendBackendToolArtifact = (
|
|||||||
tool,
|
tool,
|
||||||
kind: getToolArtifactKind(tool),
|
kind: getToolArtifactKind(tool),
|
||||||
title: getToolArtifactTitle(tool, params),
|
title: getToolArtifactTitle(tool, params),
|
||||||
description:
|
|
||||||
typeof payload.reason === "string" && payload.reason.trim()
|
|
||||||
? payload.reason.trim()
|
|
||||||
: undefined,
|
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -267,6 +375,8 @@ export const toFrontendPermission = (
|
|||||||
permission: payload.permission,
|
permission: payload.permission,
|
||||||
patterns: payload.patterns,
|
patterns: payload.patterns,
|
||||||
target: payload.target,
|
target: payload.target,
|
||||||
|
activityId: payload.activity_id,
|
||||||
|
reason: payload.reason,
|
||||||
always: payload.always,
|
always: payload.always,
|
||||||
tool: payload.tool,
|
tool: payload.tool,
|
||||||
createdAt: payload.created_at,
|
createdAt: payload.created_at,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+188
-24
@@ -1,13 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
createOpencode,
|
createOpencode,
|
||||||
createOpencodeClient,
|
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { chmod, mkdir, rmdir } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
|
import { ensureDirectory } from "../utils/fileStore.js";
|
||||||
|
import { setSandboxOwnership } from "./conversationWorkspace.js";
|
||||||
|
import {
|
||||||
|
cleanupExpiredToolOutputs,
|
||||||
|
resolveOpencodeToolOutputDirectory,
|
||||||
|
} from "./opencodeToolOutputCleanup.js";
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
@@ -47,6 +54,7 @@ const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
|
|||||||
export class OpencodeRuntimeAdapter {
|
export class OpencodeRuntimeAdapter {
|
||||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||||
private closeServer: (() => void) | null = null;
|
private closeServer: (() => void) | null = null;
|
||||||
|
private toolOutputCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async ensureClient(): Promise<OpencodeClient> {
|
async ensureClient(): Promise<OpencodeClient> {
|
||||||
if (!this.clientPromise) {
|
if (!this.clientPromise) {
|
||||||
@@ -64,12 +72,105 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "global.health");
|
return requireData(response.data, "global.health");
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(title?: string) {
|
async warmup(): Promise<void> {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const healthStartedAt = Date.now();
|
||||||
|
const healthResponse = await client.global.health();
|
||||||
|
const health = requireData(healthResponse.data, "global.health");
|
||||||
|
logDevelopmentDebug("opencode warmup health check completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
|
||||||
|
healthy: health.healthy,
|
||||||
|
version: health.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStartedAt = Date.now();
|
||||||
|
const sessionResponse = await client.session.create({
|
||||||
|
title: "tjwater-agent-warmup",
|
||||||
|
});
|
||||||
|
const session = requireData(sessionResponse.data, "session.create");
|
||||||
|
logDevelopmentDebug("opencode warmup session created", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [provider, model] = config.OPENCODE_MODEL.split("/");
|
||||||
|
if (!provider || !model) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const toolsStartedAt = Date.now();
|
||||||
|
const toolsResponse = await client.tool.list({ provider, model });
|
||||||
|
const tools = requireData(toolsResponse.data, "tool.list");
|
||||||
|
logDevelopmentDebug("opencode warmup tools loaded", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
|
||||||
|
model: config.OPENCODE_MODEL,
|
||||||
|
sessionId: session.id,
|
||||||
|
toolCount: tools.length,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const cleanupStartedAt = Date.now();
|
||||||
|
let cleanupSucceeded = true;
|
||||||
|
await client.session.delete(
|
||||||
|
{ sessionID: session.id },
|
||||||
|
{ throwOnError: true },
|
||||||
|
).catch((error) => {
|
||||||
|
cleanupSucceeded = false;
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId: session.id },
|
||||||
|
"failed to remove opencode warmup session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logDevelopmentDebug("opencode warmup session cleanup completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
succeeded: cleanupSucceeded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
const response = await client.session.create({
|
||||||
|
directory,
|
||||||
title,
|
title,
|
||||||
|
permission: [
|
||||||
|
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
|
||||||
|
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
return requireData(response.data, "session.create");
|
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) {
|
async sendPrompt(sessionId: string, text: string) {
|
||||||
@@ -79,7 +180,11 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return this.messages(sessionId);
|
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 client = await this.ensureClient();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logDevelopmentDebug(
|
logDevelopmentDebug(
|
||||||
@@ -198,35 +303,41 @@ export class OpencodeRuntimeAdapter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribeEvents() {
|
async subscribeEvents(directory?: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.event.subscribe();
|
const response = await client.event.subscribe(
|
||||||
|
directory ? { directory } : undefined,
|
||||||
|
);
|
||||||
return response.stream;
|
return response.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
async replyPermission(options: {
|
async replyPermission(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
reply: PermissionReply;
|
reply: PermissionReply;
|
||||||
message?: string;
|
message?: string;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("permission" in client && client.permission?.reply) {
|
if ("permission" in client && client.permission?.reply) {
|
||||||
const response = await client.permission.reply({
|
const response = await client.permission.reply({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
reply: options.reply,
|
reply: options.reply,
|
||||||
message: options.message,
|
message: options.message,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "permission.reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||||||
const response = await client.permission.respond({
|
const response = await client.permission.respond({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
permissionID: options.requestId,
|
permissionID: options.requestId,
|
||||||
|
directory,
|
||||||
response: options.reply,
|
response: options.reply,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "permission.respond");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode permission reply API is unavailable");
|
throw new Error("opencode permission reply API is unavailable");
|
||||||
@@ -235,16 +346,19 @@ export class OpencodeRuntimeAdapter {
|
|||||||
async replyQuestion(options: {
|
async replyQuestion(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
answers: QuestionAnswers;
|
answers: QuestionAnswers;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("question" in client && client.question?.reply) {
|
if ("question" in client && client.question?.reply) {
|
||||||
try {
|
try {
|
||||||
const response = await client.question.reply({
|
const response = await client.question.reply({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
answers: options.answers,
|
answers: options.answers,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.reply");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.sessionId) {
|
if (!options.sessionId) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -259,6 +373,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
reply?: (parameters: {
|
reply?: (parameters: {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
requestID: string;
|
requestID: string;
|
||||||
|
directory?: string;
|
||||||
questionV2Reply: { answers: QuestionAnswers };
|
questionV2Reply: { answers: QuestionAnswers };
|
||||||
}) => Promise<{ data: unknown }>;
|
}) => Promise<{ data: unknown }>;
|
||||||
};
|
};
|
||||||
@@ -270,11 +385,12 @@ export class OpencodeRuntimeAdapter {
|
|||||||
const response = await v2Question.reply({
|
const response = await v2Question.reply({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
questionV2Reply: {
|
questionV2Reply: {
|
||||||
answers: options.answers,
|
answers: options.answers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.v2.reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode question reply API is unavailable");
|
throw new Error("opencode question reply API is unavailable");
|
||||||
@@ -283,14 +399,17 @@ export class OpencodeRuntimeAdapter {
|
|||||||
async rejectQuestion(options: {
|
async rejectQuestion(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("question" in client && client.question?.reject) {
|
if ("question" in client && client.question?.reject) {
|
||||||
try {
|
try {
|
||||||
const response = await client.question.reject({
|
const response = await client.question.reject({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.reject");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.sessionId) {
|
if (!options.sessionId) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -305,6 +424,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
reject?: (parameters: {
|
reject?: (parameters: {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
requestID: string;
|
requestID: string;
|
||||||
|
directory?: string;
|
||||||
}) => Promise<{ data: unknown }>;
|
}) => Promise<{ data: unknown }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -315,32 +435,41 @@ export class OpencodeRuntimeAdapter {
|
|||||||
const response = await v2Question.reject({
|
const response = await v2Question.reject({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.v2.reject");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode question reject API is unavailable");
|
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> {
|
async dispose(): Promise<void> {
|
||||||
|
if (this.toolOutputCleanupTimer) {
|
||||||
|
clearInterval(this.toolOutputCleanupTimer);
|
||||||
|
this.toolOutputCleanupTimer = null;
|
||||||
|
}
|
||||||
this.closeServer?.();
|
this.closeServer?.();
|
||||||
this.closeServer = null;
|
this.closeServer = null;
|
||||||
this.clientPromise = null;
|
this.clientPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async bootstrapClient(): Promise<OpencodeClient> {
|
private async bootstrapClient(): Promise<OpencodeClient> {
|
||||||
if (config.OPENCODE_MODE === "client") {
|
await this.cleanupToolOutputs();
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
|
||||||
mode: config.OPENCODE_MODE,
|
|
||||||
},
|
|
||||||
"connecting to opencode server in client mode",
|
|
||||||
);
|
|
||||||
return createOpencodeClient({
|
|
||||||
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||||||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||||||
@@ -349,6 +478,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
config.AGENT_INTERNAL_TOKEN ??
|
config.AGENT_INTERNAL_TOKEN ??
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||||||
"";
|
"";
|
||||||
|
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -372,7 +502,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingOpencodeCli(error)) {
|
if (isMissingOpencodeCli(error)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
|
"embedded mode requires the opencode CLI to be installed and available in PATH",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -391,9 +521,39 @@ export class OpencodeRuntimeAdapter {
|
|||||||
this.closeServer = () => {
|
this.closeServer = () => {
|
||||||
runtime.server.close();
|
runtime.server.close();
|
||||||
};
|
};
|
||||||
|
this.startToolOutputCleanupLoop();
|
||||||
|
|
||||||
return runtime.client;
|
return runtime.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupToolOutputs(): Promise<void> {
|
||||||
|
const directory = resolveOpencodeToolOutputDirectory();
|
||||||
|
const ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000;
|
||||||
|
try {
|
||||||
|
const result = await cleanupExpiredToolOutputs(directory, ttlMs);
|
||||||
|
if (result.removed > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ directory, ...result },
|
||||||
|
"removed expired opencode tool output files",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, directory },
|
||||||
|
"failed to clean expired opencode tool output files",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startToolOutputCleanupLoop(): void {
|
||||||
|
if (this.toolOutputCleanupTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toolOutputCleanupTimer = setInterval(() => {
|
||||||
|
void this.cleanupToolOutputs();
|
||||||
|
}, config.RESULT_REF_CLEANUP_INTERVAL_MS);
|
||||||
|
this.toolOutputCleanupTimer.unref();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||||
@@ -403,6 +563,10 @@ function buildOpencodeConfig(): Record<string, unknown> {
|
|||||||
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||||||
{
|
{
|
||||||
model: config.OPENCODE_MODEL,
|
model: config.OPENCODE_MODEL,
|
||||||
|
tool_output: {
|
||||||
|
max_bytes: config.MAX_INLINE_RESULT_BYTES,
|
||||||
|
max_lines: 2000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export type ToolOutputCleanupResult = {
|
||||||
|
removed: number;
|
||||||
|
scanned: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveOpencodeToolOutputDirectory = (): string => {
|
||||||
|
const dataRoot = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
|
||||||
|
return join(dataRoot, "opencode", "tool-output");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cleanupExpiredToolOutputs = async (
|
||||||
|
directory: string,
|
||||||
|
ttlMs: number,
|
||||||
|
now = Date.now(),
|
||||||
|
): Promise<ToolOutputCleanupResult> => {
|
||||||
|
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
||||||
|
throw new Error("tool output cleanup ttlMs must be a positive number");
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error, "ENOENT")) {
|
||||||
|
return { removed: 0, scanned: 0 };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.startsWith("tool_")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
try {
|
||||||
|
const file = await stat(path);
|
||||||
|
if (now - file.mtimeMs <= ttlMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await rm(path);
|
||||||
|
removed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeError(error, "ENOENT")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removed, scanned: entries.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNodeError = (error: unknown, code: string): error is NodeJS.ErrnoException =>
|
||||||
|
error instanceof Error && "code" in error && error.code === code;
|
||||||
@@ -15,6 +15,7 @@ export type RuntimeSessionContext = {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
tokenExpiresAt?: string;
|
tokenExpiresAt?: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
workspaceDirectory?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const contexts = new Map<string, RuntimeSessionContext>();
|
const contexts = new Map<string, RuntimeSessionContext>();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
+384
-115
@@ -1,16 +1,28 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
|
||||||
import { requireAgentAuth } from "./auth/agentAuth.js";
|
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||||
|
import { buildBackendContextHeaders } from "./auth/backendContextHeaders.js";
|
||||||
|
import {
|
||||||
|
CredentialRefreshError,
|
||||||
|
CredentialRefreshCoordinator,
|
||||||
|
runWithCredentialRefresh,
|
||||||
|
} from "./auth/credentialRefresh.js";
|
||||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||||
|
import { executeCliCommand } from "./cli/executeCliCommand.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
||||||
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
|
import {
|
||||||
|
executeMemoryManager,
|
||||||
|
executeSkillManager,
|
||||||
|
type MemoryManagerInput,
|
||||||
|
type SkillManagerInput,
|
||||||
|
} from "./learning/toolManagers.js";
|
||||||
import { MemoryStore } from "./memory/store.js";
|
import { MemoryStore } from "./memory/store.js";
|
||||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,11 +32,22 @@ import {
|
|||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
|
import {
|
||||||
|
executeSandboxCommand,
|
||||||
|
probeLandlockSandbox,
|
||||||
|
type SandboxProbe,
|
||||||
|
} from "./sandbox/landlockSandbox.js";
|
||||||
import {
|
import {
|
||||||
getRuntimeSessionContext,
|
getRuntimeSessionContext,
|
||||||
markRuntimeSessionAuthExpired,
|
markRuntimeSessionAuthExpired,
|
||||||
type RuntimeSessionContext,
|
type RuntimeSessionContext,
|
||||||
} from "./runtime/sessionContext.js";
|
} from "./runtime/sessionContext.js";
|
||||||
|
import {
|
||||||
|
buildLargeCliResult,
|
||||||
|
stageLargeToolOutput,
|
||||||
|
} from "./runtime/toolOutputStaging.js";
|
||||||
|
import { ensureDirectory } from "./utils/fileStore.js";
|
||||||
|
import { SkillStore } from "./skills/store.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -33,17 +56,25 @@ const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
|||||||
const sessionMetadataStore = new SessionMetadataStore();
|
const sessionMetadataStore = new SessionMetadataStore();
|
||||||
const sessionUiStateStore = new SessionUiStateStore();
|
const sessionUiStateStore = new SessionUiStateStore();
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
|
const skillStore = new SkillStore();
|
||||||
const sessionTranscriptStore = new SessionTranscriptStore();
|
const sessionTranscriptStore = new SessionTranscriptStore();
|
||||||
const learningOrchestrator = new LearningOrchestrator(
|
const learningOrchestrator = new LearningOrchestrator(
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
sessionTranscriptStore,
|
sessionTranscriptStore,
|
||||||
|
skillStore,
|
||||||
);
|
);
|
||||||
const resultReferenceStore = new ResultReferenceStore();
|
const resultReferenceStore = new ResultReferenceStore();
|
||||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
const resultReferenceResolver = new ResultReferenceResolver(
|
||||||
|
resultReferenceStore,
|
||||||
|
config.RESULT_REF_IMPORT_DIR,
|
||||||
|
config.RESULT_REF_IMPORT_MAX_BYTES,
|
||||||
|
);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
|
||||||
|
let sandboxProbe: SandboxProbe | null = null;
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
|
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -54,13 +85,18 @@ app.get("/health", async (_req, res) => {
|
|||||||
const runtime = await opencodeRuntime.health();
|
const runtime = await opencodeRuntime.health();
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
ready: true,
|
||||||
|
warmed_up: true,
|
||||||
runtime,
|
runtime,
|
||||||
|
sandbox: sandboxProbe,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
ready: false,
|
||||||
|
warmed_up: true,
|
||||||
message: "opencode runtime unavailable",
|
message: "opencode runtime unavailable",
|
||||||
detail,
|
detail,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
@@ -68,6 +104,104 @@ app.get("/health", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/internal/tools/memory-manager", 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 context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||||
|
if (!context) {
|
||||||
|
res.status(404).json({
|
||||||
|
message: "session context not found",
|
||||||
|
detail: sessionId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = req.body?.action;
|
||||||
|
if (
|
||||||
|
typeof action !== "string" ||
|
||||||
|
!["add", "list", "replace", "remove"].includes(action) ||
|
||||||
|
typeof req.body?.scope !== "string"
|
||||||
|
) {
|
||||||
|
res.status(400).json({ message: "invalid memory manager request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await executeMemoryManager(memoryStore, context, {
|
||||||
|
action: action as MemoryManagerInput["action"],
|
||||||
|
content:
|
||||||
|
typeof req.body?.content === "string" ? req.body.content : undefined,
|
||||||
|
scope: req.body.scope,
|
||||||
|
target_id:
|
||||||
|
typeof req.body?.target_id === "string" ? req.body.target_id : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
message: "memory manager failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/internal/tools/skill-manager", 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 context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||||
|
if (!context) {
|
||||||
|
res.status(404).json({ message: "session context not found", detail: sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = req.body?.action;
|
||||||
|
if (
|
||||||
|
typeof action !== "string" ||
|
||||||
|
![
|
||||||
|
"list",
|
||||||
|
"write_skill",
|
||||||
|
"remove_skill",
|
||||||
|
"append_pattern",
|
||||||
|
"remove_pattern",
|
||||||
|
"write_reference",
|
||||||
|
"remove_reference",
|
||||||
|
"write_script",
|
||||||
|
"remove_script",
|
||||||
|
].includes(action) ||
|
||||||
|
typeof req.body?.skill_path !== "string"
|
||||||
|
) {
|
||||||
|
res.status(400).json({ message: "invalid skill manager request" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await executeSkillManager(skillStore, context, {
|
||||||
|
action: action as SkillManagerInput["action"],
|
||||||
|
content:
|
||||||
|
typeof req.body?.content === "string" ? req.body.content : undefined,
|
||||||
|
file_path:
|
||||||
|
typeof req.body?.file_path === "string" ? req.body.file_path : undefined,
|
||||||
|
pattern:
|
||||||
|
typeof req.body?.pattern === "string" ? req.body.pattern : undefined,
|
||||||
|
skill_path: req.body.skill_path,
|
||||||
|
target_id:
|
||||||
|
typeof req.body?.target_id === "string" ? req.body.target_id : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
message: "skill manager failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
@@ -84,15 +218,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isRuntimeAuthExpired(context)) {
|
|
||||||
markAuthExpired(context, "access_token_expired");
|
|
||||||
res.status(401).json({
|
|
||||||
message: "access token expired; refresh chat context",
|
|
||||||
detail: sessionId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
||||||
if (!command) {
|
if (!command) {
|
||||||
res.status(400).json({ message: "command is required" });
|
res.status(400).json({ message: "command is required" });
|
||||||
@@ -101,6 +226,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
|
|
||||||
const timeoutSec =
|
const timeoutSec =
|
||||||
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
||||||
|
const storeResult = req.body?.store_result === true;
|
||||||
|
|
||||||
if (!context.network) {
|
if (!context.network) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -110,46 +236,41 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authJson = JSON.stringify({
|
let result;
|
||||||
server: config.TJWATER_API_BASE_URL,
|
try {
|
||||||
access_token: context.accessToken,
|
result = await runWithCredentialRefresh(
|
||||||
project_id: context.projectId,
|
credentialRefreshCoordinator,
|
||||||
|
context,
|
||||||
|
(activeContext) =>
|
||||||
|
executeCliCommand(activeContext, command, timeoutSec, {
|
||||||
|
apiBaseUrl: config.TJWATER_API_BASE_URL,
|
||||||
|
cliPath: config.TJWATER_CLI_PATH,
|
||||||
|
maxStderrBytes: config.MAX_CLI_STDERR_BYTES,
|
||||||
|
maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CredentialRefreshError)) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
res.status(502).json({
|
||||||
|
message: "CLI execution failed",
|
||||||
|
detail,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.code === "cancelled") {
|
||||||
|
res.status(409).json({ message: "agent run was aborted" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markAuthExpired(context, "access_token_expired");
|
||||||
|
res.status(401).json({
|
||||||
|
message: "credential refresh failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
|
if (result.status === 504) {
|
||||||
|
|
||||||
const child = spawn(config.TJWATER_CLI_PATH, cliArgs, {
|
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
child.stdout.on("data", (data: Buffer) => {
|
|
||||||
stdout += data.toString("utf-8");
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (data: Buffer) => {
|
|
||||||
stderr += data.toString("utf-8");
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdin.write(authJson);
|
|
||||||
child.stdin.end();
|
|
||||||
|
|
||||||
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
child.kill("SIGTERM");
|
|
||||||
resolve(-1);
|
|
||||||
}, timeoutSec * 1000);
|
|
||||||
child.on("close", (code) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(code);
|
|
||||||
});
|
|
||||||
child.on("error", (err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (exitCode === -1) {
|
|
||||||
res.status(504).json({
|
res.status(504).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
schema_version: "tjwater-cli/v1",
|
schema_version: "tjwater-cli/v1",
|
||||||
@@ -163,25 +284,154 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (result.outcome === "output_limit") {
|
||||||
res.status(502).json({
|
res.status(502).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
exit_code: exitCode,
|
schema_version: "tjwater-cli/v1",
|
||||||
stderr: stderr.slice(0, 2000),
|
summary: "CLI 输出超过安全限制",
|
||||||
stdout: stdout.slice(0, 2000),
|
error: {
|
||||||
message: `CLI exited with code ${exitCode}`,
|
code: "OUTPUT_LIMIT_EXCEEDED",
|
||||||
|
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
markAuthExpired(
|
||||||
|
getRuntimeSessionContext(sessionId) ?? context,
|
||||||
|
"access_token_rejected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
res
|
||||||
|
.status(result.status)
|
||||||
|
.type("application/json")
|
||||||
|
.send(
|
||||||
|
result.stdout ||
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
exit_code: result.exitCode,
|
||||||
|
stderr: result.stderr.slice(0, 2000),
|
||||||
|
message: `CLI exited with code ${result.exitCode}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.stdout.trim()) {
|
||||||
try {
|
try {
|
||||||
res.json(JSON.parse(stdout));
|
const dataFile = await stageLargeToolOutput(context, result.stdout, {
|
||||||
} catch {
|
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;
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
schema_version: "tjwater-cli/v1",
|
schema_version: "tjwater-cli/v1",
|
||||||
raw: stdout,
|
raw: "",
|
||||||
stderr: stderr || undefined,
|
stderr: result.stderr || undefined,
|
||||||
|
stderr_truncated: result.stderrTruncated || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -207,6 +457,13 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
|||||||
res.status(400).json({ message: "file_path is required" });
|
res.status(400).json({ message: "file_path is required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!context.workspaceDirectory) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "conversation workspace is required",
|
||||||
|
detail: "create a new conversation before importing render data",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
|
||||||
@@ -217,6 +474,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
|||||||
sessionId: context.clientSessionId,
|
sessionId: context.clientSessionId,
|
||||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
traceId: context.traceId,
|
traceId: context.traceId,
|
||||||
|
workspaceDirectory: context.workspaceDirectory,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -276,45 +534,60 @@ const callBackendJson = async (
|
|||||||
context: RuntimeSessionContext,
|
context: RuntimeSessionContext,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
) => {
|
) => {
|
||||||
if (isRuntimeAuthExpired(context)) {
|
try {
|
||||||
|
const result = await runWithCredentialRefresh(
|
||||||
|
credentialRefreshCoordinator,
|
||||||
|
context,
|
||||||
|
async (activeContext) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
config.TJWATER_API_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
new URL(path, config.TJWATER_API_BASE_URL),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: buildBackendContextHeaders(activeContext),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
text: await response.text(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status === 401) {
|
||||||
|
markAuthExpired(
|
||||||
|
getRuntimeSessionContext(context.sessionId) ?? context,
|
||||||
|
"access_token_rejected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CredentialRefreshError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error.code === "cancelled") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
markAuthExpired(context, "access_token_expired");
|
markAuthExpired(context, "access_token_expired");
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 401,
|
status: 401,
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
message: "access token expired; refresh chat context",
|
message: "credential refresh failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
if (context.accessToken) {
|
|
||||||
headers.Authorization = `Bearer ${context.accessToken}`;
|
|
||||||
}
|
|
||||||
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
if (response.status === 401) {
|
|
||||||
markAuthExpired(context, "access_token_rejected");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: response.ok,
|
|
||||||
status: response.status,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseStringArray = (value: unknown) =>
|
const parseStringArray = (value: unknown) =>
|
||||||
@@ -337,19 +610,6 @@ const normalizeWebSearchFreshness = (value: unknown) => {
|
|||||||
return webSearchFreshnessMap[value] ?? value;
|
return webSearchFreshnessMap[value] ?? value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
|
||||||
|
|
||||||
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
|
|
||||||
if (!context.tokenExpiresAt) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const expiresAt = Date.parse(context.tokenExpiresAt);
|
|
||||||
if (!Number.isFinite(expiresAt)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAuthExpired(
|
function markAuthExpired(
|
||||||
context: RuntimeSessionContext,
|
context: RuntimeSessionContext,
|
||||||
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
|
||||||
@@ -471,6 +731,7 @@ const chatRouter = buildChatRouter(
|
|||||||
sessionTranscriptStore,
|
sessionTranscriptStore,
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
resultReferenceResolver,
|
resultReferenceResolver,
|
||||||
|
credentialRefreshCoordinator,
|
||||||
);
|
);
|
||||||
const authenticatedChatRouter = express.Router();
|
const authenticatedChatRouter = express.Router();
|
||||||
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
|
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
|
||||||
@@ -480,31 +741,22 @@ app.use(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
|
sandboxProbe = await probeLandlockSandbox();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sessionMetadataStore.initialize(),
|
sessionMetadataStore.initialize(),
|
||||||
sessionUiStateStore.initialize(),
|
sessionUiStateStore.initialize(),
|
||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
|
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
|
||||||
sessionTranscriptStore.initialize(),
|
sessionTranscriptStore.initialize(),
|
||||||
]);
|
]);
|
||||||
resultReferenceStore.startCleanupLoop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await bootstrap();
|
|
||||||
|
|
||||||
const server = app.listen(config.PORT, config.HOST, () => {
|
|
||||||
logger.info(
|
|
||||||
{ host: config.HOST, port: config.PORT },
|
|
||||||
"TJWaterAgent listening",
|
|
||||||
);
|
|
||||||
void warmupOpencodeRuntime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const warmupOpencodeRuntime = async () => {
|
const warmupOpencodeRuntime = async () => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
await opencodeRuntime.ensureClient();
|
await opencodeRuntime.warmup();
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||||
@@ -521,9 +773,26 @@ const warmupOpencodeRuntime = async () => {
|
|||||||
},
|
},
|
||||||
"failed to warm up opencode runtime",
|
"failed to warm up opencode runtime",
|
||||||
);
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await bootstrap();
|
||||||
|
await warmupOpencodeRuntime();
|
||||||
|
resultReferenceStore.startCleanupLoop();
|
||||||
|
|
||||||
|
const server = app.listen(config.PORT, config.HOST, () => {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
host: config.HOST,
|
||||||
|
port: config.PORT,
|
||||||
|
ready: true,
|
||||||
|
warmedUp: true,
|
||||||
|
},
|
||||||
|
"TJWaterAgent listening",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
logger.info("shutting down TJWaterAgent");
|
logger.info("shutting down TJWaterAgent");
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { buildBackendContextHeaders } from "../../src/auth/backendContextHeaders.js";
|
||||||
|
|
||||||
|
describe("buildBackendContextHeaders", () => {
|
||||||
|
it("forwards authenticated project and trace context to the backend", () => {
|
||||||
|
expect(
|
||||||
|
buildBackendContextHeaders({
|
||||||
|
accessToken: "access-token-1",
|
||||||
|
projectId: "project-id-1",
|
||||||
|
traceId: "trace-id-1",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer access-token-1",
|
||||||
|
"X-Project-Id": "project-id-1",
|
||||||
|
"X-Trace-Id": "trace-id-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits optional authentication and project headers when unavailable", () => {
|
||||||
|
expect(buildBackendContextHeaders({ traceId: "trace-id-2" })).toEqual({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Trace-Id": "trace-id-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CredentialRefreshCoordinator,
|
||||||
|
CredentialRefreshError,
|
||||||
|
runWithCredentialRefresh,
|
||||||
|
} from "../../src/auth/credentialRefresh.js";
|
||||||
|
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||||
|
|
||||||
|
const context = (overrides: Partial<RuntimeSessionContext> = {}) => ({
|
||||||
|
accessToken: "old-token",
|
||||||
|
actorKey: "user-1",
|
||||||
|
clientSessionId: "client-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CredentialRefreshCoordinator", () => {
|
||||||
|
test("deduplicates concurrent refreshes for one session", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
const requestIds: string[] = [];
|
||||||
|
coordinator.subscribe("session-1", (event) => {
|
||||||
|
if (event.type === "credential_refresh_required") {
|
||||||
|
requestIds.push(event.requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const expired = context({ tokenExpiresAt: new Date(0).toISOString() });
|
||||||
|
const execute = async (active: RuntimeSessionContext) => ({
|
||||||
|
status: 200,
|
||||||
|
token: active.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runWithCredentialRefresh(coordinator, expired, execute);
|
||||||
|
const second = runWithCredentialRefresh(coordinator, expired, execute);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(requestIds).toHaveLength(1);
|
||||||
|
expect(coordinator.getPendingEvent("session-1")).toMatchObject({
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId: requestIds[0],
|
||||||
|
reason: "access_token_expired",
|
||||||
|
});
|
||||||
|
coordinator.resolve(
|
||||||
|
"session-1",
|
||||||
|
requestIds[0]!,
|
||||||
|
context({ accessToken: "fresh-token" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await first).toEqual({ status: 200, token: "fresh-token" });
|
||||||
|
expect(await second).toEqual({ status: 200, token: "fresh-token" });
|
||||||
|
expect(coordinator.getPendingEvent("session-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries one time on 401 and never refreshes a 403", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
let requestId = "";
|
||||||
|
coordinator.subscribe("session-1", (event) => {
|
||||||
|
if (event.type === "credential_refresh_required") {
|
||||||
|
requestId = event.requestId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let attempts = 0;
|
||||||
|
const resultPromise = runWithCredentialRefresh(
|
||||||
|
coordinator,
|
||||||
|
context(),
|
||||||
|
async () => ({ status: ++attempts === 1 ? 401 : 401 }),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
coordinator.resolve("session-1", requestId, context({ accessToken: "fresh-token" }));
|
||||||
|
expect((await resultPromise).status).toBe(401);
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
|
||||||
|
requestId = "";
|
||||||
|
expect(
|
||||||
|
(await runWithCredentialRefresh(coordinator, context(), async () => ({ status: 403 })))
|
||||||
|
.status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(requestId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails explicitly when no event stream can refresh credentials", async () => {
|
||||||
|
await expect(
|
||||||
|
runWithCredentialRefresh(
|
||||||
|
new CredentialRefreshCoordinator(),
|
||||||
|
context({ tokenExpiresAt: new Date(0).toISOString() }),
|
||||||
|
async () => ({ status: 200 }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(CredentialRefreshError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports run cancellation separately from authentication failure", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
coordinator.subscribe("session-1", () => undefined);
|
||||||
|
const pending = coordinator.request("session-1", "access_token_rejected");
|
||||||
|
coordinator.cancelSession("session-1");
|
||||||
|
await expect(pending).rejects.toMatchObject({ code: "cancelled" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { executeCliCommand } from "../../src/cli/executeCliCommand.js";
|
||||||
|
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||||
|
|
||||||
|
const cliPath = fileURLToPath(
|
||||||
|
new URL("../fixtures/fakeCli.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
const context: RuntimeSessionContext = {
|
||||||
|
accessToken: "test-token",
|
||||||
|
actorKey: "actor-1",
|
||||||
|
clientSessionId: "client-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (
|
||||||
|
command: string,
|
||||||
|
options: {
|
||||||
|
maxStderrBytes?: number;
|
||||||
|
maxStdoutBytes?: number;
|
||||||
|
terminationGraceMs?: number;
|
||||||
|
timeoutSec?: number;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
executeCliCommand(context, command, options.timeoutSec ?? 1, {
|
||||||
|
apiBaseUrl: "http://127.0.0.1:8000",
|
||||||
|
cliPath,
|
||||||
|
maxStderrBytes: options.maxStderrBytes ?? 8,
|
||||||
|
maxStdoutBytes: options.maxStdoutBytes ?? 64,
|
||||||
|
terminationGraceMs: options.terminationGraceMs ?? 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeCliCommand", () => {
|
||||||
|
test("accepts output at the byte limit", async () => {
|
||||||
|
await expect(run("stdout 123456", { maxStdoutBytes: 6 })).resolves.toMatchObject({
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
status: 200,
|
||||||
|
stdout: "123456",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not treat the OpenCode 12000-byte inline threshold as a CLI limit", async () => {
|
||||||
|
const stdout = "x".repeat(12_001);
|
||||||
|
const result = await run(`stdout ${stdout}`, { maxStdoutBytes: 128 * 1024 * 1024 });
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("completed");
|
||||||
|
expect(result.stdout).toBe(stdout);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects multibyte output above the byte limit without returning a partial body", async () => {
|
||||||
|
await expect(run("stdout 水水", { maxStdoutBytes: 5 })).resolves.toMatchObject({
|
||||||
|
outcome: "output_limit",
|
||||||
|
exceededStream: "stdout",
|
||||||
|
status: 502,
|
||||||
|
stderr: "",
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates stderr independently without terminating a successful command", async () => {
|
||||||
|
await expect(
|
||||||
|
run("stderr-success 123456789", { maxStderrBytes: 6 }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
status: 200,
|
||||||
|
stderr: "123456",
|
||||||
|
stderrTruncated: true,
|
||||||
|
stdout: '{"ok":true}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for a SIGTERM-aware process to close after timeout", async () => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await run("term", {
|
||||||
|
terminationGraceMs: 100,
|
||||||
|
timeoutSec: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ outcome: "timeout", status: 504 });
|
||||||
|
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(270);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses SIGKILL when a timed-out process ignores SIGTERM", async () => {
|
||||||
|
const result = await run("ignore-term", { timeoutSec: 0.25 });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
outcome: "timeout",
|
||||||
|
signal: "SIGKILL",
|
||||||
|
status: 504,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the timeout outcome when closing stdin also errors", async () => {
|
||||||
|
const largeContext = {
|
||||||
|
...context,
|
||||||
|
accessToken: "x".repeat(1024 * 1024),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeCliCommand(largeContext, "ignore-term", 0.25, {
|
||||||
|
apiBaseUrl: "http://127.0.0.1:8000",
|
||||||
|
cliPath,
|
||||||
|
maxStderrBytes: 8,
|
||||||
|
maxStdoutBytes: 64,
|
||||||
|
terminationGraceMs: 20,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
outcome: "timeout",
|
||||||
|
signal: "SIGKILL",
|
||||||
|
status: 504,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a deterministic stdin pipe error without crashing", async () => {
|
||||||
|
const largeContext = {
|
||||||
|
...context,
|
||||||
|
accessToken: "x".repeat(1024 * 1024),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeCliCommand(largeContext, "closed-stdin", 1, {
|
||||||
|
apiBaseUrl: "http://127.0.0.1:8000",
|
||||||
|
cliPath,
|
||||||
|
maxStderrBytes: 8,
|
||||||
|
maxStdoutBytes: 64,
|
||||||
|
terminationGraceMs: 20,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ describe("Agent REST OpenAPI", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(operationCount).toBe(13);
|
expect(operationCount).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("models runs as session subresources", () => {
|
test("models runs as session subresources", () => {
|
||||||
@@ -61,6 +61,34 @@ describe("Agent REST OpenAPI", () => {
|
|||||||
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
|
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("separates automatic approval from persistent permission grants", () => {
|
||||||
|
const document = generateAgentOpenApi();
|
||||||
|
const runRequest = document.paths["/api/v1/agent/sessions/{session_id}/runs"]
|
||||||
|
?.post?.requestBody;
|
||||||
|
const permissionRequest = document.paths[
|
||||||
|
"/api/v1/agent/sessions/{session_id}/permission-responses"
|
||||||
|
]?.post?.requestBody;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
runRequest && !("$ref" in runRequest)
|
||||||
|
? runRequest.content["application/json"]?.schema
|
||||||
|
: undefined,
|
||||||
|
).toMatchObject({
|
||||||
|
properties: {
|
||||||
|
approval_mode: { enum: ["request", "auto", "always"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
permissionRequest && !("$ref" in permissionRequest)
|
||||||
|
? permissionRequest.content["application/json"]?.schema
|
||||||
|
: undefined,
|
||||||
|
).toMatchObject({
|
||||||
|
properties: {
|
||||||
|
reply: { enum: ["once", "always", "reject"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("matches the public session runtime response shapes", () => {
|
test("matches the public session runtime response shapes", () => {
|
||||||
const document = generateAgentOpenApi();
|
const document = generateAgentOpenApi();
|
||||||
const schemas = document.components?.schemas ?? {};
|
const schemas = document.components?.schemas ?? {};
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { closeSync } from "node:fs";
|
||||||
|
|
||||||
|
const command = process.argv[3];
|
||||||
|
const value = process.argv[4] ?? "";
|
||||||
|
|
||||||
|
if (command === "stdout") {
|
||||||
|
process.stdout.write(value);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "stderr") {
|
||||||
|
process.stderr.write(value);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "stderr-success") {
|
||||||
|
process.stderr.write(value);
|
||||||
|
process.stdout.write('{"ok":true}');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "term") {
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
setTimeout(() => process.exit(0), 30);
|
||||||
|
});
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "ignore-term") {
|
||||||
|
process.on("SIGTERM", () => undefined);
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "closed-stdin") {
|
||||||
|
closeSync(0);
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
executeMemoryManager,
|
||||||
|
executeSkillManager,
|
||||||
|
} from "../../src/learning/toolManagers.js";
|
||||||
|
import { MemoryStore } from "../../src/memory/store.js";
|
||||||
|
import {
|
||||||
|
getRuntimeSessionContext,
|
||||||
|
removeRuntimeSessionContext,
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
type RuntimeSessionContext,
|
||||||
|
} from "../../src/runtime/sessionContext.js";
|
||||||
|
import { SkillStore } from "../../src/skills/store.js";
|
||||||
|
|
||||||
|
describe("main-process learning tool managers", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let memoryStore: MemoryStore;
|
||||||
|
let skillStore: SkillStore;
|
||||||
|
let context: RuntimeSessionContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-learning-tools-"));
|
||||||
|
memoryStore = new MemoryStore(
|
||||||
|
join(tempDir, "memory"),
|
||||||
|
join(tempDir, "backup", "memory"),
|
||||||
|
);
|
||||||
|
skillStore = new SkillStore(
|
||||||
|
join(tempDir, "skills"),
|
||||||
|
join(tempDir, "backup", "skills"),
|
||||||
|
);
|
||||||
|
await memoryStore.initialize();
|
||||||
|
context = {
|
||||||
|
actorKey: "actor-1",
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
};
|
||||||
|
setRuntimeSessionContext(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
removeRuntimeSessionContext(context.sessionId);
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces list-before-add using the canonical runtime context", async () => {
|
||||||
|
const rejected = await executeMemoryManager(memoryStore, context, {
|
||||||
|
action: "add",
|
||||||
|
content: "用户偏好查看压力单位为 MPa",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
expect(rejected.decision).toBe("rejected");
|
||||||
|
|
||||||
|
await executeMemoryManager(memoryStore, context, {
|
||||||
|
action: "list",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
const refreshedContext = getRuntimeSessionContext(context.sessionId)!;
|
||||||
|
const accepted = await executeMemoryManager(memoryStore, refreshedContext, {
|
||||||
|
action: "add",
|
||||||
|
content: "用户偏好查看压力单位为 MPa",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
expect(accepted.decision).toBe("accepted");
|
||||||
|
expect(await memoryStore.list("user", context.actorKey)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes and removes skills through the shared store", async () => {
|
||||||
|
const content = [
|
||||||
|
"---",
|
||||||
|
"name: pressure-review",
|
||||||
|
"description: Pressure review workflow.",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"# Pressure Review",
|
||||||
|
].join("\n");
|
||||||
|
const written = await executeSkillManager(skillStore, context, {
|
||||||
|
action: "write_skill",
|
||||||
|
content,
|
||||||
|
skill_path: "workflow/pressure-review",
|
||||||
|
});
|
||||||
|
expect(written.decision).toBe("accepted");
|
||||||
|
expect("target" in written).toBe(true);
|
||||||
|
if (!("target" in written)) throw new Error("write returned no target");
|
||||||
|
await expect(readFile(written.target, "utf8")).resolves.toContain(
|
||||||
|
"# Pressure Review\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = await executeSkillManager(skillStore, context, {
|
||||||
|
action: "remove_skill",
|
||||||
|
skill_path: "workflow/pressure-review",
|
||||||
|
});
|
||||||
|
expect(removed.decision).toBe("accepted");
|
||||||
|
expect("target" in removed).toBe(true);
|
||||||
|
if (!("target" in removed)) throw new Error("remove returned no target");
|
||||||
|
await expect(readFile(removed.target, "utf8")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
|
|
||||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
||||||
import { SkillStore } from "../../src/skills/store.js";
|
|
||||||
|
|
||||||
describe("skill_manager tool", () => {
|
|
||||||
let tempDir: string;
|
|
||||||
let skillStore: SkillStore;
|
|
||||||
let context: RuntimeSessionContext;
|
|
||||||
|
|
||||||
const toolContext = {
|
|
||||||
abort: new AbortController().signal,
|
|
||||||
agent: "test",
|
|
||||||
ask: (() => undefined) as never,
|
|
||||||
directory: "",
|
|
||||||
messageID: "message-1",
|
|
||||||
metadata: () => undefined,
|
|
||||||
sessionID: "session-1",
|
|
||||||
worktree: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const skillDocument = (body: string) =>
|
|
||||||
[
|
|
||||||
"---",
|
|
||||||
"name: pressure-review",
|
|
||||||
"description: Pressure review workflow.",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
body,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
|
|
||||||
skillStore = new SkillStore(
|
|
||||||
join(tempDir, "skills"),
|
|
||||||
join(tempDir, "backup", "skills"),
|
|
||||||
);
|
|
||||||
context = {
|
|
||||||
actorKey: "actor-1",
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: "client-session-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "session-1",
|
|
||||||
traceId: "trace-1",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await rm(tempDir, { force: true, recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches skill-level write, overwrite, and remove actions", async () => {
|
|
||||||
const tool = createSkillManagerTool(
|
|
||||||
skillStore,
|
|
||||||
{ read: () => context },
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const writeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: skillDocument("# Pressure Review"),
|
|
||||||
reason: "verified reusable workflow",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(writeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# Pressure Review\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: skillDocument("# Updated Pressure Review"),
|
|
||||||
reason: "verified reusable workflow overwrite",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(updateResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# Updated Pressure Review\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "remove_skill",
|
|
||||||
reason: "workflow is obsolete",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(removeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the root skills index through the reserved alias", async () => {
|
|
||||||
const tool = createSkillManagerTool(
|
|
||||||
skillStore,
|
|
||||||
{ read: () => context },
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const writeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: [
|
|
||||||
"---",
|
|
||||||
"name: skills",
|
|
||||||
"description: TJWater Skills root index.",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
"# TJWater Skills",
|
|
||||||
].join("\n"),
|
|
||||||
reason: "refresh root skills index",
|
|
||||||
skill_path: "__root__",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(writeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# TJWater Skills\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+115
-4
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -12,13 +12,18 @@ import {
|
|||||||
|
|
||||||
describe("ResultReferenceResolver", () => {
|
describe("ResultReferenceResolver", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
let importRoot: string;
|
||||||
|
let conversationWorkspace: string;
|
||||||
let store: ResultReferenceStore;
|
let store: ResultReferenceStore;
|
||||||
let resolver: ResultReferenceResolver;
|
let resolver: ResultReferenceResolver;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||||
store = new ResultReferenceStore(tempDir, 60_000);
|
importRoot = join(tempDir, "conversation-workspaces");
|
||||||
resolver = new ResultReferenceResolver(store);
|
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();
|
await store.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +132,7 @@ describe("ResultReferenceResolver", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("registers render refs from local wrapper files and normalizes payloads", async () => {
|
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(
|
await writeFile(
|
||||||
filePath,
|
filePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -166,6 +171,7 @@ describe("ResultReferenceResolver", () => {
|
|||||||
sessionId: "session-3",
|
sessionId: "session-3",
|
||||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
traceId: "trace-3",
|
traceId: "trace-3",
|
||||||
|
workspaceDirectory: conversationWorkspace,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||||
@@ -193,6 +199,111 @@ describe("ResultReferenceResolver", () => {
|
|||||||
"DMA-2": "#00ff00",
|
"DMA-2": "#00ff00",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await expect(stat(filePath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects render payload files outside the configured import directory", async () => {
|
||||||
|
const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-"));
|
||||||
|
const filePath = join(outsideDir, "render-wrapper.json");
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
metadata: {},
|
||||||
|
location: { file_path: filePath },
|
||||||
|
data: { node_area_map: { J1: "DMA-1" } },
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
resolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-4",
|
||||||
|
clientSessionId: "client-4",
|
||||||
|
projectKey: "project-key-4",
|
||||||
|
sessionId: "session-4",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
|
traceId: "trace-4",
|
||||||
|
workspaceDirectory: outsideDir,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
|
||||||
|
} finally {
|
||||||
|
await rm(outsideDir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized render payload files before parsing", async () => {
|
||||||
|
const filePath = join(conversationWorkspace, "oversized.json");
|
||||||
|
await writeFile(filePath, "x".repeat(128), "utf8");
|
||||||
|
const sizeLimitedResolver = new ResultReferenceResolver(store, importRoot, 64);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-5",
|
||||||
|
clientSessionId: "client-5",
|
||||||
|
projectKey: "project-key-5",
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
||||||
|
import express, { Router } from "express";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js";
|
||||||
|
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||||
|
import type { ActiveRun } from "../../src/routes/chatUiState.js";
|
||||||
|
|
||||||
|
describe("chat interaction routes", () => {
|
||||||
|
let baseUrl = "";
|
||||||
|
let server: Server;
|
||||||
|
const replyQuestion = mock(async () => ({ ok: true }));
|
||||||
|
const replyPermission = mock(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const activeRuns = new Map<string, ActiveRun>();
|
||||||
|
activeRuns.set("runtime-session", {
|
||||||
|
clientSessionId: "client-session",
|
||||||
|
controller: new AbortController(),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
questions: [{ requestId: "question-1", status: "pending" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pendingPermissions: new Map([
|
||||||
|
[
|
||||||
|
"permission-1",
|
||||||
|
{
|
||||||
|
session_id: "runtime-session",
|
||||||
|
request_id: "permission-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
always: ["npm test"],
|
||||||
|
created_at: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
pendingQuestions: new Map([
|
||||||
|
[
|
||||||
|
"question-1",
|
||||||
|
{
|
||||||
|
created_at: 1,
|
||||||
|
request_id: "question-1",
|
||||||
|
session_id: "runtime-session",
|
||||||
|
questions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
status: "running",
|
||||||
|
subscribers: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use((req, _res, next) => {
|
||||||
|
req.agentAuth = {
|
||||||
|
accessToken: "access-token",
|
||||||
|
userId: "user-1",
|
||||||
|
keycloakSub: "keycloak-1",
|
||||||
|
username: "tester",
|
||||||
|
role: "user",
|
||||||
|
isSuperuser: false,
|
||||||
|
projectId: "project-1",
|
||||||
|
network: "network-1",
|
||||||
|
projectRole: "member",
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
registerChatInteractionRoutes(router, {
|
||||||
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator: new CredentialRefreshCoordinator(),
|
||||||
|
runtime: { replyPermission, replyQuestion } as never,
|
||||||
|
sessionMetadataStore: {
|
||||||
|
get: async () => ({ sessionId: "runtime-session" }),
|
||||||
|
} as never,
|
||||||
|
sessionUiStateStore: {
|
||||||
|
read: async () => null,
|
||||||
|
write: async () => undefined,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(router);
|
||||||
|
server = app.listen(0);
|
||||||
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("test server did not expose a TCP port");
|
||||||
|
}
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits answers to the stable OpenCode question adapter", async () => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/sessions/client-session/question-responses`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: "question-1",
|
||||||
|
action: "reply",
|
||||||
|
answers: [["继续"]],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(replyQuestion).toHaveBeenCalledWith({
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
answers: [["继续"]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards saved permission grants to OpenCode", async () => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/sessions/client-session/permission-responses`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: "permission-1",
|
||||||
|
reply: "always",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(replyPermission).toHaveBeenCalledWith({
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
reply: "always",
|
||||||
|
message: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canAutoApprovePermission,
|
||||||
|
resolvePermissionApproval,
|
||||||
|
} from "../../src/routes/chatPermissionPolicy.js";
|
||||||
|
|
||||||
|
describe("permission approval policy", () => {
|
||||||
|
it.each([
|
||||||
|
"show_chart",
|
||||||
|
"web_search",
|
||||||
|
"skill",
|
||||||
|
])("allows low-risk permission %s", (permission) => {
|
||||||
|
expect(canAutoApprovePermission(permission)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows structured searches within the workspace", () => {
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
const context = {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { path: join(workspaceRoot, "src"), include: "*.ts" },
|
||||||
|
patterns: ["*.ts"],
|
||||||
|
};
|
||||||
|
expect(canAutoApprovePermission("glob", context)).toBe(true);
|
||||||
|
expect(canAutoApprovePermission("grep", context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a glob with an explicit safe prefix from the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
canAutoApprovePermission("glob", {
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
metadata: { path: process.cwd(), pattern: "src/**/*.ts" },
|
||||||
|
patterns: ["src/**/*.ts"],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ metadata: { path: dirname(process.cwd()) }, patterns: ["*"] },
|
||||||
|
{ metadata: { path: join(process.cwd(), ".local.env") }, patterns: ["*"] },
|
||||||
|
{ metadata: { path: join(process.cwd(), "data") }, patterns: ["*"] },
|
||||||
|
{ metadata: { path: join(process.cwd(), "src") }, patterns: ["../logs/**"] },
|
||||||
|
{ metadata: { path: process.cwd() }, patterns: ["**/*.env"] },
|
||||||
|
{ metadata: { path: process.cwd() }, patterns: ["**/*"] },
|
||||||
|
{ metadata: { path: join(process.cwd(), "src") }, patterns: [".[e]nv"] },
|
||||||
|
])("keeps protected or external searches interactive", (request) => {
|
||||||
|
expect(
|
||||||
|
canAutoApprovePermission("glob", {
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
...request,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps grep from the workspace root interactive", () => {
|
||||||
|
expect(
|
||||||
|
canAutoApprovePermission("grep", {
|
||||||
|
workspaceRoot: process.cwd(),
|
||||||
|
metadata: { path: process.cwd(), include: "*.ts" },
|
||||||
|
patterns: ["secret"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a workspace symlink that resolves outside the workspace", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-workspace-"));
|
||||||
|
const externalRoot = await mkdtemp(join(tmpdir(), "permission-external-"));
|
||||||
|
try {
|
||||||
|
await mkdir(join(externalRoot, "src"));
|
||||||
|
const linkedPath = join(workspaceRoot, "linked");
|
||||||
|
await symlink(join(externalRoot, "src"), linkedPath, "dir");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canAutoApprovePermission("grep", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { path: linkedPath, include: "*.ts" },
|
||||||
|
patterns: ["secret"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
rm(workspaceRoot, { force: true, recursive: true }),
|
||||||
|
rm(externalRoot, { force: true, recursive: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects protected descendants below an otherwise safe search root", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-descendant-"));
|
||||||
|
try {
|
||||||
|
const sourceRoot = join(workspaceRoot, "src");
|
||||||
|
await mkdir(join(sourceRoot, "data"), { recursive: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canAutoApprovePermission("grep", {
|
||||||
|
workspaceRoot,
|
||||||
|
metadata: { path: sourceRoot, include: "*.ts" },
|
||||||
|
patterns: ["secret"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await rm(workspaceRoot, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"bash",
|
||||||
|
"edit",
|
||||||
|
"external_directory",
|
||||||
|
"store_render_ref",
|
||||||
|
"tjwater_server_query",
|
||||||
|
"tjwater_tjwater_server_query",
|
||||||
|
])(
|
||||||
|
"requires confirmation for permission %s",
|
||||||
|
(permission) => {
|
||||||
|
expect(canAutoApprovePermission(permission)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("resolves request, auto, and always modes", () => {
|
||||||
|
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
|
||||||
|
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
|
||||||
|
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
|
||||||
|
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
|
||||||
|
autoApprove: true,
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
+862
-18
@@ -15,6 +15,454 @@ const createEventStream = (events: unknown[]) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("streamPromptResponse", () => {
|
describe("streamPromptResponse", () => {
|
||||||
|
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
|
||||||
|
let subscribedDirectory: string | undefined;
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async (directory?: string) => {
|
||||||
|
subscribedDirectory = directory;
|
||||||
|
return createEventStream([
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-intermediate",
|
||||||
|
partID: "text-part-intermediate",
|
||||||
|
field: "text",
|
||||||
|
delta: "正在加载工作流并尝试分页参数。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "text-part-intermediate",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-intermediate",
|
||||||
|
type: "text",
|
||||||
|
text: "正在加载工作流并尝试分页参数。",
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
time: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
partID: "text-part-final",
|
||||||
|
field: "text",
|
||||||
|
delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "text-part-final",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "text",
|
||||||
|
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
|
||||||
|
time: { start: 3, end: 4 },
|
||||||
|
},
|
||||||
|
time: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => [
|
||||||
|
{
|
||||||
|
info: { id: "assistant-intermediate", role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "text-part-intermediate",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-intermediate",
|
||||||
|
type: "text",
|
||||||
|
text: "正在加载工作流并尝试分页参数。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { id: "assistant-final", role: "assistant" },
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
id: "text-part-final",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "text",
|
||||||
|
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} 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: "分析管网瓶颈",
|
||||||
|
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.some(
|
||||||
|
(item) => item.event === "token" && item.data.content === "我先检查相关数据。",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the final text event cache when the messages lookup fails", async () => {
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "text-part-final",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "text",
|
||||||
|
text: "最终分析结果。",
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
time: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => {
|
||||||
|
throw new Error("transient messages lookup failure");
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
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 === "token")?.data.content).toBe(
|
||||||
|
"最终分析结果。",
|
||||||
|
);
|
||||||
|
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
|
||||||
|
"最终分析结果。",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "reasoning-part-1",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-1",
|
||||||
|
type: "reasoning",
|
||||||
|
text: "内部推理:尝试 limit=5000 并读取临时路径。",
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
time: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-1",
|
||||||
|
partID: "reasoning-part-1",
|
||||||
|
field: "text",
|
||||||
|
delta: "内部推理:尝试 limit=5000 并读取临时路径。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "tool-part-1",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-1",
|
||||||
|
type: "tool",
|
||||||
|
callID: "call-1",
|
||||||
|
tool: "tjwater_cli",
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: {
|
||||||
|
command: "network get-all-pipes-properties --limit 5000",
|
||||||
|
},
|
||||||
|
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
time: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => [],
|
||||||
|
} 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 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("forwards opencode permission requests as SSE payloads", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -57,11 +505,13 @@ describe("streamPromptResponse", () => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["rm *"],
|
patterns: ["rm *"],
|
||||||
target: "rm tmp.txt",
|
target: "rm tmp.txt",
|
||||||
|
activity_id: "activity-startup",
|
||||||
|
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
|
||||||
always: ["rm *"],
|
always: ["rm *"],
|
||||||
} satisfies Partial<PermissionRequestPayload>);
|
} satisfies Partial<PermissionRequestPayload>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto replies always when approval mode is always", async () => {
|
it("auto approves an allowlisted low-risk permission once", async () => {
|
||||||
const replies: Array<Record<string, unknown>> = [];
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -71,10 +521,10 @@ describe("streamPromptResponse", () => {
|
|||||||
properties: {
|
properties: {
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
sessionID: "runtime-session-1",
|
sessionID: "runtime-session-1",
|
||||||
permission: "bash",
|
permission: "show_chart",
|
||||||
patterns: ["npm test"],
|
patterns: ["*"],
|
||||||
metadata: { command: "npm test" },
|
metadata: {},
|
||||||
always: ["npm test"],
|
always: ["*"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,7 +547,8 @@ describe("streamPromptResponse", () => {
|
|||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "run tests",
|
message: "run tests",
|
||||||
approvalMode: "always",
|
approvalMode: "auto",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,14 +556,150 @@ describe("streamPromptResponse", () => {
|
|||||||
{
|
{
|
||||||
requestId: "perm-1",
|
requestId: "perm-1",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
reply: "always",
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
reply: "once",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
||||||
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
|
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
|
||||||
session_id: "client-session-1",
|
session_id: "client-session-1",
|
||||||
request_id: "perm-1",
|
request_id: "perm-1",
|
||||||
reply: "always",
|
reply: "once",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps high-risk permissions interactive in auto mode", async () => {
|
||||||
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-auto-bash",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
metadata: { command: "npm test" },
|
||||||
|
always: ["npm test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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: "run tests",
|
||||||
|
approvalMode: "auto",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replies).toEqual([]);
|
||||||
|
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
|
||||||
|
request_id: "perm-auto-bash",
|
||||||
|
permission: "bash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approves every OpenCode ask once in always mode", async () => {
|
||||||
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-always-bash",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
metadata: { command: "npm test" },
|
||||||
|
always: ["npm test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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: "run tests",
|
||||||
|
approvalMode: "always",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replies).toEqual([
|
||||||
|
{
|
||||||
|
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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,10 +901,35 @@ describe("streamPromptResponse", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards todo updates as structured SSE payloads and progress", async () => {
|
it("forwards todo updates independently from activity progress", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
createEventStream([
|
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",
|
type: "todo.updated",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -348,15 +960,7 @@ describe("streamPromptResponse", () => {
|
|||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(events.some((item) => item.event === "progress")).toBe(false);
|
||||||
events.find(
|
|
||||||
(item) => item.event === "progress" && item.data.id === "todo-progress",
|
|
||||||
)?.data,
|
|
||||||
).toMatchObject({
|
|
||||||
id: "todo-progress",
|
|
||||||
phase: "planning",
|
|
||||||
title: "计划进度 1/2",
|
|
||||||
});
|
|
||||||
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
|
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
|
||||||
session_id: "client-session-1",
|
session_id: "client-session-1",
|
||||||
todos: [
|
todos: [
|
||||||
@@ -372,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(
|
||||||
|
"已从持久化工具结果恢复最终答案。",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
appendBackendToolArtifact,
|
appendBackendToolArtifact,
|
||||||
cancelBackendTodos,
|
cancelBackendTodos,
|
||||||
|
completeBackendActivities,
|
||||||
|
completeBackendTodos,
|
||||||
upsertBackendQuestion,
|
upsertBackendQuestion,
|
||||||
} from "../../src/routes/chatUiState.js";
|
} 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", () => {
|
describe("appendBackendToolArtifact", () => {
|
||||||
it("persists show_chart tool calls as chart artifacts", () => {
|
it("persists show_chart tool calls as chart artifacts", () => {
|
||||||
const artifacts = appendBackendToolArtifact([], {
|
const artifacts = appendBackendToolArtifact([], {
|
||||||
session_id: "session-1",
|
session_id: "session-1",
|
||||||
tool: "show_chart",
|
tool: "show_chart",
|
||||||
reason: "测试折线图渲染",
|
|
||||||
params: {
|
params: {
|
||||||
title: "压力曲线",
|
title: "压力曲线",
|
||||||
chart_type: "line",
|
chart_type: "line",
|
||||||
@@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => {
|
|||||||
tool: "show_chart",
|
tool: "show_chart",
|
||||||
kind: "chart",
|
kind: "chart",
|
||||||
title: "压力曲线",
|
title: "压力曲线",
|
||||||
description: "测试折线图渲染",
|
|
||||||
params: {
|
params: {
|
||||||
chart_type: "line",
|
chart_type: "line",
|
||||||
x_data: ["00:00", "01:00"],
|
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" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe("Agent public REST router", () => {
|
|||||||
undefined as never,
|
undefined as never,
|
||||||
undefined as never,
|
undefined as never,
|
||||||
undefined as never,
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
);
|
);
|
||||||
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
|
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
|
||||||
const runtimeOperations = layers
|
const runtimeOperations = layers
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
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";
|
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||||
|
|
||||||
const createRuntimeAdapter = (
|
const createRuntimeAdapter = (
|
||||||
@@ -85,3 +89,230 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|||||||
expect(attempts).toBe(2);
|
expect(attempts).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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[] = [];
|
||||||
|
const client = {
|
||||||
|
global: {
|
||||||
|
health: async () => {
|
||||||
|
calls.push("health");
|
||||||
|
return { data: { healthy: true, version: "test" } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
calls.push("session.create");
|
||||||
|
return { data: { id: "warmup-session" } };
|
||||||
|
},
|
||||||
|
delete: async ({ sessionID }: { sessionID: string }) => {
|
||||||
|
calls.push(`session.delete:${sessionID}`);
|
||||||
|
return { data: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
list: async (model: { provider: string; model: string }) => {
|
||||||
|
calls.push(`tool.list:${model.provider}/${model.model}`);
|
||||||
|
return { data: [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
clientPromise: null,
|
||||||
|
closeServer: null,
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await runtime.warmup();
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"health",
|
||||||
|
"session.create",
|
||||||
|
`tool.list:${config.OPENCODE_MODEL}`,
|
||||||
|
"session.delete:warmup-session",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits question answers through the stable question API", async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const client = {
|
||||||
|
question: {
|
||||||
|
reply: async (input: unknown) => {
|
||||||
|
calls.push(input);
|
||||||
|
return { data: { ok: true } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
clientPromise: null,
|
||||||
|
closeServer: null,
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await runtime.replyQuestion({
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
answers: [["继续"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
requestID: "question-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
answers: [["继续"]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
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>>;
|
||||||
|
};
|
||||||
|
const permission = config.permission ?? {};
|
||||||
|
const bash = permission.bash as Record<string, string> | undefined;
|
||||||
|
const edit = permission.edit as Record<string, string> | undefined;
|
||||||
|
const read = permission.read as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
expect(permission["*"]).toBe("ask");
|
||||||
|
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");
|
||||||
|
expect(read?.["**/logs/**"]).toBe("deny");
|
||||||
|
expect(edit?.["*"]).toBe("ask");
|
||||||
|
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/*"]).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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
symlink,
|
||||||
|
utimes,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { cleanupExpiredToolOutputs } from "../../src/runtime/opencodeToolOutputCleanup.js";
|
||||||
|
|
||||||
|
describe("cleanupExpiredToolOutputs", () => {
|
||||||
|
test("removes only expired regular tool output files", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-tool-output-cleanup-"));
|
||||||
|
const expired = join(directory, "tool_expired");
|
||||||
|
const current = join(directory, "tool_current");
|
||||||
|
const unrelated = join(directory, "keep.txt");
|
||||||
|
const toolDirectory = join(directory, "tool_directory");
|
||||||
|
const toolSymlink = join(directory, "tool_symlink");
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(expired, "expired"),
|
||||||
|
writeFile(current, "current"),
|
||||||
|
writeFile(unrelated, "unrelated"),
|
||||||
|
mkdir(toolDirectory),
|
||||||
|
]);
|
||||||
|
await symlink(unrelated, toolSymlink);
|
||||||
|
const now = Date.now();
|
||||||
|
const old = new Date(now - 8 * 24 * 60 * 60 * 1000);
|
||||||
|
await utimes(expired, old, old);
|
||||||
|
|
||||||
|
const result = await cleanupExpiredToolOutputs(
|
||||||
|
directory,
|
||||||
|
7 * 24 * 60 * 60 * 1000,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ removed: 1, scanned: 5 });
|
||||||
|
await expect(stat(expired)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
expect(await readFile(current, "utf8")).toBe("current");
|
||||||
|
expect(await readFile(unrelated, "utf8")).toBe("unrelated");
|
||||||
|
expect((await stat(toolDirectory)).isDirectory()).toBe(true);
|
||||||
|
expect((await lstat(toolSymlink)).isSymbolicLink()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats a missing tool output directory as empty", async () => {
|
||||||
|
const directory = join(tmpdir(), `missing-tool-output-${crypto.randomUUID()}`);
|
||||||
|
await expect(cleanupExpiredToolOutputs(directory, 1_000)).resolves.toEqual({
|
||||||
|
removed: 0,
|
||||||
|
scanned: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ describe("runtime session context", () => {
|
|||||||
projectKey: "project-1",
|
projectKey: "project-1",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
traceId: "trace-1",
|
traceId: "trace-1",
|
||||||
|
workspaceDirectory: "/app/data/conversation-workspaces/chat-session-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtimeContext = getRuntimeSessionContext("runtime-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?.clientSessionId).toBe("chat-session-1");
|
||||||
expect(runtimeContext?.network).toBe("fengyang");
|
expect(runtimeContext?.network).toBe("fengyang");
|
||||||
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
||||||
|
expect(runtimeContext?.workspaceDirectory).toBe(
|
||||||
|
"/app/data/conversation-workspaces/chat-session-1",
|
||||||
|
);
|
||||||
|
|
||||||
removeRuntimeSessionContext("runtime-session-1");
|
removeRuntimeSessionContext("runtime-session-1");
|
||||||
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
|
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user