75 Commits
Author SHA1 Message Date
jiang 7f4918a1de fix(agent): request approval for protected path access
Generic Container CI/CD / test-build-publish (push) Successful in 2m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m4s
2026-09-25 15:44:46 +08:00
jiang 0eab59c52e fix(docker): install only required OpenCode workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 5m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 5m4s
2026-09-25 15:10:48 +08:00
jiang 4c09eb39cd fix(agent): align DeepSeek Flash model references
Generic Container CI/CD / test-build-publish (push) Canceled after 30m50s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Canceled after 30m41s
2026-09-25 14:38:03 +08:00
jiang 72ebf4d6c1 feat(agent): 完善分析编排与结果传输
Generic Container CI/CD / test-build-publish (push) Failing after 1m20s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m20s
2026-08-26 18:04:51 +08:00
jiang a438433068 refactor(cli)!: align commands with analysis run APIs
Replace legacy scheme commands with run-id based analysis and timeseries queries. Update sensor placement and SCADA routes, and refresh CLI help, tests, and usage guidance.

BREAKING CHANGE: legacy scheme, risk, and valve-close CLI command paths are removed.
2026-08-26 17:28:41 +08:00
jiang 4ec010455b fix(agent): stabilize sandboxed streaming workflows 2026-08-26 12:26:29 +08:00
jiang 80cfc1f2ab feat(agent): sandbox conversation analysis
Generic Container CI/CD / test-build-publish (push) Successful in 2m44s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m44s
2026-08-25 16:08:33 +08:00
jiang ce04704af2 fix(agent): isolate conversation workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 2m2s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m2s
2026-08-25 13:18:39 +08:00
jiang 004c9bb72d fix(agent): stabilize large tool results
Generic Container CI/CD / test-build-publish (push) Successful in 1m53s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m53s
2026-08-25 12:02:48 +08:00
jiang 774f39cbbe fix(chat): restore tool execution details
Generic Container CI/CD / test-build-publish (push) Successful in 2m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m4s
2026-08-24 18:45:45 +08:00
jiang c6efccb88a fix(chat): only expose final agent response
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-08-24 18:30:24 +08:00
jiang 11ebf428bb merge: integrate tjwater-cli into main
Merge PR #1 after CLI, contract, test, and container gates passed.
2026-08-18 17:56:44 +08:00
jiang 18e8b25f48 fix(agent): bound CLI subprocess execution 2026-08-18 17:00:23 +08:00
jiang 9aa5a96e60 merge(agent): integrate main into tjwater-cli 2026-08-18 16:42:15 +08:00
jiang a5f6474be5 fix(health): align Agent readiness contract
Generic Container CI/CD / test-build-publish (push) Successful in 35s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 35s
2026-08-11 11:20:40 +08:00
jiang b49290bd91 fix(container): isolate Agent runtime state
Generic Container CI/CD / test-build-publish (push) Successful in 3m1s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m1s
2026-08-11 10:10:30 +08:00
jiang 31fdb36e48 ci: activate Agent v2 deployment workflow 2026-08-11 09:29:30 +08:00
TJWater CI 0a64de89bb ci: replace Agent webhook workflow with v2 deployment
Generic Container CI/CD / test-build-publish (push) Successful in 34s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 34s
2026-08-11 09:26:46 +08:00
TJWater CI 2f267af7a3 revert: remove unused Agent PostgreSQL persistence 2026-08-07 18:06:08 +08:00
TJWater CI 649af949c5 feat(deploy): include Agent storage migration script in image 2026-08-07 18:00:55 +08:00
jiang c565e89d60 ci(agent): 添加新版发布与传输验证工作流 2026-08-07 18:00:54 +08:00
TJWater CI 9c9e31c570 fix(ci): provide dependencies to Agent build test stage 2026-08-07 17:58:49 +08:00
TJWater CI c0c54e238d fix(ci): include package manifest in Agent build test stage 2026-08-07 17:54:04 +08:00
TJWater CI 99f5a0b823 ci: run Agent Docker build test target 2026-08-07 17:52:08 +08:00
TJWater CI 4a9681c148 ci: use internal offline build cache 2026-08-07 17:35:56 +08:00
TJWater CI 8530793882 ci: add reusable container deployment workflow 2026-08-07 17:23:21 +08:00
jiang cb3aa3a150 ci: add Dev deployment transport canary 2026-08-07 17:15:27 +08:00
jiang 4cbeca4e09 fix(permissions): require approval for backend queries 2026-08-06 20:03:20 +08:00
jiang f66b9c3e9d fix(agent): disable unobservable task subagents 2026-08-06 19:24:03 +08:00
jiang b19af8846a feat(agent): 完善权限与结果引用安全 2026-08-06 18:41:52 +08:00
jiang a5e91ac2b8 test(cli): remove Python parity dependency 2026-08-06 15:58:47 +08:00
jiang 258f4996eb feat(agent): add credential refresh and unify learning tools 2026-08-06 10:16:50 +08:00
jiang 2dc37e3fd8 fix(agent): preserve runtime request context
OpenCode tools run in a child service, so they cannot read the Agent process-local session map. Hydrate a sanitized context through the authenticated internal bridge and centralize backend project headers to prevent context loss across both boundaries.
2026-08-05 18:39:09 +08:00
jiang a53839e157 fix(cli): map timeseries element types for backend 2026-08-05 18:39:00 +08:00
jiang 1407dd3bbe fix(opencode): restore stable v1 runtime 2026-08-05 17:59:35 +08:00
jiang 764a1f4e82 feat(opencode): migrate agent runtime to v2 2026-08-04 16:56:04 +08:00
jiang 07016451d6 fix(agent): complete opencode warmup before serving
The previous fire-and-forget startup only created the SDK client, leaving the first chat to await project and tool initialization. Warm the real session/tool path and gate port listening on completion.
2026-08-04 15:25:00 +08:00
jiang 94529cb141 feat(api): expose REST-only agent routes
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-30 20:38:52 +08:00
jiang 2415f75841 docs: 编写中文 README 2026-07-22 11:26:06 +08:00
jiang d7faaa2ecb fix(docker): make ubuntu apt mirror optional
Agent CI/CD / docker-image (push) Successful in 3m48s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-07-17 17:59:01 +08:00
jiang 78f9f6fb42 fix(ci): build docker image with host network
Agent CI/CD / docker-image (push) Failing after 26s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-17 17:46:53 +08:00
jiang 9aad38acb1 fix(docker): vendor bun binary for image builds
Agent CI/CD / docker-image (push) Failing after 24s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-07-17 17:42:44 +08:00
jiang d782b7fa11 fix(session): propagate network context
Agent CI/CD / docker-image (push) Failing after 15m0s
Agent CI/CD / deploy-fallback-log (push) Successful in 0s
2026-07-17 16:33:11 +08:00
jiang 40c0395fb1 fix(style): validate layer style configuration 2026-07-17 15:12:15 +08:00
jiang d75b98a61b fix(cli): use renamed backend APIs 2026-06-13 13:56:44 +08:00
jiang ead76185b7 refactor(agent): normalize API naming 2026-06-13 11:15:59 +08:00
jiang 8857b18dc9 feat(auth): validate agent context upstream 2026-06-12 10:18:41 +08:00
jiang 4b572d9264 chore(model): use flash agent model 2026-06-11 15:53:41 +08:00
jiang fc7393fa2b refactor(sessions): remove transcript truncate 2026-06-11 15:52:34 +08:00
jiang c823e3935e feat(chat): expose model options config
Agent CI/CD / docker-image (push) Failing after 24s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s
2026-06-10 19:50:40 +08:00
jiang 366c05b752 chore(model): default to deepseek flash 2026-06-10 19:33:08 +08:00
jiang fa2c28c1c0 refactor(chat): centralize session persistence 2026-06-10 19:29:42 +08:00
jiang cf6cada538 fix(tools): clarify chart data format
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-10 16:19:39 +08:00
jiang d1b91a4b1e docs: add repository guidelines
Agent CI/CD / docker-image (push) Successful in 1m1s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-09 18:18:22 +08:00
jiang 20b93c688f feat(tools): add search and map tools 2026-06-09 17:54:46 +08:00
jiang 873c169c2c fix(agent): warm up opencode on startup
Agent CI/CD / docker-image (push) Successful in 1m59s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 20:19:46 +08:00
jiang 8ed73b1da6 fix(chat): hide raw permission metadata
Agent CI/CD / docker-image (push) Successful in 2m38s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 20:12:08 +08:00
jiang 60b9080c47 refactor(chat): drop branch groups state
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 19:47:19 +08:00
jiang 801f611ce5 fix(chat): restore forked context
Agent CI/CD / docker-image (push) Successful in 1m38s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 19:33:13 +08:00
jiang 15c3263369 fix(chat): handle question and todo state 2026-06-08 18:10:28 +08:00
jiang f20847399a 更新 store_render_ref.ts render_junctions.ts 工具使用说明 2026-06-08 17:40:40 +08:00
jiang f3b62ed108 fix: regenerate from target turn
Agent CI/CD / docker-image (push) Successful in 49s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 16:07:39 +08:00
jiang 0e1ca2418f fix(chat): 支持重新生成前撤销消息
Agent CI/CD / docker-image (push) Successful in 5m14s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-08 14:38:52 +08:00
jiang f61389ab07 feat(chat): 支持自动始终允许权限 2026-06-08 14:14:52 +08:00
jiang 05d36aa8ca feat: handle opencode permission requests 2026-06-08 13:32:50 +08:00
jiang 4e31b141e7 添加不使用自代理的要求 2026-06-08 12:31:16 +08:00
jiang 7dbbeb4aa9 升级@opencode-ai版本到1.16.2 2026-06-08 11:46:24 +08:00
jiang 6f3b72628f fix(chat): guard abort and early idle races 2026-06-07 20:22:05 +08:00
jiang 2295bdcb97 暂时移除后端的认证校验
Agent CI/CD / docker-image (push) Successful in 1m5s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-07 19:58:59 +08:00
jiang 359b1e6e55 build(agent): include cli in build stage
Agent CI/CD / docker-image (push) Successful in 55s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-06-07 19:44:58 +08:00
jiang 93d70da8be refactor(cli): split tjwater cli modules
Agent CI/CD / deploy-fallback-log (push) Has been cancelled
Agent CI/CD / docker-image (push) Has been cancelled
2026-06-07 19:43:44 +08:00
jiang ff87817fb5 fix(cli): show group help 2026-06-07 18:57:48 +08:00
jiang 4b03aa3a91 build(cli): replace bundled binary cli 2026-06-07 18:53:10 +08:00
jiang 8a7964dc57 build(docker): include tjwater cli 2026-06-07 18:02:42 +08:00
jiang 5ac50bfeaa 切换到使用pg数据库 2026-05-28 18:22:39 +08:00
121 changed files with 16635 additions and 2032 deletions
+4
View File
@@ -1,7 +1,11 @@
.git
node_modules
.opencode/node_modules
.env
.env.*
.local.env
data/
logs/
dist
.vscode
*.log
+15 -211
View File
@@ -1,219 +1,23 @@
name: Agent CI/CD
name: Agent CI/CD v2
on:
push:
tags:
- "v*"
- "latest"
workflow_dispatch: {}
jobs:
docker-image:
runs-on: ubuntu-22.04
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- name: Setup tools
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_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
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 \
-f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:latest"
else
docker build \
-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."
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
image_name: gitea.waternetwork.cn/orgtjwater/tjwateragent
dockerfile: Dockerfile
build_context: .
cache_image: gitea.waternetwork.cn/orgtjwater/tjwateragent:ci-cache
test_target: test
deploy_service: agent
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
-2
View File
@@ -1,12 +1,10 @@
node_modules/
__pycache__/
.opencode/node_modules/
.opencode/skills/
.local.env
.vscode
docker-compose.yml
data/
logs/
cli/
AGENT_HARNESS_REPORT.md
HARNESS_INTRODUCTION.md
+28 -28
View File
@@ -1,11 +1,21 @@
---
description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary
model: deepseek/deepseek-v4-pro
temperature: 0.2
model: deepseek/deepseek-flash
---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划;简单问答不创建计划
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
- 开始工作及每次进入新的业务阶段时调用一次 `activity_update`,用 `title` 概括当前阶段、用 `reason` 说明该阶段为何必要;已有计划时必须通过 `todos` 提交完整计划状态快照,使阶段与任务状态同时更新,不再单独调用 `todowrite` 更新里程碑
- `activity_update` 是过程分组,不是任务清单:活动描述当前正在做的一组动作,`todowrite` 描述整个任务的业务目标与完成状态
## 工作流生命周期
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
@@ -30,18 +40,26 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|------|------|
| 获取后端数据(数据源、推理、分析) | `tjwater_cli` |
| 发现可用命令 | `tjwater_cli(command="help")` |
| UI 操作 / 可视化 | `locate_features`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` |
| 持久化渲染数据 | `store_render_ref` → `render_junctions` |
| 查询实时公开网页信息 | `web_search` |
| 地址/地点转经纬度 | `geocode` |
| UI 操作 / 可视化 | `locate_features`、`zoom_to_map`、`view_scada`、`show_chart`、`render_junctions`、`view_history`、`apply_layer_style` |
| 持久化渲染数据 | ①准备 { node_area_map } JSON → ②`store_render_ref` 存为受控 ref → ③`render_junctions` 渲染到前端 |
**前端工具仅做显示,不返回数据**,不要假设其返回内容。
`tjwater_cli.command` 虽然是字符串,但命令空间不是可类推的层级语法。当前会话尚未验证某个完整命令路径和参数时,先调用 `help <命令族或前缀>`;已加载工作流中明确记录且已验证的固定命令可直接使用。禁止根据 `analysis runs` 等已有路径创造其他命令族的同名子路径。收到 `COMMAND_NOT_FOUND` 后只执行返回的 `next_commands` 做命令发现,不得继续猜测近似命令。
## 执行约束
1. 每次工具调用必须在 `reason` 字段填写具体理由
1. 普通工具不填写重复的调用理由,具体动作自动归入当前 `activity_update` 活动;切换业务阶段前先更新活动
2. `tjwater-cli` 输出为 JSON(`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果
6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程
7. Bash 始终运行在当前对话专属沙箱中:只能读写当前对话目录、读取 skills 和 Python 环境,不能联网,也不能访问 `/app` 源码、密钥、其他对话或全局 `tool-output`
8. 需要文件输入时,调用 `tjwater_cli(..., store_result=true)`,使用返回的 `data_file.file_path`;不要把 JSON 手工写到 `/tmp`
9. `store_render_ref` 的输入必须是 `{metadata, location: {file_path}, data}` 包装 JSON;若旧脚本输出裸数据,先在当前对话目录内包装,且 `location.file_path` 必须等于包装文件的绝对路径
## 工作流沉淀(skill_manager)
@@ -56,30 +74,12 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`。
**脚本编写要求——优先用 pipe 串联**:
**脚本编写要求——数据获取与本地分析分离**:
workflow skill 脚本应尽量用 shell pipe 在一次 subprocess 调用中串联多个 CLI 命令。减少 tool calling 次数,提升执行效率。
```python
import subprocess, os
# env dict 仅用于当前子进程,不污染 os.environ,多用户安全
env = {**os.environ,
"TJWATER_SERVER": auth["server"],
"TJWATER_ACCESS_TOKEN": auth["access_token"], ...}
# 好:一次 shell 调用,pipe 串联
cmd = "tjwater-cli net list-pipes | jq '...' | xargs tjwater-cli analysis calc"
result = subprocess.run(cmd, shell=True, env=env, capture_output=True, text=True)
# 差:多次 subprocess.run
step1 = subprocess.run(["tjwater-cli", "net", "list-pipes"], ...)
step2 = subprocess.run(["tjwater-cli", "analysis", "calc"], ...)
```
管道场景下用子进程隔离的 env dict 传认证,释放 stdin 给管道数据流。不修改全局 `os.environ`。认证 JSON 由内部桥接注入,脚本不硬编码。
CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell 管道中完成。
- 后端数据只能由 `tjwater_cli` 工具获取;认证与网络请求留在 Agent 主进程
- 分析脚本接收 `data_file.file_path`,只处理当前对话目录内的本地文件
- 多份互不依赖的数据可并行调用 `tjwater_cli(..., store_result=true)`,随后在一次沙箱 Bash 中运行 Python 分析
- 脚本输出文件必须写入当前工作目录;禁止使用 `/tmp`、全局 `tool-output` 或硬编码认证环境变量
**触发时机**:
- 用户明确说"保存/沉淀/记录工作流"
+23 -9
View File
@@ -4,7 +4,7 @@
"workspaces": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.14.41",
"@opencode-ai/plugin": "1.18.13",
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -13,11 +13,23 @@
},
},
"packages": {
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.41", "", { "dependencies": { "@opencode-ai/sdk": "1.14.41", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.41", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ=="],
"@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-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
"@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.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.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=="],
@@ -27,21 +39,23 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"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=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"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=="],
"msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="],
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
@@ -61,7 +75,7 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"uuid": ["uuid@13.0.2", "", { "bin": "dist-node/bin/uuid" }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="],
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+1 -1
View File
@@ -4,7 +4,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@opencode-ai/plugin": "1.14.41"
"@opencode-ai/plugin": "1.18.13"
},
"devDependencies": {
"@types/node": "^24.7.2",
+181
View File
@@ -0,0 +1,181 @@
---
name: tjwater-cli
description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输出格式、命令族、错误处理及最佳实践。
---
# tjwater-cli 使用说明
## 概述
`tjwater-cli` 是 TJWater 供水管网系统的命令行工具,用于与后端服务交互,支持数据查询、分析和工程操作。所有输出统一为 JSON 格式。
## 工具调用
通过 `tjwater_cli` 工具执行 CLI 命令:
```json
{
"command": "project list",
"timeout": 120,
"store_result": false
}
```
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context`) |
| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
| `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true |
认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。
## 命令发现
Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。
**重要:命令分为三类——触发动作、运行/结果查询与时序数据获取。**
- **触发动作**(`simulation`、各类 `analysis`):向服务端发起计算请求。
- **运行/结果查询**(`analysis runs`):按 `run_id` 查询运行元数据和非时序结果。
- **时序数据获取**(`data timeseries`):实时结果或按 `run_id` 查询节点、管道时序。
```
analysis → 触发计算 → analysis runs list/get/results
↓ run_id
data timeseries analysis → 获取元素时序
simulation → 触发实时模拟 → data timeseries realtime → 获取实时结果
```
通过 `help` 发现命令:
```
tjwater-cli help → 一级命令清单(含 commands 数组和 summary)
tjwater-cli help data timeseries → data timeseries 的子命令与参数详情
tjwater-cli help simulation → simulation 的子命令与参数详情
tjwater-cli help COMMAND → 子命令与参数详情
```
`help` 返回 JSON 格式,Agent 可直接解析 `commands` 数组识别可用能力。
**严禁猜测命令或参数!** 所有命令路径、子命令和参数(名称、类型、必填/可选)均以 `help` 输出为准。执行任何命令前,必须先通过 `help` 确认其存在及参数签名,禁止凭经验拼写。
### 已知命令族
| 命令族 | 典型子命令 | 用途 |
|------|-----------|------|
| `network` | `get-pipe-properties`, `get-all-pipes-properties` | 管网元素查询 |
| `component` | `option get`, `option schema` | 模型选项和结构查询 |
| `data` | `timeseries realtime`, `timeseries analysis`, `timeseries scada`, `scada` | 实时、分析时序和 SCADA 查询 |
| `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) |
| `analysis` | `runs`, `sensor-placement` 及各类分析命令 | 触发分析,并按运行 ID 查询元数据与结果 |
| `help` | (无子命令) | 命令发现入口 |
> 完整命令清单始终以 `tjwater-cli help` 实时输出为准。
## 输出格式
所有命令返回统一 JSON 结构:
```json
{
"schema_version": "tjwater-cli/v1",
"ok": true,
"data": { ... },
"error": {
"code": "COMMAND_NOT_FOUND",
"message": "详细错误描述"
}
}
```
- `ok: true` — 成功,数据在 `data` 字段
- `ok: false` — 失败,检查 `error.code` 和 `error.message`
### 大结果集处理
超过内联阈值的结果不会终止 CLI,而是保存到当前对话的 `tool-data/` 目录并返回:
```json
{
"ok": true,
"data_file": {
"file_path": "/app/data/conversation-workspaces/conversation-.../tool-data/cli-....json",
"bytes": 38700000,
"content_type": "application/json"
}
}
```
禁止完整读取超大结果集。优先使用:
- 采样/截断参数(如 `--limit`、`--offset`)
- `--field` 按字段过滤
- `store_result=true` 后用沙箱 Python 脚本按字段读取
## 错误码速查
| error.code | 含义 | 来源 | 处理建议 |
|------|------|------|------|
| `UNAUTHENTICATED` | 缺少 access token | CLI `core.py:162` | 检查认证上下文注入 |
| `SERVER_ERROR` | 后端返回 error 状态 | CLI `core.py:400` | 记录 `request_id`,结合后端日志排查 |
| `REQUEST_TIMEOUT` | CLI 请求后端超时 | CLI `core.py:445` | 增大 `timeout` 参数或检查后端负载 |
| `TIMEOUT` | bridge 层进程超时 | Agent `server.ts:199` | 增大 `tjwater_cli` 的 `timeout` 参数 |
| `COMMAND_NOT_FOUND` | 命令/子命令不存在 | CLI `helping.py:300` | 执行 `help` 确认命令拼写 |
| `INPUT_NOT_FOUND` | `--input` 文件不存在 | CLI `core.py:243` | 检查文件路径 |
| `REQUEST_FAILED` | 网络连接失败 | CLI `core.py:453` | 检查服务端可达性 |
| `AUTH_CONTEXT_INVALID` | 认证上下文格式错误 | CLI `core.py:111` | 检查 auth headers 格式 |
## 最佳实践
1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写
2. **阶段分组** — 调用 CLI 前确认当前业务阶段已通过 `activity_update` 建立,同一阶段的多个查询无需重复说明理由
3. **按运行 ID 取结果** — 分析完成后先用 `analysis runs list/get/results` 获取 `run_id` 和非时序结果;元素时序再用 `data timeseries analysis` 查询
4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI
5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试
6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据
7. **模拟时长控制** — 实时模拟或分析运行的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大
## 示例
### 查询所有实时节点数据
```json
{
"command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
}
```
> `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。
### 按节点查询分析运行时序字段
```json
{
"command": "data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
}
```
### 查询 SCADA 时序数据
```json
{
"command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00"
}
```
### 触发仿真并获取结果
通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真:
```json
// step 1: 先尝试获取仿真结果
{
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
}
// step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真
{
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60"
}
// step 3: 仿真完成后,再次获取结果(同 step 1)
{
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
}
```
`simulation run` 仅接受 `--start-time`(RFC3339,必填)和 `--duration`(整数分钟,必填)。
@@ -0,0 +1,154 @@
---
name: hydraulic-bottleneck-analysis
description: 基于实时水力数据的管网水力瓶颈识别与改造建议。复合评分法(流速×水头损失)定位瓶颈管段,输出分级改造方案。
---
# 水力瓶颈分析工作流
## 概述
本工作流通过复合评分法(流速分级 × 水头损失百分位)从全管网管道中识别水力瓶颈管段,并结合节点压力、管径、粗糙系数给出分级改造建议。
适用场景:管网运行评估、管网改造优先级排序、泵站阀站运行诊断。
## 评分方法论
### 双维度复合评分
| 维度 | 判定标准 | 分值 |
|------|----------|------|
| **流速** | >3.0 m/s = 极危 | 3 |
| | 2.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`
@@ -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()
+37
View File
@@ -0,0 +1,37 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"开始一个新的业务活动阶段。仅在语义阶段发生变化时调用一次,用 title 概括阶段,用 reason 说明本阶段为何必要;同一阶段内的多个工具动作不要重复调用。该工具只更新用户可见的过程信息,不执行外部操作。",
args: {
title: tool.schema
.string()
.min(1)
.describe("面向用户的简短业务阶段标题,不包含工具名、函数名、文件名或命令。"),
reason: tool.schema
.string()
.min(1)
.describe("本阶段对完成用户目标的必要性,使用一句简洁的自然语言。"),
todos: tool.schema
.array(
tool.schema.object({
id: tool.schema.string().optional(),
content: tool.schema.string().min(1),
status: tool.schema.enum([
"pending",
"in_progress",
"completed",
"cancelled",
]),
priority: tool.schema.enum(["low", "medium", "high"]).optional(),
}),
)
.optional()
.describe(
"已有任务计划时提交完整状态快照,使本阶段与任务状态在同一次更新中生效。",
),
},
async execute() {
return "活动阶段已更新。";
},
});
+28 -14
View File
@@ -4,11 +4,6 @@ export default tool({
description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema
.enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
@@ -28,8 +23,11 @@ export default tool({
.describe("Classification method."),
segments: tool.schema
.number()
.int()
.min(2)
.max(10)
.optional()
.describe("Number of segments."),
.describe("Number of rendered intervals, from 2 to 10."),
min_size: tool.schema
.number()
.optional()
@@ -54,9 +52,9 @@ export default tool({
.enum(["single", "gradient", "rainbow", "custom"])
.optional()
.describe("Color strategy."),
single_palette_index: tool.schema.number().optional(),
gradient_palette_index: tool.schema.number().optional(),
rainbow_palette_index: tool.schema.number().optional(),
single_palette_index: tool.schema.number().int().min(0).max(6).optional(),
gradient_palette_index: tool.schema.number().int().min(0).max(2).optional(),
rainbow_palette_index: tool.schema.number().int().min(0).max(1).optional(),
show_labels: tool.schema
.boolean()
.optional()
@@ -65,7 +63,7 @@ export default tool({
.boolean()
.optional()
.describe("Whether to show ids."),
opacity: tool.schema.number().optional().describe("Opacity in [0, 1]."),
opacity: tool.schema.number().min(0).max(1).optional().describe("Opacity in [0, 1]."),
adjust_width_by_property: tool.schema
.boolean()
.optional()
@@ -73,11 +71,11 @@ export default tool({
custom_breaks: tool.schema
.array(tool.schema.number())
.optional()
.describe("Custom break values."),
.describe("Strictly increasing boundaries. Length must equal segments + 1."),
custom_colors: tool.schema
.array(tool.schema.string())
.optional()
.describe("Custom rgba colors."),
.describe("Custom CSS colors. Length must equal segments."),
})
.optional()
.describe(
@@ -87,8 +85,24 @@ export default tool({
async execute(args) {
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
if (args.reset_to_default) {
return `已将${layerLabel}图层重置为默认样式。`;
return `已提交${layerLabel}图层默认样式重置请求。`;
}
return `已对${layerLabel}图层应用样式。`;
const style = args.style_config;
if (style?.custom_breaks) {
if (
style.custom_breaks.some(
(value, index, values) => index > 0 && value <= values[index - 1],
)
) {
throw new Error("custom_breaks must be strictly increasing");
}
if (style.segments && style.custom_breaks.length !== style.segments + 1) {
throw new Error("custom_breaks length must equal segments + 1");
}
}
if (style?.segments && style.custom_colors && style.custom_colors.length !== style.segments) {
throw new Error("custom_colors length must equal segments");
}
return `已提交${layerLabel}图层样式请求。`;
},
});
+53
View File
@@ -0,0 +1,53 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"在当前对话专属的 Landlock 沙箱中运行 Shell 命令。只能读写当前对话工作区,不能访问网络、密钥、其他对话或应用源码。",
args: {
command: tool.schema.string().describe("需要在沙箱中执行的 Shell 命令。"),
description: tool.schema
.string()
.optional()
.describe("面向用户的简短命令说明。"),
timeout: tool.schema
.number()
.optional()
.describe("超时秒数,默认 120,最大 1800。"),
},
async execute(args, context) {
await context.ask({
permission: "bash",
patterns: [args.command],
always: [args.command],
metadata: {
command: args.command,
...(args.description ? { description: args.description } : {}),
},
});
const response = await fetch(
`${internalBaseUrl}/internal/tools/sandbox-shell`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
command: args.command,
description: args.description,
timeout: args.timeout,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+15
View File
@@ -0,0 +1,15 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"提交直接展示给用户的最终回答。只能在全部业务动作和其他工具调用完成后调用一次;调用后不得继续调用任何工具或输出额外文本。",
args: {
answer: tool.schema
.string()
.min(1)
.describe("直接展示给用户的完整最终回答,使用简体中文和 Markdown。"),
},
async execute() {
return "最终回答已提交。";
},
});
+34
View File
@@ -0,0 +1,34 @@
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:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: {
keyword: tool.schema
.string()
.describe("Address or place name to geocode, such as 北京市人民政府."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/geocode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
keyword: args.keyword,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "在前端地图上定位并高亮指定的管网要素。",
args: {
reason: tool.schema
.string()
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema
.array(tool.schema.string())
.describe("Feature ids to locate."),
+21 -127
View File
@@ -1,12 +1,8 @@
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 initializePromise = memoryStore.initialize();
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:
@@ -15,9 +11,6 @@ export default tool({
action: tool.schema
.enum(["add", "list", "replace", "remove"])
.describe("Memory operation to perform."),
reason: tool.schema
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema
.string()
.describe(
@@ -26,130 +19,31 @@ export default tool({
content: tool.schema
.string()
.optional()
.describe(
"The durable fact or preference to remember, written as one concise sentence.",
),
.describe("The durable fact or preference to remember, written as one concise sentence."),
target_id: tool.schema
.string()
.optional()
.describe("Stable memory entry id used by replace/remove."),
},
async execute(args, context) {
await initializePromise;
const sessionContext = getRuntimeSessionContext(context.sessionID);
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 ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
const response = await fetch(
`${internalBaseUrl}/internal/tools/memory-manager`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
}
const result = await memoryStore.remove(
scope,
scopeKey,
args.target_id ?? "",
body: JSON.stringify({
...args,
session_id: context.sessionID,
}),
},
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+2 -7
View File
@@ -2,17 +2,12 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 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。",
"在前端地图上对 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: {
reason: tool.schema
.string()
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema
.string()
.describe(
"渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } };node_area_map 必填,area_ids / area_colors 可选。",
"上一步通过 store_render_ref 获得的渲染引用 ID(res-...)。前端会按该引用拉取完整 payload 并渲染。不可直接传入本地文件路径或完整 JSON 数据。",
),
},
async execute() {
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description:
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
args: {
reason: tool.schema
.string()
.describe("Why prior session history is needed for the current request."),
query: tool.schema
.string()
.describe("What to search for in prior session history."),
+8 -6
View File
@@ -1,22 +1,24 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description: "在前端对话界面中渲染图表。",
description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: {
reason: tool.schema
.string()
.describe("Why this chart should be rendered for the user request."),
title: tool.schema.string().optional().describe("Chart title."),
chart_type: tool.schema
.enum(["line", "bar", "pie"])
.optional()
.describe("Chart type."),
x_data: tool.schema.array(tool.schema.string()).describe("X-axis labels."),
x_data: tool.schema
.array(tool.schema.string())
.describe("X-axis labels. For line charts, put time/category labels here."),
series: tool.schema
.array(
tool.schema.object({
name: tool.schema.string(),
data: tool.schema.array(tool.schema.number()),
data: tool.schema
.array(tool.schema.number())
.describe("Y values only. Must align by index with x_data."),
type: tool.schema.enum(["line", "bar"]).optional(),
}),
)
+61 -151
View File
@@ -1,154 +1,64 @@
import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js";
import {
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
type ToolContextReader = {
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:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe(
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema
.string()
.optional()
.describe(
"Content used by write_skill, write_reference, or write_script.",
),
},
async execute(args, context) {
await initializePromise;
const sessionContext = toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
if (
sessionContext.allowLearningWrite === false &&
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",
...result,
});
}
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,
});
},
});
export default createSkillManagerTool();
export default tool({
description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: {
action: tool.schema
.enum([
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
])
.describe("Skill maintenance operation."),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
),
pattern: tool.schema.string().optional().describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
content: tool.schema
.string()
.optional()
.describe("Content used by write_skill, write_reference, or write_script."),
},
async execute(args, context) {
const response = await fetch(
`${internalBaseUrl}/internal/tools/skill-manager`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
...args,
session_id: context.sessionID,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+24 -8
View File
@@ -4,22 +4,38 @@ const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
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({
description:
"把本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。",
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: {
reason: tool.schema
.string()
.describe(
"Why this local render payload should be persisted as a render_ref.",
),
file_path: tool.schema
.string()
.optional()
.describe(
"Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
"位于当前对话工作目录内的包装 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) {
const filePath = resolveStoreRenderFilePath(args);
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
@@ -30,7 +46,7 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
file_path: filePath,
}),
},
);
+9 -6
View File
@@ -6,20 +6,23 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。",
"通过本地 Agent 桥接调用 tjwater-cli。命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。",
args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
command: tool.schema
.string()
.describe(
"tjwater-cli 子命令,不含二进制路径。示例:'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
.number()
.optional()
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
store_result: tool.schema
.boolean()
.optional()
.describe(
"是否强制把结果保存到当前对话工作区并返回 data_file。分析脚本需要文件输入时设为 true。",
),
},
async execute(args, context) {
const response = await fetch(
@@ -32,8 +35,8 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
reason: args.reason,
command: args.command,
store_result: args.store_result,
timeout: args.timeout,
}),
},
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
args: {
reason: tool.schema
.string()
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."),
-3
View File
@@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description: "打开前端的 SCADA 监测数据历史面板。",
args: {
reason: tool.schema
.string()
.describe("Why SCADA panel interaction is required for this request."),
device_ids: tool.schema
.array(tool.schema.string())
.optional()
+59
View File
@@ -0,0 +1,59 @@
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:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: {
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
.optional()
.describe("Optional freshness filter. Defaults to no_limit."),
summary: tool.schema
.boolean()
.optional()
.describe("Whether the backend should include page summaries."),
count: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional result count, backend accepts 1 to 50."),
include: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to include."),
exclude: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to exclude."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
query: args.query,
freshness: args.freshness,
summary: args.summary,
count: args.count,
include: args.include,
exclude: args.exclude,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
+29
View File
@@ -0,0 +1,29 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: {
x: tool.schema
.number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
y: tool.schema
.number()
.describe("Y coordinate. For EPSG:4326 this is latitude; for EPSG:3857 this is meters."),
source_crs: tool.schema
.enum(["EPSG:3857", "EPSG:4326"])
.optional()
.describe("Input coordinate CRS. Defaults to EPSG:3857."),
zoom: tool.schema
.number()
.optional()
.describe("Optional OpenLayers zoom level. Defaults to 18."),
duration_ms: tool.schema
.number()
.optional()
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
},
async execute() {
return "已缩放到指定地图坐标。";
},
});
+41
View File
@@ -0,0 +1,41 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is the internal TJWater agent service. Runtime TypeScript code is organized under `src/`; CLI-specific code is under `cli/src`; Node test files live in `node-tests/`. Agent extensions and OpenCode integration files are under `.opencode/`, including `.opencode/agents`, `.opencode/skills`, and `.opencode/tools`. Runtime data, session metadata, logs, and result references are stored under `data/` and `logs/` and should be treated as local/generated state.
Deployment files are `Dockerfile`, `docker-compose.yml`, and `.gitea/workflows/package.yml`.
## Build, Test, and Development Commands
Use Bun for this project:
```bash
bun install
bun run dev
bun run check
bun run test:cli
bun run start
```
`bun run dev` starts `src/server.ts` in watch mode. `bun run check` runs TypeScript checks for the main project and `.opencode`. `bun run test:cli` runs Node CLI tests. `bun run start` starts the service without watch mode.
## Coding Style & Naming Conventions
Use TypeScript ESM and keep types explicit at module boundaries. Use two-space indentation, `camelCase` for functions and variables, `PascalCase` for classes/types, and kebab-case or descriptive lowercase names for scripts and data files. Prefer existing `src/` service, route, and runtime patterns before introducing new structure.
## Testing Guidelines
Tests use Node's built-in test runner for CLI coverage. Name test files with `.node.mjs` when using `node --test`, matching `node-tests/cli/*.node.mjs`. Add focused tests for CLI parsing, tool behavior, and session/runtime changes. Do not depend on mutable local files under `data/`.
## Commit & Pull Request Guidelines
History uses Conventional Commit messages such as `feat(tools): add search and map tools`, `fix(agent): warm up opencode on startup`, and `refactor(chat): ...`. Prefer `feat(scope):`, `fix(scope):`, or `refactor(scope):`.
PRs should describe runtime behavior changes, list `bun run check` and any test commands run, and mention changes to `.opencode`, secrets, ports, or deploy workflow behavior.
## Security & Configuration Tips
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.
+63 -17
View File
@@ -1,8 +1,6 @@
FROM oven/bun:canary-slim AS bun-bin
FROM smanx/opencode:latest AS base
FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
USER root
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com
ARG UBUNTU_APT_MIRROR=
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
ENV VIRTUAL_ENV=/opt/venv
@@ -11,14 +9,23 @@ ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
COPY vendor/bun-linux-x64.zip /tmp/bun.zip
RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
fi && \
apt-get update && apt-get install -y --no-install-recommends \
curl \
jq \
libseccomp2 \
unzip \
python3 \
python3-venv && \
unzip -q /tmp/bun.zip -d /usr/local && \
mv /usr/local/bun-linux-x64 /usr/local/bun && \
ln -sf /usr/local/bun/bun /usr/local/bin/bun && \
rm -f /tmp/bun.zip && \
curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -sf /usr/bin/python3 /usr/local/bin/python && \
@@ -37,9 +44,34 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
rich \
ipython \
pytest && \
(getent group 10001 >/dev/null || groupadd --gid 10001 tjwater-sandbox) && \
(getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \
rm -rf /var/lib/apt/lists/*
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
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 --filter ./packages/opencode --filter @opencode-ai/llm && \
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
@@ -54,29 +86,43 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./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 cli ./cli
COPY scripts ./scripts
COPY .opencode ./.opencode
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
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN opencode --version
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8787
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
ENV OPENCODE_HOST=127.0.0.1
ENV OPENCODE_HOSTNAME=127.0.0.1
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
ENTRYPOINT ["/entrypoint.sh"]
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"]
+95 -289
View File
@@ -1,323 +1,129 @@
# TJWaterAgent 目录结构说明
# TJWaterAgent 内部智能体服务
`TJWaterAgent/` 是新的 opencode Agent 服务工程目录,负责对外提供 TJWater 智能助手接口,并通过 opencode SDK 启动或连接 opencode 运行时。
`TJWaterAgent` 是 TJWater 内部版智能体服务,负责连接前端聊天界面、OpenCode 运行时、MCP 工具和 TJWater 后端 API。它面向内部研发与部署,保留完整的 Agent 编排、会话上下文、工具调用和运行时调试能力。
## 总体边界
## 主要能力
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
- 以内嵌模式启动并预热 OpenCode 运行时。
- 管理前端 `session_id` 与 OpenCode session 的映射。
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
- 通过 `data/` 保存运行时会话元数据、结果引用和本地状态。
## 目录结构
```text
TJWaterAgent/
package.json
tsconfig.json
src/
opencode.json
.opencode/
agents/
tools/
skills/
package.json
tsconfig.json
src/ 服务端 TypeScript 源码
src/routes/ HTTP 路由
src/chat/ 聊天流和 SSE 事件适配
src/runtime/ OpenCode 运行时管理
src/session/ 会话映射和运行上下文
src/mcp/ MCP 服务与工具桥接
cli/ Agent 使用的 TypeScript 后端 API CLI
.opencode/agents/ Agent prompt 和模型行为配置
.opencode/tools/ OpenCode 自定义工具
.opencode/skills/ 可复用分析工作流
node-tests/ Node CLI 测试
data/ 本地运行时数据,禁止提交
logs/ 本地日志,禁止提交
```
| 位置 | 主要作用 | 典型内容 |
| --- | --- | --- |
| `TJWaterAgent/` | 服务宿主、API 层和编排层 | Express 服务、SSE 接口、会话管理、鉴权上下文、后端 API 代理、opencode SDK 启动逻辑 |
| `TJWaterAgent/.opencode/` | opencode 项目资产目录 | agent prompt、自定义 tools、skills 树、plugins 相关依赖 |
仓库跟踪 `.opencode/skills/` 中经过评审的默认工作流基线;部署环境仍可通过持久化卷保留 `skill_manager` 在运行中沉淀的增量内容。默认基线不得包含真实客户数据、认证信息或本地执行产物。
## `TJWaterAgent/` 根目录的职责
## 本地开发
根目录是 Node/TypeScript 服务本体,主要负责:
1. 启动 HTTP 服务。
2. 通过 `@opencode-ai/sdk` 启动内嵌 opencode server,或连接外部 opencode server。
3. 管理前端 `session_id -> opencode sessionId` 的映射。
4. 保存并传递用户 `Authorization`、`x-user-id`、`x-project-id`、`x-trace-id`。
5. 把 opencode 输出适配成前端需要的 SSE 事件。
6. 为 `.opencode/tools/tjwater_cli.ts` 提供内部回调接口。
7. 代理调用真实 TJWater 后端 API。
当前 Agent API 的主入口:
```text
POST /api/v1/agent/chat/stream
```
该接口返回 SSE,事件包括:
| event | 用途 |
| --- | --- |
| `progress` | 前端过程可视化,展示规划、工具调用和完成状态 |
| `token` | 最终回答文本流 |
| `tool_call` | 前端地图/面板/图表动作 |
| `done` | 当前轮完成 |
| `error` | 当前轮失败 |
主要目录和文件:
```text
src/
server.ts
config.ts
runtime/
session/
chat/
routes/
tools/
```
其中 `src/` 是业务服务层,不直接放 opencode skill 或 agent prompt。
## `.opencode/` 的职责
`.opencode/` 是给 opencode 运行时读取的项目资产目录,不是对外 HTTP 服务的主代码目录。
### agents
```text
.opencode/agents/agent.md
```
这里定义默认 agent 的角色、行为规则、模型配置和工具使用策略。
当前项目已将 always-loaded instructions 收敛到 `agent.md`,`opencode.json` 不再额外配置 `instructions` 数组。
### tools
```text
.opencode/tools/
tjwater_cli.ts
store_render_ref.ts
locate_features.ts
view_history.ts
view_scada.ts
show_chart.ts
render_junctions.ts
apply_layer_style.ts
memory_manager.ts
session_search.ts
skill_manager.ts
```
这些是 opencode 可以调用的自定义工具。
`tjwater_cli.ts` 不直接保存用户 token。它会回调 `TJWaterAgent` 的内部接口,由上级服务层根据当前 session 补上用户 token、项目 ID 和 trace ID,再调用 `tjwater-cli` 二进制执行后端命令。
`store_render_ref.ts` 用于把大型 junction 渲染 payload 存成 `render_ref`,再由 `render_junctions.ts` 交给前端回读并渲染。
前端类工具如 `locate_features`、`view_history`、`view_scada`、`show_chart`、`render_junctions`、`apply_layer_style` 主要用于触发 UI 动作或可视化,不应被当作数据查询工具。
### skills
```text
.opencode/skills/
SKILL.md
examples.md
runbook.md
tjwater-cli/ ← tjwater-cli 可执行文件
workflow/ ← 可复用分析工作流
SKILL.md
simulation-diagnosis/
bottleneck-analysis/
source-service-area-analysis/
```
Skills 仅保留可复用的多步工作流。Agent 通过 `tjwater-cli help` 自行发现原子命令,无需逐接口技能树。
agent 加载技能树时按需取用对应 workflow skill。
## 依赖边界
根目录和 `.opencode/` 使用两组 npm 依赖,职责不同。
### 根目录依赖
```text
TJWaterAgent/package.json
```
用于服务本体,例如:
```text
@opencode-ai/sdk
express
zod
pino
```
### `.opencode` 依赖
```text
TJWaterAgent/.opencode/package.json
```
用于 opencode 自定义 tools/plugins,例如:
```text
@opencode-ai/plugin
typescript
@types/node
```
这两组依赖不要混在一起:根目录负责服务运行,`.opencode` 负责 opencode 扩展资产的类型检查和运行依赖。
## 启动与部署
支持两种 opencode 接入方式:
1. Embedded 模式:服务通过 `@opencode-ai/sdk` 调用 `createOpencode`,启动本地 `opencode` CLI 子进程并自动创建 client。
2. Client 模式:服务通过 `createOpencodeClient` 直接连接一个已经存在的 opencode server。
因此,只有 Embedded 模式要求运行环境已安装 `opencode` CLI;Client 模式不依赖本地 CLI。
根目录的 Bun scripts 已经封装 `.opencode` 依赖安装和类型检查,日常只需要在 `TJWaterAgent/` 根目录操作。
### 本地开发
项目使用 Bun:
```bash
cd TJWaterAgent
bun install
bun run dev
```
`bun install` 会通过 `postinstall` 自动执行 `.opencode` 依赖安装;`bun run dev` 启动前会检查 `.opencode/tools` 的类型。
`bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**`、`.opencode/**`、`opencode.json` 或 `.local.env` 后会自动重启。
开发模式支持热重载,以下文件变化会触发服务重启并重新拉起 embedded opencode:
`cli/tjwater-cli` 是当前唯一的 TJWater 业务 CLI 入口,由 Bun 直接执行
`cli/tjwater-cli.ts` 及 `cli/src/` 源码,并随 Agent 镜像一起交付,不需要
Python 或 PyInstaller 构建步骤。
```text
src/**
.opencode/**
opencode.json
.local.env
## 常用命令
```bash
bun run check
bun run contract:generate
bun run test:api
bun run test:cli
bun run start
bun run start:prod
docker build -t tjwater-agent:local .
```
因此修改 agent prompt、tools、skills、模型配置或本地环境变量后,不需要手动重启 `bun run dev`。
- `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型。
- `bun run contract:generate`:生成 `contracts/agent-v1.openapi.json`。
- `bun run test:api`:验证公开 REST 契约和聊天路由。
- `bun run test:cli`:运行 `node-tests/cli/*.node.mjs`。
- `bun run start`:直接启动服务。
- `bun run start:prod`:先类型检查,再启动服务。
本地开发可以在项目根目录的 `.local.env` 中配置环境变量。
## 运行模式
Embedded 模式示例:
当前运行时使用 OpenCode 稳定版 1.x CLI,并通过稳定版 SDK 的 `@opencode-ai/sdk/v2` HTTP 客户端访问运行时;这与 `opencode2` 及 `@opencode-ai/client` 的 2.0 beta 运行时不同。Embedded 模式由服务进程拉起本机 OpenCode:
```bash
OPENCODE_MODE=embedded
DEEPSEEK_API_KEY=sk-xxx
TJWATER_API_BASE_URL=http://127.0.0.1:8000
```
Client 模式示例:
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
生产镜像会从固定的 OpenCode `v1.18.13` 源码提交构建 CLI,并应用仓库内的
`patches/opencode-1.18.13-message-phase.patch`。该补丁只透传 OpenAI Responses
输出项已有的 `commentary` / `final_answer` phase,不改变模型行为:过程文本继续写入
可折叠的 Agent 过程卡,`final_answer` 到达后立即按增量写入正式回答。未提供 phase 的
DeepSeek 模型启用 OpenCode 1.18.13 内置的 JSON Schema 最终回答工具
`StructuredOutput`:模型必须先完成全部分析和工具调用,再把完整回答写入 `answer`;
该工具成功后 OpenCode 会直接结束运行循环,不再进入下一轮模型或工具调用。Agent 将
`answer` 映射为正式文本推送;若模型未按协议调用该工具,仍保留会话 idle 后提取最终
文本的兼容兜底。
本地直接运行 `bun --watch src/server.ts` 时,`PATH` 中也需要放置应用了同一补丁的
`opencode` CLI,才能启用 phase 驱动的正式文本流式输出。
## 认证续期与学习工具
后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
`memory_manager` 和 `skill_manager` 在 OpenCode 侧只保留内部 HTTP 桥,读取会话上下文和持久化数据的逻辑统一在 Agent 主进程中执行。长期记忆、自动学习和显式工具写入因此共享同一组 `MemoryStore`、`SkillStore` 和运行时会话上下文。
本地可使用 `.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 或部署环境变量中。
## 发布
Gitea 包工作流位于 `.gitea/workflows/package.yml`。发布前至少运行:
```bash
OPENCODE_MODE=client
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
DEEPSEEK_API_KEY=sk-xxx
TJWATER_API_BASE_URL=http://127.0.0.1:8000
```
服务启动时会自动读取 `.local.env`,但系统环境变量优先级更高,适合在本机保存开发用 key。
### 生产启动
```bash
cd TJWaterAgent
bun install
bun run check
bun run start
```
也可以使用一条命令完成构建并启动:
如修改 CLI 或工具调用逻辑,同时运行:
```bash
cd TJWaterAgent
bun install
bun run start:prod
bun run test:cli
```
### Docker Compose 启动
项目根目录已提供 `Dockerfile` 和 `docker-compose.yml`,可直接使用:
```bash
cd TJWaterAgent
docker compose up -d --build
```
查看日志:
```bash
docker compose logs -f tjwater-agent
```
停止并清理容器:
```bash
docker compose down
```
### 常用脚本
| 命令 | 作用 |
| --- | --- |
| `bun run dev` | 类型检查 `.opencode` tools 后,以 watch 模式直接运行 `src/server.ts` |
| `bun run check` | 执行完整类型检查(服务与 `.opencode` tools) |
| `bun run start` | 直接运行 `src/server.ts` |
| `bun run start:prod` | 先类型检查再启动 |
| `bun run install:opencode` | 手动安装 `.opencode` 依赖 |
| `bun run pipeline:trigger` | 通过重建并强推 annotated `latest` tag 触发 Gitea CI/CD,只发布/覆盖 `latest` 镜像 |
### 模型与 API 配置
默认 Agent 模型为:
```text
deepseek/deepseek-v4-pro
```
涉及位置:
```text
opencode.json
.opencode/agents/tjwater-assistant.md
src/config.ts 的 OPENCODE_MODEL 默认值
```
如果需要临时覆盖模型,可以在启动时设置:
```bash
OPENCODE_MODEL=deepseek/deepseek-v4-pro bun run start
```
DeepSeek API key 不写入代码,部署时通过环境变量设置:
```bash
DEEPSEEK_API_KEY=sk-xxx bun run start
```
`opencode.json` 已配置从环境变量读取:
```json
{
"provider": {
"deepseek": {
"options": {
"apiKey": "{env:DEEPSEEK_API_KEY}"
}
}
}
}
```
如果需要自定义 DeepSeek 兼容 API 地址,可以通过 opencode 的 provider 配置增加 `baseURL`,例如在部署环境使用 `OPENCODE_CONFIG_CONTENT` 覆盖:
```bash
OPENCODE_CONFIG_CONTENT='{"provider":{"deepseek":{"options":{"baseURL":"https://your-api.example.com/v1"}}}}' \
DEEPSEEK_API_KEY=sk-xxx \
bun run start
```
也可以使用 opencode 的 `/connect` 命令写入用户级凭据,但服务部署更推荐使用环境变量。
如果需要连接外部独立运行的 opencode server,可以配置:
```bash
OPENCODE_MODE=client
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096
```
配置后,`TJWaterAgent` 会连接该外部 opencode server,而不是自行启动 embedded opencode server。
+9 -2
View File
@@ -5,7 +5,7 @@
"": {
"name": "tjwater-agent",
"dependencies": {
"@opencode-ai/sdk": "^1.14.29",
"@opencode-ai/sdk": "1.18.13",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
@@ -14,6 +14,7 @@
"zod": "^3.25.76",
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.7.2",
@@ -23,7 +24,9 @@
},
},
"packages": {
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="],
"@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.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -175,6 +178,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -259,6 +264,8 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+172
View File
@@ -0,0 +1,172 @@
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
return emitApi(ctx, "爆管分析执行成功", {
method: "POST",
path: "/burst-analyses",
params: {
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
burst_id: ids,
burst_size: sizes,
modify_total_duration: requiredNumber(values, "duration"),
scheme_name: schemeName,
},
requireProject: true,
}, ["tjwater-cli analysis runs list"]);
}
function analysisValveIsolation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { element: "repeat", "disabled-valve": "repeat" });
const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "at least one --element is required", 2);
return emitApi(ctx, "阀门隔离分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
requireProject: true,
});
}
function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
return emitApi(ctx, "冲洗分析执行成功", {
method: "POST",
path: "/flushing-analyses",
params: {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
valves,
valves_k: openings,
drainage_node_id: requiredString(values, "drainage-node"),
flush_flow: requiredNumber(values, "flow"),
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
return emitApi(ctx, "水龄分析执行成功", {
method: "POST",
path: "/water-age-analyses",
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
requireProject: true,
});
}
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
const params: Record<string, unknown> = {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
source: requiredString(values, "source-node"),
concentration: requiredNumber(values, "concentration"),
duration: requiredNumber(values, "duration"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
};
const pattern = optionalString(values, "pattern");
if (pattern) params.pattern = pattern;
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
}
function sensorPlacementRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", {
method: "POST",
path: "/sensor-placement-runs",
body: {
run_name: requiredString(values, "run-name"),
sensor_type: "pressure",
method: validateChoice(requiredString(values, "method"), ["sensitivity", "kmeans"] as const, "--method"),
sensor_count: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0,
},
requireProject: true,
});
}
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, startKey: string, endKey: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, {
method: "POST",
path,
body: {
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
function analysisRunGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
return emitApi(ctx, "读取分析运行成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}`,
requireProject: true,
});
}
function analysisRunResults(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
return emitApi(ctx, "读取分析结果成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}/results`,
params: { result_type: optionalString(values, "result-type") },
requireProject: true,
});
}
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
const body: Record<string, unknown> = {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
data_source: optionalString(values, "data-source") || "monitoring",
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
scada_burst_end: parseTime(requiredString(values, "end-time"), "--end-time"),
burst_leakage: requiredNumber(values, "burst-leakage"),
use_scada_flow: Boolean(values["use-scada-flow"]),
};
const pressureIds = optionalStringArray(values, "pressure-scada-id");
const flowIds = optionalStringArray(values, "flow-scada-id");
if (pressureIds) body.pressure_scada_ids = pressureIds;
if (flowIds) body.flow_scada_ids = flowIds;
const pressureFile = optionalString(values, "pressure-file");
const flowFile = optionalString(values, "flow-file");
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
}
export const analysisHandlers: HandlerMap = {
"analysis burst": analysisBurst,
"analysis valve isolation": analysisValveIsolation,
"analysis flushing": analysisFlushing,
"analysis age": analysisAge,
"analysis contaminant": analysisContaminant,
"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 burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-location locate": burstLocation,
};
+33
View File
@@ -0,0 +1,33 @@
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean): Promise<void> {
const { values } = parseOptions(argv);
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
"time:true": "/network-schemas/time",
"time:false": "/network-options/time",
"energy:true": "/network-schemas/energy",
"energy:false": "/network-options/energy",
"pump-energy:true": "/network-schemas/pump-energy",
"pump-energy:false": "/network-options/pump-energy",
"network:true": "/network-schemas/option",
"network:false": "/network-options",
};
const params: Record<string, unknown> = {};
const pump = optionalString(values, "pump");
if (kind === "pump-energy") {
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
if (pump) params.pump = pump;
}
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true });
}
export const componentHandlers: HandlerMap = {
"component option schema": (ctx, argv) => componentOption(ctx, argv, true),
"component option get": (ctx, argv) => componentOption(ctx, argv, false),
};
+155
View File
@@ -0,0 +1,155 @@
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js";
import { emitApi, requestAllPages } from "../core/http.js";
import { fieldsFor, optionalNumber, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { success } from "../core/output.js";
import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function backendElementType(type: ElementType): "link" | "node" {
return type === "pipe" ? "link" : "node";
}
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, {
method: "GET",
path,
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time") },
requireProject: true,
});
}
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时模拟数据成功", {
method: "GET",
path: "/timeseries/realtime/simulation-results",
params: { id: requiredString(values, "id"), type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true,
});
}
function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时属性聚合数据成功", {
method: "GET",
path: "/timeseries/realtime/records",
params: { type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
requireProject: true,
});
}
function analysisSeries(ctx: RuntimeContext, argv: string[], type: ElementType): Promise<void> {
const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
const idOption = type === "pipe" ? "link" : "node";
const elementId = requiredString(values, idOption);
const elementPath = type === "pipe" ? "links" : "nodes";
return emitApi(ctx, type === "pipe" ? "读取分析管道字段成功" : "读取分析节点字段成功", {
method: "GET",
path: `/timeseries/analysis/runs/${encodeURIComponent(runId)}/${elementPath}/${encodeURIComponent(elementId)}`,
params: {
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,
});
}
function analysisValues(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取分析运行指定时刻结果成功", {
method: "GET",
path: `/timeseries/analysis/runs/${encodeURIComponent(requiredString(values, "run-id"))}/values`,
params: {
result_time: parseTime(requiredString(values, "time"), "--time"),
element_type: backendElementType(type),
field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
},
requireProject: true,
});
}
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "device-id": "repeat" });
const params: Record<string, unknown> = {
device_ids: requiredStringArray(values, "device-id").join(","),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
};
const field = optionalString(values, "field");
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/timeseries/scada-readings/fields" : "/timeseries/scada-readings", params, requireProject: true });
}
function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { feature: "repeat", "use-cleaned": "boolean" });
const kind = validateChoice(requiredString(values, "kind"), ["scada-simulation", "element-simulation", "element-scada"] as const, "--kind");
const params: Record<string, unknown> = {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
};
const runId = optionalString(values, "run-id");
if (runId && kind !== "element-scada") params.run_id = runId;
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
else {
const feature = requiredStringArray(values, "feature");
if (feature.length !== 1) throw new CliError("CLI 参数错误", "FEATURE_REQUIRED", "element-scada requires exactly one --feature as element_id", 2);
params.element_id = feature[0];
params.use_cleaned = Boolean(values["use-cleaned"]);
}
const paths = {
"scada-simulation": "/timeseries/views/scada-simulations",
"element-simulation": "/timeseries/views/element-simulations",
"element-scada": "/timeseries/views/element-scada-readings",
} as const;
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: paths[kind], params, requireProject: true });
}
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取管道健康预测成功", {
method: "GET",
path: "/pipeline-health-predictions",
params: { query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true,
});
}
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取 SCADA 设备成功", { method: "GET", path: "/scada-devices/detail", params: { device_id: requiredString(values, "device-id") }, requireProject: true });
}
async function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "page-size": "integer" });
const pageSize = Math.min(1000, Math.max(1, optionalNumber(values, "page-size") ?? 1000));
const [data, durationMs] = await requestAllPages(
ctx,
{ method: "GET", path: "/scada-devices", requireProject: true },
pageSize,
);
success("读取 SCADA 设备列表成功", data, ctx, durationMs);
}
export const dataHandlers: HandlerMap = {
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"),
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
"data timeseries analysis link-field": (ctx, argv) => analysisSeries(ctx, argv, "pipe"),
"data timeseries analysis node-field": (ctx, argv) => analysisSeries(ctx, argv, "junction"),
"data timeseries analysis values": analysisValues,
"data timeseries scada query": scadaQuery,
"data timeseries composite": composite,
"data pipeline-health": pipelineHealth,
"data scada get": dataScadaGet,
"data scada list": dataScadaList,
"data scada schema": (ctx) => emitApi(ctx, "读取 SCADA 设备 schema 成功", { method: "GET", path: "/network-schemas/scada-device", requireProject: true }),
};
+35
View File
@@ -0,0 +1,35 @@
import { emitApi, requestAllPages } from "../core/http.js";
import { optionalNumber, parseOptions, requiredString } from "../core/options.js";
import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
}
async function apiGetAll(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
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 = {
"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-all-pipes-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部管道属性成功", "/pipes"),
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
"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-all-tanks-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部水箱属性成功", "/tanks"),
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
"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-all-valves-properties": (ctx, argv) => apiGetAll(ctx, argv, "读取全部阀门属性成功", "/valves"),
};
+24
View File
@@ -0,0 +1,24 @@
import { emitApi } from "../core/http.js";
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
const start = parseTime(requiredString(values, "start-time"), "--start-time");
const duration = requiredNumber(values, "duration");
const end = addMinutesPreservingOffset(start, duration);
return emitApi(
ctx,
"触发模拟成功",
{ method: "POST", path: "/simulation-runs", body: { start_time: start.replace(/\.\d+/, ""), duration }, requireProject: true },
[
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
],
);
}
export const simulationHandlers: HandlerMap = {
"simulation run": simulationRun,
};
+13
View File
@@ -0,0 +1,13 @@
export const SCHEMA_VERSION = "tjwater-cli/v1";
export const DEFAULT_TIMEOUT = 180;
export const DEFAULT_SERVER = "http://192.168.1.114:8000";
export const PIPE_FIELDS = ["flow", "friction", "headloss", "quality", "reaction", "setting", "status", "velocity"] as const;
export const JUNCTION_FIELDS = ["actual_demand", "total_head", "pressure", "quality"] as const;
export const SCADA_FIELDS = ["monitored_value", "cleaned_value"] as const;
export type ElementType = "pipe" | "junction";
export type PipeField = (typeof PIPE_FIELDS)[number];
export type JunctionField = (typeof JUNCTION_FIELDS)[number];
export type ScadaField = (typeof SCADA_FIELDS)[number];
+31
View File
@@ -0,0 +1,31 @@
export class CliError extends Error {
summary: string;
code: string;
exitCode: number;
retryable: boolean;
data: unknown;
nextCommands: string[];
constructor(
summary: string,
code: string,
message: string,
exitCode = 2,
retryable = false,
data: unknown = null,
nextCommands: string[] = [],
) {
super(message);
this.summary = summary;
this.code = code;
this.exitCode = exitCode;
this.retryable = retryable;
this.data = data;
this.nextCommands = nextCommands;
}
}
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from "node:fs";
import { CliError } from "./errors.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function readJsonFile(path: string, label: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
throw new CliError("CLI 参数错误", "INPUT_NOT_FOUND", `${label} file not found: ${path}`, 2);
}
if (error instanceof SyntaxError) throw new CliError("CLI 参数错误", "INPUT_INVALID_JSON", `${label} file must be valid JSON: ${path}`, 2);
throw error;
}
}
export function parseBurstFile(path: string): [string[], number[]] {
let raw = readJsonFile(path, "burst");
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
if (isRecord(raw) && "burst_id" in raw && "burst_size" in raw && Array.isArray(raw.burst_id) && Array.isArray(raw.burst_size)) {
const ids = raw.burst_id.map(String);
const sizes = raw.burst_size.map(Number);
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_id and burst_size must have the same length", 2);
return [ids, sizes];
}
if (Array.isArray(raw)) {
return [
raw.map((item) => {
if (!isRecord(item) || !("id" in item) || !("size" in item)) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file items must contain id and size", 2);
return String(item.id);
}),
raw.map((item) => Number((item as Record<string, unknown>).size)),
];
}
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_id/burst_size", 2);
}
export function parseValveSettingFile(path: string): [string[], number[]] {
const raw = readJsonFile(path, "valve-setting");
if (isRecord(raw) && "valves" in raw && "valves_k" in raw && Array.isArray(raw.valves) && Array.isArray(raw.valves_k)) {
const valves = raw.valves.map(String);
const openings = raw.valves_k.map(Number);
if (valves.length !== openings.length) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valves and valves_k must have the same length", 2);
return [valves, openings];
}
if (Array.isArray(raw)) {
return [
raw.map((item) => {
if (!isRecord(item) || !("valve" in item) || !("opening" in item)) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting items must contain valve and opening", 2);
return String(item.valve);
}),
raw.map((item) => Number((item as Record<string, unknown>).opening)),
];
}
throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting file must be a JSON array or object with valves/valves_k", 2);
}
export function assignDatasetKeys(target: Record<string, unknown>, path: string, keys: string[], label: string): void {
const payload = readJsonFile(path, label);
if (isRecord(payload)) {
for (const key of keys) {
if (key in payload) target[key] = payload[key];
}
}
}
+185
View File
@@ -0,0 +1,185 @@
import { CliError, errorMessage } from "./errors.js";
import { success } from "./output.js";
import type { RequestOptions, RuntimeContext } from "./types.js";
function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record<string, string> {
const out: Record<string, string> = {
Accept: "application/json, text/plain, */*",
"X-Request-Id": ctx.requestId,
...ctx.auth.headers,
};
if (requireAuth) {
if (!ctx.auth.accessToken) {
throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]);
}
out.Authorization = `Bearer ${ctx.auth.accessToken}`;
} else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`;
if (requireProject) {
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
out["X-Project-Id"] = ctx.auth.projectId;
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
return out;
}
function stringifyParam(value: unknown): string {
if (typeof value === "boolean") return value ? "True" : "False";
return String(value);
}
function appendParams(url: URL, params: Record<string, unknown> = {}): void {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item)));
else url.searchParams.set(key, stringifyParam(value));
}
}
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
const { method, path, params, body, requireAuth = true, requireProject = false } = request;
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
appendParams(url, params);
const started = performance.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
let response: Response;
try {
response = await fetch(url, {
method: method.toUpperCase(),
headers: {
...headers(ctx, requireAuth, requireProject),
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true);
throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true);
} finally {
clearTimeout(timer);
}
const durationMs = Math.trunc(performance.now() - started);
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
const text = response.status === 204 ? "" : await response.text();
let payload: unknown = {};
if (contentType.includes("application/json") && text) payload = JSON.parse(text);
else if (text) payload = { report: text };
if (!response.ok) {
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : {};
const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`;
throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500);
}
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>;
if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload);
}
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 {
if (status === 400 || status === 422) return 2;
if (status === 401) return 3;
if (status === 403) return 4;
if (status === 404) return 5;
if (status === 409 || status === 412) return 6;
return 7;
}
export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise<void> {
const [data, durationMs] = await requestJson(ctx, request);
success(summary, data, ctx, durationMs, nextCommands);
}
+112
View File
@@ -0,0 +1,112 @@
import { JUNCTION_FIELDS, PIPE_FIELDS, type ElementType } from "./constants.js";
import { CliError } from "./errors.js";
import type { OptionSchema, ParsedOptionValue, ParsedOptions } from "./types.js";
export function requireValue(argv: string[], index: number, option: string): string {
const value = argv[index];
if (!value || value.startsWith("--")) throw new CliError("CLI 参数错误", "MISSING_OPTION_VALUE", `${option} requires a value`, 2);
return value;
}
export function parseIntStrict(value: string, option: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_INTEGER", `${option} must be an integer`, 2);
return parsed;
}
export function parseFloatStrict(value: string, option: string): number {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_NUMBER", `${option} must be a number`, 2);
return parsed;
}
export function parseOptions(argv: string[], schema: OptionSchema = {}): ParsedOptions {
const values: Record<string, ParsedOptionValue> = {};
const positionals: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (!arg.startsWith("--")) {
positionals.push(arg);
continue;
}
const name = arg.slice(2);
const kind = schema[name] ?? "string";
if (kind === "boolean") {
values[name] = true;
} else {
const raw = requireValue(argv, ++i, arg);
if (kind === "repeat") values[name] = [...asStringArray(values[name]), raw];
else if (kind === "number") values[name] = parseFloatStrict(raw, arg);
else if (kind === "integer") values[name] = parseIntStrict(raw, arg);
else values[name] = raw;
}
}
return { values, positionals };
}
export function required(values: Record<string, ParsedOptionValue>, name: string): string | number | boolean | string[] {
const value = values[name];
if (value === undefined || value === null || value === "") {
throw new CliError("CLI 参数错误", "MISSING_PARAMETER", `Missing option '--${name}'`, 2);
}
return value;
}
export function requiredString(values: Record<string, ParsedOptionValue>, name: string): string {
const value = required(values, name);
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
return value;
}
export function requiredNumber(values: Record<string, ParsedOptionValue>, name: string): number {
const value = required(values, name);
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
return value;
}
export function requiredStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] {
const value = required(values, name);
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
return value;
}
export function optionalString(values: Record<string, ParsedOptionValue>, name: string): string | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
return value;
}
export function optionalNumber(values: Record<string, ParsedOptionValue>, name: string): number | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
return value;
}
export function optionalStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] | undefined {
const value = values[name];
if (value === undefined) return undefined;
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
return value;
}
export function asStringArray(value: ParsedOptionValue | undefined): string[] {
if (value === undefined) return [];
if (Array.isArray(value)) return value;
throw new CliError("CLI 参数错误", "INVALID_PARAMETER", "repeat option received a non-array value", 2);
}
export function validateChoice<T extends readonly string[]>(value: string, valid: T, option: string): T[number] {
if (!valid.includes(value)) {
throw new CliError("CLI 参数错误", `INVALID_${option.replace(/^--/, "").replaceAll("-", "_").toUpperCase()}`, `${option} must be one of: ${valid.join(", ")}`, 2);
}
return value as T[number];
}
export function fieldsFor(type: ElementType): typeof PIPE_FIELDS | typeof JUNCTION_FIELDS {
if (type === "pipe") return PIPE_FIELDS;
if (type === "junction") return JUNCTION_FIELDS;
throw new CliError("CLI 参数错误", "INVALID_TYPE", "--type must be one of: pipe, junction", 2);
}
+61
View File
@@ -0,0 +1,61 @@
import { SCHEMA_VERSION } from "./constants.js";
import type { RuntimeContext } from "./types.js";
export function json(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
export function generatedAt(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function success(summary: string, data: unknown, ctx: RuntimeContext, durationMs: number, nextCommands: string[] = []): void {
json({
ok: true,
schema_version: SCHEMA_VERSION,
summary,
data,
metadata: {
request_id: ctx.requestId,
server: ctx.server,
duration_ms: durationMs,
generated_at: generatedAt(),
},
next_commands: nextCommands,
});
}
export function failure({
summary,
code,
message,
retryable = false,
server = null,
requestId = null,
data = null,
nextCommands = [],
}: {
summary: string;
code: string;
message: string;
retryable?: boolean;
server?: string | null;
requestId?: string | null;
data?: unknown;
nextCommands?: string[];
}): void {
json({
ok: false,
schema_version: SCHEMA_VERSION,
summary,
error: { code, message, retryable },
data,
metadata: {
request_id: requestId,
server,
generated_at: generatedAt(),
},
next_commands: nextCommands,
});
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import { DEFAULT_SERVER, DEFAULT_TIMEOUT } from "./constants.js";
import { CliError } from "./errors.js";
import { requireValue, parseIntStrict } from "./options.js";
import type { AuthContext, GlobalArgs, ParsedGlobalArgs, RuntimeContext } from "./types.js";
function pick(source: Record<string, unknown>, ...keys: string[]): string | null {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && value !== "") return String(value);
}
return null;
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => {
body += chunk;
});
process.stdin.on("end", () => resolve(body));
process.stdin.on("error", reject);
});
}
export async function loadAuthContext(authStdin: boolean): Promise<AuthContext> {
const raw = authStdin
? (JSON.parse(await readStdin()) as Record<string, unknown>)
: {
server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID,
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
};
const headers = (raw.headers ?? {}) as unknown;
if (!headers || Array.isArray(headers) || typeof headers !== "object") {
throw new CliError("认证失败", "AUTH_CONTEXT_INVALID", "auth context headers must be a JSON object", 3);
}
return {
server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
};
}
export function parseGlobalArgs(argv: string[]): ParsedGlobalArgs {
const globals: GlobalArgs = {
server: null,
authStdin: false,
scheme: null,
timeout: DEFAULT_TIMEOUT,
requestId: null,
};
const rest: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--auth-stdin") globals.authStdin = true;
else if (arg === "--server") globals.server = requireValue(argv, ++i, "--server");
else if (arg === "--scheme") globals.scheme = requireValue(argv, ++i, "--scheme");
else if (arg === "--timeout") globals.timeout = parseIntStrict(requireValue(argv, ++i, "--timeout"), "--timeout");
else if (arg === "--request-id") globals.requestId = requireValue(argv, ++i, "--request-id");
else rest.push(arg);
}
return { globals, rest };
}
export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext> {
const auth = await loadAuthContext(globals.authStdin);
return {
server: globals.server || auth.server || DEFAULT_SERVER,
auth,
scheme: globals.scheme,
timeout: globals.timeout,
requestId: globals.requestId || randomUUID(),
};
}
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
const scheme = explicit || ctx.scheme;
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
return scheme ?? null;
}
+23
View File
@@ -0,0 +1,23 @@
import { CliError } from "./errors.js";
export function parseTime(value: string, option: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new CliError("CLI 参数错误", "INVALID_TIME", `${option} must be a valid ISO 8601 / RFC 3339 timestamp`, 2);
}
if (!/[zZ]|[+-]\d\d:\d\d$/.test(value)) {
throw new CliError("CLI 参数错误", "TIMEZONE_REQUIRED", `${option} must include an explicit timezone offset`, 2);
}
return value;
}
export function addMinutesPreservingOffset(value: string, minutes: number): string {
const match = value.match(/([+-])(\d\d):(\d\d)$/);
const end = new Date(new Date(value).getTime() + minutes * 60_000);
if (!match) return end.toISOString().replace(".000Z", "Z");
const sign = match[1] === "+" ? 1 : -1;
const offsetMinutes = sign * (Number(match[2]) * 60 + Number(match[3]));
const local = new Date(end.getTime() + offsetMinutes * 60_000);
return `${local.toISOString().replace(".000Z", "")}${match[1]}${match[2]}:${match[3]}`;
}
+83
View File
@@ -0,0 +1,83 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export type Dict<T = unknown> = Record<string, T>;
export interface AuthContext {
server: string | null;
accessToken: string | null;
projectId: string | null;
headers: Record<string, string>;
}
export interface RuntimeContext {
server: string;
auth: AuthContext;
scheme: string | null;
timeout: number;
requestId: string;
}
export interface GlobalArgs {
server: string | null;
authStdin: boolean;
scheme: string | null;
timeout: number;
requestId: string | null;
}
export interface ParsedGlobalArgs {
globals: GlobalArgs;
rest: string[];
}
export type OptionKind = "string" | "boolean" | "repeat" | "number" | "integer";
export type OptionSchema = Record<string, OptionKind>;
export type ParsedOptionValue = string | number | boolean | string[];
export interface ParsedOptions {
values: Record<string, ParsedOptionValue>;
positionals: string[];
}
export interface RequestOptions {
method: string;
path: string;
params?: Record<string, unknown>;
body?: unknown;
requireAuth?: boolean;
requireProject?: boolean;
}
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
export type HandlerMap = Record<string, Handler>;
export interface CommandDoc {
ok: true;
schema_version: string;
command: string;
summary: string;
description: string;
usage: string;
options: CommandOptionDoc[];
examples: string[];
output: string;
next_commands: string[];
}
export interface CommandOptionDoc {
name: string;
description: string;
required: boolean;
repeated: boolean;
default: null;
}
export type HelpPayload =
| CommandDoc
| {
ok: true;
schema_version: string;
summary: string;
menu_level?: number;
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
};
+50
View File
@@ -0,0 +1,50 @@
import { CliError } from "./core/errors.js";
import { handlers } from "./handlers.js";
import { helpPayload } from "./help/index.js";
import { failure, json } from "./core/output.js";
import type { RuntimeContext } from "./core/types.js";
export async function dispatch(ctx: RuntimeContext | null, argv: string[]): Promise<void> {
if (argv.length === 0 || argv.includes("--help")) {
process.stdout.write("Usage: tjwater-cli [OPTIONS] COMMAND [ARGS]...\n\nStructured JSON:\n tjwater-cli help\n");
return;
}
if (argv[0] === "help") {
const payload = helpPayload(argv.slice(1));
if (!payload) {
failure({
summary: "未找到命令",
code: "COMMAND_NOT_FOUND",
message: `unknown command path: ${argv.slice(1).join(" ")}`,
retryable: false,
data: { usage: "tjwater-cli help <command-path>", examples: ["tjwater-cli help simulation run", "tjwater-cli simulation help"] },
nextCommands: ["tjwater-cli help", "tjwater-cli help simulation"],
});
return;
}
json(payload);
return;
}
const matched = matchCommand(argv);
if (!matched) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
const handler = handlers[matched.path];
if (!handler) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
if (!ctx && matched.path !== "__noop") throw new CliError("CLI 参数错误", "RUNTIME_REQUIRED", "runtime context is required for command execution", 2);
await handler(ctx!, matched.args);
}
function matchCommand(argv: string[]): { path: string; args: string[] } | null {
const paths = Object.keys(handlers).sort((a, b) => b.split(" ").length - a.split(" ").length);
for (const path of paths) {
const parts = path.split(" ");
if (parts.every((part, index) => argv[index] === part)) return { path, args: argv.slice(parts.length) };
}
if (argv.at(-1) === "help") {
const payload = helpPayload(argv.slice(0, -1));
if (payload) {
json(payload);
return { path: "__noop", args: [] };
}
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
import { analysisHandlers } from "./commands/analysis.js";
import { componentHandlers } from "./commands/component.js";
import { dataHandlers } from "./commands/data.js";
import { networkHandlers } from "./commands/network.js";
import { simulationHandlers } from "./commands/simulation.js";
import type { HandlerMap } from "./core/types.js";
export const handlers: HandlerMap = {
"__noop": async () => {},
...networkHandlers,
...componentHandlers,
...simulationHandlers,
...analysisHandlers,
...dataHandlers,
};
+135
View File
@@ -0,0 +1,135 @@
import { SCHEMA_VERSION } from "../core/constants.js";
import type { CommandDoc, CommandOptionDoc } from "../core/types.js";
export const GROUP_SUMMARIES: Record<string, string> = {
network: "管网节点、管线等基础属性查询命令。",
component: "组件选项与配置读取命令。",
"component option": "组件选项查询命令。",
simulation: "模拟运行与调度相关命令。",
analysis: "分析计算与诊断相关命令。",
"analysis runs": "分析运行及结果查询命令。",
"analysis leakage": "漏损分析相关命令。",
"analysis burst-detection": "爆管检测相关命令。",
"analysis burst-location": "爆管定位相关命令。",
"analysis sensor-placement": "传感器选址相关命令。",
data: "时序、SCADA 和分析数据查询命令。",
"data timeseries": "时序数据查询命令。",
"data timeseries realtime": "实时模拟时序查询命令。",
"data timeseries analysis": "按分析运行 ID 查询历史时序命令。",
"data timeseries scada": "SCADA 时序查询命令。",
"data timeseries composite": "复合时序查询命令。",
"data scada": "SCADA 元数据查询命令。",
};
export const HIDDEN_PATH_PREFIXES: string[] = [];
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
const commandSpecs: readonly CommandSpec[] = [
["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-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-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-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-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-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 get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]],
["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]],
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli analysis runs list"]],
["analysis valve isolation", "执行阀门隔离分析", ["--element <ELEMENT>", "[--disabled-valve <DISABLED_VALVE>]"], ["tjwater-cli analysis valve isolation --element E1 --element E2", "tjwater-cli analysis valve isolation --element E1 --disabled-valve V3"]],
["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]],
["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]],
["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]],
["analysis sensor-placement 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 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-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"]],
["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]],
["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]],
["data timeseries analysis link-field", "查询分析运行的管道字段时序", ["--run-id <RUN_ID>", "--link <LINK>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis link-field --run-id 00000000-0000-0000-0000-000000000001 --link P1 --field flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries analysis node-field", "查询分析运行的节点字段时序", ["--run-id <RUN_ID>", "--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries analysis values", "查询分析运行指定时刻的全部元素字段", ["--run-id <RUN_ID>", "--type <TYPE>", "--time <TIME>", "--field <FIELD>"], ["tjwater-cli data timeseries analysis values --run-id 00000000-0000-0000-0000-000000000001 --type junction --time 2025-01-02T03:30:00+08:00 --field pressure"]],
["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]],
["data timeseries composite", "执行复合时序查询", ["--kind <KIND>", "--feature <FEATURE>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--run-id <RUN_ID>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
["data pipeline-health", "查询指定时刻的管道健康预测", ["--time <TIME>"], ["tjwater-cli data pipeline-health --time 2025-01-02T04:00:00+08:00"]],
["data scada get", "读取单条 SCADA 设备", ["--device-id <DEVICE_ID>"], ["tjwater-cli data scada get --device-id SCADA-001"]],
["data scada list", "列出 SCADA 设备", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli data scada list"]],
["data scada schema", "读取 SCADA 设备 schema", [], ["tjwater-cli data scada schema"]],
];
export const commandDocs = new Map<string, CommandDoc>(
commandSpecs.map(([path, summary, options, examples, nextCommands = []]) => [
path,
{
ok: true,
schema_version: SCHEMA_VERSION,
command: path,
summary,
description: summary,
usage: buildUsage(path, options),
options: options.filter((item) => item.startsWith("--") || item.startsWith("[--")).map((item) => optionDoc(path, item)),
examples: [...(examples ?? [`tjwater-cli ${path}${exampleSuffix(options)}`])],
output: "标准 JSON 输出",
next_commands: [...nextCommands],
},
]),
);
function buildUsage(path: string, options: readonly string[]): string {
const optionTokens = options.filter((item) => item.startsWith("--") || item.startsWith("[--"));
return `tjwater-cli ${path}${optionTokens.length ? ` ${optionTokens.join(" ")}` : ""}`;
}
function optionDoc(path: string, token: string): CommandOptionDoc {
const optional = token.startsWith("[");
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
const name = clean.slice(2).split(/\s+/)[0]!;
const repeatedOptions: Record<string, string[]> = {
"analysis valve isolation": ["element", "disabled-valve"],
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
"data timeseries scada query": ["device-id"],
"data timeseries composite": ["feature"],
};
return {
name,
description: "",
required: !optional,
repeated: (repeatedOptions[path] ?? []).includes(name),
default: null,
};
}
function exampleSuffix(options: readonly string[]): string {
return options
.map((item) => {
if (item.includes("START_TIME")) return " --start-time 2025-01-02T03:00:00+08:00";
if (item.includes("END_TIME")) return " --end-time 2025-01-02T04:00:00+08:00";
if (item.includes("DURATION")) return " --duration 900";
if (item.includes("SCHEME")) return " --scheme my_scheme";
if (item.includes("KIND")) return " --kind info";
if (item.includes("TYPE")) return " --type pipe";
if (item.includes("TIME")) return " --time 2025-01-02T03:30:00+08:00";
return "";
})
.join("");
}
export function isHiddenPath(path: string): boolean {
return HIDDEN_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix} `));
}
export function hasVisibleSubcommands(path: string): boolean {
return [...commandDocs.keys()].some((key) => !isHiddenPath(key) && key.startsWith(`${path} `));
}
+53
View File
@@ -0,0 +1,53 @@
import { SCHEMA_VERSION } from "../core/constants.js";
import { GROUP_SUMMARIES, commandDocs, hasVisibleSubcommands, isHiddenPath } from "./docs.js";
import type { HelpPayload } from "../core/types.js";
export function helpPayload(pathParts: string[]): HelpPayload | null {
const path = pathParts.join(" ");
if (path && isHiddenPath(path)) return null;
if (path && commandDocs.has(path)) return commandDocs.get(path)!;
if (!path) {
const seen = new Set<string>();
const commands: Array<{ command: string; summary: string }> = [];
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
if (isHiddenPath(doc.command)) continue;
const command = doc.command.split(" ")[0]!;
if (seen.has(command)) continue;
seen.add(command);
commands.push({ command, summary: GROUP_SUMMARIES[command] ?? `${command} 可用子命令` });
}
return {
ok: true,
schema_version: SCHEMA_VERSION,
summary: "可用一级菜单",
menu_level: 1,
commands,
};
}
const prefix = path ? `${path} ` : "";
const children: Array<{ command: string; summary: string; usage: string; example: string }> = [];
const seen = new Set<string>();
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
if (isHiddenPath(doc.command)) continue;
if (!doc.command.startsWith(prefix)) continue;
const child = doc.command.slice(prefix.length).split(" ")[0]!;
if (seen.has(child)) continue;
seen.add(child);
const childPath = path ? `${path} ${child}` : child;
const isGroup = hasVisibleSubcommands(childPath);
const childDoc = commandDocs.get(childPath) ?? doc;
children.push({
command: childPath,
summary: isGroup ? (GROUP_SUMMARIES[childPath] ?? `${childPath} 可用子命令`) : childDoc.summary,
usage: isGroup ? `tjwater-cli ${childPath} help` : childDoc.usage,
example: isGroup ? `tjwater-cli ${childPath} help` : childDoc.examples[0]!,
});
}
if (!children.length && path) return null;
return {
ok: true,
schema_version: SCHEMA_VERSION,
summary: GROUP_SUMMARIES[path] ?? `${path} 可用子命令`,
commands: children,
};
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v bun >/dev/null 2>&1; then
exec bun "$SCRIPT_DIR/tjwater-cli.ts" "$@"
fi
exec node "$SCRIPT_DIR/tjwater-cli.ts" "$@"
+45
View File
@@ -0,0 +1,45 @@
import { dispatch } from "./src/dispatch.js";
import { CliError, errorMessage } from "./src/core/errors.js";
import { failure } from "./src/core/output.js";
import { buildRuntime, parseGlobalArgs } from "./src/core/runtime.js";
let currentServer: string | null = null;
let currentRequestId: string | null = null;
async function main(): Promise<number> {
const { globals, rest } = parseGlobalArgs(process.argv.slice(2));
if (isHelpRequest(rest)) {
await dispatch(null, rest);
return 0;
}
const ctx = await buildRuntime(globals);
currentServer = ctx.server;
currentRequestId = ctx.requestId;
await dispatch(ctx, rest);
return 0;
}
function isHelpRequest(argv: string[]): boolean {
return argv.length === 0 || argv[0] === "help" || argv.includes("--help") || argv.at(-1) === "help";
}
try {
process.exitCode = await main();
} catch (error) {
if (error instanceof CliError) {
failure({
summary: error.summary,
code: error.code,
message: error.message,
retryable: error.retryable,
server: currentServer,
requestId: currentRequestId,
data: error.data,
nextCommands: error.nextCommands,
});
process.exitCode = error.exitCode;
} else {
failure({ summary: "CLI 执行失败", code: "UNHANDLED_ERROR", message: errorMessage(error), retryable: false });
process.exitCode = 1;
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "dbfaec4b72eb30592523f7c5a21097d4cfb2e3beb370540c6da24b3d4aa5b366"
}
}
}
+498
View File
@@ -0,0 +1,498 @@
import { strict as assert } from "node:assert";
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-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 = {}) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", reject);
child.on("close", (exitCode) => resolveRun({ exitCode, stdout, stderr }));
if (input !== undefined) child.stdin.end(JSON.stringify(input));
else child.stdin.end();
});
}
function runCli(args, input) {
return runCommand(cliPath, args, input);
}
function parseJsonResult(result) {
return JSON.parse(result.stdout);
}
async function startJsonServer(responseData) {
const seen = [];
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const text = Buffer.concat(chunks).toString("utf8");
seen.push({
body: text ? JSON.parse(text) : null,
headers: req.headers,
method: req.method,
url: req.url,
});
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify(
typeof responseData === "function" ? responseData(req) : responseData,
),
);
});
await new Promise((resolveListen, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolveListen);
});
const address = server.address();
return {
seen,
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolveClose) => server.close(resolveClose)),
};
}
function normalizeSeenRequest(request) {
const url = new URL(request.url, "http://127.0.0.1");
const query = {};
for (const key of [...new Set(url.searchParams.keys())].sort()) {
const values = url.searchParams.getAll(key);
query[key] = values.length === 1 ? values[0] : values;
}
return {
body: request.body,
headers: {
authorization: request.headers.authorization,
"x-project-id": request.headers["x-project-id"],
},
method: request.method,
path: url.pathname,
query,
};
}
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);
try {
const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url });
if (!result.stdout.trim()) {
throw new Error(`${name}: CLI produced empty stdout; exit=${result.exitCode}; stderr=${result.stderr}`);
}
return {
exitCode: result.exitCode,
payload: parseJsonResult(result),
requests: server.seen.map(normalizeSeenRequest),
stderr: result.stderr,
};
} finally {
await server.close();
}
}
test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
const result = await runCli(["help", "simulation", "run"]);
assert.equal(result.exitCode, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.schema_version, "tjwater-cli/v1");
assert.equal(payload.command, "simulation run");
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
});
test("discovers every visible command and keeps internal commands hidden", async () => {
const rootResult = await runCli(["help"]);
assert.equal(rootResult.exitCode, 0, rootResult.stderr);
assert.deepEqual(
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`);
}
for (const command of hiddenCommandPaths) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
const payload = parseJsonResult(result);
assert.equal(payload.ok, false, command);
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
}
});
test("sends auth headers and simulation body through the backend API contract", async () => {
const server = await startJsonServer({ accepted: true });
try {
const result = await runCli(
[
"--auth-stdin",
"simulation",
"run",
"--start-time",
"2025-01-02T03:00:00+08:00",
"--duration",
"60",
],
{
server: server.url,
access_token: "token-1",
project_id: "project-1",
headers: { "x-extra": "extra" },
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(server.seen[0].method, "POST");
assert.equal(server.seen[0].url, "/api/v1/simulation-runs");
assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
assert.equal(server.seen[0].headers["x-extra"], "extra");
assert.deepEqual(server.seen[0].body, {
start_time: "2025-01-02T03:00:00+08:00",
duration: 60,
});
const payload = JSON.parse(result.stdout);
assert.equal(payload.ok, true);
assert.match(payload.next_commands[0], /--end-time 2025-01-02T04:00:00\+08:00/);
} finally {
await server.close();
}
});
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 () => {
const server = await startJsonServer([{ id: "P1" }]);
try {
const result = await runCli(
[
"--auth-stdin",
"data",
"timeseries",
"realtime",
"links",
"--start-time",
"2025-01-02T03:00:00+08:00",
"--end-time",
"2025-01-02T04:00:00+08:00",
],
{
server: server.url,
accessToken: "token-2",
projectId: "project-1",
},
);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(server.seen[0].method, "GET");
assert.equal(
server.seen[0].url,
"/api/v1/timeseries/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
);
assert.equal(server.seen[0].headers.authorization, "Bearer token-2");
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
} finally {
await server.close();
}
});
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-"));
try {
const burstFile = join(tempDir, "burst.json");
const valveFile = join(tempDir, "valve.json");
const pressureFile = join(tempDir, "pressure.json");
const flowFile = join(tempDir, "flow.json");
await writeFile(burstFile, JSON.stringify([{ id: "B1", size: 12.5 }]));
await writeFile(valveFile, JSON.stringify([{ valve: "V1", opening: 0.5 }]));
await writeFile(pressureFile, JSON.stringify({ burst_pressure: [1.1], normal_pressure: [2.2] }));
await writeFile(flowFile, JSON.stringify({ burst_flow: [3.3], normal_flow: [4.4] }));
const auth = {
access_token: "token",
network: "tjwater",
project_id: "project-1",
username: "alice",
headers: { "x-extra": "extra" },
};
const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00";
const at = "2025-01-02T03:30:00+08:00";
const runId = "00000000-0000-0000-0000-000000000001";
const cases = [
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
["network get-all-pipes-properties", ["network", "get-all-pipes-properties"]],
["network get-reservoir-properties", ["network", "get-reservoir-properties", "--reservoir", "R1"]],
["network get-all-reservoirs-properties", ["network", "get-all-reservoirs-properties"]],
["network get-tank-properties", ["network", "get-tank-properties", "--tank", "T1"]],
["network get-all-tanks-properties", ["network", "get-all-tanks-properties"]],
["network get-pump-properties", ["network", "get-pump-properties", "--pump", "PU1"]],
["network get-all-pumps-properties", ["network", "get-all-pumps-properties"]],
["network get-valve-properties", ["network", "get-valve-properties", "--valve", "V1"]],
["network get-all-valves-properties", ["network", "get-all-valves-properties"]],
["component option schema", ["component", "option", "schema", "--kind", "time"]],
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]],
["analysis valve isolation", ["analysis", "valve", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]],
["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]],
["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]],
["analysis sensor-placement 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 burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "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"]],
["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]],
["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]],
["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]],
["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]],
["data analysis link-field", ["data", "timeseries", "analysis", "link-field", "--run-id", runId, "--link", "P1", "--field", "flow", "--start-time", start, "--end-time", end]],
["data analysis node-field", ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end]],
["data analysis values", ["data", "timeseries", "analysis", "values", "--run-id", runId, "--type", "pipe", "--time", at, "--field", "flow"]],
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--run-id", runId]],
["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]],
["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]],
["data pipeline-health", ["data", "pipeline-health", "--time", end]],
["data scada get", ["data", "scada", "get", "--device-id", "SCADA-001"]],
["data scada list", ["data", "scada", "list"]],
["data scada schema", ["data", "scada", "schema"]],
];
for (const [name, args] of cases) {
const run = await runAgainstServer(name, runCli, args, auth);
assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`);
assert.equal(run.payload.ok, true, name);
assert.equal(run.payload.schema_version, "tjwater-cli/v1", name);
assert.ok(run.requests.length > 0, `${name}: no backend request`);
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 {
await rm(tempDir, { force: true, recursive: true });
}
});
+63 -2
View File
@@ -7,10 +7,71 @@
}
}
},
"model": "deepseek/deepseek-v4-pro",
"model": "deepseek/deepseek-flash",
"server": {
"hostname": "127.0.0.1",
"port": 4096
},
"permission": {
"*": "ask",
"external_directory": {
"*": "ask",
"/app/.opencode/skills/*": "allow"
},
"read": {
"*": "allow",
".env": "ask",
".env.*": "ask",
"*.env": "ask",
"**/.env": "ask",
"**/.env.*": "ask",
"**/*.env": "ask",
"data/**": "ask",
"**/data/**": "ask",
"logs/**": "ask",
"**/logs/**": "ask"
},
"edit": {
"*": "ask",
".env": "ask",
".env.*": "ask",
"*.env": "ask",
"**/.env": "ask",
"**/.env.*": "ask",
"**/*.env": "ask",
"data/**": "ask",
"**/data/**": "ask",
"logs/**": "ask",
"**/logs/**": "ask"
},
"bash": {
"*": "ask",
"rm *": "ask",
"rm -rf *": "ask",
"rm -fr *": "ask",
"rm -r -f *": "ask",
"rm -f -r *": "ask",
"rm --recursive --force *": "ask",
"rm --force --recursive *": "ask",
"rmdir *": "ask",
"mv *": "ask",
"chmod *": "ask",
"chown *": "ask",
"sudo *": "ask",
"curl *": "ask",
"wget *": "ask",
"*.env*": "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"
}
}
+9 -1
View File
@@ -8,15 +8,22 @@
"install:opencode": "bun install --cwd .opencode",
"typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck:opencode": "bun run --cwd .opencode typecheck",
"test": "bun test tests",
"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",
"build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode",
"contract:generate": "bun scripts/generate-contract.ts",
"contract:check": "bun scripts/generate-contract.ts --check",
"test:api": "bun test tests/routes tests/contracts",
"migrate:session-identity": "bun scripts/migrate-session-identity.ts",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
"start": "bun src/server.ts",
"start:prod": "bun run check && bun src/server.ts"
},
"dependencies": {
"@opencode-ai/sdk": "^1.14.29",
"@opencode-ai/sdk": "1.18.13",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
@@ -25,6 +32,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.7.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(
+37
View File
@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { generateAgentOpenApi } from "../src/contracts/openapi.js";
const canonicalJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const document = generateAgentOpenApi();
const payload = canonicalJson(document);
const sha256 = createHash("sha256").update(payload).digest("hex");
const manifestPayload = canonicalJson({
contract_version: "1.0.0",
contracts: {
agent: {
file: "agent-v1.openapi.json",
sha256,
},
},
});
if (process.argv.includes("--check")) {
const currentContract = await readFile(
"contracts/agent-v1.openapi.json",
"utf8",
);
const currentManifest = await readFile("contracts/manifest.json", "utf8");
if (currentContract !== payload || currentManifest !== manifestPayload) {
throw new Error(
"Agent OpenAPI contract is stale: run `bun run contract:generate`",
);
}
console.log(`validated Agent OpenAPI contract (${sha256})`);
} else {
await mkdir("contracts", { recursive: true });
await writeFile("contracts/agent-v1.openapi.json", payload, "utf8");
await writeFile("contracts/manifest.json", manifestPayload, "utf8");
console.log(`generated Agent OpenAPI contract (${sha256})`);
}
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Apply a Landlock + seccomp policy, drop privileges, then exec one shell command."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import errno
import json
import os
import platform
import sys
from pathlib import Path
LANDLOCK_CREATE_RULESET_VERSION = 1
LANDLOCK_RULE_PATH_BENEATH = 1
ACCESS_FS_EXECUTE = 1 << 0
ACCESS_FS_WRITE_FILE = 1 << 1
ACCESS_FS_READ_FILE = 1 << 2
ACCESS_FS_READ_DIR = 1 << 3
ACCESS_FS_REMOVE_DIR = 1 << 4
ACCESS_FS_REMOVE_FILE = 1 << 5
ACCESS_FS_MAKE_CHAR = 1 << 6
ACCESS_FS_MAKE_DIR = 1 << 7
ACCESS_FS_MAKE_REG = 1 << 8
ACCESS_FS_MAKE_SOCK = 1 << 9
ACCESS_FS_MAKE_FIFO = 1 << 10
ACCESS_FS_MAKE_BLOCK = 1 << 11
ACCESS_FS_MAKE_SYM = 1 << 12
ACCESS_FS_REFER = 1 << 13
ACCESS_FS_TRUNCATE = 1 << 14
ACCESS_NET_CONNECT_TCP = 1 << 0
ACCESS_NET_BIND_TCP = 1 << 1
READ_ACCESS = ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR
WRITE_ACCESS = (
ACCESS_FS_WRITE_FILE
| ACCESS_FS_REMOVE_DIR
| ACCESS_FS_REMOVE_FILE
| ACCESS_FS_MAKE_CHAR
| ACCESS_FS_MAKE_DIR
| ACCESS_FS_MAKE_REG
| ACCESS_FS_MAKE_SOCK
| ACCESS_FS_MAKE_FIFO
| ACCESS_FS_MAKE_BLOCK
| ACCESS_FS_MAKE_SYM
| ACCESS_FS_REFER
| ACCESS_FS_TRUNCATE
)
HANDLED_FS_ACCESS = READ_ACCESS | WRITE_ACCESS
PR_SET_NO_NEW_PRIVS = 38
AF_INET = 2
AF_INET6 = 10
SCMP_ACT_ALLOW = 0x7FFF0000
SCMP_ACT_ERRNO = 0x00050000 | errno.EPERM
SCMP_CMP_EQ = 4
class RulesetAttr(ctypes.Structure):
_fields_ = [
("handled_access_fs", ctypes.c_uint64),
("handled_access_net", ctypes.c_uint64),
]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [
("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
]
class ScmpArgCmp(ctypes.Structure):
_fields_ = [
("arg", ctypes.c_uint32),
("op", ctypes.c_uint32),
("datum_a", ctypes.c_uint64),
("datum_b", ctypes.c_uint64),
]
def syscall_numbers() -> tuple[int, int, int]:
machine = platform.machine().lower()
if machine not in {"x86_64", "amd64", "aarch64", "arm64"}:
raise RuntimeError(f"unsupported architecture for Landlock syscalls: {machine}")
return 444, 445, 446
def checked_syscall(libc: ctypes.CDLL, number: int, *args: object) -> int:
result = int(libc.syscall(number, *args))
if result < 0:
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
return result
def get_landlock_abi(libc: ctypes.CDLL) -> int:
create_ruleset, _, _ = syscall_numbers()
return checked_syscall(
libc,
create_ruleset,
ctypes.c_void_p(),
ctypes.c_size_t(0),
ctypes.c_uint32(LANDLOCK_CREATE_RULESET_VERSION),
)
def load_seccomp() -> ctypes.CDLL:
library_name = ctypes.util.find_library("seccomp") or "libseccomp.so.2"
library = ctypes.CDLL(library_name, use_errno=True)
library.seccomp_init.argtypes = [ctypes.c_uint32]
library.seccomp_init.restype = ctypes.c_void_p
library.seccomp_release.argtypes = [ctypes.c_void_p]
library.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
library.seccomp_syscall_resolve_name.restype = ctypes.c_int
library.seccomp_rule_add_array.argtypes = [
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.c_int,
ctypes.c_uint,
ctypes.POINTER(ScmpArgCmp),
]
library.seccomp_rule_add_array.restype = ctypes.c_int
library.seccomp_load.argtypes = [ctypes.c_void_p]
library.seccomp_load.restype = ctypes.c_int
return library
def add_path_rule(
libc: ctypes.CDLL,
add_rule_number: int,
ruleset_fd: int,
path: str,
access: int,
) -> None:
resolved_path = os.path.realpath(path)
if not os.path.exists(resolved_path):
return
path_fd = os.open(resolved_path, os.O_PATH | os.O_CLOEXEC)
try:
allowed_access = access
if not Path(resolved_path).is_dir():
allowed_access &= ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE | ACCESS_FS_TRUNCATE
attribute = PathBeneathAttr(
allowed_access=allowed_access,
parent_fd=path_fd,
)
checked_syscall(
libc,
add_rule_number,
ctypes.c_int(ruleset_fd),
ctypes.c_int(LANDLOCK_RULE_PATH_BENEATH),
ctypes.byref(attribute),
ctypes.c_uint32(0),
)
finally:
os.close(path_fd)
def build_landlock_ruleset(
libc: ctypes.CDLL,
abi: int,
workspace: str,
read_only_paths: list[str],
) -> int:
create_ruleset, add_rule, _ = syscall_numbers()
handled_network = (
ACCESS_NET_CONNECT_TCP | ACCESS_NET_BIND_TCP if abi >= 4 else 0
)
ruleset_attribute = RulesetAttr(
handled_access_fs=HANDLED_FS_ACCESS,
handled_access_net=handled_network,
)
ruleset_fd = checked_syscall(
libc,
create_ruleset,
ctypes.byref(ruleset_attribute),
ctypes.sizeof(ruleset_attribute),
ctypes.c_uint32(0),
)
try:
add_path_rule(
libc,
add_rule,
ruleset_fd,
workspace,
HANDLED_FS_ACCESS,
)
for path in read_only_paths:
add_path_rule(libc, add_rule, ruleset_fd, path, READ_ACCESS)
for device_path, access in (
("/dev/null", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE),
("/dev/zero", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE),
("/dev/urandom", ACCESS_FS_READ_FILE),
("/dev/random", ACCESS_FS_READ_FILE),
):
add_path_rule(libc, add_rule, ruleset_fd, device_path, access)
except Exception:
os.close(ruleset_fd)
raise
return ruleset_fd
def install_seccomp_network_filter(library: ctypes.CDLL) -> None:
context = library.seccomp_init(SCMP_ACT_ALLOW)
if not context:
raise RuntimeError("seccomp_init failed")
try:
socket_syscall = library.seccomp_syscall_resolve_name(b"socket")
if socket_syscall < 0:
raise RuntimeError("could not resolve socket syscall")
for domain in (AF_INET, AF_INET6):
comparison = ScmpArgCmp(
arg=0,
op=SCMP_CMP_EQ,
datum_a=domain,
datum_b=0,
)
result = library.seccomp_rule_add_array(
context,
SCMP_ACT_ERRNO,
socket_syscall,
1,
ctypes.byref(comparison),
)
if result != 0:
raise OSError(-result, os.strerror(-result))
result = library.seccomp_load(context)
if result != 0:
raise OSError(-result, os.strerror(-result))
finally:
library.seccomp_release(context)
def set_no_new_privileges(libc: ctypes.CDLL) -> None:
result = libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
if result != 0:
error_number = ctypes.get_errno()
raise OSError(error_number, os.strerror(error_number))
def restrict_process(
workspace: str,
read_only_paths: list[str],
uid: int,
gid: int,
) -> int:
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
libc.prctl.restype = ctypes.c_int
seccomp = load_seccomp()
abi = get_landlock_abi(libc)
if abi < 4:
raise RuntimeError(f"Landlock ABI 4 or newer is required; detected ABI {abi}")
ruleset_fd = build_landlock_ruleset(
libc,
abi,
workspace,
read_only_paths,
)
try:
if os.geteuid() == 0:
os.setgroups([])
os.setgid(gid)
os.setuid(uid)
set_no_new_privileges(libc)
_, _, restrict_self = syscall_numbers()
checked_syscall(
libc,
restrict_self,
ctypes.c_int(ruleset_fd),
ctypes.c_uint32(0),
)
install_seccomp_network_filter(seccomp)
finally:
os.close(ruleset_fd)
return abi
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--probe", action="store_true")
parser.add_argument("--workspace")
parser.add_argument("--read-only", action="append", default=[])
parser.add_argument("--uid", type=int, default=10_001)
parser.add_argument("--gid", type=int, default=10_001)
parser.add_argument("--command")
return parser.parse_args()
def main() -> int:
args = parse_args()
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
if args.probe:
abi = get_landlock_abi(libc)
load_seccomp()
print(json.dumps({"ok": True, "landlock_abi": abi, "seccomp": True}))
return 0 if abi >= 4 else 1
if not args.workspace or args.command is None:
raise RuntimeError("workspace and command are required")
workspace = os.path.realpath(args.workspace)
if not os.path.isdir(workspace):
raise RuntimeError("workspace must be an existing directory")
os.chdir(workspace)
restrict_process(workspace, args.read_only, args.uid, args.gid)
os.execve("/bin/bash", ["bash", "-c", args.command], dict(os.environ))
return 127
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
print(
json.dumps(
{
"ok": False,
"error": "SANDBOX_UNAVAILABLE",
"message": str(error),
}
),
file=sys.stderr,
)
raise SystemExit(125)
+424
View File
@@ -0,0 +1,424 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
type JsonRecord = Record<string, unknown>;
type Options = {
backupDir: string;
dataDir: string;
dryRun: boolean;
fromActorKey: string;
fromProjectId?: string;
fromProjectKey?: string;
fromUserId?: string;
metadataDir: string;
memoryDir: string;
resultRefsDir: string;
toActorKey: string;
toProjectId?: string;
toProjectKey?: string;
toUserId?: string;
transcriptsDir: string;
};
type Change = {
detail: string;
file: string;
kind: "metadata" | "transcript" | "result-ref" | "memory";
};
const usage = `Usage:
bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
Optional:
--from-project-id <old> Match old project id
--to-project-id <new> Rewrite project id
--from-project-key <old> Match old project key
--to-project-key <new> Rewrite project key
--data-dir <dir> Default: ./data
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
--write Apply changes. Without this, dry-run only.
Examples:
bun scripts/migrate-session-identity.ts \\
--from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
--to-actor-key actor-49a0275a85079f9d
bun scripts/migrate-session-identity.ts \\
--from-actor-key actor-da98d99c20c386e7 \\
--to-actor-key actor-49a0275a85079f9d \\
--write
`;
const main = async () => {
const options = parseOptions(process.argv.slice(2));
const changes: Change[] = [];
changes.push(...(await migrateMetadata(options)));
changes.push(...(await migrateTranscripts(options)));
changes.push(...(await migrateResultRefs(options)));
changes.push(...(await migrateUserMemory(options)));
printSummary(options, changes);
};
const parseOptions = (args: string[]): Options => {
const values = new Map<string, string | boolean>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
}
const key = arg.slice(2);
if (key === "help" || key === "h") {
console.log(usage);
process.exit(0);
}
if (key === "write") {
values.set(key, true);
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for --${key}\n\n${usage}`);
}
values.set(key, value);
index += 1;
}
const dataDir = stringOption(values, "data-dir") ?? "./data";
const fromUserId = stringOption(values, "from-user-id");
const toUserId = stringOption(values, "to-user-id");
const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
const fromProjectId = stringOption(values, "from-project-id");
const toProjectId = stringOption(values, "to-project-id");
const fromProjectKey =
stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
const toProjectKey =
stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);
if (!fromActorKey || !toActorKey) {
throw new Error(
"provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
usage,
);
}
if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
throw new Error("source and target identity are identical");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir =
stringOption(values, "backup-dir") ??
join(dataDir, "backup", "session-identity-migration", timestamp);
return {
backupDir,
dataDir,
dryRun: !values.has("write"),
fromActorKey,
fromProjectId,
fromProjectKey,
fromUserId,
metadataDir: join(dataDir, "session-metadata"),
memoryDir: join(dataDir, "memory"),
resultRefsDir: join(dataDir, "result-refs"),
toActorKey,
toProjectId,
toProjectKey,
toUserId,
transcriptsDir: join(dataDir, "session-transcripts"),
};
};
const stringOption = (values: Map<string, string | boolean>, key: string) => {
const value = values.get(key);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const actorKeyFromUserId = (userId?: string) =>
userId ? scopedKey("actor", userId) : undefined;
const projectKeyFromProjectId = (projectId?: string) =>
projectId ? scopedKey("project", projectId) : undefined;
const scopedKey = (prefix: string, value: string) =>
`${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;
const migrateMetadata = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.metadataDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "metadata",
});
}
return changes;
};
const migrateTranscripts = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.transcriptsDir)) {
const transcript = await readJson(file);
if (!transcript || transcript.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
continue;
}
const next: JsonRecord = {
...transcript,
actorKey: options.toActorKey,
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
const nextPath = join(
dirname(file),
`${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
);
if (nextPath !== file && (await exists(nextPath))) {
throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
}
await writeJsonWithBackup(options, file, next);
if (nextPath !== file) {
await renameWithBackup(options, file, nextPath);
}
changes.push({
detail: `${basename(file)} -> ${basename(nextPath)}`,
file,
kind: "transcript",
});
}
return changes;
};
const migrateResultRefs = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.resultRefsDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "result-ref",
});
}
return changes;
};
const migrateUserMemory = async (options: Options): Promise<Change[]> => {
const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
if (!(await exists(fromFile))) {
return [];
}
const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
if (fromFile === toFile) {
return [];
}
if (await exists(toFile)) {
const [fromContent, toContent] = await Promise.all([
readFile(fromFile, "utf8"),
readFile(toFile, "utf8"),
]);
const merged = mergeMemoryMarkdown(toContent, fromContent);
await writeTextWithBackup(options, toFile, merged);
await removeWithBackup(options, fromFile);
} else {
await renameWithBackup(options, fromFile, toFile);
}
return [
{
detail: `${basename(fromFile)} -> ${basename(toFile)}`,
file: fromFile,
kind: "memory",
},
];
};
const mergeMemoryMarkdown = (target: string, source: string) => {
const lines = target.split("\n");
const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
for (const line of source.split("\n")) {
const normalized = line.trim();
if (!normalized || existing.has(normalized)) {
continue;
}
lines.push(line);
existing.add(normalized);
}
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
};
const listJsonFiles = async (dir: string) => {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(".json"))
.map((name) => join(dir, name));
} catch (error) {
if (isErrno(error, "ENOENT")) {
return [];
}
throw error;
}
};
const readJson = async (file: string): Promise<JsonRecord | null> => {
try {
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
return isRecord(value) ? value : null;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return null;
}
throw error;
}
};
const writeJsonWithBackup = async (
options: Options,
file: string,
value: JsonRecord,
) => {
await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
};
const writeTextWithBackup = async (
options: Options,
file: string,
content: string,
) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await writeFile(file, content, "utf8");
};
const renameWithBackup = async (options: Options, from: string, to: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, from);
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
};
const removeWithBackup = async (options: Options, file: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await rm(file, { force: true });
};
const backupFile = async (options: Options, file: string) => {
const relativePath = relative(process.cwd(), file);
const target = join(options.backupDir, relativePath);
await mkdir(dirname(target), { recursive: true });
await copyFile(file, target);
};
const exists = async (file: string) => {
try {
await stat(file);
return true;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return false;
}
throw error;
}
};
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isErrno = (error: unknown, code: string) =>
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === code;
const printSummary = (options: Options, changes: Change[]) => {
const counts = changes.reduce<Record<string, number>>((acc, change) => {
acc[change.kind] = (acc[change.kind] ?? 0) + 1;
return acc;
}, {});
console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
console.log(`from actor: ${options.fromActorKey}`);
console.log(`to actor: ${options.toActorKey}`);
if (options.fromProjectKey || options.toProjectKey) {
console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
}
if (!options.dryRun) {
console.log(`backup dir: ${options.backupDir}`);
}
console.log(`changes: ${changes.length}`);
for (const [kind, count] of Object.entries(counts)) {
console.log(` ${kind}: ${count}`);
}
for (const change of changes.slice(0, 40)) {
console.log(`- [${change.kind}] ${change.detail}`);
}
if (changes.length > 40) {
console.log(`... ${changes.length - 40} more`);
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+4 -3
View File
@@ -4,14 +4,15 @@ import { dirname } from "node:path";
import { config } from "../config.js";
export type LlmRequestAuditEntry = {
kind: "tool" | "skill";
kind: "activity" | "tool" | "skill";
sessionId: string;
clientSessionId: string;
traceId?: string;
projectId?: string;
target: string;
reason: string;
reasonProvided: boolean;
activityId?: string;
activityTitle?: string;
activityReason?: string;
payload?: Record<string, unknown>;
};
+112 -13
View File
@@ -3,6 +3,39 @@ import type { NextFunction, Request, Response } from "express";
import { config } from "../config.js";
import { logger } from "../logger.js";
export type AgentAuthContext = {
accessToken: string;
userId: string;
keycloakSub: string;
username: string;
role: string;
isSuperuser: boolean;
projectId: string;
network: string;
projectRole: string;
tokenExpiresAt?: string;
};
type AgentAuthContextResponse = {
user_id?: unknown;
keycloak_sub?: unknown;
username?: unknown;
role?: unknown;
is_superuser?: unknown;
project_id?: unknown;
network?: unknown;
project_role?: unknown;
token_expires_at?: unknown;
};
declare global {
namespace Express {
interface Request {
agentAuth?: AgentAuthContext;
}
}
}
export const extractBearerToken = (authorization?: string) => {
const value = authorization?.trim();
if (!value) {
@@ -11,8 +44,41 @@ export const extractBearerToken = (authorization?: string) => {
return value.replace(/^Bearer\s+/i, "").trim();
};
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token,
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。
const normalizeAgentAuthContext = (
payload: AgentAuthContextResponse,
accessToken: string,
): AgentAuthContext | null => {
if (
typeof payload.user_id !== "string" ||
typeof payload.keycloak_sub !== "string" ||
typeof payload.username !== "string" ||
typeof payload.role !== "string" ||
typeof payload.is_superuser !== "boolean" ||
typeof payload.project_id !== "string" ||
typeof payload.network !== "string" ||
typeof payload.project_role !== "string"
) {
return null;
}
return {
accessToken,
userId: payload.user_id,
keycloakSub: payload.keycloak_sub,
username: payload.username,
role: payload.role,
isSuperuser: payload.is_superuser,
projectId: payload.project_id,
network: payload.network,
projectRole: payload.project_role,
tokenExpiresAt:
typeof payload.token_expires_at === "string"
? payload.token_expires_at
: undefined,
};
};
// Agent API 使用 TJWater 后端专用认证上下文:后端负责校验 Keycloak token、
// metadata 用户状态和项目 membership,Agent 只信任后端返回的 user/project。
export const requireAgentAuth = async (
req: Request,
res: Response,
@@ -24,37 +90,70 @@ export const requireAgentAuth = async (
return;
}
const projectId = req.header("x-project-id")?.trim();
if (!projectId) {
res.status(400).json({ message: "x-project-id is required" });
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try {
const response = await fetch(new URL("/api/v1/auth/me", config.TJWATER_API_BASE_URL), {
const response = await fetch(new URL("/api/v1/agent-auth-context", config.TJWATER_API_BASE_URL), {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
"X-Project-Id": projectId,
...(req.header("x-trace-id") ? { "X-Trace-Id": req.header("x-trace-id") as string } : {}),
},
signal: controller.signal,
});
if (response.ok) {
const context = normalizeAgentAuthContext(
(await response.json()) as AgentAuthContextResponse,
token,
);
if (!context) {
res.status(502).json({ message: "invalid authentication context" });
return;
}
req.agentAuth = context;
next();
return;
}
const detail = await response.text();
res.status(response.status === 403 ? 403 : 401).json({
message: response.status === 403 ? "forbidden" : "unauthorized",
detail: detail || undefined,
});
const status =
response.status === 400 ||
response.status === 401 ||
response.status === 403 ||
response.status === 404
? response.status
: response.status === 503
? 503
: 502;
const messages: Record<number, string> = {
400: "invalid authentication request",
401: "unauthorized",
403: "forbidden",
404: "authentication context not found",
502: "invalid authentication service response",
503: "authentication service unavailable",
};
res.status(status).json({ message: messages[status] });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, "agent auth validation failed");
res.status(503).json({
message: "authentication service unavailable",
detail,
});
res.status(503).json({ message: "authentication service unavailable" });
} finally {
clearTimeout(timer);
}
};
export const getAgentAuthContext = (req: Request) => {
if (!req.agentAuth) {
throw new Error("agent auth context is missing");
}
return req.agentAuth;
};
+25
View File
@@ -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;
};
+232
View File
@@ -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;
};
+78
View File
@@ -0,0 +1,78 @@
export type SupportedModel = string;
export type AgentModelIcon = "bolt" | "sparkle";
export type AgentModelOption = {
id: SupportedModel;
label: string;
description: string;
icon: AgentModelIcon;
};
export const defaultAgentModelOptions: AgentModelOption[] = [
{
id: "deepseek/deepseek-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
{
id: "deepseek/deepseek-v4-pro",
label: "专家",
description: "探索、解决复杂任务",
icon: "sparkle",
},
];
export const defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const parseAgentModelOptions = (value: string): AgentModelOption[] => {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON");
}
if (!Array.isArray(parsed)) {
throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array");
}
const seen = new Set<string>();
return parsed.map((item, index) => {
if (!isObjectRecord(item)) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`);
}
const id = typeof item.id === "string" ? item.id.trim() : "";
if (!id) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`);
}
if (seen.has(id)) {
throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`);
}
seen.add(id);
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: id;
const description =
typeof item.description === "string" && item.description.trim()
? item.description.trim()
: label;
const icon = item.icon === "bolt" || item.icon === "sparkle"
? item.icon
: "sparkle";
return {
id,
label,
description,
icon,
};
});
};
export const getAgentModelIds = (options: AgentModelOption[]) =>
options.map((option) => option.id);
+28
View File
@@ -0,0 +1,28 @@
import { config } from "../config.js";
import {
getAgentModelIds,
parseAgentModelOptions,
type SupportedModel,
} from "./modelConfig.js";
export {
type AgentModelIcon,
type AgentModelOption,
type SupportedModel,
} from "./modelConfig.js";
export const agentModelOptions = parseAgentModelOptions(
config.OPENCODE_MODEL_OPTIONS,
);
export const supportedModels = getAgentModelIds(agentModelOptions);
export const isSupportedModel = (model: string): model is SupportedModel =>
supportedModels.includes(model);
export const resolveDefaultModel = (model: string): SupportedModel => {
if (!isSupportedModel(model)) {
throw new Error(`unsupported default agent model: ${model}`);
}
return model;
};
+20 -2
View File
@@ -12,12 +12,15 @@ export type SessionBinding = {
clientSessionId: string;
sessionId: string;
startedAt: number;
workspaceDirectory?: string;
};
export type SessionContext = {
clientSessionId: string;
accessToken?: string;
network?: string;
projectId?: string;
tokenExpiresAt?: string;
userId?: string;
};
@@ -35,8 +38,10 @@ export class ChatSessionBridge {
async resolve(context: {
sessionId?: string;
accessToken?: string;
network?: string;
projectId?: string;
traceId?: string;
tokenExpiresAt?: string;
userId?: string;
}): Promise<{
binding: SessionBinding;
@@ -48,20 +53,26 @@ export class ChatSessionBridge {
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
let sessionId = existingSessionId;
let runtimeSession;
let created = false;
if (!sessionId) {
const session = await this.runtime.createSession();
sessionId = session.id;
runtimeSession = await this.runtime.createSession(undefined, {
conversationWorkspace: true,
});
sessionId = runtimeSession.id;
requestContext = {
...requestContext,
clientSessionId: sessionId,
};
created = true;
} else {
runtimeSession = await this.runtime.getSession(sessionId);
}
const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId,
sessionId,
startedAt: Date.now(),
workspaceDirectory: runtimeSession.directory,
};
setRuntimeSessionContext({
accessToken: requestContext.accessToken,
@@ -69,10 +80,13 @@ export class ChatSessionBridge {
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
network: requestContext.network,
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId,
tokenExpiresAt: requestContext.tokenExpiresAt,
traceId: requestContext.traceId,
workspaceDirectory: runtimeSession.directory,
});
return { binding, requestContext, created };
@@ -141,8 +155,10 @@ export class ChatSessionBridge {
private buildRequestContext(context: {
sessionId?: string;
accessToken?: string;
network?: string;
projectId?: string;
traceId?: string;
tokenExpiresAt?: string;
userId?: string;
}): ChatRequestContext {
const sessionId = context.sessionId?.trim();
@@ -150,8 +166,10 @@ export class ChatSessionBridge {
clientSessionId: sessionId || this.createClientSessionId(),
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
network: context.network,
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
tokenExpiresAt: context.tokenExpiresAt,
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
+215
View File
@@ -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;
};
+60 -18
View File
@@ -1,6 +1,14 @@
import dotenv from "dotenv";
import { z } from "zod";
import {
defaultAgentModelOptionsJson,
getAgentModelIds,
parseAgentModelOptions,
} from "./chat/modelConfig.js";
export const RESULT_REF_IMPORT_DIRECTORY = "./data/conversation-workspaces";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false });
@@ -33,10 +41,10 @@ const envSchema = z
.default("./logs/llm-request-audit.log"),
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
AGENT_INTERNAL_TOKEN: optionalString(),
// Agent 前置认证调用后端 /api/v1/auth/me 的超时时间(毫秒)。
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// 当前仅支持 embedded;保留字段用于让旧 client 配置在启动时明确失败。
OPENCODE_MODE: z.literal("embedded").default("embedded"),
// embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口。
@@ -44,19 +52,31 @@ const envSchema = z
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
OPENCODE_MODEL: z.string().default("deepseek/deepseek-flash"),
// 聊天 UI 和 /stream 允许选择的 opencode 模型完整配置,JSON 数组。
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// tjwater-cli 可执行文件路径。
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
// 后端结果在直接内联返回给模型前允许的最大字节数。
// OpenCode 工具结果以内联形式返回给模型的阈值;更大的结果由 OpenCode 落盘。
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 时最多抽样的条目数。
MAX_PREVIEW_SAMPLE_ITEMS: z.coerce.number().int().positive().default(3),
// memory 持久化存储目录。
@@ -97,6 +117,16 @@ const envSchema = z
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
// result_ref 持久化存储目录。
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_TTL_HOURS: z.coerce.number().int().positive().default(168),
// 定时清理过期 result_ref 的扫描周期(毫秒)。
@@ -107,22 +137,34 @@ const envSchema = z
.default(3600000),
})
.superRefine((env, ctx) => {
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
let modelOptions;
try {
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_CLIENT_BASE_URL"],
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
path: ["OPENCODE_MODEL_OPTIONS"],
message: error instanceof Error ? error.message : String(error),
});
return;
}
const supportedModels = getAgentModelIds(modelOptions);
if (supportedModels.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_MODEL_OPTIONS"],
message: "OPENCODE_MODEL_OPTIONS must include at least one model",
});
}
if (!supportedModels.includes(env.OPENCODE_MODEL)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_MODEL"],
message: "OPENCODE_MODEL must be included in OPENCODE_MODEL_OPTIONS",
});
}
});
export type AppConfig = z.infer<typeof envSchema>;
const normalizedEnv = {
...process.env,
OPENCODE_MODE:
process.env.OPENCODE_MODE ??
(process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"),
};
export const config: AppConfig = envSchema.parse(normalizedEnv);
export const config: AppConfig = envSchema.parse(process.env);
+348
View File
@@ -0,0 +1,348 @@
import {
extendZodWithOpenApi,
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
const SessionId = z.object({ session_id: z.string().max(128) });
const RenderRef = z.object({ render_ref: z.string().min(1) });
const RenderReferenceQuery = z.object({
session_id: z.string().max(128).optional(),
});
const SessionCreate = z
.object({
session_id: z.string(),
title: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
})
.openapi("AgentSessionCreate");
const SessionSummary = z
.object({
id: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
is_streaming: z.boolean(),
run_status: z.string().nullable().optional(),
})
.openapi("AgentSessionSummary");
const SessionDetail = SessionSummary.extend({
session_id: z.string(),
is_title_manually_edited: z.boolean(),
messages: z.array(z.unknown()),
}).openapi("AgentSessionDetail");
const SessionUpdate = z
.object({
id: z.string(),
title: z.string(),
updated_at: z.string(),
})
.openapi("AgentSessionUpdate");
const SessionFork = z
.object({
session_id: z.string(),
})
.openapi("AgentSessionFork");
const Problem = z
.object({
type: z.string(),
title: z.string(),
status: z.number().int(),
detail: z.string(),
instance: z.string(),
code: z.string(),
trace_id: z.string(),
errors: z.array(z.unknown()),
})
.openapi("ProblemDetails");
const JsonObject = z.record(z.unknown());
const errorResponses = {
400: {
description: "Invalid request",
content: { "application/problem+json": { schema: Problem } },
},
401: {
description: "Authentication required",
content: { "application/problem+json": { schema: Problem } },
},
403: {
description: "Insufficient permission",
content: { "application/problem+json": { schema: Problem } },
},
404: {
description: "Resource not found",
content: { "application/problem+json": { schema: Problem } },
},
409: {
description: "Resource conflict",
content: { "application/problem+json": { schema: Problem } },
},
422: {
description: "Validation error",
content: { "application/problem+json": { schema: Problem } },
},
500: {
description: "Internal server error",
content: { "application/problem+json": { schema: Problem } },
},
502: {
description: "Upstream dependency error",
content: { "application/problem+json": { schema: Problem } },
},
503: {
description: "Dependency unavailable",
content: { "application/problem+json": { schema: Problem } },
},
};
const jsonResponse = (schema: z.ZodTypeAny, description = "Successful response") => ({
description,
content: { "application/json": { schema } },
});
type RegisterConfig = {
summary: string;
request?: Record<string, unknown>;
responses: Record<number, unknown>;
};
export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => {
const operations = new Set<string>();
return (
path: string,
method: "get" | "post" | "patch" | "delete",
config: RegisterConfig,
) => {
const key = `${method.toUpperCase()} ${path}`;
if (operations.has(key)) {
throw new Error(`Duplicate Agent OpenAPI operation: ${key}`);
}
operations.add(key);
targetRegistry.registerPath({
path,
method,
operationId: `${method}_${path
.replace(/^\/api\/v1\/agent\/?/, "")
.replaceAll(/[{}]/g, "")
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
.replaceAll(/^_|_$/g, "")}`,
tags: ["Agent"],
security: [{ bearerAuth: [] }],
...config,
responses: { ...config.responses, ...errorResponses },
});
};
};
const register = createRouteRegistrar(registry);
register("/api/v1/agent/models", "get", {
summary: "List available agent models",
responses: { 200: jsonResponse(JsonObject) },
});
register("/api/v1/agent/sessions", "get", {
summary: "List agent sessions",
responses: { 200: jsonResponse(z.object({ sessions: z.array(SessionSummary) })) },
});
register("/api/v1/agent/sessions", "post", {
summary: "Create an agent session",
request: {
body: {
content: {
"application/json": {
schema: z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
}),
},
},
},
},
responses: {
200: jsonResponse(SessionCreate, "Existing session returned"),
201: jsonResponse(SessionCreate, "Session created"),
},
});
register("/api/v1/agent/sessions/{session_id}", "get", {
summary: "Get an agent session",
request: { params: SessionId },
responses: { 200: jsonResponse(SessionDetail) },
});
register("/api/v1/agent/sessions/{session_id}", "patch", {
summary: "Update an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
title: z.string().min(1).max(120),
is_title_manually_edited: z.boolean().optional(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionUpdate) },
});
register("/api/v1/agent/sessions/{session_id}", "delete", {
summary: "Delete an agent session",
request: { params: SessionId },
responses: { 204: { description: "Session deleted" } },
});
register("/api/v1/agent/sessions/{session_id}/runs", "post", {
summary: "Run an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
message: z.string().min(1).max(10000),
model: z.string().optional(),
approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.describe(
"request forwards approval prompts; auto approves only the low-risk allowlist and forwards other requests; always approves routine prompts but forwards sensitive paths and force removal for user approval.",
),
}),
},
},
},
},
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
});
register(
"/api/v1/agent/sessions/{session_id}/runs/current/events",
"get",
{
summary: "Resume the current agent event stream",
request: { params: SessionId },
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
},
);
register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
summary: "Abort the current agent run",
request: { params: SessionId },
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(
"/api/v1/agent/sessions/{session_id}/permission-responses",
"post",
{
summary: "Reply to an agent permission request",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register(
"/api/v1/agent/sessions/{session_id}/question-responses",
"post",
{
summary: "Reply to or reject an agent question",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register("/api/v1/agent/sessions/{session_id}/forks", "post", {
summary: "Fork an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
keep_message_count: z.number().int().nonnegative(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionFork) },
});
register("/api/v1/agent/render-references/{render_ref}", "get", {
summary: "Resolve a render reference",
request: { params: RenderRef, query: RenderReferenceQuery },
responses: { 200: jsonResponse(JsonObject) },
});
export const generateAgentOpenApi = () => {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.3",
info: {
title: "TJWater Agent API",
version: "1.0.0",
description: "Public REST API for TJWater Agent sessions and runs",
},
});
};
+3 -4
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { writeLearningAuditLog } from "../audit/learningAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { type ChatRequestContext } from "../chat/sessionBridge.js";
import { config } from "../config.js";
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
@@ -64,8 +65,6 @@ const reviewResultSchema = z.object({
type GateResult = z.infer<typeof gateResultSchema>;
type ReviewResult = z.infer<typeof reviewResultSchema>;
type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro";
type TurnReviewInput = {
assistantMessage: string;
model?: SupportedModel;
@@ -78,12 +77,12 @@ type TurnReviewInput = {
export class LearningOrchestrator {
private readonly activeReviews = new Set<string>();
private readonly sessionLearningStateStore = new SessionLearningStateStore();
private readonly skillStore = new SkillStore();
constructor(
private readonly runtime: OpencodeRuntimeAdapter,
private readonly memoryStore: MemoryStore,
private readonly transcriptStore: SessionTranscriptStore,
private readonly skillStore: SkillStore,
) {}
async initialize() {
@@ -574,7 +573,7 @@ const toRuntimeModel = (model?: SupportedModel) => {
};
const GATE_MODEL = {
modelID: "deepseek-v4-flash",
modelID: "deepseek-flash",
providerID: "deepseek",
} as const;
+194
View File
@@ -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
View File
@@ -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 {
type ResultReferenceKind,
type ResultReferenceRecord,
@@ -33,7 +39,11 @@ export type RenderJunctionPayload = {
};
export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {}
constructor(
private readonly store: ResultReferenceStore,
private readonly importRoot: string,
private readonly importMaxBytes: number,
) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) {
@@ -61,9 +71,29 @@ export class ResultReferenceResolver {
async registerRenderPayloadFile(
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) {
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");
}
return this.register({
...input,
const { workspaceDirectory: _workspaceDirectory, ...registrationInput } = input;
const record = await this.register({
...registrationInput,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
});
await removeFileIfExists(resolvedFilePath);
return record;
}
async getFullAuthorized(
+429 -395
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
import type { Part } from "@opencode-ai/sdk/v2";
import {
getToolLabel,
normalizeToolStatus,
type ActivityActionPayload,
type ActivityPayload,
type ActivityStatus,
type ActivityUpdatePayload,
type TodoItemPayload,
} from "./chatStreamEvents.js";
type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const createActivityTracker = ({
clientSessionId,
write,
}: {
clientSessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
}) => {
const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now();
const snapshot = activity.status === "running"
? {
...activity,
elapsed_ms: Math.max(0, now - activity.started_at),
actions: activity.actions.map((action) =>
action.status === "running"
? { ...action, elapsed_ms: Math.max(0, now - action.started_at) }
: action,
),
}
: activity;
write("activity_update", {
session_id: clientSessionId,
activity: snapshot,
...(todos
? {
todos,
todos_created_at: now,
}
: {}),
} satisfies ActivityUpdatePayload);
};
const current = (): ActivityPayload | undefined =>
currentActivityId ? activities.get(currentActivityId) : undefined;
const finalize = (status: Exclude<ActivityStatus, "running">) => {
const activity = current();
if (!activity || activity.status !== "running") return;
const endedAt = Date.now();
const nextActivity: ActivityPayload = {
...activity,
status,
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - activity.started_at),
actions: activity.actions.map((action) => {
if (action.status !== "running") return action;
return {
...action,
status: status === "error" ? "error" : "completed",
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - action.started_at),
...(status === "error" ? { error: action.error ?? "活动执行失败" } : {}),
};
}),
};
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => {
finalize("completed");
const activity: ActivityPayload = {
id,
title,
reason,
status: "running",
actions: [],
started_at: Date.now(),
elapsed_ms: 0,
};
activities.set(id, activity);
currentActivityId = id;
emit(activity, todos);
return activity;
};
const ensure = (tool: string) => {
const activity = current();
if (activity?.status === "running") return activity;
return start(
`activity-fallback-${Date.now().toString(36)}`,
"执行分析操作",
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
);
};
const upsertAction = (part: ToolPart, params: Record<string, unknown>) => {
const associatedActivityId = actionActivityIds.get(part.id);
const activity = associatedActivityId
? activities.get(associatedActivityId)
: ensure(part.tool);
if (!activity) return;
if (!associatedActivityId) actionActivityIds.set(part.id, activity.id);
const now = Date.now();
const actionIndex = activity.actions.findIndex((action) => action.id === part.id);
const previous = actionIndex >= 0 ? activity.actions[actionIndex] : undefined;
const status = normalizeToolStatus(part.state.status);
const startedAt = previous?.started_at ?? now;
const endedAt = status === "running" ? undefined : now;
const action: ActivityActionPayload = {
id: part.id,
tool: part.tool,
title: getToolLabel(part.tool),
status,
target: getActionTarget(params),
error: part.state.status === "error" ? part.state.error : undefined,
started_at: startedAt,
ended_at: endedAt,
elapsed_ms: status === "running" ? Math.max(0, now - startedAt) : undefined,
duration_ms: endedAt ? Math.max(0, endedAt - startedAt) : undefined,
};
const actions = [...activity.actions];
if (actionIndex >= 0) actions[actionIndex] = action;
else actions.push(action);
const nextActivity = { ...activity, actions };
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const getActionContext = (actionId: string): ActivityContext | undefined => {
const activityId = actionActivityIds.get(actionId);
const activity = activityId ? activities.get(activityId) : undefined;
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
const getCurrentContext = (): ActivityContext | undefined => {
const activity = current();
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
return {
finalize,
getActionContext,
getCurrentContext,
start,
upsertAction,
};
};
+171
View File
@@ -0,0 +1,171 @@
import { type Router } from "express";
import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { logger } from "../logger.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortParamsSchema = z.object({
session_id: z.string().max(128),
});
type RegisterAuxiliaryRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
resultReferenceResolver: ResultReferenceResolver;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatAuxiliaryRoutes = (
chatRouter: Router,
{
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-references/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim();
const authContext = getAgentAuthContext(req);
const userId = authContext.userId;
const projectId = authContext.projectId;
const clientSessionId =
typeof req.query.session_id === "string"
? req.query.session_id.trim()
: undefined;
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
});
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
const parsed = abortParamsSchema.safeParse(req.params);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
res.status(202).json({
session_id: parsed.data.session_id,
aborted: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat abort failed");
res.status(500).json({
message: "chat abort failed",
detail,
});
}
});
};
+381
View File
@@ -0,0 +1,381 @@
import { type Router } from "express";
import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
updateLastAssistantQuestion,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
request_id: z.string().min(1),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const questionReplyPayloadSchema = z.object({
request_id: z.string().min(1),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).default([]),
});
const credentialRefreshPayloadSchema = z.object({
request_id: z.string().min(1).max(128),
});
type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
credentialRefreshCoordinator: CredentialRefreshCoordinator;
runtime: OpencodeRuntimeAdapter;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatInteractionRoutes = (
chatRouter: Router,
{
activeRuns,
credentialRefreshCoordinator,
runtime,
sessionMetadataStore,
sessionUiStateStore,
}: 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) => {
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run || run.status !== "running") {
res.status(409).json({ message: "session is not waiting for permissions" });
return;
}
const pendingPermission = run.pendingPermissions.get(requestId);
if (!pendingPermission) {
res.status(404).json({ message: "permission request not found" });
return;
}
const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.replyPermission({
requestId,
sessionId: sessionRecord.sessionId,
reply: parsed.data.reply,
message: parsed.data.message,
});
} catch (error) {
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission error state",
);
});
res.status(502).json({
message: "permission reply failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "permission reply route failed");
res.status(500).json({
message: "permission reply route failed",
detail,
});
}
});
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
if (parsed.data.action === "reject") {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} else {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
}
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: `failed to ${parsed.data.action} question`,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: `question ${parsed.data.action} failed`,
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: parsed.data.action === "reject" ? "rejected" : "answered",
answers:
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question response route failed");
res.status(500).json({
message: "question response route failed",
detail,
});
}
});
};
+450
View File
@@ -0,0 +1,450 @@
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) && !isSensitiveBashCommand(context);
}
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 = {},
) => {
const normalized = normalizePermission(permission);
if (
isSensitiveBashCommand(context, normalized) ||
(approvalMode === "always" &&
(normalized === "external_directory" ||
normalized === "read" ||
((normalized === "edit" || normalized === "write") &&
!isSafeConversationFileAccess(context))))
) {
return {
autoApprove: false,
autoReject: false,
title: "等待权限确认",
detail: undefined,
} as const;
}
if (approvalMode === "always") {
return {
autoApprove: true,
autoReject: false,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次常规权限请求。",
} 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 isSensitiveBashCommand = (
context: PermissionApprovalContext,
normalizedPermission = "bash",
): boolean => {
if (normalizedPermission !== "bash") return false;
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
return Boolean(
(command && containsProtectedPath(command, true)) ||
isDirectRecursiveForceRemove("bash", context),
);
};
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) &&
!containsSensitiveFileSegment(relativeTarget)
);
} catch {
return false;
}
};
const containsSensitiveFileSegment = (value: string): boolean =>
value
.replaceAll("\\", "/")
.toLowerCase()
.split("/")
.some((segment) => segment.includes(".env") || segment === "data" || segment === "logs");
+5
View File
@@ -212,6 +212,11 @@ export const buildPromptWithLearningContext = async (
.join("\n\n");
};
export const shouldRestoreConversationForRuntime = (options: {
hadExistingSessionRecord: boolean;
runtimeHasConversation: boolean;
}) => !options.hadExistingSessionRecord || !options.runtimeHasConversation;
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT)
+556 -440
View File
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { logger } from "../logger.js";
import { type QuestionAnswers } from "../runtime/opencode.js";
export type PermissionRequestPayload = {
session_id: string;
request_id: string;
permission: string;
patterns: string[];
target?: string;
activity_id?: string;
reason?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
export type ActivityStatus = "running" | "completed" | "error" | "cancelled";
export type ActivityActionPayload = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityPayload = {
id: string;
title: string;
reason: string;
status: ActivityStatus;
actions: ActivityActionPayload[];
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityUpdatePayload = {
session_id: string;
activity: ActivityPayload;
todos?: TodoItemPayload[];
todos_created_at?: number;
};
export const initialActivity = {
id: "activity-startup",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
} as const;
type QuestionOptionPayload = {
label: string;
description: string;
};
type QuestionInfoPayload = {
header: string;
question: string;
options: QuestionOptionPayload[];
multiple?: boolean;
custom?: boolean;
};
export type QuestionRequestPayload = {
session_id: string;
request_id: string;
questions: QuestionInfoPayload[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
export type TodoItemPayload = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
created_at?: number;
updated_at?: number;
};
export type TodoUpdatePayload = {
session_id: string;
message_id?: string;
todos: TodoItemPayload[];
created_at: number;
};
export type AssistantMessagePhase =
| "commentary"
| "final_answer"
| "unknown";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
tjwater_cli: "查询后端数据",
bash: "运行本地分析",
store_render_ref: "保存渲染结果",
memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
web_search: "网页搜索",
locate_features: "地图定位",
zoom_to_map: "地图缩放",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
apply_layer_style: "图层样式调整",
};
export const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
export const getErrorMessage = (error: {
name: string;
data?: { message?: string };
}) => error.data?.message ?? error.name;
export const getUnknownErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof error.name === "string"
) {
const maybeData = "data" in error ? error.data : undefined;
return getErrorMessage({
name: error.name,
data:
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
: undefined,
});
}
return error instanceof Error ? error.message : String(error);
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const getAssistantMessagePhase = (
metadata: unknown,
): AssistantMessagePhase => {
if (!isObjectRecord(metadata)) {
return "unknown";
}
const openai = isObjectRecord(metadata.openai) ? metadata.openai : undefined;
const phase = openai?.phase ?? metadata.phase;
return phase === "commentary" || phase === "final_answer"
? phase
: "unknown";
};
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
};
export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill");
export const extractSkillAuditInfo = (event: OpencodeEvent) => {
const payload = isObjectRecord(event.properties)
? (event.properties as Record<string, unknown>)
: {};
const candidateName =
typeof payload.skill === "string"
? payload.skill
: typeof payload.skillName === "string"
? payload.skillName
: typeof payload.name === "string"
? payload.name
: event.type;
return {
name: candidateName,
payload,
};
};
export const hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
export const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
"properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
export const isPermissionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
event.type === "permission.asked";
export const isPermissionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
event.type === "permission.v2.asked";
export const isPermissionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
event.type === "permission.replied";
export const isPermissionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
event.type === "permission.v2.replied";
export const isQuestionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.asked" }> =>
event.type === "question.asked";
export const isQuestionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.asked" }> =>
event.type === "question.v2.asked";
export const isQuestionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.replied" }> =>
event.type === "question.replied";
export const isQuestionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.replied" }> =>
event.type === "question.v2.replied";
export const isQuestionRejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.rejected" }> =>
event.type === "question.rejected";
export const isQuestionV2RejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected";
export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string,
): QuestionRequestPayload => ({
session_id: clientSessionId,
request_id: event.properties.id,
questions: event.properties.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple,
custom: question.custom,
})),
tool: event.properties.tool,
created_at: Date.now(),
});
export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) =>
Array.isArray(answers)
? answers.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
)
: [];
const questionToolNames = new Set(["question", "request_user_input"]);
const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] =>
Array.isArray(value)
? value.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
})).filter((option) => option.label.trim().length > 0)
: [];
const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => {
if (!isObjectRecord(value) || typeof value.question !== "string") {
return undefined;
}
const question = value.question.trim();
if (!question) {
return undefined;
}
return {
header:
typeof value.header === "string" && value.header.trim()
? value.header
: "补充信息",
question,
options: normalizeQuestionOptions(value.options),
multiple: typeof value.multiple === "boolean" ? value.multiple : undefined,
custom: typeof value.custom === "boolean" ? value.custom : undefined,
};
};
export const normalizeQuestionToolPayload = (
part: Extract<Part, { type: "tool" }>,
params: Record<string, unknown>,
clientSessionId: string,
): QuestionRequestPayload | undefined => {
if (!questionToolNames.has(part.tool)) {
return undefined;
}
const questions = Array.isArray(params.questions)
? params.questions
.map(normalizeToolQuestionInfo)
.filter((question): question is QuestionInfoPayload => Boolean(question))
: [];
if (questions.length === 0) {
return undefined;
}
return {
session_id: clientSessionId,
request_id: part.callID || part.id,
questions,
tool: {
messageID: part.messageID,
callID: part.callID,
},
created_at: Date.now(),
};
};
export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => {
if (status === "in_progress" || status === "completed" || status === "cancelled") {
return status;
}
return "pending";
};
export const normalizeTodoPriority = (
priority: string,
): TodoItemPayload["priority"] | undefined => {
if (priority === "low" || priority === "medium" || priority === "high") {
return priority;
}
return undefined;
};
export const collectTextContent = (parts: Part[]) =>
parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
export const normalizeToolStatus = (status: string) => {
if (status === "completed") return "completed";
if (status === "error") return "error";
return "running";
};
export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
+460
View File
@@ -0,0 +1,460 @@
import { type PermissionReply } from "../runtime/opencode.js";
import {
type ActivityPayload,
initialActivity,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoUpdatePayload,
} from "./chatStream.js";
export type RunStatus = "running" | "completed" | "error" | "aborted";
export type StreamSubscriber = {
write: (event: string, data: Record<string, unknown>) => void;
close: () => void;
};
export type ActiveRun = {
clientSessionId: string;
controller: AbortController;
messages: unknown[];
pendingPermissions: Map<string, PermissionRequestPayload>;
pendingQuestions: Map<string, QuestionRequestPayload>;
status: RunStatus;
subscribers: Set<StreamSubscriber>;
};
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
type ToolCallPayload = {
session_id?: string;
tool?: string;
params?: unknown;
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
reason?: string;
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const createFrontendMessageId = () =>
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
export const createInitialStreamingMessages = (
existingMessages: unknown[],
userContent: string,
) => {
const userMessage = {
id: createFrontendMessageId(),
role: "user",
content: userContent,
};
return [
...existingMessages,
{
...userMessage,
branchRootId: userMessage.id,
},
{
id: createFrontendMessageId(),
role: "assistant",
content: "",
activities: [
{
id: initialActivity.id,
status: "running",
title: initialActivity.title,
reason: initialActivity.reason,
actions: [],
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
},
],
},
];
};
const toFrontendActivityAction = (
action: ActivityPayload["actions"][number],
) => ({
id: action.id,
tool: action.tool,
title: action.title,
status: action.status,
target: action.target,
error: action.error,
startedAt: action.started_at,
endedAt: action.ended_at,
elapsedMs: action.elapsed_ms,
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: action.duration_ms,
});
const toFrontendActivity = (activity: ActivityPayload) => ({
id: activity.id,
title: activity.title,
reason: activity.reason,
status: activity.status,
actions: activity.actions.map(toFrontendActivityAction),
startedAt: activity.started_at,
endedAt: activity.ended_at,
elapsedMs: activity.elapsed_ms,
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: activity.duration_ms,
});
export const upsertBackendActivity = (
activities: unknown,
activity: ActivityPayload,
) => {
const next = Array.isArray(activities) ? [...activities] : [];
const index = next.findIndex(
(item) => isObjectRecord(item) && item.id === activity.id,
);
const nextItem = toFrontendActivity(activity);
if (index >= 0) next[index] = nextItem;
else next.push(nextItem);
return next;
};
export const completeBackendActivities = (
activities: unknown,
status: "completed" | "error" | "cancelled" = "completed",
) => Array.isArray(activities)
? activities.map((activity) => {
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
const endedAt = Date.now();
const startedAt = typeof activity.startedAt === "number"
? activity.startedAt
: endedAt;
const actions = Array.isArray(activity.actions)
? activity.actions.map((action) => {
if (!isObjectRecord(action) || action.status !== "running") return action;
const actionStartedAt = typeof action.startedAt === "number"
? action.startedAt
: endedAt;
return {
...action,
status: status === "error" ? "error" : "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - actionStartedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
};
})
: activity.actions;
return {
...activity,
status,
actions,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - startedAt),
};
})
: activities;
export const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
) => {
const next = Array.isArray(progress) ? [...progress] : [];
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
const nextItem = {
id,
phase: typeof payload.phase === "string" ? payload.phase : "progress",
status:
payload.status === "completed" || payload.status === "error"
? payload.status
: "running",
title: typeof payload.title === "string" ? payload.title : "正在处理",
detail: typeof payload.detail === "string" ? payload.detail : undefined,
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
elapsedSnapshotAt:
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
export const completeBackendProgress = (progress: unknown) =>
Array.isArray(progress)
? progress.map((item) => {
if (!isObjectRecord(item) || item.status !== "running") {
return item;
}
const endedAt = Date.now();
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
return {
...item,
status: "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
typeof item.durationMs === "number"
? item.durationMs
: startedAt !== undefined
? Math.max(0, endedAt - startedAt)
: item.elapsedMs,
};
})
: progress;
export const cancelBackendTodos = (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: "cancelled",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const completeBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "completed",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (isObjectRecord(message) && message.role === "assistant") {
const next = [...messages];
next[index] = updater(message);
return next;
}
}
return messages;
};
export const updateLastAssistantPermission = (
messages: unknown[],
requestId: string,
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const permissions = Array.isArray(message.permissions)
? message.permissions
: [];
return {
...message,
permissions: permissions.map((permission) =>
isObjectRecord(permission) && permission.requestId === requestId
? updater(permission)
: permission,
),
};
});
export const updateLastAssistantQuestion = (
messages: unknown[],
requestId: string,
updater: (question: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const questions = Array.isArray(message.questions)
? message.questions
: [];
return {
...message,
questions: questions.map((question) =>
isObjectRecord(question) && question.requestId === requestId
? updater(question)
: question,
),
};
});
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "show_chart" || tool === "chart") return "chart";
if (
tool === "locate_features" ||
tool === "zoom_to_map" ||
tool === "render_junctions" ||
tool === "apply_layer_style" ||
tool.startsWith("locate_")
) {
return "map";
}
if (tool === "view_history" || tool === "view_scada") return "panel";
return "tool";
};
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
if (typeof params.title === "string" && params.title.trim()) {
return params.title.trim();
}
if (tool === "show_chart" || tool === "chart") return "生成图表";
if (tool === "zoom_to_map") return "缩放到地图坐标";
if (tool === "render_junctions") return "渲染节点分区";
if (tool === "view_history") return "打开计算结果曲线";
if (tool === "view_scada") return "打开 SCADA 数据面板";
if (tool === "apply_layer_style") return "应用图层样式";
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
return tool || "工具调用";
};
export const appendBackendToolArtifact = (
artifacts: unknown,
payload: ToolCallPayload,
) => {
const tool = typeof payload.tool === "string" ? payload.tool.trim() : "";
if (!tool) {
return artifacts;
}
const params = isObjectRecord(payload.params) ? payload.params : {};
const next = Array.isArray(artifacts) ? [...artifacts] : [];
next.push({
id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
tool,
kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params),
params,
});
return next;
};
export const toFrontendPermission = (
payload: PermissionRequestPayload,
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
) => ({
requestId: payload.request_id,
sessionId: payload.session_id,
permission: payload.permission,
patterns: payload.patterns,
target: payload.target,
activityId: payload.activity_id,
reason: payload.reason,
always: payload.always,
tool: payload.tool,
createdAt: payload.created_at,
status,
});
const toFrontendQuestion = (
payload: QuestionRequestPayload,
status: "pending" | "submitting" | "answered" | "rejected" | "error" = "pending",
) => ({
requestId: payload.request_id,
sessionId: payload.session_id,
questions: payload.questions,
tool: payload.tool,
createdAt: payload.created_at,
status,
});
export const toPermissionStatus = (reply: PermissionReply) => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const upsertBackendQuestion = (
questions: unknown,
payload: QuestionRequestPayload,
) => {
const next = Array.isArray(questions) ? [...questions] : [];
const index = next.findIndex((item) => {
if (!isObjectRecord(item)) return false;
if (item.requestId === payload.request_id) return true;
const tool = isObjectRecord(item.tool) ? item.tool : undefined;
return Boolean(
payload.tool?.callID &&
tool?.callID === payload.tool.callID,
);
});
const nextItem = toFrontendQuestion(payload);
if (index >= 0) {
const current = next[index];
const currentRequestId = isObjectRecord(current) ? current.requestId : undefined;
const currentTool = isObjectRecord(current) && isObjectRecord(current.tool)
? current.tool
: undefined;
const currentIsActionable =
typeof currentRequestId === "string" &&
currentRequestId !== currentTool?.callID;
const payloadIsToolPlaceholder =
Boolean(payload.tool?.callID) && payload.request_id === payload.tool?.callID;
next[index] = {
...(isObjectRecord(current) ? current : {}),
...(currentIsActionable && payloadIsToolPlaceholder
? {
questions: nextItem.questions,
tool: nextItem.tool,
createdAt: nextItem.createdAt,
}
: nextItem),
status:
isObjectRecord(current) && current.status === "submitting"
? "submitting"
: nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
export const upsertBackendTodoUpdate = (
_todos: unknown,
payload: TodoUpdatePayload,
) => [
{
sessionId: payload.session_id,
messageId: payload.message_id,
todos: payload.todos,
createdAt: payload.created_at,
},
];
+55
View File
@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
import { type Request, type Response, Router } from "express";
const problemDetails = (req: Request, status: number, body: unknown) => {
const record =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const detail =
typeof record.detail === "string"
? record.detail
: typeof record.message === "string"
? record.message
: `Request failed with status ${status}`;
const codeByStatus: Record<number, string> = {
400: "invalid_request",
401: "unauthenticated",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "dependency_unavailable",
};
const code = codeByStatus[status] ?? "request_error";
return {
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
title: code.replaceAll("_", " "),
status,
detail,
instance: req.originalUrl.split("?")[0],
code,
trace_id: req.header("x-request-id") ?? randomUUID(),
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
};
};
export const buildAgentPublicRouter = (chatRouter: Router) => {
const router = Router();
router.use((req, res, next) => {
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (res.statusCode >= 400) {
res.type("application/problem+json");
return json(problemDetails(req, res.statusCode, body));
}
return json(body);
}) as Response["json"];
next();
});
router.use(chatRouter);
return router;
};
+137
View File
@@ -0,0 +1,137 @@
import { randomUUID } from "node:crypto";
import {
chown,
chmod,
lstat,
mkdir,
realpath,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
export const SANDBOX_UID = 10_001;
export const SANDBOX_GID = 10_001;
const isOutsideRoot = (root: string, candidate: string) => {
const relativePath = relative(root, candidate);
return (
relativePath === ".." ||
relativePath.startsWith(`..${sep}`) ||
isAbsolute(relativePath)
);
};
export const setSandboxOwnership = async (path: string) => {
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
return;
}
await chown(path, SANDBOX_UID, SANDBOX_GID);
};
export const resolveConversationWorkspace = async (
workspaceDirectory: string,
importRoot: string,
) => {
const workspaceLinkStat = await lstat(workspaceDirectory);
if (workspaceLinkStat.isSymbolicLink()) {
throw new Error("conversation workspace must not be a symbolic link");
}
const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([
realpath(importRoot),
realpath(workspaceDirectory),
]);
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
throw new Error("conversation workspace must be inside RESULT_REF_IMPORT_DIR");
}
const relativeWorkspace = relative(
resolvedImportRoot,
resolvedWorkspaceDirectory,
);
if (
!relativeWorkspace ||
relativeWorkspace.includes("/") ||
relativeWorkspace.includes("\\")
) {
throw new Error(
"conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR",
);
}
const workspaceStat = await stat(resolvedWorkspaceDirectory);
if (!workspaceStat.isDirectory()) {
throw new Error("conversation workspace must point to a directory");
}
return resolvedWorkspaceDirectory;
};
export const resolveExistingPathInsideRoot = async (
filePath: string,
rootPath: string,
outsideMessage = "path must be inside the current conversation workspace",
) => {
if (!isAbsolute(filePath)) {
throw new Error("file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
if (isOutsideRoot(resolvedRootPath, resolvedFilePath)) {
throw new Error(outsideMessage);
}
return resolvedFilePath;
};
export type StagedConversationFile = {
bytes: number;
contentType: string;
filePath: string;
};
export const stageConversationText = async (
workspaceDirectory: string,
importRoot: string,
content: string,
options: {
contentType: string;
extension: string;
prefix: string;
},
): Promise<StagedConversationFile> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
importRoot,
);
const outputDirectory = resolve(workspace, "tool-data");
if (isOutsideRoot(workspace, outputDirectory)) {
throw new Error("tool data directory escaped the conversation workspace");
}
await mkdir(outputDirectory, { mode: 0o700, recursive: true });
const resolvedOutputDirectory = await realpath(outputDirectory);
if (isOutsideRoot(workspace, resolvedOutputDirectory)) {
throw new Error("tool data directory escaped the conversation workspace");
}
await chmod(resolvedOutputDirectory, 0o700);
await setSandboxOwnership(resolvedOutputDirectory);
const fileName = `${options.prefix}-${randomUUID()}.${options.extension}`;
const filePath = join(resolvedOutputDirectory, fileName);
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
try {
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
await setSandboxOwnership(temporaryPath);
await rename(temporaryPath, filePath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw error;
}
await chmod(filePath, 0o600);
await setSandboxOwnership(filePath);
return {
bytes: Buffer.byteLength(content),
contentType: options.contentType,
filePath,
};
};
+433 -25
View File
@@ -1,11 +1,20 @@
import {
createOpencode,
createOpencodeClient,
type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { chmod, mkdir, rmdir } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { ensureDirectory } from "../utils/fileStore.js";
import { setSandboxOwnership } from "./conversationWorkspace.js";
import {
cleanupExpiredToolOutputs,
resolveOpencodeToolOutputDirectory,
} from "./opencodeToolOutputCleanup.js";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
@@ -29,13 +38,30 @@ type RuntimeModelOverride = {
modelID: string;
};
export type PermissionReply = "once" | "always" | "reject";
export type QuestionAnswers = string[][];
type RuntimeMessage = {
info: {
id: string;
role: string;
};
};
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null;
private toolOutputCleanupTimer: ReturnType<typeof setInterval> | null = null;
async ensureClient(): Promise<OpencodeClient> {
if (!this.clientPromise) {
this.clientPromise = this.bootstrapClient();
this.clientPromise = this.bootstrapClient().catch((error) => {
this.clientPromise = null;
throw error;
});
}
return this.clientPromise;
}
@@ -46,12 +72,105 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "global.health");
}
async createSession(title?: string) {
async warmup(): Promise<void> {
const client = await this.ensureClient();
const response = await client.session.create({
title,
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,
});
return requireData(response.data, "session.create");
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({
directory,
title,
permission: [
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
],
});
return requireData(response.data, "session.create");
} catch (error) {
await rmdir(directory).catch(() => undefined);
throw error;
}
}
async getSession(sessionId: string) {
const client = await this.ensureClient();
const response = await client.session.get({ sessionID: sessionId });
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId: string, text: string) {
@@ -61,7 +180,11 @@ export class OpencodeRuntimeAdapter {
return this.messages(sessionId);
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
async prompt(
sessionId: string,
text: string,
model?: RuntimeModelOverride,
) {
const client = await this.ensureClient();
const startedAt = Date.now();
logDevelopmentDebug(
@@ -95,6 +218,63 @@ export class OpencodeRuntimeAdapter {
return requireData(messages.data, "session.messages");
}
async revertMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.revert({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
}
async removeMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
const response = await client.session.deleteMessage({
sessionID: sessionId,
messageID: messageId,
});
return response.data;
}
async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
const messages = await this.messages(sessionId, 80);
const userMessages = messages.filter(
(message) => getRuntimeMessageRole(message) === "user",
);
const targetUserMessage = userMessages[options.userOrdinal - 1];
if (!targetUserMessage) {
if (messages.length === 0 && options.userOrdinal === 1) {
logger.warn(
{ sessionId, userOrdinal: options.userOrdinal },
"skipping opencode revert because runtime session has no messages",
);
return;
}
throw new Error("target user message not found to revert");
}
const targetMessageId = getRuntimeMessageId(targetUserMessage);
const targetIndex = messages.findIndex(
(message) => getRuntimeMessageId(message) === targetMessageId,
);
const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];
await this.revertMessage(sessionId, targetMessageId);
for (const message of messagesToRemove.reverse()) {
const messageId = getRuntimeMessageId(message);
try {
await this.removeMessage(sessionId, messageId);
} catch (error) {
logger.warn(
{ err: error, sessionId, messageId },
"failed to remove reverted opencode message",
);
}
}
}
async abortSession(sessionId: string) {
const client = await this.ensureClient();
const response = await client.session.abort({
@@ -123,31 +303,173 @@ export class OpencodeRuntimeAdapter {
);
}
async subscribeEvents() {
async subscribeEvents(directory?: string) {
const client = await this.ensureClient();
const response = await client.event.subscribe();
const response = await client.event.subscribe(
directory ? { directory } : undefined,
);
return response.stream;
}
async replyPermission(options: {
requestId: string;
sessionId?: string;
directory?: string;
reply: PermissionReply;
message?: string;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("permission" in client && client.permission?.reply) {
const response = await client.permission.reply({
requestID: options.requestId,
directory,
reply: options.reply,
message: options.message,
});
return requireData(response.data, "permission.reply");
}
if ("permission" in client && client.permission?.respond && options.sessionId) {
const response = await client.permission.respond({
sessionID: options.sessionId,
permissionID: options.requestId,
directory,
response: options.reply,
});
return requireData(response.data, "permission.respond");
}
throw new Error("opencode permission reply API is unavailable");
}
async replyQuestion(options: {
requestId: string;
sessionId?: string;
directory?: string;
answers: QuestionAnswers;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reply) {
try {
const response = await client.question.reply({
requestID: options.requestId,
directory,
answers: options.answers,
});
return requireData(response.data, "question.reply");
} catch (error) {
if (!options.sessionId) {
throw error;
}
}
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reply?: (parameters: {
sessionID: string;
requestID: string;
directory?: string;
questionV2Reply: { answers: QuestionAnswers };
}) => Promise<{ data: unknown }>;
};
};
};
}).v2?.session?.question;
if (v2Question?.reply && options.sessionId) {
const response = await v2Question.reply({
sessionID: options.sessionId,
requestID: options.requestId,
directory,
questionV2Reply: {
answers: options.answers,
},
});
return requireData(response.data, "question.v2.reply");
}
throw new Error("opencode question reply API is unavailable");
}
async rejectQuestion(options: {
requestId: string;
sessionId?: string;
directory?: string;
}) {
const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reject) {
try {
const response = await client.question.reject({
requestID: options.requestId,
directory,
});
return requireData(response.data, "question.reject");
} catch (error) {
if (!options.sessionId) {
throw error;
}
}
}
const v2Question = (client as unknown as {
v2?: {
session?: {
question?: {
reject?: (parameters: {
sessionID: string;
requestID: string;
directory?: string;
}) => Promise<{ data: unknown }>;
};
};
};
}).v2?.session?.question;
if (v2Question?.reject && options.sessionId) {
const response = await v2Question.reject({
sessionID: options.sessionId,
requestID: options.requestId,
directory,
});
return requireData(response.data, "question.v2.reject");
}
throw new Error("opencode question reject API is unavailable");
}
private async resolveInteractionDirectory(options: {
sessionId?: string;
directory?: string;
}): Promise<string | undefined> {
const directory = options.directory?.trim();
if (directory) {
return directory;
}
if (!options.sessionId) {
return undefined;
}
const session = await this.getSession(options.sessionId);
return session.directory;
}
async dispose(): Promise<void> {
if (this.toolOutputCleanupTimer) {
clearInterval(this.toolOutputCleanupTimer);
this.toolOutputCleanupTimer = null;
}
this.closeServer?.();
this.closeServer = null;
this.clientPromise = null;
}
private async bootstrapClient(): Promise<OpencodeClient> {
if (config.OPENCODE_MODE === "client") {
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,
});
}
await this.cleanupToolOutputs();
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
@@ -156,6 +478,7 @@ export class OpencodeRuntimeAdapter {
config.AGENT_INTERNAL_TOKEN ??
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
"";
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
logger.info(
{
@@ -174,14 +497,12 @@ export class OpencodeRuntimeAdapter {
hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT,
timeout: config.OPENCODE_TIMEOUT_MS,
config: {
model: config.OPENCODE_MODEL,
},
config: buildOpencodeConfig(),
});
} catch (error) {
if (isMissingOpencodeCli(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;
@@ -200,13 +521,100 @@ export class OpencodeRuntimeAdapter {
this.closeServer = () => {
runtime.server.close();
};
this.startToolOutputCleanupLoop();
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();
function buildOpencodeConfig(): Record<string, unknown> {
return deepMerge(
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
{
model: config.OPENCODE_MODEL,
tool_output: {
max_bytes: config.MAX_INLINE_RESULT_BYTES,
max_lines: 2000,
},
},
);
}
function readProjectOpencodeConfig(): Record<string, unknown> {
const path = resolve(process.cwd(), "opencode.json");
if (!existsSync(path)) {
return {};
}
return parseConfigJson(readFileSync(path, "utf8"), path);
}
function readEnvOpencodeConfig(): Record<string, unknown> {
const content = process.env.OPENCODE_CONFIG_CONTENT;
if (!content?.trim()) {
return {};
}
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
}
function parseConfigJson(content: string, source: string): Record<string, unknown> {
const parsed = JSON.parse(content) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${source} must contain a JSON object`);
}
return parsed as Record<string, unknown>;
}
function deepMerge(
left: Record<string, unknown>,
right: Record<string, unknown>,
): Record<string, unknown> {
const next = { ...left };
for (const [key, value] of Object.entries(right)) {
const existing = next[key];
if (isPlainObject(existing) && isPlainObject(value)) {
next[key] = deepMerge(existing, value);
} else {
next[key] = value;
}
}
return next;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
return (
typeof error === "object" &&
+59
View File
@@ -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;
+24
View File
@@ -1,14 +1,21 @@
export type RuntimeSessionContext = {
accessToken?: string;
actorKey: string;
authExpired?: {
message: string;
reason: "access_token_expired" | "access_token_rejected";
};
allowLearningWrite?: boolean;
clientSessionId: string;
learningMode?: "interactive" | "review";
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
network?: string;
projectId?: string;
projectKey: string;
sessionId: string;
tokenExpiresAt?: string;
traceId: string;
workspaceDirectory?: string;
};
const contexts = new Map<string, RuntimeSessionContext>();
@@ -22,6 +29,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
return context ? { ...context } : null;
};
export const markRuntimeSessionAuthExpired = (
sessionId: string,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
message = "登录态已过期,请刷新登录后重试",
) => {
const context = contexts.get(sessionId);
if (!context) {
return null;
}
const nextContext: RuntimeSessionContext = {
...context,
authExpired: { message, reason },
};
contexts.set(sessionId, nextContext);
return { ...nextContext };
};
export const removeRuntimeSessionContext = (sessionId: string) => {
contexts.delete(sessionId);
};
+47
View File
@@ -0,0 +1,47 @@
import { config } from "../config.js";
import { type RuntimeSessionContext } from "./sessionContext.js";
import { stageConversationText } from "./conversationWorkspace.js";
export type ToolDataFilePayload = {
bytes: number;
content_type: string;
file_path: string;
};
export const stageLargeToolOutput = async (
context: RuntimeSessionContext,
content: string,
options: {
contentType: string;
extension: string;
force?: boolean;
prefix: string;
},
): Promise<ToolDataFilePayload | null> => {
if (!options.force && Buffer.byteLength(content) <= config.MAX_INLINE_RESULT_BYTES) {
return null;
}
if (!context.workspaceDirectory) {
throw new Error(
"large tool output requires a conversation workspace; create a new conversation",
);
}
const staged = await stageConversationText(
context.workspaceDirectory,
config.RESULT_REF_IMPORT_DIR,
content,
options,
);
return {
bytes: staged.bytes,
content_type: staged.contentType,
file_path: staged.filePath,
};
};
export const buildLargeCliResult = (dataFile: ToolDataFilePayload) => ({
ok: true,
schema_version: "tjwater-cli/v1",
summary: "CLI 结果已保存到当前对话工作区",
data_file: dataFile,
});
+219
View File
@@ -0,0 +1,219 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { config } from "../config.js";
import {
resolveConversationWorkspace,
SANDBOX_GID,
SANDBOX_UID,
setSandboxOwnership,
} from "../runtime/conversationWorkspace.js";
export type SandboxProbe = {
landlockAbi: number;
seccomp: boolean;
};
export type SandboxExecutionResult = {
exitCode: number | null;
outcome: "completed" | "output_limit" | "timeout";
signal: NodeJS.Signals | null;
stderr: string;
stderrTruncated: boolean;
stdout: string;
};
const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py");
const maxSandboxTimeoutSeconds = 30 * 60;
const resolvePythonPath = () => {
const virtualEnvironment = process.env.VIRTUAL_ENV?.trim();
const candidates = [
virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "",
"/opt/venv/bin/python",
"/usr/bin/python3",
];
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3";
};
const collectProcess = async (
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxStderrBytes: number;
maxStdoutBytes: number;
timeoutMs: number;
},
): Promise<SandboxExecutionResult> => {
const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], {
cwd: options.cwd,
detached: true,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let stderrTruncated = false;
let outcome: SandboxExecutionResult["outcome"] = "completed";
let terminationStarted = false;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const killGroup = (signal: NodeJS.Signals) => {
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child when process groups are unavailable.
}
}
child.kill(signal);
};
const terminate = (nextOutcome: "output_limit" | "timeout") => {
if (terminationStarted) return;
terminationStarted = true;
outcome = nextOutcome;
killGroup("SIGTERM");
forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500);
};
const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
if (terminationStarted) return;
if (stdoutBytes + chunk.length > options.maxStdoutBytes) {
terminate("output_limit");
return;
}
stdoutChunks.push(chunk);
stdoutBytes += chunk.length;
});
child.stderr.on("data", (chunk: Buffer) => {
if (stderrTruncated) return;
const remaining = options.maxStderrBytes - stderrBytes;
if (chunk.length > remaining) {
if (remaining > 0) {
stderrChunks.push(chunk.subarray(0, remaining));
stderrBytes += remaining;
}
stderrTruncated = true;
return;
}
stderrChunks.push(chunk);
stderrBytes += chunk.length;
});
return await new Promise((resolveResult, reject) => {
child.once("error", (error) => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
reject(error);
});
child.once("close", (exitCode, signal) => {
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
resolveResult({
exitCode,
outcome,
signal,
stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"),
stderrTruncated,
stdout:
outcome === "output_limit"
? ""
: Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"),
});
});
});
};
export const probeLandlockSandbox = async (): Promise<SandboxProbe> => {
const result = await collectProcess(["--probe"], {
maxStderrBytes: 16 * 1024,
maxStdoutBytes: 16 * 1024,
timeoutMs: 5000,
});
if (result.exitCode !== 0) {
throw new Error(result.stderr || "Landlock sandbox probe failed");
}
const payload = JSON.parse(result.stdout) as {
landlock_abi?: unknown;
seccomp?: unknown;
};
if (
typeof payload.landlock_abi !== "number" ||
payload.landlock_abi < 4 ||
payload.seccomp !== true
) {
throw new Error("Landlock ABI 4 and seccomp are required");
}
return { landlockAbi: payload.landlock_abi, seccomp: true };
};
const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({
HOME: workspace,
LANG: process.env.LANG ?? "C.UTF-8",
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"),
PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
PYTHONDONTWRITEBYTECODE: "1",
TMPDIR: resolve(workspace, "tmp"),
TZ: process.env.TZ ?? "Asia/Shanghai",
VIRTUAL_ENV: "/opt/venv",
XDG_CACHE_HOME: resolve(workspace, ".cache"),
});
export const executeSandboxCommand = async (
workspaceDirectory: string,
command: string,
timeoutSeconds: number,
): Promise<SandboxExecutionResult> => {
const workspace = await resolveConversationWorkspace(
workspaceDirectory,
config.RESULT_REF_IMPORT_DIR,
);
const normalizedTimeout = Math.min(
maxSandboxTimeoutSeconds,
Math.max(1, Math.trunc(timeoutSeconds)),
);
for (const path of [resolve(workspace, "tmp"), resolve(workspace, ".cache")]) {
await mkdir(path, { mode: 0o700, recursive: true });
await setSandboxOwnership(path);
}
const readOnlyPaths = [
"/usr",
"/bin",
"/lib",
"/lib64",
"/opt/venv",
"/etc/ld.so.cache",
"/etc/localtime",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/passwd",
resolve(config.OPENCODE_SKILLS_ROOT_DIR),
].filter(existsSync);
const args = [
"--workspace",
workspace,
"--uid",
String(SANDBOX_UID),
"--gid",
String(SANDBOX_GID),
...readOnlyPaths.flatMap((path) => ["--read-only", path]),
"--command",
command,
];
return collectProcess(args, {
cwd: workspace,
env: buildSandboxEnvironment(workspace),
maxStderrBytes: config.MAX_CLI_STDERR_BYTES,
maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES,
timeoutMs: normalizedTimeout * 1000,
});
};
+575 -69
View File
@@ -1,16 +1,28 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import cors from "cors";
import express from "express";
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 { executeCliCommand } from "./cli/executeCliCommand.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js";
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
import { SessionMetadataStore } from "./sessions/metadataStore.js";
import { logger } from "./logger.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 { ResultReferenceResolver } from "./results/resolver.js";
import {
@@ -18,8 +30,24 @@ import {
ResultReferenceStore,
} from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js";
import { buildAgentPublicRouter } from "./routes/publicApi.js";
import { opencodeRuntime } from "./runtime/opencode.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import {
executeSandboxCommand,
probeLandlockSandbox,
type SandboxProbe,
} from "./sandbox/landlockSandbox.js";
import {
getRuntimeSessionContext,
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
import {
buildLargeCliResult,
stageLargeToolOutput,
} from "./runtime/toolOutputStaging.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js";
const app = express();
@@ -28,17 +56,25 @@ const sessionBridge = new ChatSessionBridge(opencodeRuntime);
const sessionMetadataStore = new SessionMetadataStore();
const sessionUiStateStore = new SessionUiStateStore();
const memoryStore = new MemoryStore();
const skillStore = new SkillStore();
const sessionTranscriptStore = new SessionTranscriptStore();
const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime,
memoryStore,
sessionTranscriptStore,
skillStore,
);
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 credentialRefreshCoordinator = new CredentialRefreshCoordinator();
let sandboxProbe: SandboxProbe | null = null;
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
@@ -49,13 +85,18 @@ app.get("/health", async (_req, res) => {
const runtime = await opencodeRuntime.health();
res.json({
ok: true,
ready: true,
warmed_up: true,
runtime,
sandbox: sandboxProbe,
sessions: sessionBridge.count(),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
ok: false,
ready: false,
warmed_up: true,
message: "opencode runtime unavailable",
detail,
sessions: sessionBridge.count(),
@@ -63,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) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
@@ -79,7 +218,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
});
return;
}
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) {
res.status(400).json({ message: "command is required" });
@@ -88,48 +226,51 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
const storeResult = req.body?.store_result === true;
const authJson = JSON.stringify({
server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken,
project_id: context.projectId,
network:"tjwater",
});
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
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);
if (!context.network) {
res.status(400).json({
message: "runtime network missing; refresh chat context",
detail: sessionId,
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
return;
}
if (exitCode === -1) {
let result;
try {
result = await runWithCredentialRefresh(
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;
}
if (result.status === 504) {
res.status(504).json({
ok: false,
schema_version: "tjwater-cli/v1",
@@ -143,25 +284,154 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return;
}
if (exitCode !== 0) {
if (result.outcome === "output_limit") {
res.status(502).json({
ok: false,
exit_code: exitCode,
stderr: stderr.slice(0, 2000),
stdout: stdout.slice(0, 2000),
message: `CLI exited with code ${exitCode}`,
schema_version: "tjwater-cli/v1",
summary: "CLI 输出超过安全限制",
error: {
code: "OUTPUT_LIMIT_EXCEEDED",
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`,
retryable: false,
},
});
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 {
const dataFile = await stageLargeToolOutput(context, result.stdout, {
contentType: "application/json",
extension: "json",
force: storeResult,
prefix: "cli",
});
if (dataFile) {
res.status(200).json(buildLargeCliResult(dataFile));
return;
}
} catch (error) {
res.status(409).json({
message: "large CLI result could not be staged",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
res.status(200).type("application/json").send(result.stdout);
return;
}
res.json({
ok: true,
schema_version: "tjwater-cli/v1",
raw: "",
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 {
res.json(JSON.parse(stdout));
} catch {
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: true,
schema_version: "tjwater-cli/v1",
raw: stdout,
stderr: stderr || undefined,
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),
},
});
}
});
@@ -187,6 +457,13 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
res.status(400).json({ message: "file_path is required" });
return;
}
if (!context.workspaceDirectory) {
res.status(400).json({
message: "conversation workspace is required",
detail: "create a new conversation before importing render data",
});
return;
}
try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
@@ -197,6 +474,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
sessionId: context.clientSessionId,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId,
workspaceDirectory: context.workspaceDirectory,
});
res.json({
ok: true,
@@ -251,38 +529,266 @@ app.post("/internal/tools/session-search", async (req, res) => {
});
});
const callBackendJson = async (
path: string,
context: RuntimeSessionContext,
payload: unknown,
) => {
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");
return {
ok: false,
status: 401,
text: JSON.stringify({
message: "credential refresh failed",
detail: error instanceof Error ? error.message : String(error),
}),
};
}
};
const parseStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: undefined;
const webSearchFreshnessMap: Record<string, string> = {
no_limit: "noLimit",
one_day: "oneDay",
one_week: "oneWeek",
one_month: "oneMonth",
one_year: "oneYear",
};
const normalizeWebSearchFreshness = (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
return webSearchFreshnessMap[value] ?? value;
};
function markAuthExpired(
context: RuntimeSessionContext,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
) {
markRuntimeSessionAuthExpired(context.sessionId, reason);
void opencodeRuntime.abortSession(context.sessionId).catch((error) => {
logger.warn(
{ err: error, sessionId: context.sessionId },
"failed to abort runtime after auth expired",
);
});
}
app.post("/internal/tools/web-search", 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 query = typeof req.body?.query === "string" ? req.body.query.trim() : "";
if (!query) {
res.status(400).json({ message: "query is required" });
return;
}
const count =
typeof req.body?.count === "number" && Number.isFinite(req.body.count)
? Math.trunc(req.body.count)
: undefined;
const payload = {
query,
freshness: normalizeWebSearchFreshness(req.body?.freshness),
summary:
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
count,
include: parseStringArray(req.body?.include),
exclude: parseStringArray(req.body?.exclude),
};
try {
const response = await callBackendJson(
"/api/v1/web-searches",
context,
payload,
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "web search service unavailable",
detail,
});
}
});
app.post("/internal/tools/geocode", 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 keyword =
typeof req.body?.keyword === "string" ? req.body.keyword.trim() : "";
if (!keyword) {
res.status(400).json({ message: "keyword is required" });
return;
}
try {
const response = await callBackendJson(
"/api/v1/geocoding-requests",
context,
{ keyword },
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "geocoding service unavailable",
detail,
});
}
});
const chatRouter = buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
credentialRefreshCoordinator,
);
const authenticatedChatRouter = express.Router();
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
app.use(
"/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
),
"/api/v1/agent",
buildAgentPublicRouter(authenticatedChatRouter),
);
const bootstrap = async () => {
sandboxProbe = await probeLandlockSandbox();
await Promise.all([
sessionMetadataStore.initialize(),
sessionUiStateStore.initialize(),
learningOrchestrator.initialize(),
memoryStore.initialize(),
resultReferenceStore.initialize(),
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
sessionTranscriptStore.initialize(),
]);
resultReferenceStore.startCleanupLoop();
};
const warmupOpencodeRuntime = async () => {
const startedAt = Date.now();
try {
await opencodeRuntime.warmup();
logger.info(
{
elapsedMs: Math.max(0, Date.now() - startedAt),
mode: config.OPENCODE_MODE,
},
"opencode runtime warmed up",
);
} catch (error) {
logger.error(
{
err: error,
elapsedMs: Math.max(0, Date.now() - startedAt),
mode: config.OPENCODE_MODE,
},
"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 },
{
host: config.HOST,
port: config.PORT,
ready: true,
warmedUp: true,
},
"TJWaterAgent listening",
);
});
+59 -14
View File
@@ -8,6 +8,7 @@ import {
readJsonFile,
removeFileIfExists,
slugify,
toStableId,
} from "../utils/fileStore.js";
export type SessionStatus = "active" | "archived";
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
parentSessionId?: string;
};
export class SessionMetadataOwnershipError extends Error {
constructor(sessionId: string) {
super(`session metadata ownership mismatch: ${sessionId}`);
this.name = "SessionMetadataOwnershipError";
}
}
export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
if (!sessionId) {
throw new Error("sessionId is required");
}
const existing = await readJsonFile<SessionRecord>(
this.filePath(sessionId),
);
const existing = await this.readRecord(sessionId);
if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionMetadataOwnershipError(sessionId);
}
await atomicWriteJson(this.filePath(sessionId), existing);
await removeFileIfExists(this.legacyFilePath(sessionId));
return { created: false, record: existing };
}
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
if (!normalizedSessionId) {
return null;
}
return await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
const record = await this.readRecord(normalizedSessionId);
return record && matchesContext(record, context) ? record : null;
}
async touch(
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
this.filePath(record.sessionId),
next,
);
await removeFileIfExists(this.legacyFilePath(record.sessionId));
return next;
}
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)),
);
return records
const uniqueRecords = new Map<string, SessionRecord>();
for (const record of records) {
if (record && matchesContext(record, context)) {
const previous = uniqueRecords.get(record.sessionId);
if (!previous || record.updatedAt > previous.updatedAt) {
uniqueRecords.set(record.sessionId, record);
}
}
}
return [...uniqueRecords.values()]
.filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async remove(record: SessionRecord) {
await removeFileIfExists(
this.filePath(record.sessionId),
await Promise.all(
this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
);
}
private filePath(sessionId: string) {
return join(
this.baseDir,
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
);
}
private legacyFilePath(sessionId: string) {
return join(this.baseDir, `${slugify(sessionId)}.json`);
}
private filePaths(sessionId: string) {
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
}
private async readRecord(sessionId: string) {
for (const path of this.filePaths(sessionId)) {
const record = await readJsonFile<SessionRecord>(path);
if (record?.sessionId === sessionId) {
return record;
}
}
return null;
}
}
const matchesContext = (
record: SessionRecord,
context: SessionMetadataContext,
) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey &&
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
(!record.projectId || record.projectId === context.projectId);
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;
-1
View File
@@ -13,7 +13,6 @@ export type SessionUiStateRecord = {
sessionId: string;
isTitleManuallyEdited?: boolean;
messages: unknown[];
branchGroups: unknown[];
};
type SessionUiStateContext = {
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "bun:test";
import type { NextFunction, Request, Response } from "express";
import { requireAgentAuth } from "../../src/auth/agentAuth.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("requireAgentAuth", () => {
it("preserves dependency outages without exposing upstream response text", async () => {
globalThis.fetch = (async () =>
new Response("postgresql://secret-host/internal-table", {
status: 503,
})) as unknown as typeof fetch;
const headers: Record<string, string> = {
authorization: "Bearer token",
"x-project-id": "project-id",
};
const req = {
header: (name: string) => headers[name.toLowerCase()],
} as Request;
let status = 200;
let body: unknown;
const res = {
status: (value: number) => {
status = value;
return res;
},
json: (value: unknown) => {
body = value;
return res;
},
} as unknown as Response;
let nextCalled = false;
await requireAgentAuth(req, res, (() => {
nextCalled = true;
}) as NextFunction);
expect(status).toBe(503);
expect(body).toEqual({ message: "authentication service unavailable" });
expect(JSON.stringify(body)).not.toContain("secret-host");
expect(nextCalled).toBe(false);
});
});
+29
View File
@@ -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",
});
});
});
+100
View File
@@ -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" });
});
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "bun:test";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
supportedModels,
} from "../../src/chat/models.js";
import {
defaultAgentModelOptions,
getAgentModelIds,
parseAgentModelOptions,
} from "../../src/chat/modelConfig.js";
describe("agent model config", () => {
it("keeps every exposed option in the supported model list", () => {
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
expect(
agentModelOptions.every(
(model) => model.label && model.description && model.icon,
),
).toBe(true);
});
it("validates supported model ids", () => {
expect(isSupportedModel(supportedModels[0])).toBe(true);
expect(isSupportedModel("unknown/model")).toBe(false);
});
it("defaults to the currently selectable DeepSeek models", () => {
expect(getAgentModelIds(defaultAgentModelOptions)).toEqual([
"deepseek/deepseek-flash",
"deepseek/deepseek-v4-pro",
]);
});
it("rejects unsupported default models", () => {
expect(resolveDefaultModel(supportedModels[0])).toBe(supportedModels[0]);
expect(() => resolveDefaultModel("unknown/model")).toThrow(
"unsupported default agent model",
);
});
it("parses full model option config from JSON", () => {
expect(
parseAgentModelOptions(
JSON.stringify([
{
id: "provider/model",
label: "自定义",
description: "自定义模型",
icon: "bolt",
},
]),
),
).toEqual([
{
id: "provider/model",
label: "自定义",
description: "自定义模型",
icon: "bolt",
},
]);
});
it("rejects invalid model option config", () => {
expect(() => parseAgentModelOptions("not-json")).toThrow(
"OPENCODE_MODEL_OPTIONS must be valid JSON",
);
expect(() =>
parseAgentModelOptions(
JSON.stringify([
{ id: "provider/model", label: "模型" },
{ id: "provider/model", label: "重复模型" },
]),
),
).toThrow("duplicate OPENCODE_MODEL_OPTIONS id");
});
});
+137
View File
@@ -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);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { describe, expect, test } from "bun:test";
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import {
createRouteRegistrar,
generateAgentOpenApi,
} from "../../src/contracts/openapi.js";
const KEBAB_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
describe("Agent REST OpenAPI", () => {
test("rejects duplicate method and path registrations", () => {
const register = createRouteRegistrar(new OpenAPIRegistry());
const config = {
summary: "Test operation",
responses: { 200: { description: "Successful response" } },
};
register("/api/v1/agent/test", "get", config);
expect(() => register("/api/v1/agent/test", "get", config)).toThrow(
"Duplicate Agent OpenAPI operation: GET /api/v1/agent/test",
);
});
test("publishes only unique kebab-case public operations", () => {
const document = generateAgentOpenApi();
const operationIds = new Set<string>();
let operationCount = 0;
for (const [path, pathItem] of Object.entries(document.paths)) {
expect(path.endsWith("/")).toBe(false);
for (const segment of path.split("/").filter(Boolean)) {
if (!segment.startsWith("{")) {
expect(segment).toMatch(KEBAB_SEGMENT);
}
}
for (const operation of Object.values(pathItem ?? {})) {
if (!operation || typeof operation !== "object" || !("responses" in operation)) {
continue;
}
operationCount += 1;
expect(operation.operationId).toBeTruthy();
expect(operationIds.has(operation.operationId!)).toBe(false);
operationIds.add(operation.operationId!);
expect(operation.security).toEqual([{ bearerAuth: [] }]);
}
}
expect(operationCount).toBe(14);
});
test("models runs as session subresources", () => {
const document = generateAgentOpenApi();
expect(document.paths["/api/v1/agent/sessions/{session_id}/runs"]?.post).toBeTruthy();
expect(
document.paths[
"/api/v1/agent/sessions/{session_id}/runs/current/events"
]?.get,
).toBeTruthy();
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", () => {
const document = generateAgentOpenApi();
const schemas = document.components?.schemas ?? {};
const createResponses =
document.paths["/api/v1/agent/sessions"]?.post?.responses ?? {};
const patchOperation =
document.paths["/api/v1/agent/sessions/{session_id}"]?.patch;
const patchRequestBody = patchOperation?.requestBody;
const forkResponses =
document.paths["/api/v1/agent/sessions/{session_id}/forks"]?.post
?.responses ?? {};
expect(Object.keys(createResponses)).toContain("200");
expect(Object.keys(createResponses)).toContain("201");
expect(schemas.AgentSessionSummary).toMatchObject({
required: expect.arrayContaining(["id", "created_at", "updated_at", "status"]),
});
expect(schemas.AgentSessionSummary).not.toMatchObject({
required: expect.arrayContaining(["session_id"]),
});
expect(
patchRequestBody && !("$ref" in patchRequestBody)
? patchRequestBody.content["application/json"]?.schema
: undefined,
).toMatchObject({
properties: {
is_title_manually_edited: { type: "boolean" },
},
});
expect(patchOperation?.responses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionUpdate" },
},
},
});
expect(forkResponses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionFork" },
},
},
});
expect(forkResponses["201"]).toBeUndefined();
});
test("documents runtime query parameters and error statuses", () => {
const document = generateAgentOpenApi();
const renderOperation =
document.paths["/api/v1/agent/render-references/{render_ref}"]?.get;
expect(renderOperation?.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
in: "query",
name: "session_id",
required: false,
}),
]),
);
expect(Object.keys(renderOperation?.responses ?? {})).toEqual(
expect.arrayContaining(["400", "500", "502"]),
);
});
});

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