Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774f39cbbe | ||
|
|
c6efccb88a | ||
|
|
11ebf428bb | ||
|
|
18e8b25f48 | ||
|
|
9aa5a96e60 | ||
|
|
a5f6474be5 | ||
|
|
b49290bd91 | ||
|
|
31fdb36e48 | ||
|
|
0a64de89bb | ||
|
|
2f267af7a3 | ||
|
|
649af949c5 | ||
|
|
c565e89d60 | ||
|
|
9c9e31c570 | ||
|
|
c0c54e238d | ||
|
|
99f5a0b823 | ||
|
|
4a9681c148 | ||
|
|
8530793882 | ||
|
|
cb3aa3a150 | ||
|
|
4cbeca4e09 | ||
|
|
f66b9c3e9d | ||
|
|
b19af8846a | ||
|
|
a5e91ac2b8 | ||
|
|
258f4996eb | ||
|
|
2dc37e3fd8 | ||
|
|
a53839e157 | ||
|
|
1407dd3bbe | ||
|
|
764a1f4e82 | ||
|
|
07016451d6 | ||
|
|
94529cb141 | ||
|
|
2415f75841 | ||
|
|
d7faaa2ecb | ||
|
|
78f9f6fb42 | ||
|
|
9aad38acb1 | ||
|
|
d782b7fa11 | ||
|
|
40c0395fb1 | ||
|
|
d75b98a61b | ||
|
|
ead76185b7 | ||
|
|
8857b18dc9 | ||
|
|
4b572d9264 | ||
|
|
fc7393fa2b | ||
|
|
c823e3935e | ||
|
|
366c05b752 | ||
|
|
fa2c28c1c0 | ||
|
|
5ac50bfeaa |
@@ -1,7 +1,11 @@
|
|||||||
.git
|
.git
|
||||||
node_modules
|
node_modules
|
||||||
.opencode/node_modules
|
.opencode/node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
.local.env
|
.local.env
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
dist
|
dist
|
||||||
.vscode
|
.vscode
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+15
-211
@@ -1,219 +1,23 @@
|
|||||||
name: Agent CI/CD
|
name: Agent CI/CD v2
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "latest"
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-image:
|
build-test-publish-and-deploy:
|
||||||
runs-on: ubuntu-22.04
|
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
with:
|
||||||
permissions:
|
image_name: gitea.waternetwork.cn/orgtjwater/tjwateragent
|
||||||
contents: read
|
dockerfile: Dockerfile
|
||||||
defaults:
|
build_context: .
|
||||||
run:
|
cache_image: gitea.waternetwork.cn/orgtjwater/tjwateragent:ci-cache
|
||||||
shell: bash
|
test_target: test
|
||||||
|
deploy_service: agent
|
||||||
steps:
|
deploy_host: 192.168.1.114
|
||||||
- name: Setup tools
|
secrets:
|
||||||
run: |
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq jq
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
jq --version
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
|
|
||||||
- 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."
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
description: TJWater Agent,用于供水网络分析和操作员工作流
|
description: TJWater Agent,用于供水网络分析和操作员工作流
|
||||||
mode: primary
|
mode: primary
|
||||||
model: deepseek/deepseek-v4-pro
|
model: deepseek/deepseek-v4-flash
|
||||||
temperature: 0.2
|
|
||||||
---
|
---
|
||||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||||
|
|
||||||
|
## 回复要求
|
||||||
|
|
||||||
|
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
|
||||||
|
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
|
||||||
|
|
||||||
## 工作流生命周期
|
## 工作流生命周期
|
||||||
|
|
||||||
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
|
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
|
||||||
@@ -44,7 +48,7 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
|
|||||||
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
|
3. 大结果集禁止完整读取,优先采样/截断/按字段读取
|
||||||
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
|
4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
|
||||||
5. 无可用数据时不得编造结果
|
5. 无可用数据时不得编造结果
|
||||||
6. 尽量不使用 `task` 子代理,避免无法观测过程进行人为干预
|
6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程
|
||||||
|
|
||||||
## 工作流沉淀(skill_manager)
|
## 工作流沉淀(skill_manager)
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.16.2",
|
"@opencode-ai/plugin": "1.18.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||||
@@ -25,9 +27,9 @@
|
|||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="],
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2H9YT80M1PYElpG+lmd/9kGqsNouiJIBCUhLblmgFwoSrB4wyahgkCS6NcFQR/AYXNH4I1Yd3lmQcVaEPu1qNg=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="],
|
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
||||||
|
|
||||||
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@
|
|||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||||
|
|
||||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||||
|
|
||||||
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "^1.16.2"
|
"@opencode-ai/plugin": "1.18.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
|||||||
@@ -28,8 +28,11 @@ export default tool({
|
|||||||
.describe("Classification method."),
|
.describe("Classification method."),
|
||||||
segments: tool.schema
|
segments: tool.schema
|
||||||
.number()
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(2)
|
||||||
|
.max(10)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Number of segments."),
|
.describe("Number of rendered intervals, from 2 to 10."),
|
||||||
min_size: tool.schema
|
min_size: tool.schema
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -54,9 +57,9 @@ export default tool({
|
|||||||
.enum(["single", "gradient", "rainbow", "custom"])
|
.enum(["single", "gradient", "rainbow", "custom"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Color strategy."),
|
.describe("Color strategy."),
|
||||||
single_palette_index: tool.schema.number().optional(),
|
single_palette_index: tool.schema.number().int().min(0).max(6).optional(),
|
||||||
gradient_palette_index: tool.schema.number().optional(),
|
gradient_palette_index: tool.schema.number().int().min(0).max(2).optional(),
|
||||||
rainbow_palette_index: tool.schema.number().optional(),
|
rainbow_palette_index: tool.schema.number().int().min(0).max(1).optional(),
|
||||||
show_labels: tool.schema
|
show_labels: tool.schema
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -65,7 +68,7 @@ export default tool({
|
|||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Whether to show ids."),
|
.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
|
adjust_width_by_property: tool.schema
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -73,11 +76,11 @@ export default tool({
|
|||||||
custom_breaks: tool.schema
|
custom_breaks: tool.schema
|
||||||
.array(tool.schema.number())
|
.array(tool.schema.number())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Custom break values."),
|
.describe("Strictly increasing boundaries. Length must equal segments + 1."),
|
||||||
custom_colors: tool.schema
|
custom_colors: tool.schema
|
||||||
.array(tool.schema.string())
|
.array(tool.schema.string())
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Custom rgba colors."),
|
.describe("Custom CSS colors. Length must equal segments."),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -87,8 +90,24 @@ export default tool({
|
|||||||
async execute(args) {
|
async execute(args) {
|
||||||
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
|
const layerLabel = args.layer_id === "junctions" ? "节点" : "管道";
|
||||||
if (args.reset_to_default) {
|
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}图层样式请求。`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
import { MemoryStore } from "../../src/memory/store.js";
|
|
||||||
import {
|
|
||||||
getRuntimeSessionContext,
|
|
||||||
setRuntimeSessionContext,
|
|
||||||
} from "../../src/runtime/sessionContext.js";
|
|
||||||
|
|
||||||
const memoryStore = new MemoryStore();
|
const internalBaseUrl =
|
||||||
const initializePromise = memoryStore.initialize();
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -26,130 +22,31 @@ export default tool({
|
|||||||
content: tool.schema
|
content: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe("The durable fact or preference to remember, written as one concise sentence."),
|
||||||
"The durable fact or preference to remember, written as one concise sentence.",
|
|
||||||
),
|
|
||||||
target_id: tool.schema
|
target_id: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Stable memory entry id used by replace/remove."),
|
.describe("Stable memory entry id used by replace/remove."),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
const response = await fetch(
|
||||||
const sessionContext = getRuntimeSessionContext(context.sessionID);
|
`${internalBaseUrl}/internal/tools/memory-manager`,
|
||||||
if (!sessionContext) {
|
{
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
method: "POST",
|
||||||
}
|
headers: {
|
||||||
const scope =
|
"Content-Type": "application/json",
|
||||||
args.scope === "user"
|
"x-agent-internal-token": internalToken,
|
||||||
? "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,
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
return JSON.stringify({
|
...args,
|
||||||
ok: true,
|
session_id: context.sessionID,
|
||||||
kind: "memory",
|
}),
|
||||||
decision: result.changed ? "accepted" : "rejected",
|
},
|
||||||
detail: result.detail,
|
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await memoryStore.remove(
|
|
||||||
scope,
|
|
||||||
scopeKey,
|
|
||||||
args.target_id ?? "",
|
|
||||||
);
|
);
|
||||||
return JSON.stringify({
|
const text = await response.text();
|
||||||
ok: true,
|
if (!response.ok) {
|
||||||
kind: "memory",
|
throw new Error(text);
|
||||||
decision: result.changed ? "accepted" : "rejected",
|
}
|
||||||
detail: result.detail,
|
return text;
|
||||||
target: scope,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node id,value 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。",
|
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
reason: tool.schema
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,154 +1,67 @@
|
|||||||
import { tool } from "@opencode-ai/plugin";
|
import { tool } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { SkillStore } from "../../src/skills/store.js";
|
const internalBaseUrl =
|
||||||
import {
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
getRuntimeSessionContext,
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
type RuntimeSessionContext,
|
|
||||||
} from "../../src/runtime/sessionContext.js";
|
|
||||||
|
|
||||||
type ToolContextReader = {
|
export default tool({
|
||||||
read(sessionId: string): RuntimeSessionContext | null;
|
description:
|
||||||
};
|
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
||||||
|
args: {
|
||||||
const runtimeContextReader: ToolContextReader = {
|
action: tool.schema
|
||||||
read: getRuntimeSessionContext,
|
.enum([
|
||||||
};
|
"list",
|
||||||
|
"write_skill",
|
||||||
export const createSkillManagerTool = (
|
"remove_skill",
|
||||||
skillStore = new SkillStore(),
|
"append_pattern",
|
||||||
toolContextStore: ToolContextReader = runtimeContextReader,
|
"remove_pattern",
|
||||||
initializePromise: Promise<unknown> = Promise.resolve(),
|
"write_reference",
|
||||||
) =>
|
"remove_reference",
|
||||||
tool({
|
"write_script",
|
||||||
description:
|
"remove_script",
|
||||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
])
|
||||||
args: {
|
.describe("Skill maintenance operation."),
|
||||||
action: tool.schema
|
reason: tool.schema
|
||||||
.enum([
|
.string()
|
||||||
"list",
|
.describe("Why this skill maintenance action is justified for future reuse."),
|
||||||
"write_skill",
|
skill_path: tool.schema
|
||||||
"remove_skill",
|
.string()
|
||||||
"append_pattern",
|
.describe(
|
||||||
"remove_pattern",
|
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
|
||||||
"write_reference",
|
),
|
||||||
"remove_reference",
|
pattern: tool.schema.string().optional().describe("Pattern text used by append_pattern."),
|
||||||
"write_script",
|
target_id: tool.schema
|
||||||
"remove_script",
|
.string()
|
||||||
])
|
.optional()
|
||||||
.describe("Skill maintenance operation."),
|
.describe("Stable learned pattern id used by remove_pattern."),
|
||||||
reason: tool.schema
|
file_path: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.optional()
|
||||||
"Why this skill maintenance action is justified for future reuse.",
|
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
|
||||||
),
|
content: tool.schema
|
||||||
skill_path: tool.schema
|
.string()
|
||||||
.string()
|
.optional()
|
||||||
.describe(
|
.describe("Content used by write_skill, write_reference, or write_script."),
|
||||||
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
|
},
|
||||||
),
|
async execute(args, context) {
|
||||||
pattern: tool.schema
|
const response = await fetch(
|
||||||
.string()
|
`${internalBaseUrl}/internal/tools/skill-manager`,
|
||||||
.optional()
|
{
|
||||||
.describe("Pattern text used by append_pattern."),
|
method: "POST",
|
||||||
target_id: tool.schema
|
headers: {
|
||||||
.string()
|
"Content-Type": "application/json",
|
||||||
.optional()
|
"x-agent-internal-token": internalToken,
|
||||||
.describe("Stable learned pattern id used by remove_pattern."),
|
},
|
||||||
file_path: tool.schema
|
body: JSON.stringify({
|
||||||
.string()
|
...args,
|
||||||
.optional()
|
session_id: context.sessionID,
|
||||||
.describe(
|
}),
|
||||||
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
|
},
|
||||||
),
|
);
|
||||||
content: tool.schema
|
const text = await response.text();
|
||||||
.string()
|
if (!response.ok) {
|
||||||
.optional()
|
throw new Error(text);
|
||||||
.describe(
|
}
|
||||||
"Content used by write_skill, write_reference, or write_script.",
|
return text;
|
||||||
),
|
},
|
||||||
},
|
});
|
||||||
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();
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
const internalBaseUrl =
|
const internalBaseUrl =
|
||||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||||
|
const importDirectory =
|
||||||
|
process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports";
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_ref(res-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。",
|
`导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受该目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`,
|
||||||
args: {
|
args: {
|
||||||
reason: tool.schema
|
reason: tool.schema
|
||||||
.string()
|
.string()
|
||||||
@@ -16,7 +18,7 @@ export default tool({
|
|||||||
file_path: tool.schema
|
file_path: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"本地 JSON 文件的绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。",
|
`位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 data;data 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default tool({
|
|||||||
command: tool.schema
|
command: tool.schema
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
"tjwater-cli 子命令,不含二进制路径。示例:'project list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'",
|
"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
|
timeout: tool.schema
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export default tool({
|
|||||||
.describe("Why web search is required for the current user request."),
|
.describe("Why web search is required for the current user request."),
|
||||||
query: tool.schema.string().describe("Search query text."),
|
query: tool.schema.string().describe("Search query text."),
|
||||||
freshness: tool.schema
|
freshness: tool.schema
|
||||||
.enum(["noLimit", "oneDay", "oneWeek", "oneMonth", "oneYear"])
|
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Optional freshness filter. Defaults to noLimit."),
|
.describe("Optional freshness filter. Defaults to no_limit."),
|
||||||
summary: tool.schema
|
summary: tool.schema
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
+27
-17
@@ -1,8 +1,6 @@
|
|||||||
FROM oven/bun:canary-slim AS bun-bin
|
FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
|
||||||
|
|
||||||
FROM smanx/opencode:latest AS base
|
|
||||||
USER root
|
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_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
@@ -11,14 +9,22 @@ ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
|
|||||||
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
|
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
|
||||||
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
|
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 && \
|
COPY vendor/bun-linux-x64.zip /tmp/bun.zip
|
||||||
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 && \
|
|
||||||
|
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 \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
jq \
|
jq \
|
||||||
unzip \
|
unzip \
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv && \
|
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 && \
|
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
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 && \
|
pytest && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -54,32 +58,38 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||||
|
COPY package.json bun.lock ./
|
||||||
COPY tsconfig.json opencode.json README.md .gitignore ./
|
COPY tsconfig.json opencode.json README.md .gitignore ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY cli ./cli
|
COPY cli ./cli
|
||||||
COPY .opencode ./.opencode
|
COPY .opencode ./.opencode
|
||||||
RUN bun run check
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8787
|
ENV PORT=8787
|
||||||
|
ENV OPENCODE_HOST=127.0.0.1
|
||||||
|
ENV OPENCODE_HOSTNAME=127.0.0.1
|
||||||
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
|
ENV TJWATER_CLI_PATH=./cli/tjwater-cli
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
COPY tsconfig.json opencode.json .gitignore ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY .opencode ./.opencode
|
|
||||||
COPY cli ./cli
|
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
|
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/health" >/dev/null || exit 1
|
||||||
CMD ["bun", "src/server.ts"]
|
CMD ["bun", "src/server.ts"]
|
||||||
|
|||||||
@@ -1,323 +1,111 @@
|
|||||||
# 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
|
```text
|
||||||
TJWaterAgent/
|
src/ 服务端 TypeScript 源码
|
||||||
package.json
|
src/routes/ HTTP 路由
|
||||||
tsconfig.json
|
src/chat/ 聊天流和 SSE 事件适配
|
||||||
src/
|
src/runtime/ OpenCode 运行时管理
|
||||||
opencode.json
|
src/session/ 会话映射和运行上下文
|
||||||
.opencode/
|
src/mcp/ MCP 服务与工具桥接
|
||||||
agents/
|
cli/ Agent 使用的 TypeScript 后端 API CLI
|
||||||
tools/
|
.opencode/agents/ Agent prompt 和模型行为配置
|
||||||
skills/
|
.opencode/tools/ OpenCode 自定义工具
|
||||||
package.json
|
.opencode/skills/ 可复用分析工作流
|
||||||
tsconfig.json
|
node-tests/ Node CLI 测试
|
||||||
|
data/ 本地运行时数据,禁止提交
|
||||||
|
logs/ 本地日志,禁止提交
|
||||||
```
|
```
|
||||||
|
|
||||||
| 位置 | 主要作用 | 典型内容 |
|
## 本地开发
|
||||||
| --- | --- | --- |
|
|
||||||
| `TJWaterAgent/` | 服务宿主、API 层和编排层 | Express 服务、SSE 接口、会话管理、鉴权上下文、后端 API 代理、opencode SDK 启动逻辑 |
|
|
||||||
| `TJWaterAgent/.opencode/` | opencode 项目资产目录 | agent prompt、自定义 tools、skills 树、plugins 相关依赖 |
|
|
||||||
|
|
||||||
## `TJWaterAgent/` 根目录的职责
|
项目使用 Bun:
|
||||||
|
|
||||||
根目录是 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/` 根目录操作。
|
|
||||||
|
|
||||||
### 本地开发
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd TJWaterAgent
|
|
||||||
bun install
|
bun install
|
||||||
bun run dev
|
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/**
|
```bash
|
||||||
opencode.json
|
bun run check
|
||||||
.local.env
|
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
|
```bash
|
||||||
OPENCODE_MODE=embedded
|
OPENCODE_MODE=embedded
|
||||||
DEEPSEEK_API_KEY=sk-xxx
|
|
||||||
TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
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 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具,其他请求仍需确认;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
|
||||||
|
|
||||||
|
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env`、`data/`、`logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。
|
||||||
|
|
||||||
|
`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出导入目录;单文件默认上限为 64 MiB,成功导入后源包装文件会被删除。
|
||||||
|
|
||||||
|
## 配置与安全
|
||||||
|
|
||||||
|
不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
Gitea 包工作流位于 `.gitea/workflows/package.yml`。发布前至少运行:
|
||||||
|
|
||||||
```bash
|
```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 check
|
||||||
bun run start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用一条命令完成构建并启动:
|
如修改 CLI 或工具调用逻辑,同时运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd TJWaterAgent
|
bun run test:cli
|
||||||
bun install
|
|
||||||
bun run start:prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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。
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "tjwater-agent",
|
"name": "tjwater-agent",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.16.2",
|
"@opencode-ai/sdk": "1.18.13",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "7.3.4",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
@@ -23,7 +24,9 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
"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=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CliError } from "../core/errors.js";
|
|||||||
import { emitApi, requestJson } from "../core/http.js";
|
import { emitApi, requestJson } from "../core/http.js";
|
||||||
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
|
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
|
||||||
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
||||||
import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js";
|
import { resolveScheme } from "../core/runtime.js";
|
||||||
import { parseTime } from "../core/time.js";
|
import { parseTime } from "../core/time.js";
|
||||||
import { success } from "../core/output.js";
|
import { success } from "../core/output.js";
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.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 [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
|
||||||
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
|
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
|
||||||
return emitApi(ctx, "爆管分析执行成功", {
|
return emitApi(ctx, "爆管分析执行成功", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
path: "/burst_analysis/",
|
path: "/burst-analyses",
|
||||||
params: {
|
params: {
|
||||||
network: requireNetwork(ctx),
|
|
||||||
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
burst_ID: ids,
|
burst_id: ids,
|
||||||
burst_size: sizes,
|
burst_size: sizes,
|
||||||
modify_total_duration: requiredNumber(values, "duration"),
|
modify_total_duration: requiredNumber(values, "duration"),
|
||||||
scheme_name: schemeName,
|
scheme_name: schemeName,
|
||||||
},
|
},
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
|
}, [`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");
|
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);
|
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, "阀门关闭分析执行成功", {
|
return emitApi(ctx, "阀门关闭分析执行成功", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
path: "/valve_close_analysis/",
|
path: "/valve-isolation-analyses",
|
||||||
params: {
|
params: {
|
||||||
network: requireNetwork(ctx),
|
|
||||||
start_time: parseTime(startTime, "--start-time"),
|
start_time: parseTime(startTime, "--start-time"),
|
||||||
valves,
|
valves,
|
||||||
duration: optionalNumber(values, "duration") || 900,
|
duration: optionalNumber(values, "duration") || 900,
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
},
|
},
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const elements = optionalStringArray(values, "element");
|
const elements = optionalStringArray(values, "element");
|
||||||
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
|
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
|
||||||
return emitApi(ctx, "阀门隔离分析执行成功", {
|
return emitApi(ctx, "阀门隔离分析执行成功", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
path: "/valve_isolation_analysis/",
|
path: "/valve-isolation-analyses",
|
||||||
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,36 +58,34 @@ function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
|
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
|
||||||
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
|
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
|
||||||
return emitApi(ctx, "冲洗分析执行成功", {
|
return emitApi(ctx, "冲洗分析执行成功", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
path: "/flushing_analysis/",
|
path: "/flushing-analyses",
|
||||||
params: {
|
params: {
|
||||||
network: requireNetwork(ctx),
|
|
||||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
valves,
|
valves,
|
||||||
valves_k: openings,
|
valves_k: openings,
|
||||||
drainage_node_ID: requiredString(values, "drainage-node"),
|
drainage_node_id: requiredString(values, "drainage-node"),
|
||||||
flush_flow: requiredNumber(values, "flow"),
|
flush_flow: requiredNumber(values, "flow"),
|
||||||
duration: optionalNumber(values, "duration") || 900,
|
duration: optionalNumber(values, "duration") || 900,
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
},
|
},
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv, { duration: "integer" });
|
const { values } = parseOptions(argv, { duration: "integer" });
|
||||||
return emitApi(ctx, "水龄分析执行成功", {
|
return emitApi(ctx, "水龄分析执行成功", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
path: "/age_analysis/",
|
path: "/water-age-analyses",
|
||||||
params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
|
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
|
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
network: requireNetwork(ctx),
|
|
||||||
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
source: requiredString(values, "source-node"),
|
source: requiredString(values, "source-node"),
|
||||||
concentration: requiredNumber(values, "concentration"),
|
concentration: requiredNumber(values, "concentration"),
|
||||||
@@ -98,55 +94,50 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
|
|||||||
};
|
};
|
||||||
const pattern = optionalString(values, "pattern");
|
const pattern = optionalString(values, "pattern");
|
||||||
if (pattern) params.pattern = 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> {
|
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
|
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
|
||||||
return emitApi(ctx, "传感器选址执行成功", {
|
return emitApi(ctx, "传感器选址执行成功", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/pressure_sensor_placement_kmeans/",
|
path: "/pressure-sensor-placement-kmeans",
|
||||||
body: {
|
body: {
|
||||||
name: requireNetwork(ctx),
|
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
sensor_number: requiredNumber(values, "count"),
|
sensor_number: requiredNumber(values, "count"),
|
||||||
min_diameter: optionalNumber(values, "min-diameter") || 0,
|
min_diameter: optionalNumber(values, "min-diameter") || 0,
|
||||||
username: requireUsername(ctx),
|
|
||||||
},
|
},
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
requireUsernameCtx: 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);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, summary, {
|
return emitApi(ctx, summary, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path,
|
path,
|
||||||
body: {
|
body: {
|
||||||
[networkKey]: requireNetwork(ctx),
|
|
||||||
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
|
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
|
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
},
|
},
|
||||||
requireNetworkCtx: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
|
||||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
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);
|
const { positionals } = parseOptions(argv);
|
||||||
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
|
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> {
|
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 { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
network: requireNetwork(ctx),
|
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
data_source: optionalString(values, "data-source") || "monitoring",
|
data_source: optionalString(values, "data-source") || "monitoring",
|
||||||
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
|
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");
|
const flowFile = optionalString(values, "flow-file");
|
||||||
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
|
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
|
||||||
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
|
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> {
|
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
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> {
|
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
|
||||||
const network = requireNetwork(ctx);
|
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
|
||||||
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true });
|
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
|
||||||
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true });
|
|
||||||
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
|
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,16 +174,16 @@ export const analysisHandlers: HandlerMap = {
|
|||||||
"analysis age": analysisAge,
|
"analysis age": analysisAge,
|
||||||
"analysis contaminant": analysisContaminant,
|
"analysis contaminant": analysisContaminant,
|
||||||
"analysis sensor-placement kmeans": sensorKmeans,
|
"analysis sensor-placement kmeans": sensorKmeans,
|
||||||
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"),
|
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
|
||||||
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"),
|
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
|
||||||
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"),
|
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
|
||||||
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"),
|
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
|
||||||
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"),
|
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
|
||||||
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"),
|
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
|
||||||
"analysis burst-location locate": burstLocation,
|
"analysis burst-location locate": burstLocation,
|
||||||
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"),
|
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
|
||||||
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"),
|
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
|
||||||
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"),
|
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
|
||||||
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"),
|
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
|
||||||
"analysis risk network": riskNetwork,
|
"analysis risk network": riskNetwork,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CliError } from "../core/errors.js";
|
import { CliError } from "../core/errors.js";
|
||||||
import { emitApi } from "../core/http.js";
|
import { emitApi } from "../core/http.js";
|
||||||
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
|
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
|
||||||
import { requireNetwork } from "../core/runtime.js";
|
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
|
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
|
||||||
@@ -10,22 +9,22 @@ function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean):
|
|||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
|
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
|
||||||
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
|
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
|
||||||
"time:true": "/gettimeschema",
|
"time:true": "/network-schemas/time",
|
||||||
"time:false": "/gettimeproperties/",
|
"time:false": "/network-options/time",
|
||||||
"energy:true": "/getenergyschema/",
|
"energy:true": "/network-schemas/energy",
|
||||||
"energy:false": "/getenergyproperties/",
|
"energy:false": "/network-options/energy",
|
||||||
"pump-energy:true": "/getpumpenergyschema/",
|
"pump-energy:true": "/network-schemas/pump-energy",
|
||||||
"pump-energy:false": "/getpumpenergyproperties//",
|
"pump-energy:false": "/network-options/pump-energy",
|
||||||
"network:true": "/getoptionschema/",
|
"network:true": "/network-schemas/option",
|
||||||
"network:false": "/getoptionproperties/",
|
"network:false": "/network-options",
|
||||||
};
|
};
|
||||||
const params: Record<string, unknown> = { network: requireNetwork(ctx) };
|
const params: Record<string, unknown> = {};
|
||||||
const pump = optionalString(values, "pump");
|
const pump = optionalString(values, "pump");
|
||||||
if (kind === "pump-energy") {
|
if (kind === "pump-energy") {
|
||||||
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
|
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
|
||||||
if (pump) params.pump = pump;
|
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 = {
|
export const componentHandlers: HandlerMap = {
|
||||||
|
|||||||
+31
-22
@@ -2,10 +2,14 @@ import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
|
|||||||
import { CliError } from "../core/errors.js";
|
import { CliError } from "../core/errors.js";
|
||||||
import { emitApi } from "../core/http.js";
|
import { emitApi } from "../core/http.js";
|
||||||
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.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 { parseTime } from "../core/time.js";
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function backendElementType(type: ElementType): "link" | "node" {
|
||||||
|
return type === "pipe" ? "link" : "node";
|
||||||
|
}
|
||||||
|
|
||||||
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, summary, {
|
return emitApi(ctx, summary, {
|
||||||
@@ -18,10 +22,11 @@ function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: st
|
|||||||
|
|
||||||
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
|
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||||
return emitApi(ctx, "读取实时模拟数据成功", {
|
return emitApi(ctx, "读取实时模拟数据成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/realtime/query/by-id-time",
|
path: "/timeseries/realtime/simulation-results",
|
||||||
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
|
params: { id: requiredString(values, "id"), type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -31,8 +36,8 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
|
|||||||
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||||
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/realtime/query/by-time-property",
|
path: "/timeseries/realtime/records",
|
||||||
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
params: { type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,7 +46,7 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, "读取方案管道数据成功", {
|
return emitApi(ctx, "读取方案管道数据成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/scheme/links",
|
path: "/timeseries/schemes/links",
|
||||||
params: {
|
params: {
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||||
@@ -56,7 +61,7 @@ function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
return emitApi(ctx, "读取方案节点字段成功", {
|
return emitApi(ctx, "读取方案节点字段成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/scheme/nodes/${requiredString(values, "node")}/field`,
|
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
|
||||||
params: {
|
params: {
|
||||||
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
|
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
|
||||||
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
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_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||||
query_time: parseTime(requiredString(values, "time"), "--time"),
|
query_time: parseTime(requiredString(values, "time"), "--time"),
|
||||||
type,
|
type: backendElementType(type),
|
||||||
};
|
};
|
||||||
if (query === "by-id-time") {
|
if (query === "by-id-time") {
|
||||||
params.id = requiredString(values, "id");
|
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");
|
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> {
|
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");
|
const field = optionalString(values, "field");
|
||||||
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--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> {
|
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.element_id = feature[0];
|
||||||
params.use_cleaned = Boolean(values["use-cleaned"]);
|
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> {
|
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
@@ -124,33 +134,32 @@ function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
|||||||
requiredString(values, "start-time");
|
requiredString(values, "start-time");
|
||||||
return emitApi(ctx, "读取管道健康预测成功", {
|
return emitApi(ctx, "读取管道健康预测成功", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/composite/pipeline-health-prediction",
|
path: "/pipeline-health-predictions",
|
||||||
params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
||||||
requireProject: true,
|
requireProject: true,
|
||||||
requireNetworkCtx: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
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> {
|
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
const { values } = parseOptions(argv);
|
||||||
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
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> {
|
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
const { values } = parseOptions(argv);
|
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 = {
|
export const dataHandlers: HandlerMap = {
|
||||||
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"),
|
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"),
|
||||||
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"),
|
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
|
||||||
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
|
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
|
||||||
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
||||||
"data timeseries scheme links": schemeLinks,
|
"data timeseries scheme links": schemeLinks,
|
||||||
@@ -161,7 +170,7 @@ export const dataHandlers: HandlerMap = {
|
|||||||
"data timeseries composite pipeline-health": pipelineHealth,
|
"data timeseries composite pipeline-health": pipelineHealth,
|
||||||
"data scada get": dataScadaGet,
|
"data scada get": dataScadaGet,
|
||||||
"data scada list": dataScadaList,
|
"data scada list": dataScadaList,
|
||||||
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/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 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 }),
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-16
@@ -1,27 +1,26 @@
|
|||||||
import { emitApi } from "../core/http.js";
|
import { emitApi } from "../core/http.js";
|
||||||
import { parseOptions, requiredString } from "../core/options.js";
|
import { parseOptions, requiredString } from "../core/options.js";
|
||||||
import { requireNetwork } from "../core/runtime.js";
|
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.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);
|
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> {
|
function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||||
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
return emitApi(ctx, summary, { method: "GET", path, requireProject: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const networkHandlers: HandlerMap = {
|
export const networkHandlers: HandlerMap = {
|
||||||
"network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"),
|
"network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
|
||||||
"network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"),
|
"network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
|
||||||
"network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"),
|
"network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"),
|
||||||
"network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"),
|
"network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
|
||||||
"network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"),
|
"network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"),
|
||||||
"network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"),
|
"network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
|
||||||
"network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"),
|
"network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"),
|
||||||
"network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"),
|
"network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
|
||||||
"network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"),
|
"network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"),
|
||||||
"network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"),
|
"network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
|
||||||
"network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"),
|
"network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { emitApi } from "../core/http.js";
|
import { emitApi } from "../core/http.js";
|
||||||
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
|
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
|
||||||
import { requireNetwork } from "../core/runtime.js";
|
|
||||||
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
|
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
|
||||||
import type { HandlerMap, RuntimeContext } from "../core/types.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 start = parseTime(requiredString(values, "start-time"), "--start-time");
|
||||||
const duration = requiredNumber(values, "duration");
|
const duration = requiredNumber(values, "duration");
|
||||||
const end = addMinutesPreservingOffset(start, duration);
|
const end = addMinutesPreservingOffset(start, duration);
|
||||||
const network = requireNetwork(ctx);
|
|
||||||
return emitApi(
|
return emitApi(
|
||||||
ctx,
|
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 links --start-time ${start} --end-time ${end}`,
|
||||||
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
|
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function readJsonFile(path: string, label: string): unknown {
|
|||||||
export function parseBurstFile(path: string): [string[], number[]] {
|
export function parseBurstFile(path: string): [string[], number[]] {
|
||||||
let raw = readJsonFile(path, "burst");
|
let raw = readJsonFile(path, "burst");
|
||||||
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
|
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)) {
|
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 ids = raw.burst_id.map(String);
|
||||||
const sizes = raw.burst_size.map(Number);
|
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];
|
return [ids, sizes];
|
||||||
}
|
}
|
||||||
if (Array.isArray(raw)) {
|
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)),
|
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[]] {
|
export function parseValveSettingFile(path: string): [string[], number[]] {
|
||||||
@@ -66,4 +66,3 @@ export function assignDatasetKeys(target: Record<string, unknown>, path: string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CliError, errorMessage } from "./errors.js";
|
import { CliError, errorMessage } from "./errors.js";
|
||||||
import { requireNetwork, requireUsername } from "./runtime.js";
|
|
||||||
import { success } from "./output.js";
|
import { success } from "./output.js";
|
||||||
import type { RequestOptions, RuntimeContext } from "./types.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"]);
|
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;
|
out["X-Project-Id"] = ctx.auth.projectId;
|
||||||
} else if (ctx.auth.projectId) 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;
|
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]> {
|
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;
|
const { method, path, params, body, requireAuth = true, requireProject = false } = request;
|
||||||
if (requireNetworkCtx) requireNetwork(ctx);
|
|
||||||
if (requireUsernameCtx) requireUsername(ctx);
|
|
||||||
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
||||||
appendParams(url, params);
|
appendParams(url, params);
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
@@ -93,4 +89,3 @@ export async function emitApi(ctx: RuntimeContext, summary: string, request: Req
|
|||||||
const [data, durationMs] = await requestJson(ctx, request);
|
const [data, durationMs] = await requestJson(ctx, request);
|
||||||
success(summary, data, ctx, durationMs, nextCommands);
|
success(summary, data, ctx, durationMs, nextCommands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
|||||||
server: process.env.TJWATER_SERVER,
|
server: process.env.TJWATER_SERVER,
|
||||||
access_token: process.env.TJWATER_ACCESS_TOKEN,
|
access_token: process.env.TJWATER_ACCESS_TOKEN,
|
||||||
project_id: process.env.TJWATER_PROJECT_ID,
|
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) : {},
|
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
|
||||||
};
|
};
|
||||||
const headers = (raw.headers ?? {}) as unknown;
|
const headers = (raw.headers ?? {}) as unknown;
|
||||||
@@ -44,9 +41,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
|
|||||||
server: pick(raw, "server", "base_url"),
|
server: pick(raw, "server", "base_url"),
|
||||||
accessToken: pick(raw, "access_token", "token", "accessToken"),
|
accessToken: pick(raw, "access_token", "token", "accessToken"),
|
||||||
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
|
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)])),
|
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 {
|
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
|
||||||
const scheme = explicit || ctx.scheme;
|
const scheme = explicit || ctx.scheme;
|
||||||
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
||||||
return scheme ?? null;
|
return scheme ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ export interface AuthContext {
|
|||||||
server: string | null;
|
server: string | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
userId: string | null;
|
|
||||||
username: string | null;
|
|
||||||
network: string | null;
|
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +46,6 @@ export interface RequestOptions {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
requireAuth?: boolean;
|
requireAuth?: boolean;
|
||||||
requireProject?: boolean;
|
requireProject?: boolean;
|
||||||
requireNetworkCtx?: boolean;
|
|
||||||
requireUsernameCtx?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
|
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
|
||||||
@@ -86,4 +81,3 @@ export type HelpPayload =
|
|||||||
menu_level?: number;
|
menu_level?: number;
|
||||||
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"contract_version": "1.0.0",
|
||||||
|
"contracts": {
|
||||||
|
"agent": {
|
||||||
|
"file": "agent-v1.openapi.json",
|
||||||
|
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,59 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
|
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
|
||||||
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
|
|
||||||
|
const visibleCommandPaths = [
|
||||||
|
"analysis age",
|
||||||
|
"analysis burst",
|
||||||
|
"analysis burst-detection detect",
|
||||||
|
"analysis burst-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 = {}) {
|
function runCommand(command, args, input, options = {}) {
|
||||||
return new Promise((resolveRun, reject) => {
|
return new Promise((resolveRun, reject) => {
|
||||||
@@ -35,10 +87,6 @@ function runCli(args, input) {
|
|||||||
return runCommand(cliPath, args, input);
|
return runCommand(cliPath, args, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPythonCli(args, input) {
|
|
||||||
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonResult(result) {
|
function parseJsonResult(result) {
|
||||||
return JSON.parse(result.stdout);
|
return JSON.parse(result.stdout);
|
||||||
}
|
}
|
||||||
@@ -83,7 +131,6 @@ function normalizeSeenRequest(request) {
|
|||||||
headers: {
|
headers: {
|
||||||
authorization: request.headers.authorization,
|
authorization: request.headers.authorization,
|
||||||
"x-project-id": request.headers["x-project-id"],
|
"x-project-id": request.headers["x-project-id"],
|
||||||
"x-user-id": request.headers["x-user-id"],
|
|
||||||
},
|
},
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
@@ -119,74 +166,31 @@ test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
|
|||||||
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
|
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("matches Python CLI help discovery and hidden command behavior", async () => {
|
test("discovers every visible command and keeps internal commands hidden", async () => {
|
||||||
for (const args of [["help"], ["help", "analysis"]]) {
|
const rootResult = await runCli(["help"]);
|
||||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
assert.equal(rootResult.exitCode, 0, rootResult.stderr);
|
||||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
|
assert.deepEqual(
|
||||||
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
|
parseJsonResult(rootResult).commands.map(({ command }) => command),
|
||||||
|
["analysis", "component", "data", "network", "simulation"],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const command of visibleCommandPaths) {
|
||||||
|
const result = await runCli(["help", ...command.split(" ")]);
|
||||||
|
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
|
||||||
|
const payload = parseJsonResult(result);
|
||||||
|
assert.equal(payload.ok, true, command);
|
||||||
|
assert.equal(payload.command, command, command);
|
||||||
|
assert.equal(payload.schema_version, "tjwater-cli/v1", command);
|
||||||
|
assert.ok(payload.usage, `${command}: missing usage`);
|
||||||
|
assert.ok(payload.examples.length > 0, `${command}: missing examples`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [nodeLeaf, pythonLeaf] = await Promise.all([
|
for (const command of hiddenCommandPaths) {
|
||||||
runCli(["help", "simulation", "run"]),
|
const result = await runCli(["help", ...command.split(" ")]);
|
||||||
runPythonCli(["help", "simulation", "run"]),
|
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
|
||||||
]);
|
const payload = parseJsonResult(result);
|
||||||
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
|
assert.equal(payload.ok, false, command);
|
||||||
const nodePayload = parseJsonResult(nodeLeaf);
|
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
|
||||||
const pythonPayload = parseJsonResult(pythonLeaf);
|
|
||||||
assert.equal(nodePayload.ok, pythonPayload.ok);
|
|
||||||
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
|
|
||||||
assert.equal(nodePayload.command, pythonPayload.command);
|
|
||||||
assert.equal(nodePayload.summary, pythonPayload.summary);
|
|
||||||
assert.equal(nodePayload.usage, pythonPayload.usage);
|
|
||||||
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
|
|
||||||
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
|
|
||||||
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
|
|
||||||
|
|
||||||
const [nodeHidden, pythonHidden] = await Promise.all([
|
|
||||||
runCli(["help", "analysis", "risk"]),
|
|
||||||
runPythonCli(["help", "analysis", "risk"]),
|
|
||||||
]);
|
|
||||||
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
|
|
||||||
const nodeError = parseJsonResult(nodeHidden);
|
|
||||||
const pythonError = parseJsonResult(pythonHidden);
|
|
||||||
delete nodeError.metadata.generated_at;
|
|
||||||
delete pythonError.metadata.generated_at;
|
|
||||||
assert.deepEqual(nodeError, pythonError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches Python CLI leaf help for every visible command", async () => {
|
|
||||||
const listResult = await runCommand(
|
|
||||||
"python",
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
{ cwd: pythonCliCwd },
|
|
||||||
);
|
|
||||||
assert.equal(listResult.exitCode, 0, listResult.stderr);
|
|
||||||
const commands = JSON.parse(listResult.stdout);
|
|
||||||
|
|
||||||
for (const command of commands) {
|
|
||||||
const args = ["help", ...command.split(" ")];
|
|
||||||
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
|
||||||
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
|
|
||||||
|
|
||||||
const nodePayload = parseJsonResult(nodeResult);
|
|
||||||
const pythonPayload = parseJsonResult(pythonResult);
|
|
||||||
const comparable = (payload) => ({
|
|
||||||
command: payload.command,
|
|
||||||
summary: payload.summary,
|
|
||||||
usage: payload.usage,
|
|
||||||
examples: payload.examples,
|
|
||||||
next_commands: payload.next_commands,
|
|
||||||
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
|
|
||||||
name,
|
|
||||||
required,
|
|
||||||
repeated,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,18 +210,17 @@ test("sends auth headers and simulation body through the backend API contract",
|
|||||||
{
|
{
|
||||||
server: server.url,
|
server: server.url,
|
||||||
access_token: "token-1",
|
access_token: "token-1",
|
||||||
network: "tjwater",
|
project_id: "project-1",
|
||||||
headers: { "x-extra": "extra" },
|
headers: { "x-extra": "extra" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.exitCode, 0, result.stderr);
|
assert.equal(result.exitCode, 0, result.stderr);
|
||||||
assert.equal(server.seen[0].method, "POST");
|
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.authorization, "Bearer token-1");
|
||||||
assert.equal(server.seen[0].headers["x-extra"], "extra");
|
assert.equal(server.seen[0].headers["x-extra"], "extra");
|
||||||
assert.deepEqual(server.seen[0].body, {
|
assert.deepEqual(server.seen[0].body, {
|
||||||
name: "tjwater",
|
|
||||||
start_time: "2025-01-02T03:00:00+08:00",
|
start_time: "2025-01-02T03:00:00+08:00",
|
||||||
duration: 60,
|
duration: 60,
|
||||||
});
|
});
|
||||||
@@ -255,7 +258,7 @@ test("uses project scoped headers for realtime data commands", async () => {
|
|||||||
assert.equal(server.seen[0].method, "GET");
|
assert.equal(server.seen[0].method, "GET");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
server.seen[0].url,
|
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.authorization, "Bearer token-2");
|
||||||
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
|
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
|
||||||
@@ -264,7 +267,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-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
|
||||||
try {
|
try {
|
||||||
const burstFile = join(tempDir, "burst.json");
|
const burstFile = join(tempDir, "burst.json");
|
||||||
@@ -280,7 +303,6 @@ test("matches Python CLI backend request shape for every command and key variant
|
|||||||
access_token: "token",
|
access_token: "token",
|
||||||
network: "tjwater",
|
network: "tjwater",
|
||||||
project_id: "project-1",
|
project_id: "project-1",
|
||||||
user_id: "user-1",
|
|
||||||
username: "alice",
|
username: "alice",
|
||||||
headers: { "x-extra": "extra" },
|
headers: { "x-extra": "extra" },
|
||||||
};
|
};
|
||||||
@@ -342,12 +364,16 @@ test("matches Python CLI backend request shape for every command and key variant
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const [name, args] of cases) {
|
for (const [name, args] of cases) {
|
||||||
const [nodeRun, pythonRun] = await Promise.all([
|
const run = await runAgainstServer(name, runCli, args, auth);
|
||||||
runAgainstServer(`${name} node`, runCli, args, auth),
|
assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`);
|
||||||
runAgainstServer(`${name} python`, runPythonCli, args, auth),
|
assert.equal(run.payload.ok, true, name);
|
||||||
]);
|
assert.equal(run.payload.schema_version, "tjwater-cli/v1", name);
|
||||||
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
|
assert.ok(run.requests.length > 0, `${name}: no backend request`);
|
||||||
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
|
for (const request of run.requests) {
|
||||||
|
assert.match(request.path, /^\/api\/v1\//, name);
|
||||||
|
assert.equal(request.headers.authorization, "Bearer token", name);
|
||||||
|
assert.equal(request.headers["x-project-id"], "project-1", name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await rm(tempDir, { force: true, recursive: true });
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
|||||||
+46
-6
@@ -7,16 +7,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model": "deepseek/deepseek-v4-pro",
|
"model": "deepseek/deepseek-v4-flash",
|
||||||
"server": {
|
"server": {
|
||||||
"hostname": "127.0.0.1",
|
"hostname": "127.0.0.1",
|
||||||
"port": 4096
|
"port": 4096
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "allow",
|
"*": "ask",
|
||||||
"external_directory": "ask",
|
"external_directory": "deny",
|
||||||
"bash": {
|
"read": {
|
||||||
"*": "allow",
|
"*": "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 *": "ask",
|
||||||
"rmdir *": "ask",
|
"rmdir *": "ask",
|
||||||
"mv *": "ask",
|
"mv *": "ask",
|
||||||
@@ -24,9 +50,23 @@
|
|||||||
"chown *": "ask",
|
"chown *": "ask",
|
||||||
"sudo *": "ask",
|
"sudo *": "ask",
|
||||||
"curl *": "ask",
|
"curl *": "ask",
|
||||||
"wget *": "ask"
|
"wget *": "ask",
|
||||||
|
"*.env*": "deny",
|
||||||
|
"*data/*": "deny",
|
||||||
|
"* data": "deny",
|
||||||
|
"*/data": "deny",
|
||||||
|
"*logs/*": "deny",
|
||||||
|
"* logs": "deny",
|
||||||
|
"*/logs": "deny"
|
||||||
},
|
},
|
||||||
"edit": "ask"
|
"question": "allow",
|
||||||
|
"task": "deny",
|
||||||
|
"todo": "allow",
|
||||||
|
"todoread": "allow",
|
||||||
|
"todowrite": "allow"
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"continue_loop_on_deny": true
|
||||||
},
|
},
|
||||||
"default_agent": "instruction"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -8,16 +8,22 @@
|
|||||||
"install:opencode": "bun install --cwd .opencode",
|
"install:opencode": "bun install --cwd .opencode",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
"typecheck:opencode": "bun run --cwd .opencode typecheck",
|
||||||
|
"test": "bun test tests",
|
||||||
"test:cli": "node --test node-tests/cli/*.node.mjs",
|
"test:cli": "node --test node-tests/cli/*.node.mjs",
|
||||||
|
"test:ci": "bun run contract:check && /usr/bin/node --test node-tests/cli/*.node.mjs && bun test tests",
|
||||||
"dev": "bun --watch src/server.ts",
|
"dev": "bun --watch src/server.ts",
|
||||||
"build": "bun run check",
|
"build": "bun run check",
|
||||||
"check": "bun run typecheck && bun run typecheck:opencode",
|
"check": "bun run typecheck && bun run typecheck:opencode",
|
||||||
|
"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",
|
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
|
||||||
"start": "bun src/server.ts",
|
"start": "bun src/server.ts",
|
||||||
"start:prod": "bun run check && bun src/server.ts"
|
"start:prod": "bun run check && bun src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.16.2",
|
"@opencode-ai/sdk": "1.18.13",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -26,6 +32,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "7.3.4",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
}
|
||||||
@@ -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
@@ -3,6 +3,39 @@ import type { NextFunction, Request, Response } from "express";
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.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) => {
|
export const extractBearerToken = (authorization?: string) => {
|
||||||
const value = authorization?.trim();
|
const value = authorization?.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -11,8 +44,41 @@ export const extractBearerToken = (authorization?: string) => {
|
|||||||
return value.replace(/^Bearer\s+/i, "").trim();
|
return value.replace(/^Bearer\s+/i, "").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agent API 复用 TJWater 后端的登录态:每个请求都向 /auth/me 校验 Bearer token,
|
const normalizeAgentAuthContext = (
|
||||||
// 成功后才允许进入会话路由,避免 Agent 服务维护第二套用户体系。
|
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 (
|
export const requireAgentAuth = async (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -24,37 +90,70 @@ export const requireAgentAuth = async (
|
|||||||
return;
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
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",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
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();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = await response.text();
|
const status =
|
||||||
res.status(response.status === 403 ? 403 : 401).json({
|
response.status === 400 ||
|
||||||
message: response.status === 403 ? "forbidden" : "unauthorized",
|
response.status === 401 ||
|
||||||
detail: detail || undefined,
|
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) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.warn({ err: error }, "agent auth validation failed");
|
logger.warn({ err: error }, "agent auth validation failed");
|
||||||
res.status(503).json({
|
res.status(503).json({ message: "authentication service unavailable" });
|
||||||
message: "authentication service unavailable",
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAgentAuthContext = (req: Request) => {
|
||||||
|
if (!req.agentAuth) {
|
||||||
|
throw new Error("agent auth context is missing");
|
||||||
|
}
|
||||||
|
return req.agentAuth;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
|
|
||||||
|
type BackendContext = Pick<
|
||||||
|
RuntimeSessionContext,
|
||||||
|
"accessToken" | "projectId" | "traceId"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const buildBackendContextHeaders = (
|
||||||
|
context: BackendContext,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Trace-Id": context.traceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (context.accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${context.accessToken}`;
|
||||||
|
}
|
||||||
|
if (context.projectId) {
|
||||||
|
headers["X-Project-Id"] = context.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import { type RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
|
|
||||||
|
export type CredentialRefreshReason =
|
||||||
|
| "access_token_expired"
|
||||||
|
| "access_token_rejected";
|
||||||
|
|
||||||
|
export type CredentialRefreshEvent =
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_required";
|
||||||
|
requestId: string;
|
||||||
|
reason: CredentialRefreshReason;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refreshed";
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_failed";
|
||||||
|
requestId: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRefresh = {
|
||||||
|
deadlineAt: number;
|
||||||
|
promise: Promise<RuntimeSessionContext>;
|
||||||
|
reason: CredentialRefreshReason;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
requestId: string;
|
||||||
|
resolve: (context: RuntimeSessionContext) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
|
||||||
|
|
||||||
|
export class CredentialRefreshError extends Error {
|
||||||
|
override readonly name = "CredentialRefreshError";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_EXPIRY_SKEW_MS = 30_000;
|
||||||
|
|
||||||
|
export const isRuntimeCredentialExpired = (
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
now = Date.now(),
|
||||||
|
) => {
|
||||||
|
if (!context.tokenExpiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expiresAt = Date.parse(context.tokenExpiresAt);
|
||||||
|
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CredentialRefreshCoordinator {
|
||||||
|
private readonly listeners = new Map<
|
||||||
|
string,
|
||||||
|
Set<CredentialRefreshListener>
|
||||||
|
>();
|
||||||
|
private readonly pending = new Map<string, PendingRefresh>();
|
||||||
|
|
||||||
|
constructor(private readonly timeoutMs = 30_000) {}
|
||||||
|
|
||||||
|
subscribe(sessionId: string, listener: CredentialRefreshListener) {
|
||||||
|
const listeners =
|
||||||
|
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
|
||||||
|
listeners.add(listener);
|
||||||
|
this.listeners.set(sessionId, listeners);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
this.listeners.delete(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(sessionId: string, reason: CredentialRefreshReason) {
|
||||||
|
const existing = this.pending.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
if (!this.listeners.get(sessionId)?.size) {
|
||||||
|
return Promise.reject(
|
||||||
|
new CredentialRefreshError(
|
||||||
|
"credential refresh channel is unavailable",
|
||||||
|
"unavailable",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `credential-${randomUUID()}`;
|
||||||
|
let resolvePromise!: (context: RuntimeSessionContext) => void;
|
||||||
|
let rejectPromise!: (error: Error) => void;
|
||||||
|
const promise = new Promise<RuntimeSessionContext>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
|
||||||
|
}, this.timeoutMs);
|
||||||
|
this.pending.set(sessionId, {
|
||||||
|
deadlineAt: Date.now() + this.timeoutMs,
|
||||||
|
promise,
|
||||||
|
reason,
|
||||||
|
reject: rejectPromise,
|
||||||
|
requestId,
|
||||||
|
resolve: resolvePromise,
|
||||||
|
timer,
|
||||||
|
});
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId,
|
||||||
|
reason,
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
) {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending || pending.requestId !== requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(sessionId);
|
||||||
|
pending.resolve(context);
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refreshed",
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
message: string,
|
||||||
|
code: CredentialRefreshError["code"] = "failed",
|
||||||
|
emitFailureEvent = true,
|
||||||
|
) {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending || pending.requestId !== requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pending.delete(sessionId);
|
||||||
|
pending.reject(new CredentialRefreshError(message, code));
|
||||||
|
if (emitFailureEvent) {
|
||||||
|
this.emit(sessionId, {
|
||||||
|
type: "credential_refresh_failed",
|
||||||
|
requestId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSession(sessionId: string, message = "credential refresh cancelled") {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.fail(
|
||||||
|
sessionId,
|
||||||
|
pending.requestId,
|
||||||
|
message,
|
||||||
|
"cancelled",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingRequestId(sessionId: string) {
|
||||||
|
return this.pending.get(sessionId)?.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingEvent(
|
||||||
|
sessionId: string,
|
||||||
|
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
|
||||||
|
const pending = this.pending.get(sessionId);
|
||||||
|
if (!pending) return null;
|
||||||
|
return {
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId: pending.requestId,
|
||||||
|
reason: pending.reason,
|
||||||
|
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(sessionId: string, event: CredentialRefreshEvent) {
|
||||||
|
for (const listener of this.listeners.get(sessionId) ?? []) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runWithCredentialRefresh = async <T extends { status: number }>(
|
||||||
|
coordinator: CredentialRefreshCoordinator,
|
||||||
|
context: RuntimeSessionContext,
|
||||||
|
execute: (context: RuntimeSessionContext) => Promise<T>,
|
||||||
|
) => {
|
||||||
|
let activeContext = context;
|
||||||
|
let refreshed = false;
|
||||||
|
if (isRuntimeCredentialExpired(activeContext)) {
|
||||||
|
activeContext = await coordinator.request(
|
||||||
|
activeContext.sessionId,
|
||||||
|
"access_token_expired",
|
||||||
|
);
|
||||||
|
refreshed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await execute(activeContext);
|
||||||
|
if (result.status !== 401 || refreshed) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
activeContext = await coordinator.request(
|
||||||
|
activeContext.sessionId,
|
||||||
|
"access_token_rejected",
|
||||||
|
);
|
||||||
|
result = await execute(activeContext);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -17,7 +17,9 @@ export type SessionBinding = {
|
|||||||
export type SessionContext = {
|
export type SessionContext = {
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
tokenExpiresAt?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,8 +37,10 @@ export class ChatSessionBridge {
|
|||||||
async resolve(context: {
|
async resolve(context: {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
|
tokenExpiresAt?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
binding: SessionBinding;
|
binding: SessionBinding;
|
||||||
@@ -69,9 +73,11 @@ export class ChatSessionBridge {
|
|||||||
allowLearningWrite: true,
|
allowLearningWrite: true,
|
||||||
clientSessionId: requestContext.clientSessionId,
|
clientSessionId: requestContext.clientSessionId,
|
||||||
learningMode: "interactive",
|
learningMode: "interactive",
|
||||||
|
network: requestContext.network,
|
||||||
projectId: requestContext.projectId,
|
projectId: requestContext.projectId,
|
||||||
projectKey: requestContext.projectKey,
|
projectKey: requestContext.projectKey,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
tokenExpiresAt: requestContext.tokenExpiresAt,
|
||||||
traceId: requestContext.traceId,
|
traceId: requestContext.traceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,8 +147,10 @@ export class ChatSessionBridge {
|
|||||||
private buildRequestContext(context: {
|
private buildRequestContext(context: {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
|
tokenExpiresAt?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}): ChatRequestContext {
|
}): ChatRequestContext {
|
||||||
const sessionId = context.sessionId?.trim();
|
const sessionId = context.sessionId?.trim();
|
||||||
@@ -150,8 +158,10 @@ export class ChatSessionBridge {
|
|||||||
clientSessionId: sessionId || this.createClientSessionId(),
|
clientSessionId: sessionId || this.createClientSessionId(),
|
||||||
accessToken: context.accessToken,
|
accessToken: context.accessToken,
|
||||||
actorKey: toActorKey(context.userId),
|
actorKey: toActorKey(context.userId),
|
||||||
|
network: context.network,
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
projectKey: toProjectKey(context.projectId),
|
projectKey: toProjectKey(context.projectId),
|
||||||
|
tokenExpiresAt: context.tokenExpiresAt,
|
||||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||||
userId: context.userId?.trim(),
|
userId: context.userId?.trim(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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;
|
||||||
|
stdout: string;
|
||||||
|
exceededStream?: OutputStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteCliCommandOptions = {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
cliPath: string;
|
||||||
|
maxOutputBytes: 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> => {
|
||||||
|
const maxOutputBytes = options.maxOutputBytes;
|
||||||
|
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
|
||||||
|
throw new Error("maxOutputBytes 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 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 capture = (stream: OutputStream, data: Buffer) => {
|
||||||
|
if (terminationReason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = stream === "stdout" ? stdoutChunks : stderrChunks;
|
||||||
|
const bytes = stream === "stdout" ? stdoutBytes : stderrBytes;
|
||||||
|
if (bytes + data.length > maxOutputBytes) {
|
||||||
|
exceededStream = stream;
|
||||||
|
terminate("output_limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(data);
|
||||||
|
if (stream === "stdout") {
|
||||||
|
stdoutBytes += data.length;
|
||||||
|
} else {
|
||||||
|
stderrBytes += data.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutTimer = setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
terminate("timeout");
|
||||||
|
}
|
||||||
|
}, timeoutSec * 1000);
|
||||||
|
|
||||||
|
child.stdout.on("data", (data: Buffer) => capture("stdout", data));
|
||||||
|
child.stderr.on("data", (data: Buffer) => capture("stderr", data));
|
||||||
|
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: "",
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (executionError) {
|
||||||
|
reject(executionError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (terminationReason === "output_limit") {
|
||||||
|
resolve({
|
||||||
|
outcome: "output_limit",
|
||||||
|
exceededStream,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
status: 502,
|
||||||
|
stderr: "",
|
||||||
|
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,
|
||||||
|
stdout,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdin.end(
|
||||||
|
JSON.stringify({
|
||||||
|
server: options.apiBaseUrl,
|
||||||
|
access_token: context.accessToken,
|
||||||
|
project_id: context.projectId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
+43
-23
@@ -1,6 +1,12 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultAgentModelOptionsJson,
|
||||||
|
getAgentModelIds,
|
||||||
|
parseAgentModelOptions,
|
||||||
|
} from "./chat/modelConfig.js";
|
||||||
|
|
||||||
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
|
||||||
dotenv.config({ path: ".local.env", override: false });
|
dotenv.config({ path: ".local.env", override: false });
|
||||||
|
|
||||||
@@ -33,10 +39,10 @@ const envSchema = z
|
|||||||
.default("./logs/llm-request-audit.log"),
|
.default("./logs/llm-request-audit.log"),
|
||||||
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
// 内部工具桥调用本服务时使用的鉴权 token;未显式配置时启动阶段会自动生成。
|
||||||
AGENT_INTERNAL_TOKEN: optionalString(),
|
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),
|
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||||
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。
|
// 当前仅支持 embedded;保留字段用于让旧 client 配置在启动时明确失败。
|
||||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
OPENCODE_MODE: z.literal("embedded").default("embedded"),
|
||||||
// embedded opencode server 的监听地址。
|
// embedded opencode server 的监听地址。
|
||||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||||
// embedded opencode server 的监听端口。
|
// embedded opencode server 的监听端口。
|
||||||
@@ -44,13 +50,11 @@ const envSchema = z
|
|||||||
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
||||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||||
// 默认使用的 opencode 模型标识。
|
// 默认使用的 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 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
|
||||||
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
||||||
// client 模式下,目标 opencode server 的基础地址。
|
|
||||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
|
||||||
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
|
|
||||||
OPENCODE_BASE_URL: z.string().url().optional(),
|
|
||||||
// tjwater-cli 可执行文件路径。
|
// tjwater-cli 可执行文件路径。
|
||||||
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
|
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
|
||||||
// TJWater 后端 API 的基础地址。
|
// TJWater 后端 API 的基础地址。
|
||||||
@@ -99,6 +103,14 @@ const envSchema = z
|
|||||||
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
|
||||||
// result_ref 持久化存储目录。
|
// result_ref 持久化存储目录。
|
||||||
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
|
||||||
|
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
|
||||||
|
RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"),
|
||||||
|
// 单个渲染包装 JSON 的最大导入字节数。
|
||||||
|
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(64 * 1024 * 1024),
|
||||||
// result_ref 保留时长(小时)。
|
// result_ref 保留时长(小时)。
|
||||||
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
|
||||||
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
// 定时清理过期 result_ref 的扫描周期(毫秒)。
|
||||||
@@ -109,26 +121,34 @@ const envSchema = z
|
|||||||
.default(3600000),
|
.default(3600000),
|
||||||
})
|
})
|
||||||
.superRefine((env, ctx) => {
|
.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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["OPENCODE_CLIENT_BASE_URL"],
|
path: ["OPENCODE_MODEL_OPTIONS"],
|
||||||
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
|
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>;
|
export type AppConfig = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
const normalizedEnv = {
|
export const config: AppConfig = envSchema.parse(process.env);
|
||||||
...process.env,
|
|
||||||
OPENCODE_MODE:
|
|
||||||
process.env.OPENCODE_MODE ??
|
|
||||||
(process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL
|
|
||||||
? "client"
|
|
||||||
: "embedded"),
|
|
||||||
OPENCODE_CLIENT_BASE_URL:
|
|
||||||
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config: AppConfig = envSchema.parse(normalizedEnv);
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { writeLearningAuditLog } from "../audit/learningAudit.js";
|
import { writeLearningAuditLog } from "../audit/learningAudit.js";
|
||||||
|
import { type SupportedModel } from "../chat/models.js";
|
||||||
import { type ChatRequestContext } from "../chat/sessionBridge.js";
|
import { type ChatRequestContext } from "../chat/sessionBridge.js";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
import { type SessionTurnRecord, SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||||
@@ -64,8 +65,6 @@ const reviewResultSchema = z.object({
|
|||||||
type GateResult = z.infer<typeof gateResultSchema>;
|
type GateResult = z.infer<typeof gateResultSchema>;
|
||||||
type ReviewResult = z.infer<typeof reviewResultSchema>;
|
type ReviewResult = z.infer<typeof reviewResultSchema>;
|
||||||
|
|
||||||
type SupportedModel = "deepseek/deepseek-v4-flash" | "deepseek/deepseek-v4-pro";
|
|
||||||
|
|
||||||
type TurnReviewInput = {
|
type TurnReviewInput = {
|
||||||
assistantMessage: string;
|
assistantMessage: string;
|
||||||
model?: SupportedModel;
|
model?: SupportedModel;
|
||||||
@@ -78,12 +77,12 @@ type TurnReviewInput = {
|
|||||||
export class LearningOrchestrator {
|
export class LearningOrchestrator {
|
||||||
private readonly activeReviews = new Set<string>();
|
private readonly activeReviews = new Set<string>();
|
||||||
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
||||||
private readonly skillStore = new SkillStore();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runtime: OpencodeRuntimeAdapter,
|
private readonly runtime: OpencodeRuntimeAdapter,
|
||||||
private readonly memoryStore: MemoryStore,
|
private readonly memoryStore: MemoryStore,
|
||||||
private readonly transcriptStore: SessionTranscriptStore,
|
private readonly transcriptStore: SessionTranscriptStore,
|
||||||
|
private readonly skillStore: SkillStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { type MemoryScope, MemoryStore } from "../memory/store.js";
|
||||||
|
import {
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
type RuntimeSessionContext,
|
||||||
|
} from "../runtime/sessionContext.js";
|
||||||
|
import { SkillStore } from "../skills/store.js";
|
||||||
|
|
||||||
|
export type MemoryManagerInput = {
|
||||||
|
action: "add" | "list" | "replace" | "remove";
|
||||||
|
content?: string;
|
||||||
|
scope: string;
|
||||||
|
target_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SkillManagerInput = {
|
||||||
|
action:
|
||||||
|
| "list"
|
||||||
|
| "write_skill"
|
||||||
|
| "remove_skill"
|
||||||
|
| "append_pattern"
|
||||||
|
| "remove_pattern"
|
||||||
|
| "write_reference"
|
||||||
|
| "remove_reference"
|
||||||
|
| "write_script"
|
||||||
|
| "remove_script";
|
||||||
|
content?: string;
|
||||||
|
file_path?: string;
|
||||||
|
pattern?: string;
|
||||||
|
skill_path: string;
|
||||||
|
target_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeMemoryManager = async (
|
||||||
|
memoryStore: MemoryStore,
|
||||||
|
sessionContext: RuntimeSessionContext,
|
||||||
|
input: MemoryManagerInput,
|
||||||
|
) => {
|
||||||
|
const scope: MemoryScope | null =
|
||||||
|
input.scope === "user"
|
||||||
|
? "user"
|
||||||
|
: input.scope === "workspace"
|
||||||
|
? "workspace"
|
||||||
|
: null;
|
||||||
|
if (!scope) {
|
||||||
|
return rejected(
|
||||||
|
"memory",
|
||||||
|
`unsupported scope: ${input.scope}; use exact keyword 'user' or 'workspace'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
|
||||||
|
return rejected("memory", "memory writes are disabled for this session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey =
|
||||||
|
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
|
||||||
|
if (input.action === "list") {
|
||||||
|
setRuntimeSessionContext({
|
||||||
|
...sessionContext,
|
||||||
|
memoryListReadScopes: {
|
||||||
|
...(sessionContext.memoryListReadScopes ?? {}),
|
||||||
|
[scope]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: "accepted",
|
||||||
|
detail: "memory listed",
|
||||||
|
items: await memoryStore.list(scope, scopeKey),
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === "add") {
|
||||||
|
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
|
||||||
|
return {
|
||||||
|
...rejected(
|
||||||
|
"memory",
|
||||||
|
`must list ${scope} memory and review existing entries before add`,
|
||||||
|
),
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||||
|
content: input.content ?? "",
|
||||||
|
sessionId: sessionContext.clientSessionId,
|
||||||
|
source: "tool",
|
||||||
|
traceId: sessionContext.traceId,
|
||||||
|
});
|
||||||
|
if (!result.entry) {
|
||||||
|
return rejected("memory", "content rejected by persistence policy");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: result.changed ? "accepted" : "deduped",
|
||||||
|
detail: result.detail,
|
||||||
|
entry: result.entry,
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
input.action === "replace"
|
||||||
|
? await memoryStore.replace(scope, scopeKey, input.target_id ?? "", {
|
||||||
|
content: input.content ?? "",
|
||||||
|
sessionId: sessionContext.clientSessionId,
|
||||||
|
source: "tool",
|
||||||
|
traceId: sessionContext.traceId,
|
||||||
|
})
|
||||||
|
: await memoryStore.remove(scope, scopeKey, input.target_id ?? "");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "memory",
|
||||||
|
decision: result.changed ? "accepted" : "rejected",
|
||||||
|
detail: result.detail,
|
||||||
|
target: scope,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeSkillManager = async (
|
||||||
|
skillStore: SkillStore,
|
||||||
|
sessionContext: RuntimeSessionContext,
|
||||||
|
input: SkillManagerInput,
|
||||||
|
) => {
|
||||||
|
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
|
||||||
|
return rejected("skill", "skill writes are disabled for this session");
|
||||||
|
}
|
||||||
|
if (input.action === "list") {
|
||||||
|
const result = await skillStore.list(input.skill_path);
|
||||||
|
if (!result) {
|
||||||
|
return rejected(
|
||||||
|
"skill",
|
||||||
|
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "skill",
|
||||||
|
decision: "accepted",
|
||||||
|
detail: "skill listed",
|
||||||
|
references: result.references,
|
||||||
|
scripts: result.scripts,
|
||||||
|
skill_path: result.skillPath,
|
||||||
|
target: result.target,
|
||||||
|
patterns: result.patterns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
input.action === "write_skill"
|
||||||
|
? await skillStore.writeSkill(input.skill_path, input.content ?? "")
|
||||||
|
: input.action === "remove_skill"
|
||||||
|
? await skillStore.removeSkill(input.skill_path)
|
||||||
|
: input.action === "append_pattern"
|
||||||
|
? await skillStore.appendPattern(input.skill_path, input.pattern ?? "")
|
||||||
|
: input.action === "remove_pattern"
|
||||||
|
? await skillStore.removePattern(input.skill_path, input.target_id ?? "")
|
||||||
|
: input.action === "write_reference"
|
||||||
|
? await skillStore.writeReference(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
input.content ?? "",
|
||||||
|
)
|
||||||
|
: input.action === "remove_reference"
|
||||||
|
? await skillStore.removeReference(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
)
|
||||||
|
: input.action === "write_script"
|
||||||
|
? await skillStore.writeScript(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
input.content ?? "",
|
||||||
|
)
|
||||||
|
: await skillStore.removeScript(
|
||||||
|
input.skill_path,
|
||||||
|
input.file_path ?? "",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
kind: "skill",
|
||||||
|
decision: result.changed ? "accepted" : "rejected",
|
||||||
|
detail: result.detail,
|
||||||
|
target: result.target,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejected = (kind: "memory" | "skill", detail: string) => ({
|
||||||
|
ok: true,
|
||||||
|
kind,
|
||||||
|
decision: "rejected",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
+42
-4
@@ -1,4 +1,7 @@
|
|||||||
import { readJsonFile } from "../utils/fileStore.js";
|
import { realpath, stat } from "node:fs/promises";
|
||||||
|
import { isAbsolute, relative } from "node:path";
|
||||||
|
|
||||||
|
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
type ResultReferenceKind,
|
type ResultReferenceKind,
|
||||||
type ResultReferenceRecord,
|
type ResultReferenceRecord,
|
||||||
@@ -33,7 +36,11 @@ export type RenderJunctionPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ResultReferenceResolver {
|
export class ResultReferenceResolver {
|
||||||
constructor(private readonly store: ResultReferenceStore) {}
|
constructor(
|
||||||
|
private readonly store: ResultReferenceStore,
|
||||||
|
private readonly importRoot: string,
|
||||||
|
private readonly importMaxBytes: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
|
||||||
async register(input: RegisterResultReferenceInput) {
|
async register(input: RegisterResultReferenceInput) {
|
||||||
@@ -63,7 +70,17 @@ export class ResultReferenceResolver {
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
|
||||||
) {
|
) {
|
||||||
const raw = await readJsonFile<unknown>(filePath);
|
const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot);
|
||||||
|
const fileStat = await stat(resolvedFilePath);
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
throw new Error("render payload path must point to a regular file");
|
||||||
|
}
|
||||||
|
if (fileStat.size > this.importMaxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`render payload file exceeds RESULT_REF_IMPORT_MAX_BYTES (${this.importMaxBytes})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = await readJsonFile<unknown>(resolvedFilePath);
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
throw new Error(`render payload file not found: ${filePath}`);
|
throw new Error(`render payload file not found: ${filePath}`);
|
||||||
}
|
}
|
||||||
@@ -78,13 +95,15 @@ export class ResultReferenceResolver {
|
|||||||
throw new Error("render payload file does not contain a valid junction render payload");
|
throw new Error("render payload file does not contain a valid junction render payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.register({
|
const record = await this.register({
|
||||||
...input,
|
...input,
|
||||||
data: payload,
|
data: payload,
|
||||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
});
|
});
|
||||||
|
await removeFileIfExists(resolvedFilePath);
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFullAuthorized(
|
async getFullAuthorized(
|
||||||
@@ -167,6 +186,25 @@ export const extractRenderJunctionPayload = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolvePathInsideRoot = async (filePath: string, rootPath: string) => {
|
||||||
|
if (!isAbsolute(filePath)) {
|
||||||
|
throw new Error("render payload file_path must be absolute");
|
||||||
|
}
|
||||||
|
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
|
||||||
|
realpath(filePath),
|
||||||
|
realpath(rootPath),
|
||||||
|
]);
|
||||||
|
const relativePath = relative(resolvedRootPath, resolvedFilePath);
|
||||||
|
if (
|
||||||
|
relativePath === ".." ||
|
||||||
|
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
|
||||||
|
isAbsolute(relativePath)
|
||||||
|
) {
|
||||||
|
throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR");
|
||||||
|
}
|
||||||
|
return resolvedFilePath;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDataForKind = (
|
const normalizeDataForKind = (
|
||||||
kind: ResultReferenceKind,
|
kind: ResultReferenceKind,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
|
|||||||
+212
-130
@@ -1,6 +1,15 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { z } from "zod";
|
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 LearningOrchestrator } from "../learning/orchestrator.js";
|
||||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
@@ -11,6 +20,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
|
|||||||
import {
|
import {
|
||||||
type OpencodeRuntimeAdapter,
|
type OpencodeRuntimeAdapter,
|
||||||
} from "../runtime/opencode.js";
|
} from "../runtime/opencode.js";
|
||||||
|
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
@@ -28,14 +38,13 @@ import {
|
|||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
streamPromptResponse,
|
streamPromptResponse,
|
||||||
supportedModels,
|
|
||||||
type SupportedModel,
|
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
} from "./chatStream.js";
|
} from "./chatStream.js";
|
||||||
import {
|
import {
|
||||||
type ActiveRun,
|
type ActiveRun,
|
||||||
type RunStatus,
|
type RunStatus,
|
||||||
type StreamSubscriber,
|
type StreamSubscriber,
|
||||||
|
appendBackendToolArtifact,
|
||||||
cancelBackendTodos,
|
cancelBackendTodos,
|
||||||
completeBackendProgress,
|
completeBackendProgress,
|
||||||
createInitialStreamingMessages,
|
createInitialStreamingMessages,
|
||||||
@@ -53,8 +62,16 @@ import {
|
|||||||
const payloadSchema = z.object({
|
const payloadSchema = z.object({
|
||||||
message: z.string().min(1).max(10000),
|
message: z.string().min(1).max(10000),
|
||||||
session_id: z.string().max(128).optional(),
|
session_id: z.string().max(128).optional(),
|
||||||
model: z.enum(supportedModels).optional(),
|
model: z.string().refine(isSupportedModel, {
|
||||||
approval_mode: z.enum(["request", "always"]).optional().default("request"),
|
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({
|
const createSessionPayloadSchema = z.object({
|
||||||
@@ -67,12 +84,6 @@ const forkPayloadSchema = z.object({
|
|||||||
keep_message_count: z.coerce.number().int().min(0),
|
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 activeRuns = new Map<string, ActiveRun>();
|
||||||
const lastRunStatuses = new Map<string, RunStatus>();
|
const lastRunStatuses = new Map<string, RunStatus>();
|
||||||
|
|
||||||
@@ -80,6 +91,20 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
|||||||
sessionId: sessionRecord.sessionId,
|
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) =>
|
const getSessionRunStatus = (sessionId: string) =>
|
||||||
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
|
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
|
||||||
|
|
||||||
@@ -103,10 +128,18 @@ export const buildChatRouter = (
|
|||||||
sessionTranscriptStore: SessionTranscriptStore,
|
sessionTranscriptStore: SessionTranscriptStore,
|
||||||
learningOrchestrator: LearningOrchestrator,
|
learningOrchestrator: LearningOrchestrator,
|
||||||
resultReferenceResolver: ResultReferenceResolver,
|
resultReferenceResolver: ResultReferenceResolver,
|
||||||
|
credentialRefreshCoordinator: CredentialRefreshCoordinator,
|
||||||
) => {
|
) => {
|
||||||
const chatRouter = Router();
|
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 ?? {});
|
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -116,8 +149,9 @@ export const buildChatRouter = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
const requestedSessionId = parsed.data.session_id?.trim();
|
const requestedSessionId = parsed.data.session_id?.trim();
|
||||||
@@ -143,8 +177,9 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.get("/sessions", async (req, res) => {
|
chatRouter.get("/sessions", async (req, res) => {
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
const records = await sessionMetadataStore.list({
|
const records = await sessionMetadataStore.list({
|
||||||
@@ -167,10 +202,11 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.get("/session/:sessionId", async (req, res) => {
|
chatRouter.get("/sessions/:session_id", async (req, res) => {
|
||||||
const sessionId = req.params.sessionId?.trim();
|
const sessionId = req.params.session_id?.trim();
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -210,10 +246,11 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.get("/session/:sessionId/stream", async (req, res) => {
|
chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
|
||||||
const sessionId = req.params.sessionId?.trim();
|
const sessionId = req.params.session_id?.trim();
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -266,6 +303,16 @@ export const buildChatRouter = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
run.subscribers.add(subscriber);
|
run.subscribers.add(subscriber);
|
||||||
|
const pendingCredentialRefresh =
|
||||||
|
credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
|
||||||
|
if (pendingCredentialRefresh) {
|
||||||
|
subscriber.write(pendingCredentialRefresh.type, {
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
request_id: pendingCredentialRefresh.requestId,
|
||||||
|
reason: pendingCredentialRefresh.reason,
|
||||||
|
timeout_ms: pendingCredentialRefresh.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
run.subscribers.delete(subscriber);
|
run.subscribers.delete(subscriber);
|
||||||
@@ -274,81 +321,17 @@ export const buildChatRouter = (
|
|||||||
res.on("close", cleanup);
|
res.on("close", cleanup);
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.put("/session/:sessionId", async (req, res) => {
|
chatRouter.patch("/sessions/:session_id", async (req, res) => {
|
||||||
const sessionId = req.params.sessionId?.trim();
|
const sessionId = req.params.session_id?.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();
|
|
||||||
const title =
|
const title =
|
||||||
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||||
const isTitleManuallyEdited =
|
const isTitleManuallyEdited =
|
||||||
typeof req.body?.is_title_manually_edited === "boolean"
|
typeof req.body?.is_title_manually_edited === "boolean"
|
||||||
? req.body.is_title_manually_edited
|
? req.body.is_title_manually_edited
|
||||||
: undefined;
|
: undefined;
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
if (!sessionId || !title) {
|
if (!sessionId || !title) {
|
||||||
@@ -384,10 +367,11 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.delete("/session/:sessionId", async (req, res) => {
|
chatRouter.delete("/sessions/:session_id", async (req, res) => {
|
||||||
const sessionId = req.params.sessionId?.trim();
|
const sessionId = req.params.session_id?.trim();
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -424,13 +408,17 @@ export const buildChatRouter = (
|
|||||||
|
|
||||||
registerChatInteractionRoutes(chatRouter, {
|
registerChatInteractionRoutes(chatRouter, {
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator,
|
||||||
runtime,
|
runtime,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.post("/fork", async (req, res) => {
|
chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
|
||||||
const parsed = forkPayloadSchema.safeParse(req.body);
|
const parsed = forkPayloadSchema.safeParse({
|
||||||
|
...req.body,
|
||||||
|
session_id: req.params.session_id,
|
||||||
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "invalid request payload",
|
message: "invalid request payload",
|
||||||
@@ -440,9 +428,10 @@ export const buildChatRouter = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 traceId = req.header("x-trace-id") ?? undefined;
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const userId = authContext.userId;
|
||||||
|
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
@@ -458,6 +447,10 @@ export const buildChatRouter = (
|
|||||||
sourceSessionId,
|
sourceSessionId,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
if (!sourceSessionId || !sourceSessionRecord) {
|
||||||
|
res.status(404).json({ message: "source session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const forkSession = await runtime.createSession();
|
const forkSession = await runtime.createSession();
|
||||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||||
actorKey,
|
actorKey,
|
||||||
@@ -469,28 +462,38 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
const nextSessionId = targetSessionRecord.sessionId;
|
const nextSessionId = targetSessionRecord.sessionId;
|
||||||
|
|
||||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
await sessionTranscriptStore.cloneThread(
|
||||||
await sessionTranscriptStore.cloneThread(
|
{
|
||||||
{
|
actorKey,
|
||||||
actorKey,
|
clientSessionId: sourceSessionId,
|
||||||
clientSessionId: sourceSessionId,
|
projectKey,
|
||||||
projectKey,
|
sessionId: sourceSessionId,
|
||||||
sessionId: sourceSessionId,
|
},
|
||||||
},
|
{
|
||||||
{
|
actorKey,
|
||||||
actorKey,
|
clientSessionId: nextSessionId,
|
||||||
clientSessionId: nextSessionId,
|
projectKey,
|
||||||
projectKey,
|
sessionId: nextSessionId,
|
||||||
sessionId: nextSessionId,
|
},
|
||||||
},
|
parsed.data.keep_message_count,
|
||||||
parsed.data.keep_message_count,
|
);
|
||||||
);
|
const sourceState = await sessionUiStateStore.read(
|
||||||
if (sourceSessionRecord?.title) {
|
toSessionUiStateContext(sourceSessionRecord),
|
||||||
await sessionMetadataStore.touch(targetSessionRecord, {
|
);
|
||||||
title: sourceSessionRecord.title,
|
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(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -516,8 +519,11 @@ export const buildChatRouter = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.post("/stream", async (req, res) => {
|
chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
|
||||||
const parsed = payloadSchema.safeParse(req.body);
|
const parsed = payloadSchema.safeParse({
|
||||||
|
...req.body,
|
||||||
|
session_id: req.params.session_id,
|
||||||
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "invalid request payload",
|
message: "invalid request payload",
|
||||||
@@ -527,13 +533,11 @@ export const buildChatRouter = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authHeader = req.header("authorization");
|
const authContext = getAgentAuthContext(req);
|
||||||
const accessToken = authHeader?.startsWith("Bearer ")
|
const accessToken = authContext.accessToken;
|
||||||
? authHeader.slice("Bearer ".length)
|
const projectId = authContext.projectId;
|
||||||
: authHeader;
|
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
|
||||||
const traceId = req.header("x-trace-id") ?? undefined;
|
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 actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
const requestedSessionId = parsed.data.session_id?.trim();
|
const requestedSessionId = parsed.data.session_id?.trim();
|
||||||
@@ -548,8 +552,10 @@ export const buildChatRouter = (
|
|||||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||||
sessionId: requestedSessionId,
|
sessionId: requestedSessionId,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
network: authContext.network,
|
||||||
projectId,
|
projectId,
|
||||||
traceId,
|
traceId,
|
||||||
|
tokenExpiresAt: authContext.tokenExpiresAt,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||||
@@ -706,6 +712,19 @@ export const buildChatRouter = (
|
|||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
todos: cancelBackendTodos(message.todos),
|
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") {
|
} else if (event === "permission_request") {
|
||||||
const payload = data as PermissionRequestPayload;
|
const payload = data as PermissionRequestPayload;
|
||||||
activeRun.pendingPermissions.set(payload.request_id, payload);
|
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||||
@@ -789,6 +808,11 @@ export const buildChatRouter = (
|
|||||||
...message,
|
...message,
|
||||||
todos: upsertBackendTodoUpdate(message.todos, payload),
|
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) {
|
for (const subscriber of activeRun.subscribers) {
|
||||||
@@ -798,6 +822,35 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(
|
||||||
|
binding.sessionId,
|
||||||
|
(event) => {
|
||||||
|
publish(event.type, {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.requestId,
|
||||||
|
...(event.type === "credential_refresh_required"
|
||||||
|
? {
|
||||||
|
reason: event.reason,
|
||||||
|
timeout_ms: event.timeoutMs,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(event.type === "credential_refresh_failed"
|
||||||
|
? { message: event.message }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cancelCredentialRefreshOnAbort = () => {
|
||||||
|
credentialRefreshCoordinator.cancelSession(
|
||||||
|
binding.sessionId,
|
||||||
|
"credential refresh cancelled because the agent run was aborted",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
abortController.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
cancelCredentialRefreshOnAbort,
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preparedMessage = await buildPromptWithLearningContext(
|
const preparedMessage = await buildPromptWithLearningContext(
|
||||||
@@ -829,6 +882,16 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const 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) {
|
if (!streamResult.aborted && !streamResult.failed) {
|
||||||
const messages = await runtime.messages(binding.sessionId, 60);
|
const messages = await runtime.messages(binding.sessionId, 60);
|
||||||
const assistantMessage = [...messages]
|
const assistantMessage = [...messages]
|
||||||
@@ -876,6 +939,19 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const 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 {
|
} finally {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
@@ -897,6 +973,12 @@ export const buildChatRouter = (
|
|||||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||||
});
|
});
|
||||||
sessionBridge.finalizeRequest(clientSessionId);
|
sessionBridge.finalizeRequest(clientSessionId);
|
||||||
|
abortController.signal.removeEventListener(
|
||||||
|
"abort",
|
||||||
|
cancelCredentialRefreshOnAbort,
|
||||||
|
);
|
||||||
|
credentialRefreshCoordinator.cancelSession(binding.sessionId);
|
||||||
|
unsubscribeCredentialRefresh();
|
||||||
activeRun.status = abortController.signal.aborted
|
activeRun.status = abortController.signal.aborted
|
||||||
? activeRun.status === "aborted"
|
? activeRun.status === "aborted"
|
||||||
? "aborted"
|
? "aborted"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type Router } from "express";
|
import { type Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
updateLastAssistantMessage,
|
updateLastAssistantMessage,
|
||||||
} from "./chatUiState.js";
|
} from "./chatUiState.js";
|
||||||
|
|
||||||
const abortPayloadSchema = z.object({
|
const abortParamsSchema = z.object({
|
||||||
session_id: z.string().max(128),
|
session_id: z.string().max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,22 +45,16 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
}: RegisterAuxiliaryRoutesOptions,
|
}: RegisterAuxiliaryRoutesOptions,
|
||||||
) => {
|
) => {
|
||||||
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
|
chatRouter.get("/render-references/:render_ref", async (req, res) => {
|
||||||
const renderRef = req.params.renderRef?.trim();
|
const renderRef = req.params.render_ref?.trim();
|
||||||
const userId = req.header("x-user-id")?.trim();
|
const authContext = getAgentAuthContext(req);
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const userId = authContext.userId;
|
||||||
|
const projectId = authContext.projectId;
|
||||||
const clientSessionId =
|
const clientSessionId =
|
||||||
typeof req.query.session_id === "string"
|
typeof req.query.session_id === "string"
|
||||||
? req.query.session_id.trim()
|
? req.query.session_id.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
res.status(400).json({
|
|
||||||
message: "x-user-id is required",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderRef) {
|
if (!renderRef) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "render_ref is required",
|
message: "render_ref is required",
|
||||||
@@ -87,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.post("/abort", async (req, res) => {
|
chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
|
||||||
const parsed = abortPayloadSchema.safeParse(req.body);
|
const parsed = abortParamsSchema.safeParse(req.params);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "invalid request payload",
|
message: "invalid request payload",
|
||||||
@@ -98,8 +93,9 @@ export const registerChatAuxiliaryRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
|
|||||||
+110
-163
@@ -1,8 +1,14 @@
|
|||||||
import { type Router } from "express";
|
import { type Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getAgentAuthContext } from "../auth/agentAuth.js";
|
||||||
|
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
import {
|
||||||
|
getRuntimeSessionContext,
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
} from "../runtime/sessionContext.js";
|
||||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||||
@@ -14,22 +20,24 @@ import {
|
|||||||
} from "./chatUiState.js";
|
} from "./chatUiState.js";
|
||||||
|
|
||||||
const permissionReplyPayloadSchema = z.object({
|
const permissionReplyPayloadSchema = z.object({
|
||||||
session_id: z.string().max(128),
|
request_id: z.string().min(1),
|
||||||
reply: z.enum(["once", "always", "reject"]),
|
reply: z.enum(["once", "always", "reject"]),
|
||||||
message: z.string().max(1000).optional(),
|
message: z.string().max(1000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const questionReplyPayloadSchema = z.object({
|
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([]),
|
answers: z.array(z.array(z.string().max(2000))).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const questionRejectPayloadSchema = z.object({
|
const credentialRefreshPayloadSchema = z.object({
|
||||||
session_id: z.string().max(128),
|
request_id: z.string().min(1).max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
type RegisterInteractionRoutesOptions = {
|
type RegisterInteractionRoutesOptions = {
|
||||||
activeRuns: Map<string, ActiveRun>;
|
activeRuns: Map<string, ActiveRun>;
|
||||||
|
credentialRefreshCoordinator: CredentialRefreshCoordinator;
|
||||||
runtime: OpencodeRuntimeAdapter;
|
runtime: OpencodeRuntimeAdapter;
|
||||||
sessionMetadataStore: SessionMetadataStore;
|
sessionMetadataStore: SessionMetadataStore;
|
||||||
sessionUiStateStore: SessionUiStateStore;
|
sessionUiStateStore: SessionUiStateStore;
|
||||||
@@ -43,18 +51,75 @@ export const registerChatInteractionRoutes = (
|
|||||||
chatRouter: Router,
|
chatRouter: Router,
|
||||||
{
|
{
|
||||||
activeRuns,
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator,
|
||||||
runtime,
|
runtime,
|
||||||
sessionMetadataStore,
|
sessionMetadataStore,
|
||||||
sessionUiStateStore,
|
sessionUiStateStore,
|
||||||
}: RegisterInteractionRoutesOptions,
|
}: RegisterInteractionRoutesOptions,
|
||||||
) => {
|
) => {
|
||||||
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
|
chatRouter.post("/sessions/:session_id/credential-refreshes", async (req, res) => {
|
||||||
const requestId = req.params.requestId?.trim();
|
const parsed = credentialRefreshPayloadSchema.safeParse(req.body);
|
||||||
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
if (!parsed.success) {
|
||||||
if (!requestId) {
|
res.status(400).json({
|
||||||
res.status(400).json({ message: "request_id is required" });
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
return;
|
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) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "invalid request payload",
|
message: "invalid request payload",
|
||||||
@@ -64,13 +129,15 @@ export const registerChatInteractionRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const requestId = parsed.data.request_id;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
req.params.session_id,
|
||||||
);
|
);
|
||||||
if (!sessionRecord) {
|
if (!sessionRecord) {
|
||||||
res.status(404).json({ message: "session not found" });
|
res.status(404).json({ message: "session not found" });
|
||||||
@@ -172,13 +239,8 @@ export const registerChatInteractionRoutes = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chatRouter.post("/question/:requestId/reply", async (req, res) => {
|
chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
|
||||||
const requestId = req.params.requestId?.trim();
|
|
||||||
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
const parsed = questionReplyPayloadSchema.safeParse(req.body);
|
||||||
if (!requestId) {
|
|
||||||
res.status(400).json({ message: "request_id is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
message: "invalid request payload",
|
message: "invalid request payload",
|
||||||
@@ -188,13 +250,15 @@ export const registerChatInteractionRoutes = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = req.header("x-project-id") ?? undefined;
|
const authContext = getAgentAuthContext(req);
|
||||||
const userId = req.header("x-user-id") ?? undefined;
|
const projectId = authContext.projectId;
|
||||||
|
const userId = authContext.userId;
|
||||||
const actorKey = toActorKey(userId);
|
const actorKey = toActorKey(userId);
|
||||||
const projectKey = toProjectKey(projectId);
|
const projectKey = toProjectKey(projectId);
|
||||||
|
const requestId = parsed.data.request_id;
|
||||||
const sessionRecord = await sessionMetadataStore.get(
|
const sessionRecord = await sessionMetadataStore.get(
|
||||||
{ actorKey, projectId, projectKey, userId },
|
{ actorKey, projectId, projectKey, userId },
|
||||||
parsed.data.session_id,
|
req.params.session_id,
|
||||||
);
|
);
|
||||||
if (!sessionRecord) {
|
if (!sessionRecord) {
|
||||||
res.status(404).json({ message: "session not found" });
|
res.status(404).json({ message: "session not found" });
|
||||||
@@ -224,11 +288,18 @@ export const registerChatInteractionRoutes = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runtime.replyQuestion({
|
if (parsed.data.action === "reject") {
|
||||||
requestId,
|
await runtime.rejectQuestion({
|
||||||
sessionId: sessionRecord.sessionId,
|
requestId,
|
||||||
answers: parsed.data.answers,
|
sessionId: sessionRecord.sessionId,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await runtime.replyQuestion({
|
||||||
|
requestId,
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
answers: parsed.data.answers,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
run.messages = updateLastAssistantQuestion(
|
run.messages = updateLastAssistantQuestion(
|
||||||
run.messages,
|
run.messages,
|
||||||
@@ -239,7 +310,7 @@ export const registerChatInteractionRoutes = (
|
|||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "failed to reply question",
|
: `failed to ${parsed.data.action} question`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await persistQuestionState().catch((persistError) => {
|
await persistQuestionState().catch((persistError) => {
|
||||||
@@ -249,7 +320,7 @@ export const registerChatInteractionRoutes = (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
res.status(502).json({
|
res.status(502).json({
|
||||||
message: "question reply failed",
|
message: `question ${parsed.data.action} failed`,
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -261,8 +332,9 @@ export const registerChatInteractionRoutes = (
|
|||||||
requestId,
|
requestId,
|
||||||
(question) => ({
|
(question) => ({
|
||||||
...question,
|
...question,
|
||||||
status: "answered",
|
status: parsed.data.action === "reject" ? "rejected" : "answered",
|
||||||
answers: parsed.data.answers,
|
answers:
|
||||||
|
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
|
||||||
repliedAt: Date.now(),
|
repliedAt: Date.now(),
|
||||||
error: undefined,
|
error: undefined,
|
||||||
}),
|
}),
|
||||||
@@ -277,7 +349,9 @@ export const registerChatInteractionRoutes = (
|
|||||||
subscriber.write("question_response", {
|
subscriber.write("question_response", {
|
||||||
session_id: pendingQuestion.session_id,
|
session_id: pendingQuestion.session_id,
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
answers: parsed.data.answers,
|
...(parsed.data.action === "reject"
|
||||||
|
? { rejected: true }
|
||||||
|
: { answers: parsed.data.answers }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -291,142 +365,15 @@ export const registerChatInteractionRoutes = (
|
|||||||
res.status(202).json({
|
res.status(202).json({
|
||||||
session_id: pendingQuestion.session_id,
|
session_id: pendingQuestion.session_id,
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
answers: parsed.data.answers,
|
...(parsed.data.action === "reject"
|
||||||
|
? { rejected: true }
|
||||||
|
: { answers: parsed.data.answers }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(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({
|
res.status(500).json({
|
||||||
message: "question reply route failed",
|
message: "question response 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",
|
|
||||||
detail,
|
detail,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type ApprovalMode = "request" | "auto" | "always";
|
||||||
|
|
||||||
|
const lowRiskToolPermissions = new Set([
|
||||||
|
"apply_layer_style",
|
||||||
|
"geocode",
|
||||||
|
"locate_features",
|
||||||
|
"render_junctions",
|
||||||
|
"show_chart",
|
||||||
|
"view_history",
|
||||||
|
"view_scada",
|
||||||
|
"web_search",
|
||||||
|
"zoom_to_map",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const canAutoApprovePermission = (permission: string): boolean => {
|
||||||
|
const normalized = normalizePermission(permission);
|
||||||
|
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,
|
||||||
|
) => {
|
||||||
|
if (approvalMode === "always") {
|
||||||
|
return {
|
||||||
|
autoApprove: true,
|
||||||
|
title: "已按始终允许模式放行",
|
||||||
|
detail:
|
||||||
|
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalMode === "auto" && canAutoApprovePermission(permission)) {
|
||||||
|
return {
|
||||||
|
autoApprove: true,
|
||||||
|
title: "已自动批准低风险权限",
|
||||||
|
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
autoApprove: false,
|
||||||
|
title: "等待权限确认",
|
||||||
|
detail: undefined,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
+82
-92
@@ -1,6 +1,7 @@
|
|||||||
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||||
|
|
||||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
|
import { type SupportedModel } from "../chat/models.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
type PermissionReply,
|
type PermissionReply,
|
||||||
@@ -45,6 +46,10 @@ import {
|
|||||||
type TodoItemPayload,
|
type TodoItemPayload,
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
} from "./chatStreamEvents.js";
|
} from "./chatStreamEvents.js";
|
||||||
|
import {
|
||||||
|
resolvePermissionApproval,
|
||||||
|
type ApprovalMode,
|
||||||
|
} from "./chatPermissionPolicy.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
@@ -54,13 +59,7 @@ export {
|
|||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
} from "./chatStreamEvents.js";
|
} from "./chatStreamEvents.js";
|
||||||
|
|
||||||
export const supportedModels = [
|
export type { ApprovalMode } from "./chatPermissionPolicy.js";
|
||||||
"deepseek/deepseek-v4-flash",
|
|
||||||
"deepseek/deepseek-v4-pro",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type SupportedModel = (typeof supportedModels)[number];
|
|
||||||
export type ApprovalMode = "request" | "always";
|
|
||||||
|
|
||||||
type StreamPromptOptions = {
|
type StreamPromptOptions = {
|
||||||
runtime: OpencodeRuntimeAdapter;
|
runtime: OpencodeRuntimeAdapter;
|
||||||
@@ -112,24 +111,40 @@ const toRuntimeModel = (model?: SupportedModel) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitFallbackMessage = async (
|
const emitFinalMessage = async (
|
||||||
runtime: OpencodeRuntimeAdapter,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
clientSessionId: string,
|
clientSessionId: string,
|
||||||
|
currentAssistantMessageIds: Set<string>,
|
||||||
|
assistantTextParts: Map<string, Map<string, string>>,
|
||||||
write: (event: string, data: Record<string, unknown>) => void,
|
write: (event: string, data: Record<string, unknown>) => void,
|
||||||
) => {
|
) => {
|
||||||
const messages = await runtime.messages(sessionId);
|
let text = [...currentAssistantMessageIds]
|
||||||
const assistantMessage = [...messages]
|
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.info.role === "assistant");
|
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
|
||||||
const parts = assistantMessage?.parts ?? [];
|
.find((content) => content.length > 0) ?? "";
|
||||||
const text = collectTextContent(parts);
|
|
||||||
|
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) {
|
if (text) {
|
||||||
write("token", {
|
write("token", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
content: text,
|
content: text,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const streamPromptResponse = async ({
|
export const streamPromptResponse = async ({
|
||||||
@@ -157,15 +172,14 @@ export const streamPromptResponse = async ({
|
|||||||
const emittedToolParts = new Set<string>();
|
const emittedToolParts = new Set<string>();
|
||||||
const emittedQuestionToolParts = new Set<string>();
|
const emittedQuestionToolParts = new Set<string>();
|
||||||
const emittedQuestionRequestIds = new Set<string>();
|
const emittedQuestionRequestIds = new Set<string>();
|
||||||
|
const currentAssistantMessageIds = new Set<string>();
|
||||||
|
const assistantTextParts = new Map<string, Map<string, string>>();
|
||||||
const partTypes = new Map<string, Part["type"]>();
|
const partTypes = new Map<string, Part["type"]>();
|
||||||
const pendingPartTextDeltas = new Map<string, string[]>();
|
const pendingTextDeltas = new Map<string, string[]>();
|
||||||
const reasoningDeltas = new Map<string, string[]>();
|
|
||||||
const reasoningStatuses = new Map<string, "running" | "completed">();
|
const reasoningStatuses = new Map<string, "running" | "completed">();
|
||||||
const toolStatuses = new Map<string, string>();
|
const toolStatuses = new Map<string, string>();
|
||||||
let firstSessionEventLogged = false;
|
let firstSessionEventLogged = false;
|
||||||
let firstNonStatusEventLogged = false;
|
let firstNonStatusEventLogged = false;
|
||||||
let firstTokenLogged = false;
|
|
||||||
let firstReasoningLogged = false;
|
|
||||||
let firstToolEventLogged = false;
|
let firstToolEventLogged = false;
|
||||||
let lastSessionStatus: string | null = null;
|
let lastSessionStatus: string | null = null;
|
||||||
let lastSessionStatusMessage: string | null = null;
|
let lastSessionStatusMessage: string | null = null;
|
||||||
@@ -377,6 +391,10 @@ export const streamPromptResponse = async ({
|
|||||||
|
|
||||||
if (isPermissionAskedEvent(event)) {
|
if (isPermissionAskedEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
const permissionApproval = resolvePermissionApproval(
|
||||||
|
approvalMode,
|
||||||
|
event.properties.permission,
|
||||||
|
);
|
||||||
logDevelopmentDebug("permission request received", {
|
logDevelopmentDebug("permission request received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
@@ -387,23 +405,20 @@ export const streamPromptResponse = async ({
|
|||||||
emitProgress({
|
emitProgress({
|
||||||
id: `permission-${event.properties.id}`,
|
id: `permission-${event.properties.id}`,
|
||||||
phase: "permission",
|
phase: "permission",
|
||||||
status: approvalMode === "always" ? "completed" : "running",
|
status: permissionApproval.autoApprove ? "completed" : "running",
|
||||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
title: permissionApproval.title,
|
||||||
detail:
|
detail: permissionApproval.detail ?? buildPermissionDetail(event),
|
||||||
approvalMode === "always"
|
|
||||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
||||||
: buildPermissionDetail(event),
|
|
||||||
});
|
});
|
||||||
if (approvalMode === "always") {
|
if (permissionApproval.autoApprove) {
|
||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
reply: "always",
|
reply: "once",
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
reply: "always" satisfies PermissionReply,
|
reply: "once" satisfies PermissionReply,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -422,6 +437,10 @@ export const streamPromptResponse = async ({
|
|||||||
|
|
||||||
if (isPermissionV2AskedEvent(event)) {
|
if (isPermissionV2AskedEvent(event)) {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
const permissionApproval = resolvePermissionApproval(
|
||||||
|
approvalMode,
|
||||||
|
event.properties.action,
|
||||||
|
);
|
||||||
logDevelopmentDebug("permission v2 request received", {
|
logDevelopmentDebug("permission v2 request received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
@@ -432,23 +451,20 @@ export const streamPromptResponse = async ({
|
|||||||
emitProgress({
|
emitProgress({
|
||||||
id: `permission-${event.properties.id}`,
|
id: `permission-${event.properties.id}`,
|
||||||
phase: "permission",
|
phase: "permission",
|
||||||
status: approvalMode === "always" ? "completed" : "running",
|
status: permissionApproval.autoApprove ? "completed" : "running",
|
||||||
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
title: permissionApproval.title,
|
||||||
detail:
|
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
|
||||||
approvalMode === "always"
|
|
||||||
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
||||||
: buildPermissionV2Detail(event),
|
|
||||||
});
|
});
|
||||||
if (approvalMode === "always") {
|
if (permissionApproval.autoApprove) {
|
||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
reply: "always",
|
reply: "once",
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
session_id: clientSessionId,
|
session_id: clientSessionId,
|
||||||
request_id: event.properties.id,
|
request_id: event.properties.id,
|
||||||
reply: "always" satisfies PermissionReply,
|
reply: "once" satisfies PermissionReply,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -625,45 +641,26 @@ export const streamPromptResponse = async ({
|
|||||||
if (event.type === "message.updated") {
|
if (event.type === "message.updated") {
|
||||||
if (event.properties.info.role === "assistant") {
|
if (event.properties.info.role === "assistant") {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
currentAssistantMessageIds.add(event.properties.info.id);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
|
currentAssistantMessageIds.add(event.properties.messageID);
|
||||||
const partType = partTypes.get(event.properties.partID);
|
const partType = partTypes.get(event.properties.partID);
|
||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
if (!firstTokenLogged) {
|
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
|
||||||
firstTokenLogged = true;
|
messageParts.set(
|
||||||
logDevelopmentDebug("first response token emitted", {
|
event.properties.partID,
|
||||||
...debugContext,
|
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
|
||||||
partId: event.properties.partID,
|
);
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
assistantTextParts.set(event.properties.messageID, messageParts);
|
||||||
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);
|
|
||||||
} else if (!partType) {
|
} else if (!partType) {
|
||||||
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
|
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
|
||||||
pending.push(event.properties.delta);
|
pending.push(event.properties.delta);
|
||||||
pendingPartTextDeltas.set(event.properties.partID, pending);
|
pendingTextDeltas.set(event.properties.partID, pending);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -672,23 +669,19 @@ export const streamPromptResponse = async ({
|
|||||||
sawResponseActivity = true;
|
sawResponseActivity = true;
|
||||||
const part = event.properties.part;
|
const part = event.properties.part;
|
||||||
partTypes.set(part.id, part.type);
|
partTypes.set(part.id, part.type);
|
||||||
|
if (part.type === "text" || part.type === "reasoning" || part.type === "tool") {
|
||||||
|
currentAssistantMessageIds.add(part.messageID);
|
||||||
|
}
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
|
||||||
pendingPartTextDeltas.delete(part.id);
|
pendingTextDeltas.delete(part.id);
|
||||||
for (const content of pending) {
|
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
|
||||||
emittedText = true;
|
messageParts.set(part.id, part.text || pendingText);
|
||||||
write("token", {
|
assistantTextParts.set(part.messageID, messageParts);
|
||||||
session_id: clientSessionId,
|
} else {
|
||||||
content,
|
pendingTextDeltas.delete(part.id);
|
||||||
});
|
}
|
||||||
}
|
if (part.type === "reasoning") {
|
||||||
} 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 reasoningStatus = part.time.end ? "completed" : "running";
|
const reasoningStatus = part.time.end ? "completed" : "running";
|
||||||
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
||||||
reasoningStatuses.set(part.id, reasoningStatus);
|
reasoningStatuses.set(part.id, reasoningStatus);
|
||||||
@@ -696,14 +689,10 @@ export const streamPromptResponse = async ({
|
|||||||
...debugContext,
|
...debugContext,
|
||||||
partId: part.id,
|
partId: part.id,
|
||||||
status: reasoningStatus,
|
status: reasoningStatus,
|
||||||
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
|
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const reasoningDetail = buildReasoningProgressDetail(
|
const reasoningDetail = buildReasoningProgressDetail(part.time.end);
|
||||||
reasoningDeltas.get(part.id) ?? [],
|
|
||||||
part.time.end,
|
|
||||||
);
|
|
||||||
emitProgress({
|
emitProgress({
|
||||||
id: part.id,
|
id: part.id,
|
||||||
phase: "planning",
|
phase: "planning",
|
||||||
@@ -931,13 +920,14 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
if (!emittedText) {
|
emittedText = await emitFinalMessage(
|
||||||
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
runtime,
|
||||||
...debugContext,
|
sessionId,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
clientSessionId,
|
||||||
});
|
currentAssistantMessageIds,
|
||||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
|
assistantTextParts,
|
||||||
}
|
write,
|
||||||
|
);
|
||||||
emitProgress({
|
emitProgress({
|
||||||
id: "request-received",
|
id: "request-received",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
|
|||||||
@@ -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 summarizeToolParams = (params: Record<string, unknown>) => {
|
||||||
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
|
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
|
||||||
const summary = Object.entries(params)
|
const summary = Object.entries(params)
|
||||||
@@ -413,19 +407,8 @@ export const buildSessionStatusDetail = (status: { type: string; message?: strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const buildReasoningProgressDetail = (
|
export const buildReasoningProgressDetail = (
|
||||||
chunks: string[],
|
|
||||||
ended?: string | number | Date | null,
|
ended?: string | number | Date | null,
|
||||||
) => {
|
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
|
||||||
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
|
||||||
if (ended) {
|
|
||||||
return reasoningText
|
|
||||||
? `推理过程:${reasoningText}`
|
|
||||||
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
|
||||||
}
|
|
||||||
return reasoningText
|
|
||||||
? `正在推理:${reasoningText}`
|
|
||||||
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildToolProgressDetail = (
|
export const buildToolProgressDetail = (
|
||||||
tool: string,
|
tool: string,
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export type ActiveRun = {
|
|||||||
subscribers: Set<StreamSubscriber>;
|
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> =>
|
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
@@ -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 = (
|
export const toFrontendPermission = (
|
||||||
payload: PermissionRequestPayload,
|
payload: PermissionRequestPayload,
|
||||||
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
+60
-15
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createOpencode,
|
createOpencode,
|
||||||
createOpencodeClient,
|
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -64,6 +63,64 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "global.health");
|
return requireData(response.data, "global.health");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async warmup(): Promise<void> {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
const healthStartedAt = Date.now();
|
||||||
|
const healthResponse = await client.global.health();
|
||||||
|
const health = requireData(healthResponse.data, "global.health");
|
||||||
|
logDevelopmentDebug("opencode warmup health check completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
|
||||||
|
healthy: health.healthy,
|
||||||
|
version: health.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStartedAt = Date.now();
|
||||||
|
const sessionResponse = await client.session.create({
|
||||||
|
title: "tjwater-agent-warmup",
|
||||||
|
});
|
||||||
|
const session = requireData(sessionResponse.data, "session.create");
|
||||||
|
logDevelopmentDebug("opencode warmup session created", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [provider, model] = config.OPENCODE_MODEL.split("/");
|
||||||
|
if (!provider || !model) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const toolsStartedAt = Date.now();
|
||||||
|
const toolsResponse = await client.tool.list({ provider, model });
|
||||||
|
const tools = requireData(toolsResponse.data, "tool.list");
|
||||||
|
logDevelopmentDebug("opencode warmup tools loaded", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
|
||||||
|
model: config.OPENCODE_MODEL,
|
||||||
|
sessionId: session.id,
|
||||||
|
toolCount: tools.length,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const cleanupStartedAt = Date.now();
|
||||||
|
let cleanupSucceeded = true;
|
||||||
|
await client.session.delete(
|
||||||
|
{ sessionID: session.id },
|
||||||
|
{ throwOnError: true },
|
||||||
|
).catch((error) => {
|
||||||
|
cleanupSucceeded = false;
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId: session.id },
|
||||||
|
"failed to remove opencode warmup session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logDevelopmentDebug("opencode warmup session cleanup completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
succeeded: cleanupSucceeded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(title?: string) {
|
async createSession(title?: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.session.create({
|
const response = await client.session.create({
|
||||||
@@ -329,19 +386,6 @@ export class OpencodeRuntimeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async bootstrapClient(): Promise<OpencodeClient> {
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
|
||||||
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
// 这样 .opencode/tools 下的自定义工具可以回调本服务。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
|
||||||
@@ -349,6 +393,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
config.AGENT_INTERNAL_TOKEN ??
|
config.AGENT_INTERNAL_TOKEN ??
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||||||
"";
|
"";
|
||||||
|
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -372,7 +417,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingOpencodeCli(error)) {
|
if (isMissingOpencodeCli(error)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
|
"embedded mode requires the opencode CLI to be installed and available in PATH",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
export type RuntimeSessionContext = {
|
export type RuntimeSessionContext = {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
actorKey: string;
|
actorKey: string;
|
||||||
|
authExpired?: {
|
||||||
|
message: string;
|
||||||
|
reason: "access_token_expired" | "access_token_rejected";
|
||||||
|
};
|
||||||
allowLearningWrite?: boolean;
|
allowLearningWrite?: boolean;
|
||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
learningMode?: "interactive" | "review";
|
learningMode?: "interactive" | "review";
|
||||||
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
|
memoryListReadScopes?: Partial<Record<"user" | "workspace", boolean>>;
|
||||||
|
network?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
tokenExpiresAt?: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +28,23 @@ export const getRuntimeSessionContext = (sessionId: string) => {
|
|||||||
return context ? { ...context } : null;
|
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) => {
|
export const removeRuntimeSessionContext = (sessionId: string) => {
|
||||||
contexts.delete(sessionId);
|
contexts.delete(sessionId);
|
||||||
};
|
};
|
||||||
|
|||||||
+323
-102
@@ -1,15 +1,28 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
|
||||||
|
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||||
|
import { buildBackendContextHeaders } from "./auth/backendContextHeaders.js";
|
||||||
|
import {
|
||||||
|
CredentialRefreshError,
|
||||||
|
CredentialRefreshCoordinator,
|
||||||
|
runWithCredentialRefresh,
|
||||||
|
} from "./auth/credentialRefresh.js";
|
||||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||||
|
import { executeCliCommand } from "./cli/executeCliCommand.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
||||||
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
|
import {
|
||||||
|
executeMemoryManager,
|
||||||
|
executeSkillManager,
|
||||||
|
type MemoryManagerInput,
|
||||||
|
type SkillManagerInput,
|
||||||
|
} from "./learning/toolManagers.js";
|
||||||
import { MemoryStore } from "./memory/store.js";
|
import { MemoryStore } from "./memory/store.js";
|
||||||
import { ResultReferenceResolver } from "./results/resolver.js";
|
import { ResultReferenceResolver } from "./results/resolver.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +30,15 @@ import {
|
|||||||
ResultReferenceStore,
|
ResultReferenceStore,
|
||||||
} from "./results/store.js";
|
} from "./results/store.js";
|
||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
|
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.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();
|
const app = express();
|
||||||
|
|
||||||
@@ -27,17 +47,24 @@ const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
|||||||
const sessionMetadataStore = new SessionMetadataStore();
|
const sessionMetadataStore = new SessionMetadataStore();
|
||||||
const sessionUiStateStore = new SessionUiStateStore();
|
const sessionUiStateStore = new SessionUiStateStore();
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
|
const skillStore = new SkillStore();
|
||||||
const sessionTranscriptStore = new SessionTranscriptStore();
|
const sessionTranscriptStore = new SessionTranscriptStore();
|
||||||
const learningOrchestrator = new LearningOrchestrator(
|
const learningOrchestrator = new LearningOrchestrator(
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
sessionTranscriptStore,
|
sessionTranscriptStore,
|
||||||
|
skillStore,
|
||||||
);
|
);
|
||||||
const resultReferenceStore = new ResultReferenceStore();
|
const resultReferenceStore = new ResultReferenceStore();
|
||||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
const resultReferenceResolver = new ResultReferenceResolver(
|
||||||
|
resultReferenceStore,
|
||||||
|
config.RESULT_REF_IMPORT_DIR,
|
||||||
|
config.RESULT_REF_IMPORT_MAX_BYTES,
|
||||||
|
);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
|
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -48,6 +75,8 @@ app.get("/health", async (_req, res) => {
|
|||||||
const runtime = await opencodeRuntime.health();
|
const runtime = await opencodeRuntime.health();
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
ready: true,
|
||||||
|
warmed_up: true,
|
||||||
runtime,
|
runtime,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
});
|
});
|
||||||
@@ -55,6 +84,8 @@ app.get("/health", async (_req, res) => {
|
|||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
ready: false,
|
||||||
|
warmed_up: true,
|
||||||
message: "opencode runtime unavailable",
|
message: "opencode runtime unavailable",
|
||||||
detail,
|
detail,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
@@ -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) => {
|
app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
@@ -78,7 +207,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
||||||
if (!command) {
|
if (!command) {
|
||||||
res.status(400).json({ message: "command is required" });
|
res.status(400).json({ message: "command is required" });
|
||||||
@@ -88,47 +216,48 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
const timeoutSec =
|
const timeoutSec =
|
||||||
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
||||||
|
|
||||||
const authJson = JSON.stringify({
|
if (!context.network) {
|
||||||
server: config.TJWATER_API_BASE_URL,
|
res.status(400).json({
|
||||||
access_token: context.accessToken,
|
message: "runtime network missing; refresh chat context",
|
||||||
project_id: context.projectId,
|
detail: sessionId,
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
child.on("error", (err) => {
|
return;
|
||||||
clearTimeout(timer);
|
}
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
||||||
|
maxOutputBytes: config.MAX_INLINE_RESULT_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({
|
res.status(504).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
schema_version: "tjwater-cli/v1",
|
schema_version: "tjwater-cli/v1",
|
||||||
@@ -142,25 +271,50 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
if (result.outcome === "output_limit") {
|
||||||
res.status(502).json({
|
res.status(502).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
exit_code: exitCode,
|
schema_version: "tjwater-cli/v1",
|
||||||
stderr: stderr.slice(0, 2000),
|
summary: "CLI 输出超过安全限制",
|
||||||
stdout: stdout.slice(0, 2000),
|
error: {
|
||||||
message: `CLI exited with code ${exitCode}`,
|
code: "OUTPUT_LIMIT_EXCEEDED",
|
||||||
|
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_INLINE_RESULT_BYTES} bytes`,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
markAuthExpired(
|
||||||
|
getRuntimeSessionContext(sessionId) ?? context,
|
||||||
|
"access_token_rejected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
res
|
||||||
|
.status(result.status)
|
||||||
|
.type("application/json")
|
||||||
|
.send(
|
||||||
|
result.stdout ||
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
exit_code: result.exitCode,
|
||||||
|
stderr: result.stderr.slice(0, 2000),
|
||||||
|
message: `CLI exited with code ${result.exitCode}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
res.json(JSON.parse(stdout));
|
res.json(JSON.parse(result.stdout));
|
||||||
} catch {
|
} catch {
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
schema_version: "tjwater-cli/v1",
|
schema_version: "tjwater-cli/v1",
|
||||||
raw: stdout,
|
raw: result.stdout,
|
||||||
stderr: stderr || undefined,
|
stderr: result.stderr || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -252,33 +406,62 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
|||||||
|
|
||||||
const callBackendJson = async (
|
const callBackendJson = async (
|
||||||
path: string,
|
path: string,
|
||||||
accessToken: string | undefined,
|
context: RuntimeSessionContext,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
) => {
|
) => {
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const result = await runWithCredentialRefresh(
|
||||||
Accept: "application/json",
|
credentialRefreshCoordinator,
|
||||||
"Content-Type": "application/json",
|
context,
|
||||||
};
|
async (activeContext) => {
|
||||||
if (accessToken) {
|
const controller = new AbortController();
|
||||||
headers.Authorization = `Bearer ${accessToken}`;
|
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), {
|
return result;
|
||||||
method: "POST",
|
} catch (error) {
|
||||||
headers,
|
if (!(error instanceof CredentialRefreshError)) {
|
||||||
body: JSON.stringify(payload),
|
throw error;
|
||||||
signal: controller.signal,
|
}
|
||||||
});
|
if (error.code === "cancelled") {
|
||||||
const text = await response.text();
|
throw error;
|
||||||
|
}
|
||||||
|
markAuthExpired(context, "access_token_expired");
|
||||||
return {
|
return {
|
||||||
ok: response.ok,
|
ok: false,
|
||||||
status: response.status,
|
status: 401,
|
||||||
text,
|
text: JSON.stringify({
|
||||||
|
message: "credential refresh failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +470,34 @@ const parseStringArray = (value: unknown) =>
|
|||||||
? value.filter((item): item is string => typeof item === "string")
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
: undefined;
|
: 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) => {
|
app.post("/internal/tools/web-search", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
@@ -316,8 +527,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const payload = {
|
const payload = {
|
||||||
query,
|
query,
|
||||||
freshness:
|
freshness: normalizeWebSearchFreshness(req.body?.freshness),
|
||||||
typeof req.body?.freshness === "string" ? req.body.freshness : undefined,
|
|
||||||
summary:
|
summary:
|
||||||
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
|
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
|
||||||
count,
|
count,
|
||||||
@@ -327,8 +537,8 @@ app.post("/internal/tools/web-search", async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await callBackendJson(
|
const response = await callBackendJson(
|
||||||
"/api/v1/web-search",
|
"/api/v1/web-searches",
|
||||||
context.accessToken,
|
context,
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
res
|
res
|
||||||
@@ -370,8 +580,8 @@ app.post("/internal/tools/geocode", async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await callBackendJson(
|
const response = await callBackendJson(
|
||||||
"/api/v1/tianditu/geocode",
|
"/api/v1/geocoding-requests",
|
||||||
context.accessToken,
|
context,
|
||||||
{ keyword },
|
{ keyword },
|
||||||
);
|
);
|
||||||
res
|
res
|
||||||
@@ -387,18 +597,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(
|
app.use(
|
||||||
"/api/v1/agent/chat",
|
"/api/v1/agent",
|
||||||
buildChatRouter(
|
buildAgentPublicRouter(authenticatedChatRouter),
|
||||||
sessionBridge,
|
|
||||||
opencodeRuntime,
|
|
||||||
sessionMetadataStore,
|
|
||||||
sessionUiStateStore,
|
|
||||||
memoryStore,
|
|
||||||
sessionTranscriptStore,
|
|
||||||
learningOrchestrator,
|
|
||||||
resultReferenceResolver,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
@@ -408,25 +622,15 @@ const bootstrap = async () => {
|
|||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
|
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
|
||||||
sessionTranscriptStore.initialize(),
|
sessionTranscriptStore.initialize(),
|
||||||
]);
|
]);
|
||||||
resultReferenceStore.startCleanupLoop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await bootstrap();
|
|
||||||
|
|
||||||
const server = app.listen(config.PORT, config.HOST, () => {
|
|
||||||
logger.info(
|
|
||||||
{ host: config.HOST, port: config.PORT },
|
|
||||||
"TJWaterAgent listening",
|
|
||||||
);
|
|
||||||
void warmupOpencodeRuntime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const warmupOpencodeRuntime = async () => {
|
const warmupOpencodeRuntime = async () => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
await opencodeRuntime.ensureClient();
|
await opencodeRuntime.warmup();
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||||
@@ -443,9 +647,26 @@ const warmupOpencodeRuntime = async () => {
|
|||||||
},
|
},
|
||||||
"failed to warm up opencode runtime",
|
"failed to warm up opencode runtime",
|
||||||
);
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await bootstrap();
|
||||||
|
await warmupOpencodeRuntime();
|
||||||
|
resultReferenceStore.startCleanupLoop();
|
||||||
|
|
||||||
|
const server = app.listen(config.PORT, config.HOST, () => {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
host: config.HOST,
|
||||||
|
port: config.PORT,
|
||||||
|
ready: true,
|
||||||
|
warmedUp: true,
|
||||||
|
},
|
||||||
|
"TJWaterAgent listening",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
logger.info("shutting down TJWaterAgent");
|
logger.info("shutting down TJWaterAgent");
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
slugify,
|
slugify,
|
||||||
|
toStableId,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type SessionStatus = "active" | "archived";
|
export type SessionStatus = "active" | "archived";
|
||||||
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
|
|||||||
parentSessionId?: string;
|
parentSessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class SessionMetadataOwnershipError extends Error {
|
||||||
|
constructor(sessionId: string) {
|
||||||
|
super(`session metadata ownership mismatch: ${sessionId}`);
|
||||||
|
this.name = "SessionMetadataOwnershipError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionMetadataStore {
|
export class SessionMetadataStore {
|
||||||
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
|
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
|
||||||
|
|
||||||
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
throw new Error("sessionId is required");
|
throw new Error("sessionId is required");
|
||||||
}
|
}
|
||||||
const existing = await readJsonFile<SessionRecord>(
|
const existing = await this.readRecord(sessionId);
|
||||||
this.filePath(sessionId),
|
|
||||||
);
|
|
||||||
if (existing) {
|
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 };
|
return { created: false, record: existing };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
|
|||||||
if (!normalizedSessionId) {
|
if (!normalizedSessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await readJsonFile<SessionRecord>(
|
const record = await this.readRecord(normalizedSessionId);
|
||||||
this.filePath(normalizedSessionId),
|
return record && matchesContext(record, context) ? record : null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async touch(
|
async touch(
|
||||||
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
|
|||||||
this.filePath(record.sessionId),
|
this.filePath(record.sessionId),
|
||||||
next,
|
next,
|
||||||
);
|
);
|
||||||
|
await removeFileIfExists(this.legacyFilePath(record.sessionId));
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
|
|||||||
const records = await Promise.all(
|
const records = await Promise.all(
|
||||||
files.map((file) => readJsonFile<SessionRecord>(file)),
|
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 is SessionRecord => Boolean(record))
|
||||||
.filter(
|
|
||||||
(record) =>
|
|
||||||
record.actorKey === context.actorKey &&
|
|
||||||
record.projectKey === context.projectKey,
|
|
||||||
)
|
|
||||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(record: SessionRecord) {
|
async remove(record: SessionRecord) {
|
||||||
await removeFileIfExists(
|
await Promise.all(
|
||||||
this.filePath(record.sessionId),
|
this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private filePath(sessionId: string) {
|
private filePath(sessionId: string) {
|
||||||
|
return join(
|
||||||
|
this.baseDir,
|
||||||
|
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private legacyFilePath(sessionId: string) {
|
||||||
return join(this.baseDir, `${slugify(sessionId)}.json`);
|
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 normalizeSessionId = (value?: string) => {
|
||||||
const normalized = value?.trim();
|
const normalized = value?.trim();
|
||||||
return normalized ? normalized.slice(0, 128) : undefined;
|
return normalized ? normalized.slice(0, 128) : undefined;
|
||||||
|
|||||||
@@ -147,29 +147,6 @@ export class SessionTranscriptStore {
|
|||||||
return nextTranscript;
|
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(
|
async search(
|
||||||
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
context: Pick<SessionTranscriptContext, "actorKey" | "projectKey">,
|
||||||
query: string,
|
query: string,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { buildBackendContextHeaders } from "../../src/auth/backendContextHeaders.js";
|
||||||
|
|
||||||
|
describe("buildBackendContextHeaders", () => {
|
||||||
|
it("forwards authenticated project and trace context to the backend", () => {
|
||||||
|
expect(
|
||||||
|
buildBackendContextHeaders({
|
||||||
|
accessToken: "access-token-1",
|
||||||
|
projectId: "project-id-1",
|
||||||
|
traceId: "trace-id-1",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer access-token-1",
|
||||||
|
"X-Project-Id": "project-id-1",
|
||||||
|
"X-Trace-Id": "trace-id-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits optional authentication and project headers when unavailable", () => {
|
||||||
|
expect(buildBackendContextHeaders({ traceId: "trace-id-2" })).toEqual({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Trace-Id": "trace-id-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CredentialRefreshCoordinator,
|
||||||
|
CredentialRefreshError,
|
||||||
|
runWithCredentialRefresh,
|
||||||
|
} from "../../src/auth/credentialRefresh.js";
|
||||||
|
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||||
|
|
||||||
|
const context = (overrides: Partial<RuntimeSessionContext> = {}) => ({
|
||||||
|
accessToken: "old-token",
|
||||||
|
actorKey: "user-1",
|
||||||
|
clientSessionId: "client-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CredentialRefreshCoordinator", () => {
|
||||||
|
test("deduplicates concurrent refreshes for one session", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
const requestIds: string[] = [];
|
||||||
|
coordinator.subscribe("session-1", (event) => {
|
||||||
|
if (event.type === "credential_refresh_required") {
|
||||||
|
requestIds.push(event.requestId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const expired = context({ tokenExpiresAt: new Date(0).toISOString() });
|
||||||
|
const execute = async (active: RuntimeSessionContext) => ({
|
||||||
|
status: 200,
|
||||||
|
token: active.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runWithCredentialRefresh(coordinator, expired, execute);
|
||||||
|
const second = runWithCredentialRefresh(coordinator, expired, execute);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(requestIds).toHaveLength(1);
|
||||||
|
expect(coordinator.getPendingEvent("session-1")).toMatchObject({
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
requestId: requestIds[0],
|
||||||
|
reason: "access_token_expired",
|
||||||
|
});
|
||||||
|
coordinator.resolve(
|
||||||
|
"session-1",
|
||||||
|
requestIds[0]!,
|
||||||
|
context({ accessToken: "fresh-token" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await first).toEqual({ status: 200, token: "fresh-token" });
|
||||||
|
expect(await second).toEqual({ status: 200, token: "fresh-token" });
|
||||||
|
expect(coordinator.getPendingEvent("session-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries one time on 401 and never refreshes a 403", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
let requestId = "";
|
||||||
|
coordinator.subscribe("session-1", (event) => {
|
||||||
|
if (event.type === "credential_refresh_required") {
|
||||||
|
requestId = event.requestId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let attempts = 0;
|
||||||
|
const resultPromise = runWithCredentialRefresh(
|
||||||
|
coordinator,
|
||||||
|
context(),
|
||||||
|
async () => ({ status: ++attempts === 1 ? 401 : 401 }),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
coordinator.resolve("session-1", requestId, context({ accessToken: "fresh-token" }));
|
||||||
|
expect((await resultPromise).status).toBe(401);
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
|
||||||
|
requestId = "";
|
||||||
|
expect(
|
||||||
|
(await runWithCredentialRefresh(coordinator, context(), async () => ({ status: 403 })))
|
||||||
|
.status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(requestId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails explicitly when no event stream can refresh credentials", async () => {
|
||||||
|
await expect(
|
||||||
|
runWithCredentialRefresh(
|
||||||
|
new CredentialRefreshCoordinator(),
|
||||||
|
context({ tokenExpiresAt: new Date(0).toISOString() }),
|
||||||
|
async () => ({ status: 200 }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(CredentialRefreshError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports run cancellation separately from authentication failure", async () => {
|
||||||
|
const coordinator = new CredentialRefreshCoordinator();
|
||||||
|
coordinator.subscribe("session-1", () => undefined);
|
||||||
|
const pending = coordinator.request("session-1", "access_token_rejected");
|
||||||
|
coordinator.cancelSession("session-1");
|
||||||
|
await expect(pending).rejects.toMatchObject({ code: "cancelled" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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: {
|
||||||
|
maxOutputBytes?: number;
|
||||||
|
terminationGraceMs?: number;
|
||||||
|
timeoutSec?: number;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
executeCliCommand(context, command, options.timeoutSec ?? 1, {
|
||||||
|
apiBaseUrl: "http://127.0.0.1:8000",
|
||||||
|
cliPath,
|
||||||
|
maxOutputBytes: options.maxOutputBytes ?? 64,
|
||||||
|
terminationGraceMs: options.terminationGraceMs ?? 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeCliCommand", () => {
|
||||||
|
test("accepts output at the byte limit", async () => {
|
||||||
|
await expect(run("stdout 123456", { maxOutputBytes: 6 })).resolves.toMatchObject({
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
status: 200,
|
||||||
|
stdout: "123456",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects multibyte output above the byte limit without returning a partial body", async () => {
|
||||||
|
await expect(run("stdout 水水", { maxOutputBytes: 5 })).resolves.toMatchObject({
|
||||||
|
outcome: "output_limit",
|
||||||
|
exceededStream: "stdout",
|
||||||
|
status: 502,
|
||||||
|
stderr: "",
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits stderr independently", async () => {
|
||||||
|
await expect(run("stderr 1234567", { maxOutputBytes: 6 })).resolves.toMatchObject({
|
||||||
|
outcome: "output_limit",
|
||||||
|
exceededStream: "stderr",
|
||||||
|
status: 502,
|
||||||
|
stderr: "",
|
||||||
|
stdout: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
maxOutputBytes: 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,
|
||||||
|
maxOutputBytes: 64,
|
||||||
|
terminationGraceMs: 20,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/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 === "term") {
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
setTimeout(() => process.exit(0), 30);
|
||||||
|
});
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "ignore-term") {
|
||||||
|
process.on("SIGTERM", () => undefined);
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "closed-stdin") {
|
||||||
|
closeSync(0);
|
||||||
|
setInterval(() => undefined, 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
executeMemoryManager,
|
||||||
|
executeSkillManager,
|
||||||
|
} from "../../src/learning/toolManagers.js";
|
||||||
|
import { MemoryStore } from "../../src/memory/store.js";
|
||||||
|
import {
|
||||||
|
getRuntimeSessionContext,
|
||||||
|
removeRuntimeSessionContext,
|
||||||
|
setRuntimeSessionContext,
|
||||||
|
type RuntimeSessionContext,
|
||||||
|
} from "../../src/runtime/sessionContext.js";
|
||||||
|
import { SkillStore } from "../../src/skills/store.js";
|
||||||
|
|
||||||
|
describe("main-process learning tool managers", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let memoryStore: MemoryStore;
|
||||||
|
let skillStore: SkillStore;
|
||||||
|
let context: RuntimeSessionContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-learning-tools-"));
|
||||||
|
memoryStore = new MemoryStore(
|
||||||
|
join(tempDir, "memory"),
|
||||||
|
join(tempDir, "backup", "memory"),
|
||||||
|
);
|
||||||
|
skillStore = new SkillStore(
|
||||||
|
join(tempDir, "skills"),
|
||||||
|
join(tempDir, "backup", "skills"),
|
||||||
|
);
|
||||||
|
await memoryStore.initialize();
|
||||||
|
context = {
|
||||||
|
actorKey: "actor-1",
|
||||||
|
allowLearningWrite: true,
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
projectKey: "project-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
traceId: "trace-1",
|
||||||
|
};
|
||||||
|
setRuntimeSessionContext(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
removeRuntimeSessionContext(context.sessionId);
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces list-before-add using the canonical runtime context", async () => {
|
||||||
|
const rejected = await executeMemoryManager(memoryStore, context, {
|
||||||
|
action: "add",
|
||||||
|
content: "用户偏好查看压力单位为 MPa",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
expect(rejected.decision).toBe("rejected");
|
||||||
|
|
||||||
|
await executeMemoryManager(memoryStore, context, {
|
||||||
|
action: "list",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
const refreshedContext = getRuntimeSessionContext(context.sessionId)!;
|
||||||
|
const accepted = await executeMemoryManager(memoryStore, refreshedContext, {
|
||||||
|
action: "add",
|
||||||
|
content: "用户偏好查看压力单位为 MPa",
|
||||||
|
scope: "user",
|
||||||
|
});
|
||||||
|
expect(accepted.decision).toBe("accepted");
|
||||||
|
expect(await memoryStore.list("user", context.actorKey)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes and removes skills through the shared store", async () => {
|
||||||
|
const content = [
|
||||||
|
"---",
|
||||||
|
"name: pressure-review",
|
||||||
|
"description: Pressure review workflow.",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"# Pressure Review",
|
||||||
|
].join("\n");
|
||||||
|
const written = await executeSkillManager(skillStore, context, {
|
||||||
|
action: "write_skill",
|
||||||
|
content,
|
||||||
|
skill_path: "workflow/pressure-review",
|
||||||
|
});
|
||||||
|
expect(written.decision).toBe("accepted");
|
||||||
|
expect("target" in written).toBe(true);
|
||||||
|
if (!("target" in written)) throw new Error("write returned no target");
|
||||||
|
await expect(readFile(written.target, "utf8")).resolves.toContain(
|
||||||
|
"# Pressure Review\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const removed = await executeSkillManager(skillStore, context, {
|
||||||
|
action: "remove_skill",
|
||||||
|
skill_path: "workflow/pressure-review",
|
||||||
|
});
|
||||||
|
expect(removed.decision).toBe("accepted");
|
||||||
|
expect("target" in removed).toBe(true);
|
||||||
|
if (!("target" in removed)) throw new Error("remove returned no target");
|
||||||
|
await expect(readFile(removed.target, "utf8")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
||||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
|
|
||||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
|
||||||
import { SkillStore } from "../../src/skills/store.js";
|
|
||||||
|
|
||||||
describe("skill_manager tool", () => {
|
|
||||||
let tempDir: string;
|
|
||||||
let skillStore: SkillStore;
|
|
||||||
let context: RuntimeSessionContext;
|
|
||||||
|
|
||||||
const toolContext = {
|
|
||||||
abort: new AbortController().signal,
|
|
||||||
agent: "test",
|
|
||||||
ask: (() => undefined) as never,
|
|
||||||
directory: "",
|
|
||||||
messageID: "message-1",
|
|
||||||
metadata: () => undefined,
|
|
||||||
sessionID: "session-1",
|
|
||||||
worktree: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const skillDocument = (body: string) =>
|
|
||||||
[
|
|
||||||
"---",
|
|
||||||
"name: pressure-review",
|
|
||||||
"description: Pressure review workflow.",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
body,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
|
|
||||||
skillStore = new SkillStore(
|
|
||||||
join(tempDir, "skills"),
|
|
||||||
join(tempDir, "backup", "skills"),
|
|
||||||
);
|
|
||||||
context = {
|
|
||||||
actorKey: "actor-1",
|
|
||||||
allowLearningWrite: true,
|
|
||||||
clientSessionId: "client-session-1",
|
|
||||||
projectKey: "project-1",
|
|
||||||
sessionId: "session-1",
|
|
||||||
traceId: "trace-1",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await rm(tempDir, { force: true, recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches skill-level write, overwrite, and remove actions", async () => {
|
|
||||||
const tool = createSkillManagerTool(
|
|
||||||
skillStore,
|
|
||||||
{ read: () => context },
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const writeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: skillDocument("# Pressure Review"),
|
|
||||||
reason: "verified reusable workflow",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(writeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# Pressure Review\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: skillDocument("# Updated Pressure Review"),
|
|
||||||
reason: "verified reusable workflow overwrite",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(updateResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# Updated Pressure Review\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "remove_skill",
|
|
||||||
reason: "workflow is obsolete",
|
|
||||||
skill_path: "workflow/pressure-review",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
expect(removeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the root skills index through the reserved alias", async () => {
|
|
||||||
const tool = createSkillManagerTool(
|
|
||||||
skillStore,
|
|
||||||
{ read: () => context },
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const writeResult = JSON.parse(
|
|
||||||
await tool.execute(
|
|
||||||
{
|
|
||||||
action: "write_skill",
|
|
||||||
content: [
|
|
||||||
"---",
|
|
||||||
"name: skills",
|
|
||||||
"description: TJWater Skills root index.",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
"# TJWater Skills",
|
|
||||||
].join("\n"),
|
|
||||||
reason: "refresh root skills index",
|
|
||||||
skill_path: "__root__",
|
|
||||||
},
|
|
||||||
toolContext,
|
|
||||||
) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(writeResult.decision).toBe("accepted");
|
|
||||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
|
||||||
"# TJWater Skills\n",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ describe("ResultReferenceResolver", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||||
store = new ResultReferenceStore(tempDir, 60_000);
|
store = new ResultReferenceStore(tempDir, 60_000);
|
||||||
resolver = new ResultReferenceResolver(store);
|
resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
|
||||||
await store.initialize();
|
await store.initialize();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +193,53 @@ describe("ResultReferenceResolver", () => {
|
|||||||
"DMA-2": "#00ff00",
|
"DMA-2": "#00ff00",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await expect(stat(filePath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects render payload files outside the configured import directory", async () => {
|
||||||
|
const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-"));
|
||||||
|
const filePath = join(outsideDir, "render-wrapper.json");
|
||||||
|
await writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
metadata: {},
|
||||||
|
location: { file_path: filePath },
|
||||||
|
data: { node_area_map: { J1: "DMA-1" } },
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
resolver.registerRenderPayloadFile(filePath, {
|
||||||
|
actorKey: "actor-4",
|
||||||
|
clientSessionId: "client-4",
|
||||||
|
projectKey: "project-key-4",
|
||||||
|
sessionId: "session-4",
|
||||||
|
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||||
|
traceId: "trace-4",
|
||||||
|
}),
|
||||||
|
).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(tempDir, "oversized.json");
|
||||||
|
await writeFile(filePath, "x".repeat(128), "utf8");
|
||||||
|
const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 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",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
||||||
|
import express, { Router } from "express";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js";
|
||||||
|
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||||
|
import type { ActiveRun } from "../../src/routes/chatUiState.js";
|
||||||
|
|
||||||
|
describe("chat interaction routes", () => {
|
||||||
|
let baseUrl = "";
|
||||||
|
let server: Server;
|
||||||
|
const replyQuestion = mock(async () => ({ ok: true }));
|
||||||
|
const replyPermission = mock(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const activeRuns = new Map<string, ActiveRun>();
|
||||||
|
activeRuns.set("runtime-session", {
|
||||||
|
clientSessionId: "client-session",
|
||||||
|
controller: new AbortController(),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
questions: [{ requestId: "question-1", status: "pending" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pendingPermissions: new Map([
|
||||||
|
[
|
||||||
|
"permission-1",
|
||||||
|
{
|
||||||
|
session_id: "runtime-session",
|
||||||
|
request_id: "permission-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
always: ["npm test"],
|
||||||
|
created_at: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
pendingQuestions: new Map([
|
||||||
|
[
|
||||||
|
"question-1",
|
||||||
|
{
|
||||||
|
created_at: 1,
|
||||||
|
request_id: "question-1",
|
||||||
|
session_id: "runtime-session",
|
||||||
|
questions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
status: "running",
|
||||||
|
subscribers: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use((req, _res, next) => {
|
||||||
|
req.agentAuth = {
|
||||||
|
accessToken: "access-token",
|
||||||
|
userId: "user-1",
|
||||||
|
keycloakSub: "keycloak-1",
|
||||||
|
username: "tester",
|
||||||
|
role: "user",
|
||||||
|
isSuperuser: false,
|
||||||
|
projectId: "project-1",
|
||||||
|
network: "network-1",
|
||||||
|
projectRole: "member",
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
registerChatInteractionRoutes(router, {
|
||||||
|
activeRuns,
|
||||||
|
credentialRefreshCoordinator: new CredentialRefreshCoordinator(),
|
||||||
|
runtime: { replyPermission, replyQuestion } as never,
|
||||||
|
sessionMetadataStore: {
|
||||||
|
get: async () => ({ sessionId: "runtime-session" }),
|
||||||
|
} as never,
|
||||||
|
sessionUiStateStore: {
|
||||||
|
read: async () => null,
|
||||||
|
write: async () => undefined,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(router);
|
||||||
|
server = app.listen(0);
|
||||||
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("test server did not expose a TCP port");
|
||||||
|
}
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits answers to the stable OpenCode question adapter", async () => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/sessions/client-session/question-responses`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: "question-1",
|
||||||
|
action: "reply",
|
||||||
|
answers: [["继续"]],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(replyQuestion).toHaveBeenCalledWith({
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
answers: [["继续"]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards saved permission grants to OpenCode", async () => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/sessions/client-session/permission-responses`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: "permission-1",
|
||||||
|
reply: "always",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(replyPermission).toHaveBeenCalledWith({
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "runtime-session",
|
||||||
|
reply: "always",
|
||||||
|
message: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
canAutoApprovePermission,
|
||||||
|
resolvePermissionApproval,
|
||||||
|
} from "../../src/routes/chatPermissionPolicy.js";
|
||||||
|
|
||||||
|
describe("permission approval policy", () => {
|
||||||
|
it.each([
|
||||||
|
"show_chart",
|
||||||
|
"web_search",
|
||||||
|
])("allows low-risk permission %s", (permission) => {
|
||||||
|
expect(canAutoApprovePermission(permission)).toBe(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: "已按始终允许模式放行",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildForkedSessionUiState,
|
||||||
|
} from "../../src/routes/chat.js";
|
||||||
import {
|
import {
|
||||||
buildPromptWithLearningContext,
|
buildPromptWithLearningContext,
|
||||||
extractLatestFrontendTurn,
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,242 @@ const createEventStream = (events: unknown[]) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("streamPromptResponse", () => {
|
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 () => {
|
it("forwards opencode permission requests as SSE payloads", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -61,7 +297,7 @@ describe("streamPromptResponse", () => {
|
|||||||
} satisfies Partial<PermissionRequestPayload>);
|
} satisfies Partial<PermissionRequestPayload>);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto replies always when approval mode is always", async () => {
|
it("auto approves an allowlisted low-risk permission once", async () => {
|
||||||
const replies: Array<Record<string, unknown>> = [];
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -71,10 +307,10 @@ describe("streamPromptResponse", () => {
|
|||||||
properties: {
|
properties: {
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
sessionID: "runtime-session-1",
|
sessionID: "runtime-session-1",
|
||||||
permission: "bash",
|
permission: "show_chart",
|
||||||
patterns: ["npm test"],
|
patterns: ["*"],
|
||||||
metadata: { command: "npm test" },
|
metadata: {},
|
||||||
always: ["npm test"],
|
always: ["*"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -97,7 +333,7 @@ describe("streamPromptResponse", () => {
|
|||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "run tests",
|
message: "run tests",
|
||||||
approvalMode: "always",
|
approvalMode: "auto",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,17 +341,100 @@ describe("streamPromptResponse", () => {
|
|||||||
{
|
{
|
||||||
requestId: "perm-1",
|
requestId: "perm-1",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
reply: "always",
|
reply: "once",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
||||||
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
|
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
|
||||||
session_id: "client-session-1",
|
session_id: "client-session-1",
|
||||||
request_id: "perm-1",
|
request_id: "perm-1",
|
||||||
reply: "always",
|
reply: "once",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps high-risk permissions interactive in auto mode", async () => {
|
||||||
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-auto-bash",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
metadata: { command: "npm test" },
|
||||||
|
always: ["npm test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => [],
|
||||||
|
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
message: "run tests",
|
||||||
|
approvalMode: "auto",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replies).toEqual([]);
|
||||||
|
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
|
||||||
|
request_id: "perm-auto-bash",
|
||||||
|
permission: "bash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approves every OpenCode ask once in always mode", async () => {
|
||||||
|
const replies: Array<Record<string, unknown>> = [];
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-always-bash",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
metadata: { command: "npm test" },
|
||||||
|
always: ["npm test"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => [],
|
||||||
|
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
message: "run tests",
|
||||||
|
approvalMode: "always",
|
||||||
|
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("forwards opencode v2 permission requests as SSE payloads", async () => {
|
it("forwards opencode v2 permission requests as SSE payloads", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
|
|||||||
@@ -1,10 +1,45 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendBackendToolArtifact,
|
||||||
cancelBackendTodos,
|
cancelBackendTodos,
|
||||||
upsertBackendQuestion,
|
upsertBackendQuestion,
|
||||||
} from "../../src/routes/chatUiState.js";
|
} 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", () => {
|
describe("upsertBackendQuestion", () => {
|
||||||
it("replaces a tool-call placeholder with the actionable question request", () => {
|
it("replaces a tool-call placeholder with the actionable question request", () => {
|
||||||
const questions = upsertBackendQuestion(
|
const questions = upsertBackendQuestion(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||||
|
|
||||||
|
import { config } from "../../src/config.js";
|
||||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||||
|
|
||||||
const createRuntimeAdapter = (
|
const createRuntimeAdapter = (
|
||||||
@@ -85,3 +86,74 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|||||||
expect(attempts).toBe(2);
|
expect(attempts).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: [["继续"]] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
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?.["*.env*"]).toBe("deny");
|
||||||
|
expect(bash?.["*data/*"]).toBe("deny");
|
||||||
|
expect(bash?.["*logs/*"]).toBe("deny");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ describe("runtime session context", () => {
|
|||||||
allowLearningWrite: true,
|
allowLearningWrite: true,
|
||||||
clientSessionId: "chat-session-1",
|
clientSessionId: "chat-session-1",
|
||||||
learningMode: "interactive",
|
learningMode: "interactive",
|
||||||
|
network: "fengyang",
|
||||||
projectId: "project-id-1",
|
projectId: "project-id-1",
|
||||||
projectKey: "project-1",
|
projectKey: "project-1",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
@@ -24,6 +25,7 @@ describe("runtime session context", () => {
|
|||||||
|
|
||||||
expect(runtimeContext?.accessToken).toBe("token-1");
|
expect(runtimeContext?.accessToken).toBe("token-1");
|
||||||
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||||
|
expect(runtimeContext?.network).toBe("fengyang");
|
||||||
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
||||||
|
|
||||||
removeRuntimeSessionContext("runtime-session-1");
|
removeRuntimeSessionContext("runtime-session-1");
|
||||||
|
|||||||
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
|
|||||||
);
|
);
|
||||||
expect(fetched?.title).toBe("新标题");
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user