46 Commits
Author SHA1 Message Date
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 5ac50bfeaa 切换到使用pg数据库 2026-05-28 18:22:39 +08:00
81 changed files with 8444 additions and 1842 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 }}
+7 -3
View File
@@ -1,11 +1,15 @@
---
description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary
model: deepseek/deepseek-v4-pro
temperature: 0.2
model: deepseek/deepseek-v4-flash
---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
## 工作流生命周期
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
@@ -44,7 +48,7 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果
6. 尽量不使用 `task` 子代理,避免无法观测过程进行人为干预
6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程
## 工作流沉淀(skill_manager)
+8 -4
View File
@@ -4,7 +4,7 @@
"workspaces": {
"": {
"dependencies": {
"@opencode-ai/plugin": "^1.16.2",
"@opencode-ai/plugin": "1.18.13",
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -13,6 +13,8 @@
},
},
"packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
@@ -25,9 +27,9 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2H9YT80M1PYElpG+lmd/9kGqsNouiJIBCUhLblmgFwoSrB4wyahgkCS6NcFQR/AYXNH4I1Yd3lmQcVaEPu1qNg=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -37,7 +39,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
@@ -47,6 +49,8 @@
"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@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
+1 -1
View File
@@ -4,7 +4,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@opencode-ai/plugin": "^1.16.2"
"@opencode-ai/plugin": "1.18.13"
},
"devDependencies": {
"@types/node": "^24.7.2",
+28 -9
View File
@@ -28,8 +28,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 +57,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 +68,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 +76,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 +90,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}图层样式请求。`;
},
});
+21 -124
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:
@@ -26,130 +22,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;
},
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: {
reason: tool.schema
.string()
+64 -151
View File
@@ -1,154 +1,67 @@
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."),
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) {
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 -3
View File
@@ -4,9 +4,24 @@ 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_junctions 使用的 render_ref(res-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: {
reason: tool.schema
.string()
@@ -15,11 +30,17 @@ export default tool({
),
file_path: tool.schema
.string()
.optional()
.describe(
"本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
"位于当前对话工作目录内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。",
),
filePath: tool.schema
.string()
.optional()
.describe("兼容旧调用的参数名;新调用应优先使用 file_path。"),
},
async execute(args, context) {
const filePath = resolveStoreRenderFilePath(args);
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
@@ -30,7 +51,7 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
file_path: filePath,
}),
},
);
+1 -1
View File
@@ -14,7 +14,7 @@ export default tool({
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'",
"tjwater-cli 子命令,不含二进制路径。示例:'data scheme list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'",
),
timeout: tool.schema
.number()
+2 -2
View File
@@ -13,9 +13,9 @@ export default tool({
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"])
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
.optional()
.describe("Optional freshness filter. Defaults to noLimit."),
.describe("Optional freshness filter. Defaults to no_limit."),
summary: tool.schema
.boolean()
.optional()
+2
View File
@@ -37,3 +37,5 @@ PRs should describe runtime behavior changes, list `bun run check` and any test
## 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 workspace-root searches, symlink escapes, external paths, and `.env`, `data/`, or `logs/` targets must stay interactive.
+27 -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,22 @@ 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 \
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 && \
@@ -39,8 +45,6 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
pytest && \
rm -rf /var/lib/apt/lists/*
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
FROM base AS deps
WORKDIR /app
@@ -54,32 +58,38 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json README.md .gitignore ./
COPY src ./src
COPY cli ./cli
COPY .opencode ./.opencode
RUN bun run check
FROM base AS runner
FROM build AS test
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
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=8787
ENV OPENCODE_HOST=127.0.0.1
ENV OPENCODE_HOSTNAME=127.0.0.1
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json .gitignore ./
COPY src ./src
COPY .opencode ./.opencode
COPY cli ./cli
COPY entrypoint.sh /entrypoint.sh
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"]
+80 -290
View File
@@ -1,323 +1,113 @@
# 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 相关依赖 |
## 本地开发
## `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。
## 认证续期与学习工具
后端工具调用遇到即将过期的 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 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill,以及真实路径位于工作区安全子树且不涉及 `.env`、`data/`、`logs/` 的 glob/grep;工作区根目录的宽泛搜索仍需确认。“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env` 的直接访问以及通过结构化读写工具访问普通 `data/`、`logs/` 路径仍保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录,结果导入只能读取当前对话目录。普通 `rm <文件>` 仍可审批执行,常见的 `rm -rf`/`rm -fr` 递归强制删除形式会被静态拒绝。
`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 字节)只控制 OpenCode 的内联阈值,较大结果由 OpenCode 写入标准 `tool-output` 目录。Agent 启动时及后续定期清理其中超过 `RESULT_REF_TTL_HOURS`(默认 7 天)的 `tool_*` 文件。
## 配置与安全
不要提交 `.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.16.2",
"@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.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
"@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=="],
+43 -53
View File
@@ -2,7 +2,7 @@ import { CliError } from "../core/errors.js";
import { emitApi, requestJson } 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 { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js";
import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js";
import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -12,17 +12,16 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
return emitApi(ctx, "爆管分析执行成功", {
method: "GET",
path: "/burst_analysis/",
method: "POST",
path: "/burst-analyses",
params: {
network: requireNetwork(ctx),
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
burst_ID: ids,
burst_id: ids,
burst_size: sizes,
modify_total_duration: requiredNumber(values, "duration"),
scheme_name: schemeName,
},
requireNetworkCtx: true,
requireProject: true,
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
}
@@ -34,25 +33,24 @@ function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
const startTime = optionalString(values, "start-time");
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
return emitApi(ctx, "阀门关闭分析执行成功", {
method: "GET",
path: "/valve_close_analysis/",
method: "POST",
path: "/valve-isolation-analyses",
params: {
network: requireNetwork(ctx),
start_time: parseTime(startTime, "--start-time"),
valves,
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireNetworkCtx: true,
requireProject: true,
});
}
const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
return emitApi(ctx, "阀门隔离分析执行成功", {
method: "GET",
path: "/valve_isolation_analysis/",
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
requireNetworkCtx: true,
method: "POST",
path: "/valve-isolation-analyses",
params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
requireProject: true,
});
}
@@ -60,36 +58,34 @@ 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: "GET",
path: "/flushing_analysis/",
method: "POST",
path: "/flushing-analyses",
params: {
network: requireNetwork(ctx),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
valves,
valves_k: openings,
drainage_node_ID: requiredString(values, "drainage-node"),
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),
},
requireNetworkCtx: true,
requireProject: true,
});
}
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" });
return emitApi(ctx, "水龄分析执行成功", {
method: "GET",
path: "/age_analysis/",
params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
requireNetworkCtx: true,
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> = {
network: requireNetwork(ctx),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
source: requiredString(values, "source-node"),
concentration: requiredNumber(values, "concentration"),
@@ -98,55 +94,50 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
};
const pattern = optionalString(values, "pattern");
if (pattern) params.pattern = pattern;
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant_simulation/", params, requireNetworkCtx: true });
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
}
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", {
method: "POST",
path: "/pressure_sensor_placement_kmeans/",
path: "/pressure-sensor-placement-kmeans",
body: {
name: requireNetwork(ctx),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
sensor_number: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0,
username: requireUsername(ctx),
},
requireNetworkCtx: true,
requireUsernameCtx: true,
requireProject: true,
});
}
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise<void> {
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: {
[networkKey]: requireNetwork(ctx),
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireNetworkCtx: true,
requireProject: true,
});
}
function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
}
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> {
const { positionals } = parseOptions(argv);
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, 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> = {
network: requireNetwork(ctx),
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"),
@@ -162,18 +153,17 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
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-location/locate/", body, requireNetworkCtx: true });
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
}
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), pipe_id: requiredString(values, "pipe") }, requireNetworkCtx: true });
return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
}
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
const network = requireNetwork(ctx);
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true });
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
}
@@ -184,16 +174,16 @@ export const analysisHandlers: HandlerMap = {
"analysis age": analysisAge,
"analysis contaminant": analysisContaminant,
"analysis sensor-placement kmeans": sensorKmeans,
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"),
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
"analysis burst-location locate": burstLocation,
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"),
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
"analysis risk network": riskNetwork,
};
+10 -11
View File
@@ -1,7 +1,6 @@
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
@@ -10,22 +9,22 @@ function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean):
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": "/gettimeschema",
"time:false": "/gettimeproperties/",
"energy:true": "/getenergyschema/",
"energy:false": "/getenergyproperties/",
"pump-energy:true": "/getpumpenergyschema/",
"pump-energy:false": "/getpumpenergyproperties//",
"network:true": "/getoptionschema/",
"network:false": "/getoptionproperties/",
"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> = { network: requireNetwork(ctx) };
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, requireNetworkCtx: true });
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true });
}
export const componentHandlers: HandlerMap = {
+31 -22
View File
@@ -2,10 +2,14 @@ import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js";
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { requireNetwork, resolveScheme } from "../core/runtime.js";
import { resolveScheme } from "../core/runtime.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, {
@@ -18,10 +22,11 @@ function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: st
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: "/realtime/query/by-id-time",
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
path: "/timeseries/realtime/simulation-results",
params: { id: requiredString(values, "id"), type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true,
});
}
@@ -31,8 +36,8 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时属性聚合数据成功", {
method: "GET",
path: "/realtime/query/by-time-property",
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
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,
});
}
@@ -41,7 +46,7 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案管道数据成功", {
method: "GET",
path: "/scheme/links",
path: "/timeseries/schemes/links",
params: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
@@ -56,7 +61,7 @@ function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案节点字段成功", {
method: "GET",
path: `/scheme/nodes/${requiredString(values, "node")}/field`,
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
params: {
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
@@ -76,14 +81,14 @@ function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
query_time: parseTime(requiredString(values, "time"), "--time"),
type,
type: backendElementType(type),
};
if (query === "by-id-time") {
params.id = requiredString(values, "id");
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/scheme/query/by-id-time", params, requireProject: true });
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
}
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/scheme/query/by-scheme-time-property", params, requireProject: true });
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
}
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -95,7 +100,7 @@ function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
};
const field = optionalString(values, "field");
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true });
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> {
@@ -115,7 +120,12 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
params.element_id = feature[0];
params.use_cleaned = Boolean(values["use-cleaned"]);
}
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true });
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> {
@@ -124,33 +134,32 @@ function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
requiredString(values, "start-time");
return emitApi(ctx, "读取管道健康预测成功", {
method: "GET",
path: "/composite/pipeline-health-prediction",
params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
path: "/pipeline-health-predictions",
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
requireProject: true,
requireNetworkCtx: true,
});
}
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true });
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
}
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
}
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/getscheme/", params: { network: requireNetwork(ctx), schema_name: requiredString(values, "name") }, requireNetworkCtx: true });
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
}
export const dataHandlers: HandlerMap = {
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"),
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"),
"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 scheme links": schemeLinks,
@@ -161,7 +170,7 @@ export const dataHandlers: HandlerMap = {
"data timeseries composite pipeline-health": pipelineHealth,
"data scada get": dataScadaGet,
"data scada list": dataScadaList,
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
"data scheme get": dataSchemeGet,
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/getallschemes/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
};
+26 -18
View File
@@ -1,27 +1,35 @@
import { emitApi } from "../core/http.js";
import { parseOptions, requiredString } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
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 legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), [key]: requiredString(values, key) }, requireNetworkCtx: true });
return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
}
function legacyGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: 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) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"),
"network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"),
"network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"),
"network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"),
"network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"),
"network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"),
"network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"),
"network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"),
"network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"),
"network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"),
"network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"),
"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"),
};
+1 -3
View File
@@ -1,6 +1,5 @@
import { emitApi } from "../core/http.js";
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -9,11 +8,10 @@ function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const start = parseTime(requiredString(values, "start-time"), "--start-time");
const duration = requiredNumber(values, "duration");
const end = addMinutesPreservingOffset(start, duration);
const network = requireNetwork(ctx);
return emitApi(
ctx,
"触发模拟成功",
{ method: "POST", path: "/runsimulationmanuallybydate/", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true },
{ 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}`,
+4 -5
View File
@@ -20,10 +20,10 @@ export function readJsonFile(path: string, label: string): unknown {
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);
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);
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)) {
@@ -35,7 +35,7 @@ export function parseBurstFile(path: string): [string[], number[]] {
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);
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[]] {
@@ -66,4 +66,3 @@ export function assignDatasetKeys(target: Record<string, unknown>, path: string,
}
}
}
+95 -6
View File
@@ -1,5 +1,4 @@
import { CliError, errorMessage } from "./errors.js";
import { requireNetwork, requireUsername } from "./runtime.js";
import { success } from "./output.js";
import type { RequestOptions, RuntimeContext } from "./types.js";
@@ -19,7 +18,6 @@ function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: bool
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;
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
return out;
}
@@ -37,9 +35,7 @@ function appendParams(url: URL, params: Record<string, unknown> = {}): void {
}
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request;
if (requireNetworkCtx) requireNetwork(ctx);
if (requireUsernameCtx) requireUsername(ctx);
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();
@@ -80,6 +76,100 @@ export async function requestJson(ctx: RuntimeContext, request: RequestOptions):
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;
@@ -93,4 +183,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req
const [data, durationMs] = await requestJson(ctx, request);
success(summary, data, ctx, durationMs, nextCommands);
}
-17
View File
@@ -31,9 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID,
user_id: process.env.TJWATER_USER_ID,
username: process.env.TJWATER_USERNAME,
network: process.env.TJWATER_NETWORK,
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
};
const headers = (raw.headers ?? {}) as unknown;
@@ -44,9 +41,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
userId: pick(raw, "user_id", "userId", "x_user_id"),
username: pick(raw, "username", "preferred_username"),
network: pick(raw, "network", "project_code", "projectCode", "project"),
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
};
}
@@ -83,19 +77,8 @@ export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext>
};
}
export function requireNetwork(ctx: RuntimeContext): string {
if (ctx.auth.network) return ctx.auth.network;
throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]);
}
export function requireUsername(ctx: RuntimeContext): string {
if (ctx.auth.username) return ctx.auth.username;
throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]);
}
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;
}
-6
View File
@@ -6,9 +6,6 @@ export interface AuthContext {
server: string | null;
accessToken: string | null;
projectId: string | null;
userId: string | null;
username: string | null;
network: string | null;
headers: Record<string, string>;
}
@@ -49,8 +46,6 @@ export interface RequestOptions {
body?: unknown;
requireAuth?: boolean;
requireProject?: boolean;
requireNetworkCtx?: boolean;
requireUsernameCtx?: boolean;
}
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
@@ -86,4 +81,3 @@ export type HelpPayload =
menu_level?: number;
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
};
+5 -5
View File
@@ -32,15 +32,15 @@ type CommandSpec = readonly [path: string, summary: string, options: readonly st
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", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]],
["network get-all-pipes-properties", "读取全部管道属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-pipes-properties"]],
["network get-reservoir-properties", "读取水库属性", ["--reservoir <RESERVOIR>"], ["tjwater-cli network get-reservoir-properties --reservoir R1"]],
["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]],
["network get-all-reservoirs-properties", "读取全部水库属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-reservoirs-properties"]],
["network get-tank-properties", "读取水箱属性", ["--tank <TANK>"], ["tjwater-cli network get-tank-properties --tank T1"]],
["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]],
["network get-all-tanks-properties", "读取全部水箱属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-tanks-properties"]],
["network get-pump-properties", "读取水泵属性", ["--pump <PUMP>"], ["tjwater-cli network get-pump-properties --pump PU1"]],
["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]],
["network get-all-pumps-properties", "读取全部水泵属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-pumps-properties"]],
["network get-valve-properties", "读取阀门属性", ["--valve <VALVE>"], ["tjwater-cli network get-valve-properties --valve V1"]],
["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]],
["network get-all-valves-properties", "读取全部阀门属性", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli network get-all-valves-properties"]],
["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]],
["component option 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"]],
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": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
}
}
}
+175 -86
View File
@@ -8,7 +8,59 @@ import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
const visibleCommandPaths = [
"analysis age",
"analysis burst",
"analysis burst-detection detect",
"analysis burst-detection schemes get",
"analysis burst-detection schemes list",
"analysis contaminant",
"analysis flushing",
"analysis leakage identify",
"analysis leakage schemes get",
"analysis leakage schemes list",
"analysis sensor-placement kmeans",
"analysis valve",
"component option get",
"component option schema",
"data scada get",
"data scada list",
"data scheme get",
"data scheme list",
"data scheme schema",
"data timeseries composite",
"data timeseries composite pipeline-health",
"data timeseries realtime links",
"data timeseries realtime nodes",
"data timeseries realtime simulation-by-id-time",
"data timeseries realtime simulation-by-time-property",
"data timeseries scada query",
"data timeseries scheme links",
"data timeseries scheme node-field",
"data timeseries scheme simulation",
"network get-all-pipes-properties",
"network get-all-pumps-properties",
"network get-all-reservoirs-properties",
"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 = [
"analysis burst-location locate",
"analysis burst-location schemes get",
"analysis burst-location schemes list",
"analysis risk network",
"analysis risk pipe-history",
"analysis risk pipe-now",
];
function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => {
@@ -35,10 +87,6 @@ function runCli(args, input) {
return runCommand(cliPath, args, input);
}
function runPythonCli(args, input) {
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
}
function parseJsonResult(result) {
return JSON.parse(result.stdout);
}
@@ -56,7 +104,11 @@ async function startJsonServer(responseData) {
url: req.url,
});
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(responseData));
res.end(
JSON.stringify(
typeof responseData === "function" ? responseData(req) : responseData,
),
);
});
await new Promise((resolveListen, reject) => {
@@ -83,7 +135,6 @@ function normalizeSeenRequest(request) {
headers: {
authorization: request.headers.authorization,
"x-project-id": request.headers["x-project-id"],
"x-user-id": request.headers["x-user-id"],
},
method: request.method,
path: url.pathname,
@@ -91,7 +142,20 @@ function normalizeSeenRequest(request) {
};
}
async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) {
function defaultContractResponse(req) {
const url = new URL(req.url, "http://127.0.0.1");
if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves"].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 });
@@ -119,74 +183,31 @@ test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
});
test("matches Python CLI help discovery and hidden command behavior", async () => {
for (const args of [["help"], ["help", "analysis"]]) {
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
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`);
}
const [nodeLeaf, pythonLeaf] = await Promise.all([
runCli(["help", "simulation", "run"]),
runPythonCli(["help", "simulation", "run"]),
]);
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
const nodePayload = parseJsonResult(nodeLeaf);
const pythonPayload = parseJsonResult(pythonLeaf);
assert.equal(nodePayload.ok, pythonPayload.ok);
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
assert.equal(nodePayload.command, pythonPayload.command);
assert.equal(nodePayload.summary, pythonPayload.summary);
assert.equal(nodePayload.usage, pythonPayload.usage);
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
const [nodeHidden, pythonHidden] = await Promise.all([
runCli(["help", "analysis", "risk"]),
runPythonCli(["help", "analysis", "risk"]),
]);
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
const nodeError = parseJsonResult(nodeHidden);
const pythonError = parseJsonResult(pythonHidden);
delete nodeError.metadata.generated_at;
delete pythonError.metadata.generated_at;
assert.deepEqual(nodeError, pythonError);
});
test("matches Python CLI leaf help for every visible command", async () => {
const listResult = await runCommand(
"python",
[
"-c",
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
],
undefined,
{ cwd: pythonCliCwd },
);
assert.equal(listResult.exitCode, 0, listResult.stderr);
const commands = JSON.parse(listResult.stdout);
for (const command of commands) {
const args = ["help", ...command.split(" ")];
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
const nodePayload = parseJsonResult(nodeResult);
const pythonPayload = parseJsonResult(pythonResult);
const comparable = (payload) => ({
command: payload.command,
summary: payload.summary,
usage: payload.usage,
examples: payload.examples,
next_commands: payload.next_commands,
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
name,
required,
repeated,
})),
});
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
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);
}
});
@@ -206,18 +227,17 @@ test("sends auth headers and simulation body through the backend API contract",
{
server: server.url,
access_token: "token-1",
network: "tjwater",
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/runsimulationmanuallybydate/");
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, {
name: "tjwater",
start_time: "2025-01-02T03:00:00+08:00",
duration: 60,
});
@@ -229,6 +249,52 @@ test("sends auth headers and simulation body through the backend API contract",
}
});
test("get-all network commands collect every backend page", async () => {
const items = Array.from({ length: 2_005 }, (_, index) => ({
id: `P${index + 1}`,
node1: `N${index + 1}`,
node2: `N${index + 2}`,
}));
const server = await startJsonServer((req) => {
const url = new URL(req.url, "http://127.0.0.1");
const limit = Number(url.searchParams.get("limit") ?? 100);
const offset = Number(url.searchParams.get("offset") ?? 0);
return {
items: items.slice(offset, offset + limit),
limit,
offset,
total: items.length,
};
});
try {
const result = await runCli(
["--auth-stdin", "network", "get-all-pipes-properties"],
{
server: server.url,
access_token: "token-1",
project_id: "project-1",
},
);
assert.equal(result.exitCode, 0, result.stderr);
const payload = parseJsonResult(result);
assert.equal(payload.data.length, items.length);
assert.deepEqual(payload.data[0], items[0]);
assert.deepEqual(payload.data.at(-1), items.at(-1));
assert.deepEqual(
server.seen.map((request) => normalizeSeenRequest(request).query),
[
{ limit: "1000", offset: "0" },
{ limit: "1000", offset: "1000" },
{ limit: "1000", offset: "2000" },
],
);
} finally {
await server.close();
}
});
test("uses project scoped headers for realtime data commands", async () => {
const server = await startJsonServer([{ id: "P1" }]);
try {
@@ -255,7 +321,7 @@ test("uses project scoped headers for realtime data commands", async () => {
assert.equal(server.seen[0].method, "GET");
assert.equal(
server.seen[0].url,
"/api/v1/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
"/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");
@@ -264,7 +330,27 @@ test("uses project scoped headers for realtime data commands", async () => {
}
});
test("matches Python CLI backend request shape for every command and key variants", async () => {
test("maps CLI pipe and junction types to backend link and node types", async () => {
const server = await startJsonServer({ accepted: true });
const auth = { server: server.url, access_token: "token-3", project_id: "project-1" };
const at = "2025-01-02T03:30:00+08:00";
try {
for (const args of [
["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at],
["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"],
["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "P1", "--type", "pipe", "--time", at, "--scheme", "scheme_case"],
]) {
const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr);
}
const queries = server.seen.map(normalizeSeenRequest).map((request) => request.query.type);
assert.deepEqual(queries, ["node", "link", "link"]);
} finally {
await server.close();
}
});
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");
@@ -280,7 +366,6 @@ test("matches Python CLI backend request shape for every command and key variant
access_token: "token",
network: "tjwater",
project_id: "project-1",
user_id: "user-1",
username: "alice",
headers: { "x-extra": "extra" },
};
@@ -342,12 +427,16 @@ test("matches Python CLI backend request shape for every command and key variant
];
for (const [name, args] of cases) {
const [nodeRun, pythonRun] = await Promise.all([
runAgainstServer(`${name} node`, runCli, args, auth),
runAgainstServer(`${name} python`, runPythonCli, args, auth),
]);
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
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 });
+46 -6
View File
@@ -7,26 +7,66 @@
}
}
},
"model": "deepseek/deepseek-v4-pro",
"model": "deepseek/deepseek-v4-flash",
"server": {
"hostname": "127.0.0.1",
"port": 4096
},
"permission": {
"*": "allow",
"external_directory": "ask",
"bash": {
"*": "ask",
"external_directory": "deny",
"read": {
"*": "allow",
".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"edit": {
"*": "ask",
".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"bash": {
"*": "ask",
"rm *": "ask",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -r -f *": "deny",
"rm -f -r *": "deny",
"rm --recursive --force *": "deny",
"rm --force --recursive *": "deny",
"rmdir *": "ask",
"mv *": "ask",
"chmod *": "ask",
"chown *": "ask",
"sudo *": "ask",
"curl *": "ask",
"wget *": "ask"
"wget *": "ask",
"*.env*": "deny"
},
"edit": "ask"
"question": "allow",
"task": "deny",
"todo": "allow",
"todoread": "allow",
"todowrite": "allow"
},
"experimental": {
"continue_loop_on_deny": true
},
"default_agent": "instruction"
}
+8 -1
View File
@@ -8,16 +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.16.2",
"@opencode-ai/sdk": "1.18.13",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
@@ -26,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",
+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})`);
}
+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);
});
+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-v4-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 -24
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,21 +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-v4-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(),
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
OPENCODE_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 持久化存储目录。
@@ -99,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 的扫描周期(毫秒)。
@@ -109,26 +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 || process.env.OPENCODE_BASE_URL
? "client"
: "embedded"),
OPENCODE_CLIENT_BASE_URL:
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
};
export const config: AppConfig = envSchema.parse(normalizedEnv);
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; always approves every prompt not explicitly denied by OpenCode.",
),
}),
},
},
},
},
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",
},
});
};
+2 -3
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() {
+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,
});
+84 -6
View File
@@ -1,4 +1,7 @@
import { readJsonFile } from "../utils/fileStore.js";
import { lstat, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative } from "node:path";
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
import {
type ResultReferenceKind,
type ResultReferenceRecord,
@@ -33,7 +36,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 +68,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 resolvePathInsideRoot(
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 +105,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(
@@ -167,6 +197,54 @@ export const extractRenderJunctionPayload = (
};
};
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 = await realpath(importRoot);
const resolvedWorkspaceDirectory = await resolvePathInsideRoot(
workspaceDirectory,
resolvedImportRoot,
"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;
};
const resolvePathInsideRoot = async (
filePath: string,
rootPath: string,
outsideMessage = "render payload file must be inside RESULT_REF_IMPORT_DIR",
) => {
if (!isAbsolute(filePath)) {
throw new Error("render payload file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
const relativePath = relative(resolvedRootPath, resolvedFilePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
isAbsolute(relativePath)
) {
throw new Error(outsideMessage);
}
return resolvedFilePath;
};
const normalizeDataForKind = (
kind: ResultReferenceKind,
data: unknown,
+219 -132
View File
@@ -1,6 +1,15 @@
import { Router } from "express";
import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
type SupportedModel,
} from "../chat/models.js";
import { config } from "../config.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.js";
@@ -11,6 +20,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import {
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
@@ -28,14 +38,13 @@ import {
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
supportedModels,
type SupportedModel,
type TodoUpdatePayload,
} from "./chatStream.js";
import {
type ActiveRun,
type RunStatus,
type StreamSubscriber,
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
@@ -53,8 +62,16 @@ import {
const payloadSchema = z.object({
message: z.string().min(1).max(10000),
session_id: z.string().max(128).optional(),
model: z.enum(supportedModels).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"),
model: z.string().refine(isSupportedModel, {
message: "unsupported model",
}).optional(),
approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.default("request")
.describe(
"request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode",
),
});
const createSessionPayloadSchema = z.object({
@@ -67,12 +84,6 @@ const forkPayloadSchema = z.object({
keep_message_count: z.coerce.number().int().min(0),
});
const sessionStateSchema = z.object({
title: z.string().max(120).optional(),
is_title_manually_edited: z.boolean().optional(),
messages: z.array(z.unknown()).default([]),
});
const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>();
@@ -80,6 +91,20 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
export const buildForkedSessionUiState = (
sourceState: { messages?: unknown[] } | null | undefined,
input: {
keepMessageCount: number;
targetSessionId: string;
},
) => ({
sessionId: input.targetSessionId,
isTitleManuallyEdited: false,
messages: Array.isArray(sourceState?.messages)
? sourceState.messages.slice(0, input.keepMessageCount)
: [],
});
const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
@@ -103,10 +128,18 @@ export const buildChatRouter = (
sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver,
credentialRefreshCoordinator: CredentialRefreshCoordinator,
) => {
const chatRouter = Router();
chatRouter.post("/session", async (req, res) => {
chatRouter.get("/models", (_req, res) => {
res.json({
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
models: agentModelOptions,
});
});
chatRouter.post("/sessions", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
@@ -116,12 +149,15 @@ export const buildChatRouter = (
return;
}
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const sessionId =
requestedSessionId ||
(await runtime.createSession(undefined, { conversationWorkspace: true })).id;
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
@@ -143,8 +179,9 @@ export const buildChatRouter = (
});
chatRouter.get("/sessions", async (req, res) => {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const records = await sessionMetadataStore.list({
@@ -167,10 +204,11 @@ export const buildChatRouter = (
});
});
chatRouter.get("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
chatRouter.get("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
@@ -210,10 +248,11 @@ export const buildChatRouter = (
});
});
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
@@ -266,6 +305,16 @@ export const buildChatRouter = (
},
};
run.subscribers.add(subscriber);
const pendingCredentialRefresh =
credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
if (pendingCredentialRefresh) {
subscriber.write(pendingCredentialRefresh.type, {
session_id: sessionRecord.sessionId,
request_id: pendingCredentialRefresh.requestId,
reason: pendingCredentialRefresh.reason,
timeout_ms: pendingCredentialRefresh.timeoutMs,
});
}
const cleanup = () => {
run.subscribers.delete(subscriber);
@@ -274,81 +323,17 @@ export const buildChatRouter = (
res.on("close", cleanup);
});
chatRouter.put("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const parsed = sessionStateSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const { record } = await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId,
userId,
});
const nextRecord = await sessionMetadataStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}),
});
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages,
});
const latestTurn = extractLatestFrontendTurn(parsed.data.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext: {
actorKey,
clientSessionId: nextRecord.sessionId,
projectId,
projectKey,
traceId: req.header("x-trace-id") ?? `save-${nextRecord.sessionId}`,
userId,
},
sessionId: nextRecord.sessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: nextRecord.sessionId },
"post-save learning failed",
);
});
}
res.json({
id: nextRecord.sessionId,
title: nextRecord.title ?? "新对话",
created_at: nextRecord.createdAt,
updated_at: nextRecord.updatedAt,
status: nextRecord.status,
session_id: nextRecord.sessionId,
});
});
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
chatRouter.patch("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited
: undefined;
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId || !title) {
@@ -384,10 +369,11 @@ export const buildChatRouter = (
});
});
chatRouter.delete("/session/:sessionId", async (req, res) => {
const sessionId = req.params.sessionId?.trim();
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
chatRouter.delete("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
@@ -424,13 +410,17 @@ export const buildChatRouter = (
registerChatInteractionRoutes(chatRouter, {
activeRuns,
credentialRefreshCoordinator,
runtime,
sessionMetadataStore,
sessionUiStateStore,
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
const parsed = forkPayloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -440,9 +430,10 @@ export const buildChatRouter = (
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
@@ -458,7 +449,13 @@ export const buildChatRouter = (
sourceSessionId,
)
: null;
const forkSession = await runtime.createSession();
if (!sourceSessionId || !sourceSessionRecord) {
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession(undefined, {
conversationWorkspace: true,
});
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
@@ -469,28 +466,38 @@ export const buildChatRouter = (
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId && parsed.data.keep_message_count > 0) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
if (sourceSessionRecord?.title) {
await sessionMetadataStore.touch(targetSessionRecord, {
title: sourceSessionRecord.title,
});
}
}
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
const sourceState = await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord),
);
const forkTitle = sourceSessionRecord.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
targetSessionRecord,
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
}),
);
logger.info(
{
@@ -516,8 +523,11 @@ export const buildChatRouter = (
}
});
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
const parsed = payloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -527,13 +537,11 @@ export const buildChatRouter = (
}
try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const authContext = getAgentAuthContext(req);
const accessToken = authContext.accessToken;
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
@@ -548,8 +556,10 @@ export const buildChatRouter = (
const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId,
accessToken,
network: authContext.network,
projectId,
traceId,
tokenExpiresAt: authContext.tokenExpiresAt,
userId,
});
const { record: ensuredSessionRecord, created: sessionCreated } =
@@ -706,6 +716,19 @@ export const buildChatRouter = (
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "auth_required") {
activeRun.status = "error";
lastRunStatuses.set(clientSessionId, "error");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof data.message === "string"
? `⚠️ **${data.message}**`
: "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
@@ -789,6 +812,11 @@ export const buildChatRouter = (
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
}
for (const subscriber of activeRun.subscribers) {
@@ -798,6 +826,35 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
};
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(
binding.sessionId,
(event) => {
publish(event.type, {
session_id: clientSessionId,
request_id: event.requestId,
...(event.type === "credential_refresh_required"
? {
reason: event.reason,
timeout_ms: event.timeoutMs,
}
: {}),
...(event.type === "credential_refresh_failed"
? { message: event.message }
: {}),
});
},
);
const cancelCredentialRefreshOnAbort = () => {
credentialRefreshCoordinator.cancelSession(
binding.sessionId,
"credential refresh cancelled because the agent run was aborted",
);
};
abortController.signal.addEventListener(
"abort",
cancelCredentialRefreshOnAbort,
{ once: true },
);
try {
const preparedMessage = await buildPromptWithLearningContext(
@@ -821,6 +878,7 @@ export const buildChatRouter = (
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
workspaceRoot: binding.workspaceDirectory,
write: (event, data) => {
publish(event, data);
},
@@ -829,6 +887,16 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
const latestRuntimeContext = getRuntimeSessionContext(binding.sessionId);
if (latestRuntimeContext?.authExpired) {
publish("auth_required", {
session_id: clientSessionId,
reason: latestRuntimeContext.authExpired.reason,
message: latestRuntimeContext.authExpired.message,
});
return;
}
if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages]
@@ -876,6 +944,19 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
}
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext,
sessionId: clientSessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"stream-completed learning failed",
);
});
}
}
} finally {
if (abortController.signal.aborted) {
@@ -897,6 +978,12 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
sessionBridge.finalizeRequest(clientSessionId);
abortController.signal.removeEventListener(
"abort",
cancelCredentialRefreshOnAbort,
);
credentialRefreshCoordinator.cancelSession(binding.sessionId);
unsubscribeCredentialRefresh();
activeRun.status = abortController.signal.aborted
? activeRun.status === "aborted"
? "aborted"
+12 -16
View File
@@ -1,6 +1,7 @@
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";
@@ -16,7 +17,7 @@ import {
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortPayloadSchema = z.object({
const abortParamsSchema = z.object({
session_id: z.string().max(128),
});
@@ -44,22 +45,16 @@ export const registerChatAuxiliaryRoutes = (
sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
const renderRef = req.params.renderRef?.trim();
const userId = req.header("x-user-id")?.trim();
const projectId = req.header("x-project-id") ?? undefined;
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 (!userId) {
res.status(400).json({
message: "x-user-id is required",
});
return;
}
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
@@ -87,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
res.json(result);
});
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
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",
@@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = (
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
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(
+110 -163
View File
@@ -1,8 +1,14 @@
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";
@@ -14,22 +20,24 @@ import {
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
request_id: z.string().min(1),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const questionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
request_id: z.string().min(1),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).default([]),
});
const questionRejectPayloadSchema = z.object({
session_id: z.string().max(128),
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;
@@ -43,18 +51,75 @@ export const registerChatInteractionRoutes = (
chatRouter: Router,
{
activeRuns,
credentialRefreshCoordinator,
runtime,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
const requestId = req.params.requestId?.trim();
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
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",
@@ -64,13 +129,15 @@ export const registerChatInteractionRoutes = (
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
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 },
parsed.data.session_id,
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
@@ -172,13 +239,8 @@ export const registerChatInteractionRoutes = (
}
});
chatRouter.post("/question/:requestId/reply", async (req, res) => {
const requestId = req.params.requestId?.trim();
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
@@ -188,13 +250,15 @@ export const registerChatInteractionRoutes = (
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
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 },
parsed.data.session_id,
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
@@ -224,11 +288,18 @@ export const registerChatInteractionRoutes = (
};
try {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
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,
@@ -239,7 +310,7 @@ export const registerChatInteractionRoutes = (
error:
error instanceof Error
? error.message
: "failed to reply question",
: `failed to ${parsed.data.action} question`,
}),
);
await persistQuestionState().catch((persistError) => {
@@ -249,7 +320,7 @@ export const registerChatInteractionRoutes = (
);
});
res.status(502).json({
message: "question reply failed",
message: `question ${parsed.data.action} failed`,
detail: error instanceof Error ? error.message : String(error),
});
return;
@@ -261,8 +332,9 @@ export const registerChatInteractionRoutes = (
requestId,
(question) => ({
...question,
status: "answered",
answers: parsed.data.answers,
status: parsed.data.action === "reject" ? "rejected" : "answered",
answers:
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
@@ -277,7 +349,9 @@ export const registerChatInteractionRoutes = (
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
});
}
if (
@@ -291,142 +365,15 @@ export const registerChatInteractionRoutes = (
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
...(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 reply route failed");
logger.error({ err: error }, "question response route failed");
res.status(500).json({
message: "question reply route failed",
detail,
});
}
});
chatRouter.post("/question/:requestId/reject", async (req, res) => {
const requestId = req.params.requestId?.trim();
const parsed = questionRejectPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.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 {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
}
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,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
message: "question response route failed",
detail,
});
}
+370
View File
@@ -0,0 +1,370 @@
import { lstatSync, readdirSync, realpathSync } from "node:fs";
import { 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 (lowRiskToolPermissions.has(normalized)) {
return true;
}
if (normalized.startsWith("tjwater_")) {
return lowRiskToolPermissions.has(normalized.slice("tjwater_".length));
}
return false;
};
export const resolvePermissionApproval = (
approvalMode: ApprovalMode,
permission: string,
context: PermissionApprovalContext = {},
) => {
if (isDirectRecursiveForceRemove(permission, context)) {
return {
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
} as const;
}
if (
approvalMode === "always" &&
normalizePermission(permission) === "bash" &&
containsPotentialRemoveCommand(context)
) {
return {
autoApprove: false,
autoReject: false,
title: "等待删除命令确认",
detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。",
} as const;
}
if (approvalMode === "always") {
return {
autoApprove: true,
autoReject: false,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
} as const;
}
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
return {
autoApprove: true,
autoReject: false,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
}
return {
autoApprove: false,
autoReject: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
const isDirectRecursiveForceRemove = (
permission: string,
context: PermissionApprovalContext,
): boolean => {
if (normalizePermission(permission) !== "bash") {
return false;
}
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
return splitShellCommandSegments(command).some((segment) => {
const words = tokenizeShellSegment(segment);
let commandIndex = 0;
while (commandIndex < words.length) {
const word = words[commandIndex]!;
const executable = word.split("/").at(-1)?.toLowerCase();
if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
commandIndex += 1;
continue;
}
if (executable === "command") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") {
commandIndex += 1;
}
if (words[commandIndex] === "--") commandIndex += 1;
continue;
}
if (executable === "env") {
commandIndex += 1;
while (commandIndex < words.length) {
const envWord = words[commandIndex]!;
if (envWord === "--") {
commandIndex += 1;
break;
}
if (envWord === "-u" || envWord === "--unset") {
commandIndex += 2;
continue;
}
if (
envWord.startsWith("-") ||
/^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord)
) {
commandIndex += 1;
continue;
}
break;
}
continue;
}
if (executable === "sudo" || executable === "doas") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-")) {
commandIndex += 1;
}
continue;
}
if (executable === "busybox" && words[commandIndex + 1] === "rm") {
commandIndex += 1;
}
break;
}
const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase();
if (executable !== "rm") {
return false;
}
let recursive = false;
let force = false;
for (const word of words.slice(commandIndex + 1)) {
if (word === "--") {
break;
}
if (word === "--recursive") {
recursive = true;
} else if (word === "--force") {
force = true;
} else if (/^-[^-]/.test(word)) {
recursive ||= /[rR]/.test(word.slice(1));
force ||= word.slice(1).includes("f");
}
}
return recursive && force;
});
};
const containsPotentialRemoveCommand = (
context: PermissionApprovalContext,
): boolean => {
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
const normalized = command
.replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "")
.replace(/["'\\]/gu, "");
return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized);
};
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),
)
) {
return false;
}
if (!relativePath) {
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 !== ".." &&
!relativePath.startsWith(`..${sep}`) &&
!isAbsolute(relativePath) &&
!containsProtectedPath(relativePath) &&
isSafeSearchTarget(searchRoot, relativePath)
);
};
const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => {
try {
const target = lstatSync(searchRoot);
if (target.isFile()) {
return true;
}
if (!target.isDirectory()) {
return false;
}
const topLevelName = relativePath.split(sep)[0];
if (!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)) {
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): boolean => {
const normalized = value.replaceAll("\\", "/").toLowerCase();
return (
normalized.includes(".env") ||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)
);
};
+104 -92
View File
@@ -1,6 +1,7 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { logger } from "../logger.js";
import {
type PermissionReply,
@@ -45,6 +46,10 @@ import {
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
import {
resolvePermissionApproval,
type ApprovalMode,
} from "./chatPermissionPolicy.js";
export {
collectTextContent,
@@ -54,13 +59,7 @@ export {
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export const supportedModels = [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-pro",
] as const;
export type SupportedModel = (typeof supportedModels)[number];
export type ApprovalMode = "request" | "always";
export type { ApprovalMode } from "./chatPermissionPolicy.js";
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
@@ -72,6 +71,7 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
workspaceRoot?: string;
write: (event: string, data: Record<string, unknown>) => void;
};
@@ -112,24 +112,40 @@ const toRuntimeModel = (model?: SupportedModel) => {
};
};
const emitFallbackMessage = async (
const emitFinalMessage = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
clientSessionId: string,
currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>,
write: (event: string, data: Record<string, unknown>) => void,
) => {
const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages]
let text = [...currentAssistantMessageIds]
.reverse()
.find((message) => message.info.role === "assistant");
const parts = assistantMessage?.parts ?? [];
const text = collectTextContent(parts);
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
.find((content) => content.length > 0) ?? "";
if (!text) {
const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages]
.reverse()
.find(
(message) =>
message.info.role === "assistant" &&
(currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)),
);
text = collectTextContent(assistantMessage?.parts ?? []);
}
if (text) {
write("token", {
session_id: clientSessionId,
content: text,
});
return true;
}
return false;
};
export const streamPromptResponse = async ({
@@ -142,6 +158,7 @@ export const streamPromptResponse = async ({
traceId,
projectId,
signal,
workspaceRoot,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
@@ -157,15 +174,14 @@ export const streamPromptResponse = async ({
const emittedToolParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>();
const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>();
const pendingTextDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">();
const toolStatuses = new Map<string, string>();
let firstSessionEventLogged = false;
let firstNonStatusEventLogged = false;
let firstTokenLogged = false;
let firstReasoningLogged = false;
let firstToolEventLogged = false;
let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null;
@@ -377,6 +393,15 @@ export const streamPromptResponse = async ({
if (isPermissionAskedEvent(event)) {
sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.permission,
{
metadata: event.properties.metadata,
patterns: event.properties.patterns,
workspaceRoot: workspaceRoot ?? process.cwd(),
},
);
logDevelopmentDebug("permission request received", {
...debugContext,
requestId: event.properties.id,
@@ -387,23 +412,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (approvalMode === "always") {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
@@ -422,6 +449,15 @@ export const streamPromptResponse = async ({
if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.action,
{
metadata: event.properties.metadata,
patterns: event.properties.resources,
workspaceRoot: workspaceRoot ?? process.cwd(),
},
);
logDevelopmentDebug("permission v2 request received", {
...debugContext,
requestId: event.properties.id,
@@ -432,23 +468,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
@@ -625,45 +663,26 @@ export const streamPromptResponse = async ({
if (event.type === "message.updated") {
if (event.properties.info.role === "assistant") {
sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.info.id);
}
continue;
}
if (event.type === "message.part.delta" && event.properties.field === "text") {
sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.messageID);
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
if (!firstTokenLogged) {
firstTokenLogged = true;
logDevelopmentDebug("first response token emitted", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
emittedText = true;
write("token", {
session_id: clientSessionId,
content: event.properties.delta,
});
} else if (partType === "reasoning") {
if (!firstReasoningLogged) {
firstReasoningLogged = true;
logDevelopmentDebug("first reasoning delta received", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
reasoningDeltas.set(event.properties.partID, pending);
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
messageParts.set(
event.properties.partID,
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
);
assistantTextParts.set(event.properties.messageID, messageParts);
} else if (!partType) {
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
pendingPartTextDeltas.set(event.properties.partID, pending);
pendingTextDeltas.set(event.properties.partID, pending);
}
continue;
}
@@ -672,23 +691,19 @@ export const streamPromptResponse = async ({
sawResponseActivity = true;
const part = event.properties.part;
partTypes.set(part.id, part.type);
if (part.type === "text" || part.type === "reasoning" || part.type === "tool") {
currentAssistantMessageIds.add(part.messageID);
}
if (part.type === "text") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
pendingPartTextDeltas.delete(part.id);
for (const content of pending) {
emittedText = true;
write("token", {
session_id: clientSessionId,
content,
});
}
} else if (part.type === "reasoning") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
if (pending.length > 0) {
const existing = reasoningDeltas.get(part.id) ?? [];
reasoningDeltas.set(part.id, existing.concat(pending));
}
pendingPartTextDeltas.delete(part.id);
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingTextDeltas.delete(part.id);
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
messageParts.set(part.id, part.text || pendingText);
assistantTextParts.set(part.messageID, messageParts);
} else {
pendingTextDeltas.delete(part.id);
}
if (part.type === "reasoning") {
const reasoningStatus = part.time.end ? "completed" : "running";
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
reasoningStatuses.set(part.id, reasoningStatus);
@@ -696,14 +711,10 @@ export const streamPromptResponse = async ({
...debugContext,
partId: part.id,
status: reasoningStatus,
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
const reasoningDetail = buildReasoningProgressDetail(
reasoningDeltas.get(part.id) ?? [],
part.time.end,
);
const reasoningDetail = buildReasoningProgressDetail(part.time.end);
emitProgress({
id: part.id,
phase: "planning",
@@ -931,13 +942,14 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (!emittedText) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
}
emittedText = await emitFinalMessage(
runtime,
sessionId,
clientSessionId,
currentAssistantMessageIds,
assistantTextParts,
write,
);
emitProgress({
id: "request-received",
phase: "start",
+1 -18
View File
@@ -376,12 +376,6 @@ const formatProgressValue = (value: unknown): string => {
}
};
const normalizeProgressText = (chunks: string[]) =>
chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
@@ -413,19 +407,8 @@ export const buildSessionStatusDetail = (status: { type: string; message?: strin
};
export const buildReasoningProgressDetail = (
chunks: string[],
ended?: string | number | Date | null,
) => {
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
export const buildToolProgressDetail = (
tool: string,
+62
View File
@@ -22,6 +22,15 @@ export type ActiveRun = {
subscribers: Set<StreamSubscriber>;
};
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
type ToolCallPayload = {
session_id?: string;
tool?: string;
params?: unknown;
reason?: string;
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -196,6 +205,59 @@ export const updateLastAssistantQuestion = (
};
});
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),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params,
});
return next;
};
export const toFrontendPermission = (
payload: PermissionRequestPayload,
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
+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;
};
+145 -18
View File
@@ -1,13 +1,19 @@
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 {
cleanupExpiredToolOutputs,
resolveOpencodeToolOutputDirectory,
} from "./opencodeToolOutputCleanup.js";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
@@ -47,6 +53,7 @@ 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) {
@@ -64,12 +71,104 @@ 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 });
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) {
@@ -323,24 +422,17 @@ export class OpencodeRuntimeAdapter {
}
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 下的自定义工具可以回调本服务。
@@ -349,6 +441,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(
{
@@ -372,7 +465,7 @@ export class OpencodeRuntimeAdapter {
} 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;
@@ -391,9 +484,39 @@ 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();
@@ -403,6 +526,10 @@ function buildOpencodeConfig(): Record<string, unknown> {
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
{
model: config.OPENCODE_MODEL,
tool_output: {
max_bytes: config.MAX_INLINE_RESULT_BYTES,
max_lines: 2000,
},
},
);
}
+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);
};
+339 -108
View File
@@ -1,15 +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 {
@@ -17,8 +30,15 @@ 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 {
getRuntimeSessionContext,
markRuntimeSessionAuthExpired,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js";
const app = express();
@@ -27,17 +47,24 @@ 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();
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors());
@@ -48,6 +75,8 @@ app.get("/health", async (_req, res) => {
const runtime = await opencodeRuntime.health();
res.json({
ok: true,
ready: true,
warmed_up: true,
runtime,
sessions: sessionBridge.count(),
});
@@ -55,6 +84,8 @@ app.get("/health", async (_req, res) => {
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(),
@@ -62,6 +93,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" });
@@ -78,7 +207,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,47 +216,49 @@ 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 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",
@@ -142,27 +272,53 @@ 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;
}
try {
res.json(JSON.parse(stdout));
} catch {
res.json({
ok: true,
schema_version: "tjwater-cli/v1",
raw: stdout,
stderr: stderr || undefined,
});
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()) {
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/store-render-ref", async (req, res) => {
@@ -186,6 +342,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, {
@@ -196,6 +359,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,
@@ -252,33 +416,62 @@ app.post("/internal/tools/session-search", async (req, res) => {
const callBackendJson = async (
path: string,
accessToken: string | undefined,
context: RuntimeSessionContext,
payload: unknown,
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
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",
);
}
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
const text = await response.text();
return result;
} catch (error) {
if (!(error instanceof CredentialRefreshError)) {
throw error;
}
if (error.code === "cancelled") {
throw error;
}
markAuthExpired(context, "access_token_expired");
return {
ok: response.ok,
status: response.status,
text,
ok: false,
status: 401,
text: JSON.stringify({
message: "credential refresh failed",
detail: error instanceof Error ? error.message : String(error),
}),
};
} finally {
clearTimeout(timer);
}
};
@@ -287,6 +480,34 @@ const parseStringArray = (value: unknown) =>
? 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" });
@@ -316,8 +537,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
: undefined;
const payload = {
query,
freshness:
typeof req.body?.freshness === "string" ? req.body.freshness : undefined,
freshness: normalizeWebSearchFreshness(req.body?.freshness),
summary:
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
count,
@@ -327,8 +547,8 @@ app.post("/internal/tools/web-search", async (req, res) => {
try {
const response = await callBackendJson(
"/api/v1/web-search",
context.accessToken,
"/api/v1/web-searches",
context,
payload,
);
res
@@ -370,8 +590,8 @@ app.post("/internal/tools/geocode", async (req, res) => {
try {
const response = await callBackendJson(
"/api/v1/tianditu/geocode",
context.accessToken,
"/api/v1/geocoding-requests",
context,
{ keyword },
);
res
@@ -387,18 +607,22 @@ app.post("/internal/tools/geocode", async (req, res) => {
}
});
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",
buildChatRouter(
sessionBridge,
opencodeRuntime,
sessionMetadataStore,
sessionUiStateStore,
memoryStore,
sessionTranscriptStore,
learningOrchestrator,
resultReferenceResolver,
),
"/api/v1/agent",
buildAgentPublicRouter(authenticatedChatRouter),
);
const bootstrap = async () => {
@@ -408,25 +632,15 @@ const bootstrap = async () => {
learningOrchestrator.initialize(),
memoryStore.initialize(),
resultReferenceStore.initialize(),
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
sessionTranscriptStore.initialize(),
]);
resultReferenceStore.startCleanupLoop();
};
await bootstrap();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{ host: config.HOST, port: config.PORT },
"TJWaterAgent listening",
);
void warmupOpencodeRuntime();
});
const warmupOpencodeRuntime = async () => {
const startedAt = Date.now();
try {
await opencodeRuntime.ensureClient();
await opencodeRuntime.warmup();
logger.info(
{
elapsedMs: Math.max(0, Date.now() - startedAt),
@@ -443,9 +657,26 @@ const warmupOpencodeRuntime = async () => {
},
"failed to warm up opencode runtime",
);
throw error;
}
};
await bootstrap();
await warmupOpencodeRuntime();
resultReferenceStore.startCleanupLoop();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{
host: config.HOST,
port: config.PORT,
ready: true,
warmedUp: true,
},
"TJWaterAgent listening",
);
});
const shutdown = async () => {
logger.info("shutting down TJWaterAgent");
server.close();
+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;
-23
View File
@@ -147,29 +147,6 @@ export class SessionTranscriptStore {
return nextTranscript;
}
async truncateThread(
context: SessionTranscriptContext,
keepMessageCount: number,
) {
const key = this.filePath(context);
return this.serializeWrite(key, async () => {
const transcript = await this.readTranscript(context);
if (!transcript) {
return null;
}
const nextTranscript: SessionTranscriptRecord = {
...transcript,
clientSessionId: context.clientSessionId ?? transcript.clientSessionId,
sessionId: context.sessionId,
turns: projectTurnsForFork(transcript.turns, keepMessageCount),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(key, nextTranscript);
return nextTranscript;
});
}
async search(
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
query: string,
+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" });
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "bun:test";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
supportedModels,
} from "../../src/chat/models.js";
import { 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("deepseek/deepseek-v4-flash")).toBe(true);
expect(isSupportedModel("unknown/model")).toBe(false);
});
it("rejects unsupported default models", () => {
expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe(
"deepseek/deepseek-v4-pro",
);
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"]),
);
});
});
Vendored Executable
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { closeSync } from "node:fs";
const command = process.argv[3];
const value = process.argv[4] ?? "";
if (command === "stdout") {
process.stdout.write(value);
process.exit(0);
}
if (command === "stderr") {
process.stderr.write(value);
process.exit(1);
}
if (command === "stderr-success") {
process.stderr.write(value);
process.stdout.write('{"ok":true}');
process.exit(0);
}
if (command === "term") {
process.on("SIGTERM", () => {
setTimeout(() => process.exit(0), 30);
});
setInterval(() => undefined, 1000);
}
if (command === "ignore-term") {
process.on("SIGTERM", () => undefined);
setInterval(() => undefined, 1000);
}
if (command === "closed-stdin") {
closeSync(0);
setInterval(() => undefined, 1000);
}
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
executeMemoryManager,
executeSkillManager,
} from "../../src/learning/toolManagers.js";
import { MemoryStore } from "../../src/memory/store.js";
import {
getRuntimeSessionContext,
removeRuntimeSessionContext,
setRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("main-process learning tool managers", () => {
let tempDir: string;
let memoryStore: MemoryStore;
let skillStore: SkillStore;
let context: RuntimeSessionContext;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-learning-tools-"));
memoryStore = new MemoryStore(
join(tempDir, "memory"),
join(tempDir, "backup", "memory"),
);
skillStore = new SkillStore(
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
await memoryStore.initialize();
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
setRuntimeSessionContext(context);
});
afterEach(async () => {
removeRuntimeSessionContext(context.sessionId);
await rm(tempDir, { force: true, recursive: true });
});
it("enforces list-before-add using the canonical runtime context", async () => {
const rejected = await executeMemoryManager(memoryStore, context, {
action: "add",
content: "用户偏好查看压力单位为 MPa",
scope: "user",
});
expect(rejected.decision).toBe("rejected");
await executeMemoryManager(memoryStore, context, {
action: "list",
scope: "user",
});
const refreshedContext = getRuntimeSessionContext(context.sessionId)!;
const accepted = await executeMemoryManager(memoryStore, refreshedContext, {
action: "add",
content: "用户偏好查看压力单位为 MPa",
scope: "user",
});
expect(accepted.decision).toBe("accepted");
expect(await memoryStore.list("user", context.actorKey)).toHaveLength(1);
});
it("writes and removes skills through the shared store", async () => {
const content = [
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"---",
"",
"# Pressure Review",
].join("\n");
const written = await executeSkillManager(skillStore, context, {
action: "write_skill",
content,
skill_path: "workflow/pressure-review",
});
expect(written.decision).toBe("accepted");
expect("target" in written).toBe(true);
if (!("target" in written)) throw new Error("write returned no target");
await expect(readFile(written.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
);
const removed = await executeSkillManager(skillStore, context, {
action: "remove_skill",
skill_path: "workflow/pressure-review",
});
expect(removed.decision).toBe("accepted");
expect("target" in removed).toBe(true);
if (!("target" in removed)) throw new Error("remove returned no target");
await expect(readFile(removed.target, "utf8")).rejects.toThrow();
});
});
-140
View File
@@ -1,140 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("skill_manager tool", () => {
let tempDir: string;
let skillStore: SkillStore;
let context: RuntimeSessionContext;
const toolContext = {
abort: new AbortController().signal,
agent: "test",
ask: (() => undefined) as never,
directory: "",
messageID: "message-1",
metadata: () => undefined,
sessionID: "session-1",
worktree: "",
};
const skillDocument = (body: string) =>
[
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"---",
"",
body,
].join("\n");
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
skillStore = new SkillStore(
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("dispatches skill-level write, overwrite, and remove actions", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Pressure Review"),
reason: "verified reusable workflow",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
);
const updateResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Updated Pressure Review"),
reason: "verified reusable workflow overwrite",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(updateResult.decision).toBe("accepted");
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
);
const removeResult = JSON.parse(
await tool.execute(
{
action: "remove_skill",
reason: "workflow is obsolete",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(removeResult.decision).toBe("accepted");
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
});
it("writes the root skills index through the reserved alias", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: [
"---",
"name: skills",
"description: TJWater Skills root index.",
"---",
"",
"# TJWater Skills",
].join("\n"),
reason: "refresh root skills index",
skill_path: "__root__",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# TJWater Skills\n",
);
});
});
+115 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -12,13 +12,18 @@ import {
describe("ResultReferenceResolver", () => {
let tempDir: string;
let importRoot: string;
let conversationWorkspace: string;
let store: ResultReferenceStore;
let resolver: ResultReferenceResolver;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store);
importRoot = join(tempDir, "conversation-workspaces");
conversationWorkspace = join(importRoot, "conversation-1");
await mkdir(conversationWorkspace, { recursive: true });
store = new ResultReferenceStore(join(tempDir, "refs"), 60_000);
resolver = new ResultReferenceResolver(store, importRoot, 1024 * 1024);
await store.initialize();
});
@@ -127,7 +132,7 @@ describe("ResultReferenceResolver", () => {
});
it("registers render refs from local wrapper files and normalizes payloads", async () => {
const filePath = join(tempDir, "render-wrapper.json");
const filePath = join(conversationWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify(
@@ -166,6 +171,7 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3",
workspaceDirectory: conversationWorkspace,
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
@@ -193,6 +199,111 @@ describe("ResultReferenceResolver", () => {
"DMA-2": "#00ff00",
},
});
await expect(stat(filePath)).rejects.toThrow();
});
it("rejects render payload files outside the configured import directory", async () => {
const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-"));
const filePath = join(outsideDir, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
try {
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-4",
workspaceDirectory: outsideDir,
}),
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
} finally {
await rm(outsideDir, { force: true, recursive: true });
}
});
it("rejects oversized render payload files before parsing", async () => {
const filePath = join(conversationWorkspace, "oversized.json");
await writeFile(filePath, "x".repeat(128), "utf8");
const sizeLimitedResolver = new ResultReferenceResolver(store, importRoot, 64);
await expect(
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-5",
clientSessionId: "client-5",
projectKey: "project-key-5",
sessionId: "session-5",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-5",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
});
it("rejects render payload files owned by another conversation workspace", async () => {
const otherWorkspace = join(importRoot, "conversation-2");
await mkdir(otherWorkspace);
const filePath = join(otherWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-6",
clientSessionId: "client-6",
projectKey: "project-key-6",
sessionId: "session-6",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-6",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("current conversation workspace");
});
it("rejects a conversation workspace that is itself a symbolic link", async () => {
const targetWorkspace = join(importRoot, "conversation-target");
const linkedWorkspace = join(importRoot, "conversation-linked");
await mkdir(targetWorkspace);
await symlink(targetWorkspace, linkedWorkspace, "dir");
const filePath = join(targetWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-7",
clientSessionId: "client-7",
projectKey: "project-key-7",
sessionId: "session-7",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-7",
workspaceDirectory: linkedWorkspace,
}),
).rejects.toThrow("symbolic link");
});
});
+153
View File
@@ -0,0 +1,153 @@
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
describe("chat interaction routes", () => {
let baseUrl = "";
let server: Server;
const replyQuestion = mock(async () => ({ ok: true }));
const replyPermission = mock(async () => ({ ok: true }));
beforeAll(async () => {
const activeRuns = new Map<string, ActiveRun>();
activeRuns.set("runtime-session", {
clientSessionId: "client-session",
controller: new AbortController(),
messages: [
{
id: "assistant-1",
role: "assistant",
permissions: [
{
requestId: "permission-1",
sessionId: "runtime-session",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
questions: [{ requestId: "question-1", status: "pending" }],
},
],
pendingPermissions: new Map([
[
"permission-1",
{
session_id: "runtime-session",
request_id: "permission-1",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
created_at: 1,
},
],
]),
pendingQuestions: new Map([
[
"question-1",
{
created_at: 1,
request_id: "question-1",
session_id: "runtime-session",
questions: [],
},
],
]),
status: "running",
subscribers: new Set(),
});
const router = Router();
router.use((req, _res, next) => {
req.agentAuth = {
accessToken: "access-token",
userId: "user-1",
keycloakSub: "keycloak-1",
username: "tester",
role: "user",
isSuperuser: false,
projectId: "project-1",
network: "network-1",
projectRole: "member",
};
next();
});
registerChatInteractionRoutes(router, {
activeRuns,
credentialRefreshCoordinator: new CredentialRefreshCoordinator(),
runtime: { replyPermission, replyQuestion } as never,
sessionMetadataStore: {
get: async () => ({ sessionId: "runtime-session" }),
} as never,
sessionUiStateStore: {
read: async () => null,
write: async () => undefined,
} as never,
});
const app = express();
app.use(express.json());
app.use(router);
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
it("submits answers to the stable OpenCode question adapter", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/question-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "question-1",
action: "reply",
answers: [["继续"]],
}),
},
);
expect(response.status).toBe(202);
expect(replyQuestion).toHaveBeenCalledWith({
requestId: "question-1",
sessionId: "runtime-session",
answers: [["继续"]],
});
});
it("forwards saved permission grants to OpenCode", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/permission-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "permission-1",
reply: "always",
}),
},
);
expect(response.status).toBe(202);
expect(replyPermission).toHaveBeenCalledWith({
requestId: "permission-1",
sessionId: "runtime-session",
reply: "always",
message: undefined,
});
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "bun:test";
import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
canAutoApprovePermission,
resolvePermissionApproval,
} from "../../src/routes/chatPermissionPolicy.js";
describe("permission approval policy", () => {
it.each([
"show_chart",
"web_search",
"skill",
])("allows low-risk permission %s", (permission) => {
expect(canAutoApprovePermission(permission)).toBe(true);
});
it("allows structured searches within the workspace", () => {
const workspaceRoot = process.cwd();
const context = {
workspaceRoot,
metadata: { path: join(workspaceRoot, "src"), include: "*.ts" },
patterns: ["*.ts"],
};
expect(canAutoApprovePermission("glob", context)).toBe(true);
expect(canAutoApprovePermission("grep", context)).toBe(true);
});
it("allows a glob with an explicit safe prefix from the workspace root", () => {
expect(
canAutoApprovePermission("glob", {
workspaceRoot: process.cwd(),
metadata: { path: process.cwd(), pattern: "src/**/*.ts" },
patterns: ["src/**/*.ts"],
}),
).toBe(true);
});
it.each([
{ metadata: { path: dirname(process.cwd()) }, patterns: ["*"] },
{ metadata: { path: join(process.cwd(), ".local.env") }, patterns: ["*"] },
{ metadata: { path: join(process.cwd(), "data") }, patterns: ["*"] },
{ metadata: { path: join(process.cwd(), "src") }, patterns: ["../logs/**"] },
{ metadata: { path: process.cwd() }, patterns: ["**/*.env"] },
{ metadata: { path: process.cwd() }, patterns: ["**/*"] },
{ metadata: { path: join(process.cwd(), "src") }, patterns: [".[e]nv"] },
])("keeps protected or external searches interactive", (request) => {
expect(
canAutoApprovePermission("glob", {
workspaceRoot: process.cwd(),
...request,
}),
).toBe(false);
});
it("keeps grep from the workspace root interactive", () => {
expect(
canAutoApprovePermission("grep", {
workspaceRoot: process.cwd(),
metadata: { path: process.cwd(), include: "*.ts" },
patterns: ["secret"],
}),
).toBe(false);
});
it("rejects a workspace symlink that resolves outside the workspace", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-workspace-"));
const externalRoot = await mkdtemp(join(tmpdir(), "permission-external-"));
try {
await mkdir(join(externalRoot, "src"));
const linkedPath = join(workspaceRoot, "linked");
await symlink(join(externalRoot, "src"), linkedPath, "dir");
expect(
canAutoApprovePermission("grep", {
workspaceRoot,
metadata: { path: linkedPath, include: "*.ts" },
patterns: ["secret"],
}),
).toBe(false);
} finally {
await Promise.all([
rm(workspaceRoot, { force: true, recursive: true }),
rm(externalRoot, { force: true, recursive: true }),
]);
}
});
it("rejects protected descendants below an otherwise safe search root", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "permission-descendant-"));
try {
const sourceRoot = join(workspaceRoot, "src");
await mkdir(join(sourceRoot, "data"), { recursive: true });
expect(
canAutoApprovePermission("grep", {
workspaceRoot,
metadata: { path: sourceRoot, include: "*.ts" },
patterns: ["secret"],
}),
).toBe(false);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
it.each([
"bash",
"edit",
"external_directory",
"store_render_ref",
"tjwater_server_query",
"tjwater_tjwater_server_query",
])(
"requires confirmation for permission %s",
(permission) => {
expect(canAutoApprovePermission(permission)).toBe(false);
},
);
it("resolves request, auto, and always modes", () => {
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
autoApprove: true,
title: "已按始终允许模式放行",
});
});
it.each([
"rm -rf ./target",
"rm -rf ./target",
"rm -Rf ./target",
"/bin/rm -rf ./target",
"command rm --force --recursive ./target",
"env LANG=C rm -r -f ./target",
"SAFE=1 rm -rf ./target",
"env -u HOME rm -rf ./target",
"r\"\"m -rf ./target",
"(rm -rf ./target)",
"! rm -rf ./target",
"npm test && rm --recursive --force ./target",
])("rejects direct recursive force removal in always mode: %s", (command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
});
});
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
"keeps non-recursive or non-executed removal text available for confirmation: %s",
(command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({ autoApprove: false, autoReject: false });
},
);
});
+79
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "bun:test";
import {
buildForkedSessionUiState,
} from "../../src/routes/chat.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
@@ -199,3 +202,79 @@ describe("extractLatestFrontendTurn", () => {
});
});
});
describe("buildForkedSessionUiState", () => {
it("copies truncated source messages and preserves tool artifacts", () => {
const forked = buildForkedSessionUiState(
{
messages: [
{ role: "user", content: "画压力曲线" },
{
role: "assistant",
content: "已生成图表",
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
},
],
},
{ role: "user", content: "继续分析" },
],
},
{
keepMessageCount: 2,
targetSessionId: "forked-session",
},
);
expect(forked).toEqual({
sessionId: "forked-session",
isTitleManuallyEdited: false,
messages: [
{ role: "user", content: "画压力曲线" },
{
role: "assistant",
content: "已生成图表",
artifacts: [
{
id: "chart-1",
tool: "show_chart",
kind: "chart",
params: { chart_type: "line" },
},
],
},
],
});
});
it("creates an empty branch state when source UI state is missing or keep count is zero", () => {
expect(
buildForkedSessionUiState(null, {
keepMessageCount: 3,
targetSessionId: "forked-without-source",
}),
).toEqual({
sessionId: "forked-without-source",
isTitleManuallyEdited: false,
messages: [],
});
expect(
buildForkedSessionUiState(
{ messages: [{ role: "user", content: "不保留" }] },
{
keepMessageCount: 0,
targetSessionId: "forked-empty",
},
),
).toEqual({
sessionId: "forked-empty",
isTitleManuallyEdited: false,
messages: [],
});
});
});
+375 -8
View File
@@ -15,6 +15,242 @@ const createEventStream = (events: unknown[]) => ({
});
describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
partID: "text-part-intermediate",
field: "text",
delta: "正在加载工作流并尝试分页参数。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "text-part-final",
field: "text",
delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
time: { start: 3, end: 4 },
},
time: 4,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-intermediate", role: "assistant" },
parts: [
{
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
},
],
},
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
});
it("uses the final text event cache when the messages lookup fails", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "最终分析结果。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
throw new Error("transient messages lookup failure");
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
});
it("keeps reasoning generic while preserving tool execution details", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "reasoning-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "reasoning",
text: "内部推理:尝试 limit=5000 并读取临时路径。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-1",
partID: "reasoning-part-1",
field: "text",
delta: "内部推理:尝试 limit=5000 并读取临时路径。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "call-1",
tool: "tjwater_cli",
state: {
status: "error",
input: {
command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
},
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
const reasoningProgress = events.find(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1",
);
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe(
"tjwater_cli 调用失败;调用原因:尝试突破分页限制;关键参数:command=network get-all-pipes-properties --limit 5000;错误:HTTP_422 raw backend payload with trace_id=secret-trace",
);
});
it("forwards opencode permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
@@ -61,7 +297,7 @@ describe("streamPromptResponse", () => {
} satisfies Partial<PermissionRequestPayload>);
});
it("auto replies always when approval mode is always", async () => {
it("auto approves an allowlisted low-risk permission once", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
@@ -71,10 +307,10 @@ describe("streamPromptResponse", () => {
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
permission: "show_chart",
patterns: ["*"],
metadata: {},
always: ["*"],
},
},
{
@@ -97,7 +333,7 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
@@ -105,14 +341,145 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
reply: "always",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "always",
reply: "once",
});
});
it("keeps high-risk permissions interactive in auto mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-auto-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
request_id: "perm-auto-bash",
permission: "bash",
});
});
it("approves every OpenCode ask once in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("rejects recursive force removal even in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-rm-rf",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["/bin/rm -rf ./target"],
metadata: { command: "/bin/rm -rf ./target" },
always: ["/bin/rm -rf ./target"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "delete recursively",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1",
reply: "reject",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-always-rm-rf",
reply: "reject",
});
});
+35
View File
@@ -1,10 +1,45 @@
import { describe, expect, it } from "bun:test";
import {
appendBackendToolArtifact,
cancelBackendTodos,
upsertBackendQuestion,
} from "../../src/routes/chatUiState.js";
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
}) as Array<Record<string, unknown>>;
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
tool: "show_chart",
kind: "chart",
title: "压力曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
},
});
expect(artifacts[0]).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^show_chart-/),
}),
);
});
});
describe("upsertBackendQuestion", () => {
it("replaces a tool-call placeholder with the actionable question request", () => {
const questions = upsertBackendQuestion(
+137
View File
@@ -0,0 +1,137 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { generateAgentOpenApi } from "../../src/contracts/openapi.js";
import { buildChatRouter } from "../../src/routes/chat.js";
import { buildAgentPublicRouter } from "../../src/routes/publicApi.js";
describe("Agent public REST router", () => {
let baseUrl = "";
let server: Server;
beforeAll(async () => {
const chatRouter = Router();
chatRouter.use(express.json());
chatRouter.post("/sessions/:session_id/runs", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.delete("/sessions/:session_id/runs/current", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.get("/auth-failure", (_req, res) => {
res.status(503).json({ message: "authentication service unavailable" });
});
const app = express();
app.use(express.json());
app.use("/api/v1/agent", buildAgentPublicRouter(chatRouter));
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
test("matches the published OpenAPI method and path set", () => {
type RouterLayer = {
route?: {
methods: Record<string, boolean>;
path: string;
};
};
const router = buildChatRouter(
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
);
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
const runtimeOperations = layers
.flatMap((layer) => {
if (!layer.route) {
return [];
}
const path = `/api/v1/agent${layer.route.path.replace(
/:([^/]+)/g,
"{$1}",
)}`;
return Object.entries(layer.route.methods)
.filter(([, enabled]) => enabled)
.map(([method]) => `${method.toUpperCase()} ${path}`);
})
.sort();
const document = generateAgentOpenApi();
const contractOperations = Object.entries(document.paths)
.flatMap(([path, pathItem]) =>
Object.entries(pathItem ?? {})
.filter(([, operation]) => {
return (
operation &&
typeof operation === "object" &&
"responses" in operation
);
})
.map(([method]) => `${method.toUpperCase()} ${path}`),
)
.sort();
expect(runtimeOperations).toEqual(contractOperations);
});
test("serves session runs without rewriting the REST request", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-1/runs`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hello" }),
},
);
expect(await response.json()).toEqual({
method: "POST",
path: "/sessions/session-1/runs",
body: { message: "hello" },
});
});
test("serves DELETE current run without rewriting the HTTP method", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-2/runs/current`,
{ method: "DELETE" },
);
expect(await response.json()).toEqual({
method: "DELETE",
path: "/sessions/session-2/runs/current",
body: {},
});
});
test("wraps authentication middleware failures as Problem Details", async () => {
const response = await fetch(`${baseUrl}/api/v1/agent/auth-failure`);
const body = await response.json();
expect(response.status).toBe(503);
expect(response.headers.get("content-type")).toContain(
"application/problem+json",
);
expect(body).toMatchObject({
status: 503,
code: "dependency_unavailable",
detail: "authentication service unavailable",
});
});
});
+148
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createRuntimeAdapter = (
@@ -85,3 +89,147 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
expect(attempts).toBe(2);
});
});
describe("OpencodeRuntimeAdapter.createSession", () => {
it("creates a real chat session inside a dedicated conversation workspace", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const calls: Array<Record<string, unknown>> = [];
const client = {
session: {
create: async (input: Record<string, unknown>) => {
calls.push(input);
return {
data: {
id: "runtime-session-1",
directory: input.directory,
},
};
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
const session = await runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
});
const directory = String(calls[0]?.directory);
expect(relative(workspaceRoot, directory).startsWith("..")).toBe(false);
const workspaceStat = await stat(directory);
expect(workspaceStat.isDirectory()).toBe(true);
expect(workspaceStat.mode & 0o777).toBe(0o700);
expect(session.directory).toBe(directory);
expect(calls[0]?.permission).toEqual([
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
it("removes an empty conversation workspace when session creation fails", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const client = {
session: {
create: async () => {
throw new Error("session creation failed");
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
await expect(
runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
}),
).rejects.toThrow("session creation failed");
expect(await readdir(workspaceRoot)).toEqual([]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
});
describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = [];
const client = {
global: {
health: async () => {
calls.push("health");
return { data: { healthy: true, version: "test" } };
},
},
session: {
create: async () => {
calls.push("session.create");
return { data: { id: "warmup-session" } };
},
delete: async ({ sessionID }: { sessionID: string }) => {
calls.push(`session.delete:${sessionID}`);
return { data: true };
},
},
tool: {
list: async (model: { provider: string; model: string }) => {
calls.push(`tool.list:${model.provider}/${model.model}`);
return { data: [] };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.warmup();
expect(calls).toEqual([
"health",
"session.create",
`tool.list:${config.OPENCODE_MODEL}`,
"session.delete:warmup-session",
]);
});
it("submits question answers through the stable question API", async () => {
const calls: unknown[] = [];
const client = {
question: {
reply: async (input: unknown) => {
calls.push(input);
return { data: { ok: true } };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.replyQuestion({
requestId: "question-1",
sessionId: "session-1",
answers: [["继续"]],
});
expect(calls).toEqual([
{ requestID: "question-1", answers: [["继续"]] },
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import storeRenderRef, {
resolveStoreRenderFilePath,
} from "../../.opencode/tools/store_render_ref.js";
describe("internal OpenCode permissions", () => {
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
};
const permission = config.permission ?? {};
const bash = permission.bash as Record<string, string> | undefined;
const edit = permission.edit as Record<string, string> | undefined;
const read = permission.read as Record<string, string> | undefined;
expect(permission["*"]).toBe("ask");
expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow");
expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny");
expect(read?.["**/logs/**"]).toBe("deny");
expect(edit?.["*"]).toBe("ask");
expect(edit?.["data/**"]).toBe("deny");
expect(edit?.["**/logs/**"]).toBe("deny");
expect(bash?.["*"]).toBe("ask");
expect(bash?.["rm *"]).toBe("ask");
expect(bash?.["rm -rf *"]).toBe("deny");
expect(bash?.["rm -fr *"]).toBe("deny");
expect(bash?.["rm -r -f *"]).toBe("deny");
expect(bash?.["rm -f -r *"]).toBe("deny");
expect(bash?.["rm --recursive --force *"]).toBe("deny");
expect(bash?.["rm --force --recursive *"]).toBe("deny");
expect(bash?.["*.env*"]).toBe("deny");
expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined();
});
});
describe("store_render_ref arguments", () => {
it("accepts the observed camelCase alias without changing snake_case precedence", () => {
expect(
resolveStoreRenderFilePath({
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/partition.json");
expect(
resolveStoreRenderFilePath({
file_path: "/app/data/conversation-workspaces/chat-1/preferred.json",
filePath: "/app/data/conversation-workspaces/chat-1/compatibility.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/preferred.json");
});
it("forwards a camelCase compatibility argument as file_path", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
globalThis.fetch = (async (
_input: RequestInfo | URL,
init?: RequestInit,
) => {
requestBody = JSON.parse(String(init?.body));
return new Response('{"render_ref":"res-test"}');
}) as unknown as typeof fetch;
try {
const definition = storeRenderRef as unknown as {
args: Record<string, unknown>;
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
expect(definition.args.filePath).toBeDefined();
await definition.execute(
{
reason: "regression test",
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toEqual({
session_id: "session-test",
file_path: "/app/data/conversation-workspaces/chat-1/partition.json",
});
});
});
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import {
lstat,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
utimes,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { cleanupExpiredToolOutputs } from "../../src/runtime/opencodeToolOutputCleanup.js";
describe("cleanupExpiredToolOutputs", () => {
test("removes only expired regular tool output files", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-tool-output-cleanup-"));
const expired = join(directory, "tool_expired");
const current = join(directory, "tool_current");
const unrelated = join(directory, "keep.txt");
const toolDirectory = join(directory, "tool_directory");
const toolSymlink = join(directory, "tool_symlink");
try {
await Promise.all([
writeFile(expired, "expired"),
writeFile(current, "current"),
writeFile(unrelated, "unrelated"),
mkdir(toolDirectory),
]);
await symlink(unrelated, toolSymlink);
const now = Date.now();
const old = new Date(now - 8 * 24 * 60 * 60 * 1000);
await utimes(expired, old, old);
const result = await cleanupExpiredToolOutputs(
directory,
7 * 24 * 60 * 60 * 1000,
now,
);
expect(result).toEqual({ removed: 1, scanned: 5 });
await expect(stat(expired)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(current, "utf8")).toBe("current");
expect(await readFile(unrelated, "utf8")).toBe("unrelated");
expect((await stat(toolDirectory)).isDirectory()).toBe(true);
expect((await lstat(toolSymlink)).isSymbolicLink()).toBe(true);
} finally {
await rm(directory, { force: true, recursive: true });
}
});
test("treats a missing tool output directory as empty", async () => {
const directory = join(tmpdir(), `missing-tool-output-${crypto.randomUUID()}`);
await expect(cleanupExpiredToolOutputs(directory, 1_000)).resolves.toEqual({
removed: 0,
scanned: 0,
});
});
});
+6
View File
@@ -14,17 +14,23 @@ describe("runtime session context", () => {
allowLearningWrite: true,
clientSessionId: "chat-session-1",
learningMode: "interactive",
network: "fengyang",
projectId: "project-id-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
traceId: "trace-1",
workspaceDirectory: "/app/data/conversation-workspaces/chat-session-1",
});
const runtimeContext = getRuntimeSessionContext("runtime-session-1");
expect(runtimeContext?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.network).toBe("fengyang");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
expect(runtimeContext?.workspaceDirectory).toBe(
"/app/data/conversation-workspaces/chat-session-1",
);
removeRuntimeSessionContext("runtime-session-1");
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
+75
View File
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
);
expect(fetched?.title).toBe("新标题");
});
it("does not expose a session across users or projects", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "private-session",
userId: "user-a",
});
expect(
await store.get(
{
actorKey: "actor-b",
projectId: "project-a",
projectKey: "project-key-a",
userId: "user-b",
},
"private-session",
),
).toBeNull();
expect(
await store.get(
{
actorKey: "actor-a",
projectId: "project-b",
projectKey: "project-key-b",
userId: "user-a",
},
"private-session",
),
).toBeNull();
});
it("rejects idempotent creation when the session belongs to another scope", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "claimed-session",
userId: "user-a",
});
await expect(
store.ensure({
actorKey: "actor-b",
projectId: "project-b",
projectKey: "project-key-b",
sessionId: "claimed-session",
userId: "user-b",
}),
).rejects.toThrow("session metadata ownership mismatch");
});
it("keeps long session ids with the same slug prefix distinct", async () => {
const prefix = "s".repeat(64);
const firstSessionId = `${prefix}-first`;
const secondSessionId = `${prefix}-second`;
const context = {
actorKey: "actor-long",
projectId: "project-long",
projectKey: "project-key-long",
userId: "user-long",
};
await store.ensure({ ...context, sessionId: firstSessionId });
await store.ensure({ ...context, sessionId: secondSessionId });
expect((await store.get(context, firstSessionId))?.sessionId).toBe(
firstSessionId,
);
expect((await store.get(context, secondSessionId))?.sessionId).toBe(
secondSessionId,
);
});
});
BIN
View File
Binary file not shown.