Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d36aa8ca | ||
|
|
4e31b141e7 | ||
|
|
7dbbeb4aa9 | ||
|
|
6f3b72628f | ||
|
|
2295bdcb97 | ||
|
|
359b1e6e55 | ||
|
|
93d70da8be | ||
|
|
ff87817fb5 | ||
|
|
4b03aa3a91 | ||
|
|
8a7964dc57 |
@@ -7,6 +7,5 @@ __pycache__/
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
cli/
|
|
||||||
AGENT_HARNESS_REPORT.md
|
AGENT_HARNESS_REPORT.md
|
||||||
HARNESS_INTRODUCTION.md
|
HARNESS_INTRODUCTION.md
|
||||||
|
|||||||
@@ -42,6 +42,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` 子代理,避免无法观测过程进行人为干预
|
||||||
|
|
||||||
## 工作流沉淀(skill_manager)
|
## 工作流沉淀(skill_manager)
|
||||||
|
|
||||||
|
|||||||
+19
-9
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.14.41",
|
"@opencode-ai/plugin": "^1.16.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
@@ -13,11 +13,21 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.41", "", { "dependencies": { "@opencode-ai/sdk": "1.14.41", "effect": "4.0.0-beta.59", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Q/QdDKSfHyYX+Xqd79o4XgyZKqF8h5qgqgfmOQbKVLhbduc9zMYdpV2yvWT6gaJPrpOftpka/kpr56PCqzetYQ=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.41", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-RYb2dCUv0TWIvBNnnO6ANbAPYri6rKuWizSoVFw/Pw+SCDj9ASHM5gAZ+jkskp8gYMfLLHe/Fpkun/9mr8m0IQ=="],
|
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||||
|
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.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/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -27,21 +37,21 @@
|
|||||||
|
|
||||||
"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.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
|
"effect": ["effect@4.0.0-beta.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=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||||
|
|
||||||
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
|
||||||
|
|
||||||
"msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="],
|
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||||
|
|
||||||
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
|
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||||
|
|
||||||
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||||
|
|
||||||
@@ -61,7 +71,7 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"uuid": ["uuid@13.0.2", "", { "bin": "dist-node/bin/uuid" }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="],
|
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.14.41"
|
"@opencode-ai/plugin": "^1.16.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
|
|||||||
+4
-1
@@ -56,6 +56,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||||
COPY tsconfig.json opencode.json README.md .gitignore ./
|
COPY tsconfig.json opencode.json README.md .gitignore ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY cli ./cli
|
||||||
COPY .opencode ./.opencode
|
COPY .opencode ./.opencode
|
||||||
RUN bun run check
|
RUN bun run check
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ 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 TJWATER_CLI_PATH=./cli/tjwater-cli
|
||||||
|
|
||||||
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
|
||||||
@@ -72,9 +74,10 @@ COPY package.json bun.lock ./
|
|||||||
COPY tsconfig.json opencode.json .gitignore ./
|
COPY tsconfig.json opencode.json .gitignore ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY .opencode ./.opencode
|
COPY .opencode ./.opencode
|
||||||
|
COPY cli ./cli
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "tjwater-agent",
|
"name": "tjwater-agent",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.14.29",
|
"@opencode-ai/sdk": "^1.16.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="],
|
||||||
|
|
||||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { CliError } from "../core/errors.js";
|
||||||
|
import { emitApi, requestJson } from "../core/http.js";
|
||||||
|
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
|
||||||
|
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
|
||||||
|
import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js";
|
||||||
|
import { parseTime } from "../core/time.js";
|
||||||
|
import { success } from "../core/output.js";
|
||||||
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { duration: "integer" });
|
||||||
|
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
|
||||||
|
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
|
||||||
|
return emitApi(ctx, "爆管分析执行成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/burst_analysis/",
|
||||||
|
params: {
|
||||||
|
network: requireNetwork(ctx),
|
||||||
|
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
burst_ID: ids,
|
||||||
|
burst_size: sizes,
|
||||||
|
modify_total_duration: requiredNumber(values, "duration"),
|
||||||
|
scheme_name: schemeName,
|
||||||
|
},
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" });
|
||||||
|
const mode = validateChoice(requiredString(values, "mode"), ["close", "isolation"] as const, "--mode");
|
||||||
|
if (mode === "close") {
|
||||||
|
const valves = optionalStringArray(values, "valve");
|
||||||
|
const startTime = optionalString(values, "start-time");
|
||||||
|
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
|
||||||
|
return emitApi(ctx, "阀门关闭分析执行成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/valve_close_analysis/",
|
||||||
|
params: {
|
||||||
|
network: requireNetwork(ctx),
|
||||||
|
start_time: parseTime(startTime, "--start-time"),
|
||||||
|
valves,
|
||||||
|
duration: optionalNumber(values, "duration") || 900,
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
},
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const elements = optionalStringArray(values, "element");
|
||||||
|
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
|
||||||
|
return emitApi(ctx, "阀门隔离分析执行成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/valve_isolation_analysis/",
|
||||||
|
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
|
||||||
|
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
|
||||||
|
return emitApi(ctx, "冲洗分析执行成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/flushing_analysis/",
|
||||||
|
params: {
|
||||||
|
network: requireNetwork(ctx),
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
valves,
|
||||||
|
valves_k: openings,
|
||||||
|
drainage_node_ID: requiredString(values, "drainage-node"),
|
||||||
|
flush_flow: requiredNumber(values, "flow"),
|
||||||
|
duration: optionalNumber(values, "duration") || 900,
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
},
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { duration: "integer" });
|
||||||
|
return emitApi(ctx, "水龄分析执行成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/age_analysis/",
|
||||||
|
params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
network: requireNetwork(ctx),
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
source: requiredString(values, "source-node"),
|
||||||
|
concentration: requiredNumber(values, "concentration"),
|
||||||
|
duration: requiredNumber(values, "duration"),
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
};
|
||||||
|
const pattern = optionalString(values, "pattern");
|
||||||
|
if (pattern) params.pattern = pattern;
|
||||||
|
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant_simulation/", params, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
|
||||||
|
return emitApi(ctx, "传感器选址执行成功", {
|
||||||
|
method: "POST",
|
||||||
|
path: "/pressure_sensor_placement_kmeans/",
|
||||||
|
body: {
|
||||||
|
name: requireNetwork(ctx),
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
sensor_number: requiredNumber(values, "count"),
|
||||||
|
min_diameter: optionalNumber(values, "min-diameter") || 0,
|
||||||
|
username: requireUsername(ctx),
|
||||||
|
},
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
requireUsernameCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, summary, {
|
||||||
|
method: "POST",
|
||||||
|
path,
|
||||||
|
body: {
|
||||||
|
[networkKey]: requireNetwork(ctx),
|
||||||
|
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
},
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||||
|
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
|
const { positionals } = parseOptions(argv);
|
||||||
|
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
|
||||||
|
return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
network: requireNetwork(ctx),
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
data_source: optionalString(values, "data-source") || "monitoring",
|
||||||
|
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
scada_burst_end: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
burst_leakage: requiredNumber(values, "burst-leakage"),
|
||||||
|
use_scada_flow: Boolean(values["use-scada-flow"]),
|
||||||
|
};
|
||||||
|
const pressureIds = optionalStringArray(values, "pressure-scada-id");
|
||||||
|
const flowIds = optionalStringArray(values, "flow-scada-id");
|
||||||
|
if (pressureIds) body.pressure_scada_ids = pressureIds;
|
||||||
|
if (flowIds) body.flow_scada_ids = flowIds;
|
||||||
|
const pressureFile = optionalString(values, "pressure-file");
|
||||||
|
const flowFile = optionalString(values, "flow-file");
|
||||||
|
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
|
||||||
|
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
|
||||||
|
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-location/locate/", body, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), pipe_id: requiredString(values, "pipe") }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
|
||||||
|
const network = requireNetwork(ctx);
|
||||||
|
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true });
|
||||||
|
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true });
|
||||||
|
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analysisHandlers: HandlerMap = {
|
||||||
|
"analysis burst": analysisBurst,
|
||||||
|
"analysis valve": analysisValve,
|
||||||
|
"analysis flushing": analysisFlushing,
|
||||||
|
"analysis age": analysisAge,
|
||||||
|
"analysis contaminant": analysisContaminant,
|
||||||
|
"analysis sensor-placement kmeans": sensorKmeans,
|
||||||
|
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"),
|
||||||
|
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"),
|
||||||
|
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"),
|
||||||
|
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"),
|
||||||
|
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"),
|
||||||
|
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"),
|
||||||
|
"analysis burst-location locate": burstLocation,
|
||||||
|
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"),
|
||||||
|
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"),
|
||||||
|
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"),
|
||||||
|
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"),
|
||||||
|
"analysis risk network": riskNetwork,
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { CliError } from "../core/errors.js";
|
||||||
|
import { emitApi } from "../core/http.js";
|
||||||
|
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
|
||||||
|
import { requireNetwork } from "../core/runtime.js";
|
||||||
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
type ComponentKind = "time" | "energy" | "pump-energy" | "network";
|
||||||
|
|
||||||
|
function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
|
||||||
|
const routes: Record<`${ComponentKind}:${boolean}`, string> = {
|
||||||
|
"time:true": "/gettimeschema",
|
||||||
|
"time:false": "/gettimeproperties/",
|
||||||
|
"energy:true": "/getenergyschema/",
|
||||||
|
"energy:false": "/getenergyproperties/",
|
||||||
|
"pump-energy:true": "/getpumpenergyschema/",
|
||||||
|
"pump-energy:false": "/getpumpenergyproperties//",
|
||||||
|
"network:true": "/getoptionschema/",
|
||||||
|
"network:false": "/getoptionproperties/",
|
||||||
|
};
|
||||||
|
const params: Record<string, unknown> = { network: requireNetwork(ctx) };
|
||||||
|
const pump = optionalString(values, "pump");
|
||||||
|
if (kind === "pump-energy") {
|
||||||
|
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
|
||||||
|
if (pump) params.pump = pump;
|
||||||
|
}
|
||||||
|
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const componentHandlers: HandlerMap = {
|
||||||
|
"component option schema": (ctx, argv) => componentOption(ctx, argv, true),
|
||||||
|
"component option get": (ctx, argv) => componentOption(ctx, argv, false),
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
|
||||||
|
import { CliError } from "../core/errors.js";
|
||||||
|
import { emitApi } from "../core/http.js";
|
||||||
|
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
|
||||||
|
import { requireNetwork, resolveScheme } from "../core/runtime.js";
|
||||||
|
import { parseTime } from "../core/time.js";
|
||||||
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, summary, {
|
||||||
|
method: "GET",
|
||||||
|
path,
|
||||||
|
params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
||||||
|
requireProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, "读取实时模拟数据成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/realtime/query/by-id-time",
|
||||||
|
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
|
||||||
|
requireProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
|
||||||
|
return emitApi(ctx, "读取实时属性聚合数据成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/realtime/query/by-time-property",
|
||||||
|
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
|
||||||
|
requireProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, "读取方案管道数据成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/scheme/links",
|
||||||
|
params: {
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
},
|
||||||
|
requireProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, "读取方案节点字段成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: `/scheme/nodes/${requiredString(values, "node")}/field`,
|
||||||
|
params: {
|
||||||
|
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
},
|
||||||
|
requireProject: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
const query = validateChoice(requiredString(values, "query"), ["by-id-time", "by-scheme-time-property"] as const, "--query");
|
||||||
|
const type = validateChoice(optionalString(values, "type") || "pipe", ["pipe", "junction"] as const, "--type") as ElementType;
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
|
||||||
|
scheme_type: optionalString(values, "scheme-type") || "simulation",
|
||||||
|
query_time: parseTime(requiredString(values, "time"), "--time"),
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
if (query === "by-id-time") {
|
||||||
|
params.id = requiredString(values, "id");
|
||||||
|
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/scheme/query/by-id-time", params, requireProject: true });
|
||||||
|
}
|
||||||
|
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
|
||||||
|
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/scheme/query/by-scheme-time-property", params, requireProject: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { "device-id": "repeat" });
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
device_ids: requiredStringArray(values, "device-id").join(","),
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
};
|
||||||
|
const field = optionalString(values, "field");
|
||||||
|
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
|
||||||
|
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { feature: "repeat", "use-cleaned": "boolean" });
|
||||||
|
const kind = validateChoice(requiredString(values, "kind"), ["scada-simulation", "element-simulation", "element-scada"] as const, "--kind");
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
|
||||||
|
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
|
||||||
|
};
|
||||||
|
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"));
|
||||||
|
if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" });
|
||||||
|
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
|
||||||
|
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
|
||||||
|
else {
|
||||||
|
const feature = requiredStringArray(values, "feature");
|
||||||
|
if (feature.length !== 1) throw new CliError("CLI 参数错误", "FEATURE_REQUIRED", "element-scada requires exactly one --feature as element_id", 2);
|
||||||
|
params.element_id = feature[0];
|
||||||
|
params.use_cleaned = Boolean(values["use-cleaned"]);
|
||||||
|
}
|
||||||
|
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
requiredString(values, "pipe");
|
||||||
|
requiredString(values, "start-time");
|
||||||
|
return emitApi(ctx, "读取管道健康预测成功", {
|
||||||
|
method: "GET",
|
||||||
|
path: "/composite/pipeline-health-prediction",
|
||||||
|
params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
|
||||||
|
requireProject: true,
|
||||||
|
requireNetworkCtx: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
||||||
|
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
|
||||||
|
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/getscheme/", params: { network: requireNetwork(ctx), schema_name: requiredString(values, "name") }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dataHandlers: HandlerMap = {
|
||||||
|
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"),
|
||||||
|
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"),
|
||||||
|
"data timeseries realtime simulation-by-id-time": realtimeByIdTime,
|
||||||
|
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
|
||||||
|
"data timeseries scheme links": schemeLinks,
|
||||||
|
"data timeseries scheme node-field": schemeNodeField,
|
||||||
|
"data timeseries scheme simulation": schemeSimulation,
|
||||||
|
"data timeseries scada query": scadaQuery,
|
||||||
|
"data timeseries composite": composite,
|
||||||
|
"data timeseries composite pipeline-health": pipelineHealth,
|
||||||
|
"data scada get": dataScadaGet,
|
||||||
|
"data scada list": dataScadaList,
|
||||||
|
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||||
|
"data scheme get": dataSchemeGet,
|
||||||
|
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/getallschemes/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { emitApi } from "../core/http.js";
|
||||||
|
import { parseOptions, requiredString } from "../core/options.js";
|
||||||
|
import { requireNetwork } from "../core/runtime.js";
|
||||||
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv);
|
||||||
|
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), [key]: requiredString(values, key) }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
|
||||||
|
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const networkHandlers: HandlerMap = {
|
||||||
|
"network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"),
|
||||||
|
"network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"),
|
||||||
|
"network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"),
|
||||||
|
"network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"),
|
||||||
|
"network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"),
|
||||||
|
"network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"),
|
||||||
|
"network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"),
|
||||||
|
"network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"),
|
||||||
|
"network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"),
|
||||||
|
"network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"),
|
||||||
|
"network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"),
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { emitApi } from "../core/http.js";
|
||||||
|
import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
|
||||||
|
import { requireNetwork } from "../core/runtime.js";
|
||||||
|
import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
|
||||||
|
import type { HandlerMap, RuntimeContext } from "../core/types.js";
|
||||||
|
|
||||||
|
function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
|
||||||
|
const { values } = parseOptions(argv, { duration: "integer" });
|
||||||
|
const start = parseTime(requiredString(values, "start-time"), "--start-time");
|
||||||
|
const duration = requiredNumber(values, "duration");
|
||||||
|
const end = addMinutesPreservingOffset(start, duration);
|
||||||
|
const network = requireNetwork(ctx);
|
||||||
|
return emitApi(
|
||||||
|
ctx,
|
||||||
|
"触发模拟成功",
|
||||||
|
{ method: "POST", path: "/runsimulationmanuallybydate/", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true },
|
||||||
|
[
|
||||||
|
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
|
||||||
|
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const simulationHandlers: HandlerMap = {
|
||||||
|
"simulation run": simulationRun,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const SCHEMA_VERSION = "tjwater-cli/v1";
|
||||||
|
export const DEFAULT_TIMEOUT = 180;
|
||||||
|
export const DEFAULT_SERVER = "http://192.168.1.114:8000";
|
||||||
|
|
||||||
|
export const PIPE_FIELDS = ["flow", "friction", "headloss", "quality", "reaction", "setting", "status", "velocity"] as const;
|
||||||
|
export const JUNCTION_FIELDS = ["actual_demand", "total_head", "pressure", "quality"] as const;
|
||||||
|
export const SCADA_FIELDS = ["monitored_value", "cleaned_value"] as const;
|
||||||
|
|
||||||
|
export type ElementType = "pipe" | "junction";
|
||||||
|
export type PipeField = (typeof PIPE_FIELDS)[number];
|
||||||
|
export type JunctionField = (typeof JUNCTION_FIELDS)[number];
|
||||||
|
export type ScadaField = (typeof SCADA_FIELDS)[number];
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export class CliError extends Error {
|
||||||
|
summary: string;
|
||||||
|
code: string;
|
||||||
|
exitCode: number;
|
||||||
|
retryable: boolean;
|
||||||
|
data: unknown;
|
||||||
|
nextCommands: string[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
summary: string,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
exitCode = 2,
|
||||||
|
retryable = false,
|
||||||
|
data: unknown = null,
|
||||||
|
nextCommands: string[] = [],
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.summary = summary;
|
||||||
|
this.code = code;
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
this.retryable = retryable;
|
||||||
|
this.data = data;
|
||||||
|
this.nextCommands = nextCommands;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { CliError } from "./errors.js";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJsonFile(path: string, label: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||||
|
throw new CliError("CLI 参数错误", "INPUT_NOT_FOUND", `${label} file not found: ${path}`, 2);
|
||||||
|
}
|
||||||
|
if (error instanceof SyntaxError) throw new CliError("CLI 参数错误", "INPUT_INVALID_JSON", `${label} file must be valid JSON: ${path}`, 2);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBurstFile(path: string): [string[], number[]] {
|
||||||
|
let raw = readJsonFile(path, "burst");
|
||||||
|
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
|
||||||
|
if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) {
|
||||||
|
const ids = raw.burst_ID.map(String);
|
||||||
|
const sizes = raw.burst_size.map(Number);
|
||||||
|
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2);
|
||||||
|
return [ids, sizes];
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return [
|
||||||
|
raw.map((item) => {
|
||||||
|
if (!isRecord(item) || !("id" in item) || !("size" in item)) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file items must contain id and size", 2);
|
||||||
|
return String(item.id);
|
||||||
|
}),
|
||||||
|
raw.map((item) => Number((item as Record<string, unknown>).size)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_ID/burst_size", 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseValveSettingFile(path: string): [string[], number[]] {
|
||||||
|
const raw = readJsonFile(path, "valve-setting");
|
||||||
|
if (isRecord(raw) && "valves" in raw && "valves_k" in raw && Array.isArray(raw.valves) && Array.isArray(raw.valves_k)) {
|
||||||
|
const valves = raw.valves.map(String);
|
||||||
|
const openings = raw.valves_k.map(Number);
|
||||||
|
if (valves.length !== openings.length) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valves and valves_k must have the same length", 2);
|
||||||
|
return [valves, openings];
|
||||||
|
}
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return [
|
||||||
|
raw.map((item) => {
|
||||||
|
if (!isRecord(item) || !("valve" in item) || !("opening" in item)) throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting items must contain valve and opening", 2);
|
||||||
|
return String(item.valve);
|
||||||
|
}),
|
||||||
|
raw.map((item) => Number((item as Record<string, unknown>).opening)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
throw new CliError("CLI 参数错误", "VALVE_SETTING_INVALID", "valve-setting file must be a JSON array or object with valves/valves_k", 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignDatasetKeys(target: Record<string, unknown>, path: string, keys: string[], label: string): void {
|
||||||
|
const payload = readJsonFile(path, label);
|
||||||
|
if (isRecord(payload)) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in payload) target[key] = payload[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { CliError, errorMessage } from "./errors.js";
|
||||||
|
import { requireNetwork, requireUsername } from "./runtime.js";
|
||||||
|
import { success } from "./output.js";
|
||||||
|
import type { RequestOptions, RuntimeContext } from "./types.js";
|
||||||
|
|
||||||
|
function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {
|
||||||
|
Accept: "application/json, text/plain, */*",
|
||||||
|
"X-Request-Id": ctx.requestId,
|
||||||
|
...ctx.auth.headers,
|
||||||
|
};
|
||||||
|
if (requireAuth) {
|
||||||
|
if (!ctx.auth.accessToken) {
|
||||||
|
throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]);
|
||||||
|
}
|
||||||
|
out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
||||||
|
} else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
||||||
|
if (requireProject) {
|
||||||
|
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
|
||||||
|
out["X-Project-Id"] = ctx.auth.projectId;
|
||||||
|
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
|
||||||
|
if (ctx.auth.userId) out["X-User-Id"] = ctx.auth.userId;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyParam(value: unknown): string {
|
||||||
|
if (typeof value === "boolean") return value ? "True" : "False";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendParams(url: URL, params: Record<string, unknown> = {}): void {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item)));
|
||||||
|
else url.searchParams.set(key, stringifyParam(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
|
||||||
|
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request;
|
||||||
|
if (requireNetworkCtx) requireNetwork(ctx);
|
||||||
|
if (requireUsernameCtx) requireUsername(ctx);
|
||||||
|
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
||||||
|
appendParams(url, params);
|
||||||
|
const started = performance.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
headers: {
|
||||||
|
...headers(ctx, requireAuth, requireProject),
|
||||||
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true);
|
||||||
|
throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const durationMs = Math.trunc(performance.now() - started);
|
||||||
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
const text = response.status === 204 ? "" : await response.text();
|
||||||
|
let payload: unknown = {};
|
||||||
|
if (contentType.includes("application/json") && text) payload = JSON.parse(text);
|
||||||
|
else if (text) payload = { report: text };
|
||||||
|
if (!response.ok) {
|
||||||
|
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : {};
|
||||||
|
const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`;
|
||||||
|
throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500);
|
||||||
|
}
|
||||||
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload);
|
||||||
|
}
|
||||||
|
return [payload, durationMs];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatus(status: number): number {
|
||||||
|
if (status === 400 || status === 422) return 2;
|
||||||
|
if (status === 401) return 3;
|
||||||
|
if (status === 403) return 4;
|
||||||
|
if (status === 404) return 5;
|
||||||
|
if (status === 409 || status === 412) return 6;
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise<void> {
|
||||||
|
const [data, durationMs] = await requestJson(ctx, request);
|
||||||
|
success(summary, data, ctx, durationMs, nextCommands);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { JUNCTION_FIELDS, PIPE_FIELDS, type ElementType } from "./constants.js";
|
||||||
|
import { CliError } from "./errors.js";
|
||||||
|
import type { OptionSchema, ParsedOptionValue, ParsedOptions } from "./types.js";
|
||||||
|
|
||||||
|
export function requireValue(argv: string[], index: number, option: string): string {
|
||||||
|
const value = argv[index];
|
||||||
|
if (!value || value.startsWith("--")) throw new CliError("CLI 参数错误", "MISSING_OPTION_VALUE", `${option} requires a value`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIntStrict(value: string, option: string): number {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_INTEGER", `${option} must be an integer`, 2);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFloatStrict(value: string, option: string): number {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(parsed)) throw new CliError("CLI 参数错误", "INVALID_NUMBER", `${option} must be a number`, 2);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptions(argv: string[], schema: OptionSchema = {}): ParsedOptions {
|
||||||
|
const values: Record<string, ParsedOptionValue> = {};
|
||||||
|
const positionals: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
positionals.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = arg.slice(2);
|
||||||
|
const kind = schema[name] ?? "string";
|
||||||
|
if (kind === "boolean") {
|
||||||
|
values[name] = true;
|
||||||
|
} else {
|
||||||
|
const raw = requireValue(argv, ++i, arg);
|
||||||
|
if (kind === "repeat") values[name] = [...asStringArray(values[name]), raw];
|
||||||
|
else if (kind === "number") values[name] = parseFloatStrict(raw, arg);
|
||||||
|
else if (kind === "integer") values[name] = parseIntStrict(raw, arg);
|
||||||
|
else values[name] = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { values, positionals };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function required(values: Record<string, ParsedOptionValue>, name: string): string | number | boolean | string[] {
|
||||||
|
const value = values[name];
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
throw new CliError("CLI 参数错误", "MISSING_PARAMETER", `Missing option '--${name}'`, 2);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredString(values: Record<string, ParsedOptionValue>, name: string): string {
|
||||||
|
const value = required(values, name);
|
||||||
|
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredNumber(values: Record<string, ParsedOptionValue>, name: string): number {
|
||||||
|
const value = required(values, name);
|
||||||
|
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] {
|
||||||
|
const value = required(values, name);
|
||||||
|
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalString(values: Record<string, ParsedOptionValue>, name: string): string | undefined {
|
||||||
|
const value = values[name];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "string") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a string`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalNumber(values: Record<string, ParsedOptionValue>, name: string): number | undefined {
|
||||||
|
const value = values[name];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "number") throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be a number`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalStringArray(values: Record<string, ParsedOptionValue>, name: string): string[] | undefined {
|
||||||
|
const value = values[name];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!Array.isArray(value)) throw new CliError("CLI 参数错误", "INVALID_PARAMETER", `--${name} must be repeatable`, 2);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asStringArray(value: ParsedOptionValue | undefined): string[] {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
throw new CliError("CLI 参数错误", "INVALID_PARAMETER", "repeat option received a non-array value", 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateChoice<T extends readonly string[]>(value: string, valid: T, option: string): T[number] {
|
||||||
|
if (!valid.includes(value)) {
|
||||||
|
throw new CliError("CLI 参数错误", `INVALID_${option.replace(/^--/, "").replaceAll("-", "_").toUpperCase()}`, `${option} must be one of: ${valid.join(", ")}`, 2);
|
||||||
|
}
|
||||||
|
return value as T[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldsFor(type: ElementType): typeof PIPE_FIELDS | typeof JUNCTION_FIELDS {
|
||||||
|
if (type === "pipe") return PIPE_FIELDS;
|
||||||
|
if (type === "junction") return JUNCTION_FIELDS;
|
||||||
|
throw new CliError("CLI 参数错误", "INVALID_TYPE", "--type must be one of: pipe, junction", 2);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { SCHEMA_VERSION } from "./constants.js";
|
||||||
|
import type { RuntimeContext } from "./types.js";
|
||||||
|
|
||||||
|
export function json(value: unknown): void {
|
||||||
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatedAt(): string {
|
||||||
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function success(summary: string, data: unknown, ctx: RuntimeContext, durationMs: number, nextCommands: string[] = []): void {
|
||||||
|
json({
|
||||||
|
ok: true,
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
summary,
|
||||||
|
data,
|
||||||
|
metadata: {
|
||||||
|
request_id: ctx.requestId,
|
||||||
|
server: ctx.server,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
generated_at: generatedAt(),
|
||||||
|
},
|
||||||
|
next_commands: nextCommands,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function failure({
|
||||||
|
summary,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
retryable = false,
|
||||||
|
server = null,
|
||||||
|
requestId = null,
|
||||||
|
data = null,
|
||||||
|
nextCommands = [],
|
||||||
|
}: {
|
||||||
|
summary: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
retryable?: boolean;
|
||||||
|
server?: string | null;
|
||||||
|
requestId?: string | null;
|
||||||
|
data?: unknown;
|
||||||
|
nextCommands?: string[];
|
||||||
|
}): void {
|
||||||
|
json({
|
||||||
|
ok: false,
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
summary,
|
||||||
|
error: { code, message, retryable },
|
||||||
|
data,
|
||||||
|
metadata: {
|
||||||
|
request_id: requestId,
|
||||||
|
server,
|
||||||
|
generated_at: generatedAt(),
|
||||||
|
},
|
||||||
|
next_commands: nextCommands,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { DEFAULT_SERVER, DEFAULT_TIMEOUT } from "./constants.js";
|
||||||
|
import { CliError } from "./errors.js";
|
||||||
|
import { requireValue, parseIntStrict } from "./options.js";
|
||||||
|
import type { AuthContext, GlobalArgs, ParsedGlobalArgs, RuntimeContext } from "./types.js";
|
||||||
|
|
||||||
|
function pick(source: Record<string, unknown>, ...keys: string[]): string | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key];
|
||||||
|
if (value !== undefined && value !== null && value !== "") return String(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStdin(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk: string) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on("end", () => resolve(body));
|
||||||
|
process.stdin.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAuthContext(authStdin: boolean): Promise<AuthContext> {
|
||||||
|
const raw = authStdin
|
||||||
|
? (JSON.parse(await readStdin()) as Record<string, unknown>)
|
||||||
|
: {
|
||||||
|
server: process.env.TJWATER_SERVER,
|
||||||
|
access_token: process.env.TJWATER_ACCESS_TOKEN,
|
||||||
|
project_id: process.env.TJWATER_PROJECT_ID,
|
||||||
|
user_id: process.env.TJWATER_USER_ID,
|
||||||
|
username: process.env.TJWATER_USERNAME,
|
||||||
|
network: process.env.TJWATER_NETWORK,
|
||||||
|
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
|
||||||
|
};
|
||||||
|
const headers = (raw.headers ?? {}) as unknown;
|
||||||
|
if (!headers || Array.isArray(headers) || typeof headers !== "object") {
|
||||||
|
throw new CliError("认证失败", "AUTH_CONTEXT_INVALID", "auth context headers must be a JSON object", 3);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
server: pick(raw, "server", "base_url"),
|
||||||
|
accessToken: pick(raw, "access_token", "token", "accessToken"),
|
||||||
|
projectId: pick(raw, "project_id", "projectId", "x_project_id"),
|
||||||
|
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)])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGlobalArgs(argv: string[]): ParsedGlobalArgs {
|
||||||
|
const globals: GlobalArgs = {
|
||||||
|
server: null,
|
||||||
|
authStdin: false,
|
||||||
|
scheme: null,
|
||||||
|
timeout: DEFAULT_TIMEOUT,
|
||||||
|
requestId: null,
|
||||||
|
};
|
||||||
|
const rest: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (arg === "--auth-stdin") globals.authStdin = true;
|
||||||
|
else if (arg === "--server") globals.server = requireValue(argv, ++i, "--server");
|
||||||
|
else if (arg === "--scheme") globals.scheme = requireValue(argv, ++i, "--scheme");
|
||||||
|
else if (arg === "--timeout") globals.timeout = parseIntStrict(requireValue(argv, ++i, "--timeout"), "--timeout");
|
||||||
|
else if (arg === "--request-id") globals.requestId = requireValue(argv, ++i, "--request-id");
|
||||||
|
else rest.push(arg);
|
||||||
|
}
|
||||||
|
return { globals, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext> {
|
||||||
|
const auth = await loadAuthContext(globals.authStdin);
|
||||||
|
return {
|
||||||
|
server: globals.server || auth.server || DEFAULT_SERVER,
|
||||||
|
auth,
|
||||||
|
scheme: globals.scheme,
|
||||||
|
timeout: globals.timeout,
|
||||||
|
requestId: globals.requestId || randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNetwork(ctx: RuntimeContext): string {
|
||||||
|
if (ctx.auth.network) return ctx.auth.network;
|
||||||
|
throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireUsername(ctx: RuntimeContext): string {
|
||||||
|
if (ctx.auth.username) return ctx.auth.username;
|
||||||
|
throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
|
||||||
|
const scheme = explicit || ctx.scheme;
|
||||||
|
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
|
||||||
|
return scheme ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CliError } from "./errors.js";
|
||||||
|
|
||||||
|
export function parseTime(value: string, option: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new CliError("CLI 参数错误", "INVALID_TIME", `${option} must be a valid ISO 8601 / RFC 3339 timestamp`, 2);
|
||||||
|
}
|
||||||
|
if (!/[zZ]|[+-]\d\d:\d\d$/.test(value)) {
|
||||||
|
throw new CliError("CLI 参数错误", "TIMEZONE_REQUIRED", `${option} must include an explicit timezone offset`, 2);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMinutesPreservingOffset(value: string, minutes: number): string {
|
||||||
|
const match = value.match(/([+-])(\d\d):(\d\d)$/);
|
||||||
|
const end = new Date(new Date(value).getTime() + minutes * 60_000);
|
||||||
|
if (!match) return end.toISOString().replace(".000Z", "Z");
|
||||||
|
const sign = match[1] === "+" ? 1 : -1;
|
||||||
|
const offsetMinutes = sign * (Number(match[2]) * 60 + Number(match[3]));
|
||||||
|
const local = new Date(end.getTime() + offsetMinutes * 60_000);
|
||||||
|
return `${local.toISOString().replace(".000Z", "")}${match[1]}${match[2]}:${match[3]}`;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||||
|
export type Dict<T = unknown> = Record<string, T>;
|
||||||
|
|
||||||
|
export interface AuthContext {
|
||||||
|
server: string | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
username: string | null;
|
||||||
|
network: string | null;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
server: string;
|
||||||
|
auth: AuthContext;
|
||||||
|
scheme: string | null;
|
||||||
|
timeout: number;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalArgs {
|
||||||
|
server: string | null;
|
||||||
|
authStdin: boolean;
|
||||||
|
scheme: string | null;
|
||||||
|
timeout: number;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedGlobalArgs {
|
||||||
|
globals: GlobalArgs;
|
||||||
|
rest: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OptionKind = "string" | "boolean" | "repeat" | "number" | "integer";
|
||||||
|
export type OptionSchema = Record<string, OptionKind>;
|
||||||
|
export type ParsedOptionValue = string | number | boolean | string[];
|
||||||
|
|
||||||
|
export interface ParsedOptions {
|
||||||
|
values: Record<string, ParsedOptionValue>;
|
||||||
|
positionals: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
requireAuth?: boolean;
|
||||||
|
requireProject?: boolean;
|
||||||
|
requireNetworkCtx?: boolean;
|
||||||
|
requireUsernameCtx?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
|
||||||
|
export type HandlerMap = Record<string, Handler>;
|
||||||
|
|
||||||
|
export interface CommandDoc {
|
||||||
|
ok: true;
|
||||||
|
schema_version: string;
|
||||||
|
command: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
usage: string;
|
||||||
|
options: CommandOptionDoc[];
|
||||||
|
examples: string[];
|
||||||
|
output: string;
|
||||||
|
next_commands: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandOptionDoc {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
repeated: boolean;
|
||||||
|
default: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HelpPayload =
|
||||||
|
| CommandDoc
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
schema_version: string;
|
||||||
|
summary: string;
|
||||||
|
menu_level?: number;
|
||||||
|
commands: Array<{ command: string; summary: string; usage?: string; example?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { CliError } from "./core/errors.js";
|
||||||
|
import { handlers } from "./handlers.js";
|
||||||
|
import { helpPayload } from "./help/index.js";
|
||||||
|
import { failure, json } from "./core/output.js";
|
||||||
|
import type { RuntimeContext } from "./core/types.js";
|
||||||
|
|
||||||
|
export async function dispatch(ctx: RuntimeContext | null, argv: string[]): Promise<void> {
|
||||||
|
if (argv.length === 0 || argv.includes("--help")) {
|
||||||
|
process.stdout.write("Usage: tjwater-cli [OPTIONS] COMMAND [ARGS]...\n\nStructured JSON:\n tjwater-cli help\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (argv[0] === "help") {
|
||||||
|
const payload = helpPayload(argv.slice(1));
|
||||||
|
if (!payload) {
|
||||||
|
failure({
|
||||||
|
summary: "未找到命令",
|
||||||
|
code: "COMMAND_NOT_FOUND",
|
||||||
|
message: `unknown command path: ${argv.slice(1).join(" ")}`,
|
||||||
|
retryable: false,
|
||||||
|
data: { usage: "tjwater-cli help <command-path>", examples: ["tjwater-cli help simulation run", "tjwater-cli simulation help"] },
|
||||||
|
nextCommands: ["tjwater-cli help", "tjwater-cli help simulation"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matched = matchCommand(argv);
|
||||||
|
if (!matched) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
|
||||||
|
const handler = handlers[matched.path];
|
||||||
|
if (!handler) throw new CliError("未找到命令", "COMMAND_NOT_FOUND", `No such command: ${argv.join(" ")}`, 2, false, { usage: "tjwater-cli help" }, ["tjwater-cli help"]);
|
||||||
|
if (!ctx && matched.path !== "__noop") throw new CliError("CLI 参数错误", "RUNTIME_REQUIRED", "runtime context is required for command execution", 2);
|
||||||
|
await handler(ctx!, matched.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCommand(argv: string[]): { path: string; args: string[] } | null {
|
||||||
|
const paths = Object.keys(handlers).sort((a, b) => b.split(" ").length - a.split(" ").length);
|
||||||
|
for (const path of paths) {
|
||||||
|
const parts = path.split(" ");
|
||||||
|
if (parts.every((part, index) => argv[index] === part)) return { path, args: argv.slice(parts.length) };
|
||||||
|
}
|
||||||
|
if (argv.at(-1) === "help") {
|
||||||
|
const payload = helpPayload(argv.slice(0, -1));
|
||||||
|
if (payload) {
|
||||||
|
json(payload);
|
||||||
|
return { path: "__noop", args: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { analysisHandlers } from "./commands/analysis.js";
|
||||||
|
import { componentHandlers } from "./commands/component.js";
|
||||||
|
import { dataHandlers } from "./commands/data.js";
|
||||||
|
import { networkHandlers } from "./commands/network.js";
|
||||||
|
import { simulationHandlers } from "./commands/simulation.js";
|
||||||
|
import type { HandlerMap } from "./core/types.js";
|
||||||
|
|
||||||
|
export const handlers: HandlerMap = {
|
||||||
|
"__noop": async () => {},
|
||||||
|
...networkHandlers,
|
||||||
|
...componentHandlers,
|
||||||
|
...simulationHandlers,
|
||||||
|
...analysisHandlers,
|
||||||
|
...dataHandlers,
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { SCHEMA_VERSION } from "../core/constants.js";
|
||||||
|
import type { CommandDoc, CommandOptionDoc } from "../core/types.js";
|
||||||
|
|
||||||
|
export const GROUP_SUMMARIES: Record<string, string> = {
|
||||||
|
network: "管网节点、管线等基础属性查询命令。",
|
||||||
|
component: "组件选项与配置读取命令。",
|
||||||
|
"component option": "组件选项查询命令。",
|
||||||
|
simulation: "模拟运行与调度相关命令。",
|
||||||
|
analysis: "分析计算与诊断相关命令。",
|
||||||
|
"analysis leakage": "漏损分析相关命令。",
|
||||||
|
"analysis leakage schemes": "漏损方案查询命令。",
|
||||||
|
"analysis burst-detection": "爆管检测相关命令。",
|
||||||
|
"analysis burst-detection schemes": "爆管检测方案查询命令。",
|
||||||
|
"analysis burst-location": "爆管定位相关命令。",
|
||||||
|
"analysis burst-location schemes": "爆管定位方案查询命令。",
|
||||||
|
"analysis risk": "风险分析相关命令。",
|
||||||
|
"analysis sensor-placement": "传感器选址相关命令。",
|
||||||
|
data: "时序、SCADA 和方案数据查询命令。",
|
||||||
|
"data timeseries": "时序数据查询命令。",
|
||||||
|
"data timeseries realtime": "实时模拟时序查询命令。",
|
||||||
|
"data timeseries scheme": "方案时序查询命令。",
|
||||||
|
"data timeseries scada": "SCADA 时序查询命令。",
|
||||||
|
"data timeseries composite": "复合时序查询命令。",
|
||||||
|
"data scada": "SCADA 元数据查询命令。",
|
||||||
|
"data scheme": "方案数据查询命令。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HIDDEN_PATH_PREFIXES = ["analysis burst-location", "analysis risk"];
|
||||||
|
|
||||||
|
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
|
||||||
|
|
||||||
|
const commandSpecs: readonly CommandSpec[] = [
|
||||||
|
["network get-junction-properties", "读取节点属性", ["--junction <JUNCTION>"], ["tjwater-cli network get-junction-properties --junction J1"]],
|
||||||
|
["network get-pipe-properties", "读取管道属性", ["--pipe <PIPE>"], ["tjwater-cli network get-pipe-properties --pipe P1"]],
|
||||||
|
["network get-all-pipes-properties", "读取全部管道属性", [], ["tjwater-cli network get-all-pipes-properties"]],
|
||||||
|
["network get-reservoir-properties", "读取水库属性", ["--reservoir <RESERVOIR>"], ["tjwater-cli network get-reservoir-properties --reservoir R1"]],
|
||||||
|
["network get-all-reservoirs-properties", "读取全部水库属性", [], ["tjwater-cli network get-all-reservoirs-properties"]],
|
||||||
|
["network get-tank-properties", "读取水箱属性", ["--tank <TANK>"], ["tjwater-cli network get-tank-properties --tank T1"]],
|
||||||
|
["network get-all-tanks-properties", "读取全部水箱属性", [], ["tjwater-cli network get-all-tanks-properties"]],
|
||||||
|
["network get-pump-properties", "读取水泵属性", ["--pump <PUMP>"], ["tjwater-cli network get-pump-properties --pump PU1"]],
|
||||||
|
["network get-all-pumps-properties", "读取全部水泵属性", [], ["tjwater-cli network get-all-pumps-properties"]],
|
||||||
|
["network get-valve-properties", "读取阀门属性", ["--valve <VALVE>"], ["tjwater-cli network get-valve-properties --valve V1"]],
|
||||||
|
["network get-all-valves-properties", "读取全部阀门属性", [], ["tjwater-cli network get-all-valves-properties"]],
|
||||||
|
["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]],
|
||||||
|
["component option get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]],
|
||||||
|
["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]],
|
||||||
|
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list"]],
|
||||||
|
["analysis valve", "阀门工况分析。", ["--mode <MODE>", "[--start-time <START_TIME>]", "[--valve <VALVE>]", "[--element <ELEMENT>]", "[--disabled-valve <DISABLED_VALVE>]", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", "tjwater-cli analysis valve --mode isolation --element E1 --element E2", "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3"]],
|
||||||
|
["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]],
|
||||||
|
["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]],
|
||||||
|
["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]],
|
||||||
|
["analysis sensor-placement kmeans", "执行 KMeans 传感器选址", ["--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01"]],
|
||||||
|
["analysis leakage identify", "执行漏损识别", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]],
|
||||||
|
["analysis leakage schemes list", "列出漏损方案", [], ["tjwater-cli analysis leakage schemes list"]],
|
||||||
|
["analysis leakage schemes get", "读取漏损方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis leakage schemes get my_scheme"]],
|
||||||
|
["analysis burst-detection detect", "执行爆管检测", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]],
|
||||||
|
["analysis burst-detection schemes list", "列出爆管检测方案", [], ["tjwater-cli analysis burst-detection schemes list"]],
|
||||||
|
["analysis burst-detection schemes get", "读取爆管检测方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-detection schemes get my_scheme"]],
|
||||||
|
["analysis burst-location locate", "执行爆管定位", ["--start-time <START_TIME>", "--end-time <END_TIME>", "--burst-leakage <BURST_LEAKAGE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]],
|
||||||
|
["analysis burst-location schemes list", "列出爆管定位方案", [], ["tjwater-cli analysis burst-location schemes list"]],
|
||||||
|
["analysis burst-location schemes get", "读取爆管定位方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-location schemes get my_scheme"]],
|
||||||
|
["analysis risk pipe-now", "读取单条管道当前风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-now --pipe P1"]],
|
||||||
|
["analysis risk pipe-history", "读取单条管道历史风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-history --pipe P1"]],
|
||||||
|
["analysis risk network", "读取全网风险", [], ["tjwater-cli analysis risk network"]],
|
||||||
|
["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
|
||||||
|
["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
|
||||||
|
["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]],
|
||||||
|
["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]],
|
||||||
|
["data timeseries scheme links", "查询方案管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
|
||||||
|
["data timeseries scheme node-field", "查询方案节点字段时序", ["--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]],
|
||||||
|
["data timeseries scheme simulation", "查询方案模拟数据", ["--query <QUERY>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--id <ID>]", "[--time <TIME>]", "[--type <TYPE>]", "[--property <PROPERTY>]"], ["tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme"]],
|
||||||
|
["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]],
|
||||||
|
["data timeseries composite", "执行复合时序查询", ["[--kind <KIND>]", "[--feature <FEATURE>]", "[--start-time <START_TIME>]", "[--end-time <END_TIME>]", "[--pipe <PIPE>]", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
|
||||||
|
["data timeseries composite pipeline-health", "查询管道健康预测", ["--pipe <PIPE>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
|
||||||
|
["data scada get", "读取单条 SCADA 元数据", ["--kind <KIND>", "--id <ID>"], ["tjwater-cli data scada get --kind info --id SCADA-001"]],
|
||||||
|
["data scada list", "列出 SCADA 元数据", ["--kind <KIND>"], ["tjwater-cli data scada list --kind info"]],
|
||||||
|
["data scheme schema", "读取方案 schema", [], ["tjwater-cli data scheme schema"]],
|
||||||
|
["data scheme get", "读取单条方案", ["--name <NAME>"], ["tjwater-cli data scheme get --name my_scheme"]],
|
||||||
|
["data scheme list", "列出方案", [], ["tjwater-cli data scheme list"]],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const commandDocs = new Map<string, CommandDoc>(
|
||||||
|
commandSpecs.map(([path, summary, options, examples, nextCommands = []]) => [
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
command: path,
|
||||||
|
summary,
|
||||||
|
description: summary,
|
||||||
|
usage: buildUsage(path, options),
|
||||||
|
options: options.filter((item) => item.startsWith("--") || item.startsWith("[--")).map((item) => optionDoc(path, item)),
|
||||||
|
examples: [...(examples ?? [`tjwater-cli ${path}${exampleSuffix(options)}`])],
|
||||||
|
output: "标准 JSON 输出",
|
||||||
|
next_commands: [...nextCommands],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildUsage(path: string, options: readonly string[]): string {
|
||||||
|
const optionTokens = options.filter((item) => item.startsWith("--") || item.startsWith("[--"));
|
||||||
|
return `tjwater-cli ${path}${optionTokens.length ? ` ${optionTokens.join(" ")}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionDoc(path: string, token: string): CommandOptionDoc {
|
||||||
|
const optional = token.startsWith("[");
|
||||||
|
const clean = token.replace(/^\[/, "").replace(/\]$/, "");
|
||||||
|
const name = clean.slice(2).split(/\s+/)[0]!;
|
||||||
|
const repeatedOptions: Record<string, string[]> = {
|
||||||
|
"analysis valve": ["valve", "element", "disabled-valve"],
|
||||||
|
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
|
||||||
|
"data timeseries scada query": ["device-id"],
|
||||||
|
"data timeseries composite": ["feature"],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
required: !optional,
|
||||||
|
repeated: (repeatedOptions[path] ?? []).includes(name),
|
||||||
|
default: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleSuffix(options: readonly string[]): string {
|
||||||
|
return options
|
||||||
|
.map((item) => {
|
||||||
|
if (item.includes("START_TIME")) return " --start-time 2025-01-02T03:00:00+08:00";
|
||||||
|
if (item.includes("END_TIME")) return " --end-time 2025-01-02T04:00:00+08:00";
|
||||||
|
if (item.includes("DURATION")) return " --duration 900";
|
||||||
|
if (item.includes("SCHEME")) return " --scheme my_scheme";
|
||||||
|
if (item.includes("KIND")) return " --kind info";
|
||||||
|
if (item.includes("TYPE")) return " --type pipe";
|
||||||
|
if (item.includes("TIME")) return " --time 2025-01-02T03:30:00+08:00";
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHiddenPath(path: string): boolean {
|
||||||
|
return HIDDEN_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix} `));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasVisibleSubcommands(path: string): boolean {
|
||||||
|
return [...commandDocs.keys()].some((key) => !isHiddenPath(key) && key.startsWith(`${path} `));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { SCHEMA_VERSION } from "../core/constants.js";
|
||||||
|
import { GROUP_SUMMARIES, commandDocs, hasVisibleSubcommands, isHiddenPath } from "./docs.js";
|
||||||
|
import type { HelpPayload } from "../core/types.js";
|
||||||
|
|
||||||
|
export function helpPayload(pathParts: string[]): HelpPayload | null {
|
||||||
|
const path = pathParts.join(" ");
|
||||||
|
if (path && isHiddenPath(path)) return null;
|
||||||
|
if (path && commandDocs.has(path)) return commandDocs.get(path)!;
|
||||||
|
if (!path) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const commands: Array<{ command: string; summary: string }> = [];
|
||||||
|
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
|
||||||
|
if (isHiddenPath(doc.command)) continue;
|
||||||
|
const command = doc.command.split(" ")[0]!;
|
||||||
|
if (seen.has(command)) continue;
|
||||||
|
seen.add(command);
|
||||||
|
commands.push({ command, summary: GROUP_SUMMARIES[command] ?? `${command} 可用子命令` });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
summary: "可用一级菜单",
|
||||||
|
menu_level: 1,
|
||||||
|
commands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const prefix = path ? `${path} ` : "";
|
||||||
|
const children: Array<{ command: string; summary: string; usage: string; example: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const doc of [...commandDocs.values()].sort((a, b) => a.command.localeCompare(b.command))) {
|
||||||
|
if (isHiddenPath(doc.command)) continue;
|
||||||
|
if (!doc.command.startsWith(prefix)) continue;
|
||||||
|
const child = doc.command.slice(prefix.length).split(" ")[0]!;
|
||||||
|
if (seen.has(child)) continue;
|
||||||
|
seen.add(child);
|
||||||
|
const childPath = path ? `${path} ${child}` : child;
|
||||||
|
const isGroup = hasVisibleSubcommands(childPath);
|
||||||
|
const childDoc = commandDocs.get(childPath) ?? doc;
|
||||||
|
children.push({
|
||||||
|
command: childPath,
|
||||||
|
summary: isGroup ? (GROUP_SUMMARIES[childPath] ?? `${childPath} 可用子命令`) : childDoc.summary,
|
||||||
|
usage: isGroup ? `tjwater-cli ${childPath} help` : childDoc.usage,
|
||||||
|
example: isGroup ? `tjwater-cli ${childPath} help` : childDoc.examples[0]!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!children.length && path) return null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
summary: GROUP_SUMMARIES[path] ?? `${path} 可用子命令`,
|
||||||
|
commands: children,
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
if command -v bun >/dev/null 2>&1; then
|
||||||
|
exec bun "$SCRIPT_DIR/tjwater-cli.ts" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node "$SCRIPT_DIR/tjwater-cli.ts" "$@"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { dispatch } from "./src/dispatch.js";
|
||||||
|
import { CliError, errorMessage } from "./src/core/errors.js";
|
||||||
|
import { failure } from "./src/core/output.js";
|
||||||
|
import { buildRuntime, parseGlobalArgs } from "./src/core/runtime.js";
|
||||||
|
|
||||||
|
let currentServer: string | null = null;
|
||||||
|
let currentRequestId: string | null = null;
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
const { globals, rest } = parseGlobalArgs(process.argv.slice(2));
|
||||||
|
if (isHelpRequest(rest)) {
|
||||||
|
await dispatch(null, rest);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const ctx = await buildRuntime(globals);
|
||||||
|
currentServer = ctx.server;
|
||||||
|
currentRequestId = ctx.requestId;
|
||||||
|
await dispatch(ctx, rest);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHelpRequest(argv: string[]): boolean {
|
||||||
|
return argv.length === 0 || argv[0] === "help" || argv.includes("--help") || argv.at(-1) === "help";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.exitCode = await main();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CliError) {
|
||||||
|
failure({
|
||||||
|
summary: error.summary,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
retryable: error.retryable,
|
||||||
|
server: currentServer,
|
||||||
|
requestId: currentRequestId,
|
||||||
|
data: error.data,
|
||||||
|
nextCommands: error.nextCommands,
|
||||||
|
});
|
||||||
|
process.exitCode = error.exitCode;
|
||||||
|
} else {
|
||||||
|
failure({ summary: "CLI 执行失败", code: "UNHANDLED_ERROR", message: errorMessage(error), retryable: false });
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
|
||||||
|
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
|
||||||
|
|
||||||
|
function runCommand(command, args, input, options = {}) {
|
||||||
|
return new Promise((resolveRun, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
stdout += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (exitCode) => resolveRun({ exitCode, stdout, stderr }));
|
||||||
|
if (input !== undefined) child.stdin.end(JSON.stringify(input));
|
||||||
|
else child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(args, input) {
|
||||||
|
return runCommand(cliPath, args, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPythonCli(args, input) {
|
||||||
|
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonResult(result) {
|
||||||
|
return JSON.parse(result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startJsonServer(responseData) {
|
||||||
|
const seen = [];
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8");
|
||||||
|
seen.push({
|
||||||
|
body: text ? JSON.parse(text) : null,
|
||||||
|
headers: req.headers,
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
});
|
||||||
|
res.setHeader("content-type", "application/json");
|
||||||
|
res.end(JSON.stringify(responseData));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolveListen, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolveListen);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
return {
|
||||||
|
seen,
|
||||||
|
url: `http://127.0.0.1:${address.port}`,
|
||||||
|
close: () => new Promise((resolveClose) => server.close(resolveClose)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSeenRequest(request) {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
const query = {};
|
||||||
|
for (const key of [...new Set(url.searchParams.keys())].sort()) {
|
||||||
|
const values = url.searchParams.getAll(key);
|
||||||
|
query[key] = values.length === 1 ? values[0] : values;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: request.body,
|
||||||
|
headers: {
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
"x-project-id": request.headers["x-project-id"],
|
||||||
|
"x-user-id": request.headers["x-user-id"],
|
||||||
|
},
|
||||||
|
method: request.method,
|
||||||
|
path: url.pathname,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgainstServer(name, runner, args, auth, responseData = { accepted: true }) {
|
||||||
|
const server = await startJsonServer(responseData);
|
||||||
|
try {
|
||||||
|
const result = await runner(["--auth-stdin", ...args], { ...auth, server: server.url });
|
||||||
|
if (!result.stdout.trim()) {
|
||||||
|
throw new Error(`${name}: CLI produced empty stdout; exit=${result.exitCode}; stderr=${result.stderr}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
payload: parseJsonResult(result),
|
||||||
|
requests: server.seen.map(normalizeSeenRequest),
|
||||||
|
stderr: result.stderr,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
|
||||||
|
const result = await runCli(["help", "simulation", "run"]);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0, result.stderr);
|
||||||
|
const payload = JSON.parse(result.stdout);
|
||||||
|
assert.equal(payload.schema_version, "tjwater-cli/v1");
|
||||||
|
assert.equal(payload.command, "simulation run");
|
||||||
|
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches Python CLI help discovery and hidden command behavior", async () => {
|
||||||
|
for (const args of [["help"], ["help", "analysis"]]) {
|
||||||
|
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
||||||
|
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
|
||||||
|
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nodeLeaf, pythonLeaf] = await Promise.all([
|
||||||
|
runCli(["help", "simulation", "run"]),
|
||||||
|
runPythonCli(["help", "simulation", "run"]),
|
||||||
|
]);
|
||||||
|
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
|
||||||
|
const nodePayload = parseJsonResult(nodeLeaf);
|
||||||
|
const pythonPayload = parseJsonResult(pythonLeaf);
|
||||||
|
assert.equal(nodePayload.ok, pythonPayload.ok);
|
||||||
|
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
|
||||||
|
assert.equal(nodePayload.command, pythonPayload.command);
|
||||||
|
assert.equal(nodePayload.summary, pythonPayload.summary);
|
||||||
|
assert.equal(nodePayload.usage, pythonPayload.usage);
|
||||||
|
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
|
||||||
|
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
|
||||||
|
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
|
||||||
|
|
||||||
|
const [nodeHidden, pythonHidden] = await Promise.all([
|
||||||
|
runCli(["help", "analysis", "risk"]),
|
||||||
|
runPythonCli(["help", "analysis", "risk"]),
|
||||||
|
]);
|
||||||
|
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
|
||||||
|
const nodeError = parseJsonResult(nodeHidden);
|
||||||
|
const pythonError = parseJsonResult(pythonHidden);
|
||||||
|
delete nodeError.metadata.generated_at;
|
||||||
|
delete pythonError.metadata.generated_at;
|
||||||
|
assert.deepEqual(nodeError, pythonError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches Python CLI leaf help for every visible command", async () => {
|
||||||
|
const listResult = await runCommand(
|
||||||
|
"python",
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
{ cwd: pythonCliCwd },
|
||||||
|
);
|
||||||
|
assert.equal(listResult.exitCode, 0, listResult.stderr);
|
||||||
|
const commands = JSON.parse(listResult.stdout);
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
const args = ["help", ...command.split(" ")];
|
||||||
|
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
|
||||||
|
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
|
||||||
|
|
||||||
|
const nodePayload = parseJsonResult(nodeResult);
|
||||||
|
const pythonPayload = parseJsonResult(pythonResult);
|
||||||
|
const comparable = (payload) => ({
|
||||||
|
command: payload.command,
|
||||||
|
summary: payload.summary,
|
||||||
|
usage: payload.usage,
|
||||||
|
examples: payload.examples,
|
||||||
|
next_commands: payload.next_commands,
|
||||||
|
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
|
||||||
|
name,
|
||||||
|
required,
|
||||||
|
repeated,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends auth headers and simulation body through the backend API contract", async () => {
|
||||||
|
const server = await startJsonServer({ accepted: true });
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
[
|
||||||
|
"--auth-stdin",
|
||||||
|
"simulation",
|
||||||
|
"run",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:00:00+08:00",
|
||||||
|
"--duration",
|
||||||
|
"60",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
server: server.url,
|
||||||
|
access_token: "token-1",
|
||||||
|
network: "tjwater",
|
||||||
|
headers: { "x-extra": "extra" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0, result.stderr);
|
||||||
|
assert.equal(server.seen[0].method, "POST");
|
||||||
|
assert.equal(server.seen[0].url, "/api/v1/runsimulationmanuallybydate/");
|
||||||
|
assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
|
||||||
|
assert.equal(server.seen[0].headers["x-extra"], "extra");
|
||||||
|
assert.deepEqual(server.seen[0].body, {
|
||||||
|
name: "tjwater",
|
||||||
|
start_time: "2025-01-02T03:00:00+08:00",
|
||||||
|
duration: 60,
|
||||||
|
});
|
||||||
|
const payload = JSON.parse(result.stdout);
|
||||||
|
assert.equal(payload.ok, true);
|
||||||
|
assert.match(payload.next_commands[0], /--end-time 2025-01-02T04:00:00\+08:00/);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses project scoped headers for realtime data commands", async () => {
|
||||||
|
const server = await startJsonServer([{ id: "P1" }]);
|
||||||
|
try {
|
||||||
|
const result = await runCli(
|
||||||
|
[
|
||||||
|
"--auth-stdin",
|
||||||
|
"data",
|
||||||
|
"timeseries",
|
||||||
|
"realtime",
|
||||||
|
"links",
|
||||||
|
"--start-time",
|
||||||
|
"2025-01-02T03:00:00+08:00",
|
||||||
|
"--end-time",
|
||||||
|
"2025-01-02T04:00:00+08:00",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
server: server.url,
|
||||||
|
accessToken: "token-2",
|
||||||
|
projectId: "project-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.exitCode, 0, result.stderr);
|
||||||
|
assert.equal(server.seen[0].method, "GET");
|
||||||
|
assert.equal(
|
||||||
|
server.seen[0].url,
|
||||||
|
"/api/v1/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
|
||||||
|
);
|
||||||
|
assert.equal(server.seen[0].headers.authorization, "Bearer token-2");
|
||||||
|
assert.equal(server.seen[0].headers["x-project-id"], "project-1");
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches Python CLI backend request shape for every command and key variants", async () => {
|
||||||
|
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
|
||||||
|
try {
|
||||||
|
const burstFile = join(tempDir, "burst.json");
|
||||||
|
const valveFile = join(tempDir, "valve.json");
|
||||||
|
const pressureFile = join(tempDir, "pressure.json");
|
||||||
|
const flowFile = join(tempDir, "flow.json");
|
||||||
|
await writeFile(burstFile, JSON.stringify([{ id: "B1", size: 12.5 }]));
|
||||||
|
await writeFile(valveFile, JSON.stringify([{ valve: "V1", opening: 0.5 }]));
|
||||||
|
await writeFile(pressureFile, JSON.stringify({ burst_pressure: [1.1], normal_pressure: [2.2] }));
|
||||||
|
await writeFile(flowFile, JSON.stringify({ burst_flow: [3.3], normal_flow: [4.4] }));
|
||||||
|
|
||||||
|
const auth = {
|
||||||
|
access_token: "token",
|
||||||
|
network: "tjwater",
|
||||||
|
project_id: "project-1",
|
||||||
|
user_id: "user-1",
|
||||||
|
username: "alice",
|
||||||
|
headers: { "x-extra": "extra" },
|
||||||
|
};
|
||||||
|
const start = "2025-01-02T03:00:00+08:00";
|
||||||
|
const end = "2025-01-02T04:00:00+08:00";
|
||||||
|
const at = "2025-01-02T03:30:00+08:00";
|
||||||
|
const cases = [
|
||||||
|
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
|
||||||
|
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
|
||||||
|
["network get-all-pipes-properties", ["network", "get-all-pipes-properties"]],
|
||||||
|
["network get-reservoir-properties", ["network", "get-reservoir-properties", "--reservoir", "R1"]],
|
||||||
|
["network get-all-reservoirs-properties", ["network", "get-all-reservoirs-properties"]],
|
||||||
|
["network get-tank-properties", ["network", "get-tank-properties", "--tank", "T1"]],
|
||||||
|
["network get-all-tanks-properties", ["network", "get-all-tanks-properties"]],
|
||||||
|
["network get-pump-properties", ["network", "get-pump-properties", "--pump", "PU1"]],
|
||||||
|
["network get-all-pumps-properties", ["network", "get-all-pumps-properties"]],
|
||||||
|
["network get-valve-properties", ["network", "get-valve-properties", "--valve", "V1"]],
|
||||||
|
["network get-all-valves-properties", ["network", "get-all-valves-properties"]],
|
||||||
|
["component option schema", ["component", "option", "schema", "--kind", "time"]],
|
||||||
|
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
|
||||||
|
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
|
||||||
|
["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]],
|
||||||
|
["analysis valve close", ["analysis", "valve", "--mode", "close", "--start-time", start, "--valve", "V1", "--valve", "V2", "--duration", "900", "--scheme", "valve_case"]],
|
||||||
|
["analysis valve isolation", ["analysis", "valve", "--mode", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
|
||||||
|
["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]],
|
||||||
|
["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]],
|
||||||
|
["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]],
|
||||||
|
["analysis sensor-placement kmeans", ["analysis", "sensor-placement", "kmeans", "--count", "5", "--min-diameter", "100", "--scheme", "place_case"]],
|
||||||
|
["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]],
|
||||||
|
["analysis leakage schemes list", ["analysis", "leakage", "schemes", "list"]],
|
||||||
|
["analysis leakage schemes get", ["analysis", "leakage", "schemes", "get", "leak_case"]],
|
||||||
|
["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]],
|
||||||
|
["analysis burst-detection schemes list", ["analysis", "burst-detection", "schemes", "list"]],
|
||||||
|
["analysis burst-detection schemes get", ["analysis", "burst-detection", "schemes", "get", "detect_case"]],
|
||||||
|
["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]],
|
||||||
|
["analysis burst-location schemes list", ["analysis", "burst-location", "schemes", "list"]],
|
||||||
|
["analysis burst-location schemes get", ["analysis", "burst-location", "schemes", "get", "locate_case"]],
|
||||||
|
["analysis risk pipe-now", ["analysis", "risk", "pipe-now", "--pipe", "P1"]],
|
||||||
|
["analysis risk pipe-history", ["analysis", "risk", "pipe-history", "--pipe", "P1"]],
|
||||||
|
["analysis risk network", ["analysis", "risk", "network"]],
|
||||||
|
["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]],
|
||||||
|
["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]],
|
||||||
|
["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]],
|
||||||
|
["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]],
|
||||||
|
["data scheme links", ["data", "timeseries", "scheme", "links", "--start-time", start, "--end-time", end, "--scheme", "scheme_case", "--scheme-type", "simulation"]],
|
||||||
|
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
|
||||||
|
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]],
|
||||||
|
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
|
||||||
|
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
|
||||||
|
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]],
|
||||||
|
["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]],
|
||||||
|
["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]],
|
||||||
|
["data composite pipeline-health", ["data", "timeseries", "composite", "pipeline-health", "--pipe", "P1", "--start-time", start, "--end-time", end]],
|
||||||
|
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]],
|
||||||
|
["data scada list", ["data", "scada", "list", "--kind", "info"]],
|
||||||
|
["data scheme schema", ["data", "scheme", "schema"]],
|
||||||
|
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
|
||||||
|
["data scheme list", ["data", "scheme", "list"]],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, args] of cases) {
|
||||||
|
const [nodeRun, pythonRun] = await Promise.all([
|
||||||
|
runAgainstServer(`${name} node`, runCli, args, auth),
|
||||||
|
runAgainstServer(`${name} python`, runPythonCli, args, auth),
|
||||||
|
]);
|
||||||
|
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
|
||||||
|
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
+17
-1
@@ -12,5 +12,21 @@
|
|||||||
"hostname": "127.0.0.1",
|
"hostname": "127.0.0.1",
|
||||||
"port": 4096
|
"port": 4096
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"*": "allow",
|
||||||
|
"external_directory": "ask",
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"rm *": "ask",
|
||||||
|
"rmdir *": "ask",
|
||||||
|
"mv *": "ask",
|
||||||
|
"chmod *": "ask",
|
||||||
|
"chown *": "ask",
|
||||||
|
"sudo *": "ask",
|
||||||
|
"curl *": "ask",
|
||||||
|
"wget *": "ask"
|
||||||
|
},
|
||||||
|
"edit": "ask"
|
||||||
|
},
|
||||||
"default_agent": "instruction"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@
|
|||||||
"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:cli": "node --test node-tests/cli/*.node.mjs",
|
||||||
"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",
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
"start:prod": "bun run check && bun src/server.ts"
|
"start:prod": "bun run check && bun src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "^1.14.29",
|
"@opencode-ai/sdk": "^1.16.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
+7
-1
@@ -49,6 +49,8 @@ const envSchema = z
|
|||||||
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
|
||||||
// client 模式下,目标 opencode server 的基础地址。
|
// client 模式下,目标 opencode server 的基础地址。
|
||||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
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 的基础地址。
|
||||||
@@ -122,7 +124,11 @@ const normalizedEnv = {
|
|||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_MODE:
|
OPENCODE_MODE:
|
||||||
process.env.OPENCODE_MODE ??
|
process.env.OPENCODE_MODE ??
|
||||||
(process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"),
|
(process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL
|
||||||
|
? "client"
|
||||||
|
: "embedded"),
|
||||||
|
OPENCODE_CLIENT_BASE_URL:
|
||||||
|
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config: AppConfig = envSchema.parse(normalizedEnv);
|
export const config: AppConfig = envSchema.parse(normalizedEnv);
|
||||||
|
|||||||
+207
-1
@@ -9,7 +9,10 @@ import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
|||||||
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||||
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import {
|
||||||
|
type PermissionReply,
|
||||||
|
type OpencodeRuntimeAdapter,
|
||||||
|
} from "../runtime/opencode.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";
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
} from "./chatSession.js";
|
} from "./chatSession.js";
|
||||||
import {
|
import {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
|
type PermissionRequestPayload,
|
||||||
streamPromptResponse,
|
streamPromptResponse,
|
||||||
supportedModels,
|
supportedModels,
|
||||||
type SupportedModel,
|
type SupportedModel,
|
||||||
@@ -36,6 +40,12 @@ const abortPayloadSchema = z.object({
|
|||||||
session_id: z.string().max(128),
|
session_id: z.string().max(128),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const permissionReplyPayloadSchema = z.object({
|
||||||
|
session_id: z.string().max(128),
|
||||||
|
reply: z.enum(["once", "always", "reject"]),
|
||||||
|
message: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const createSessionPayloadSchema = z.object({
|
const createSessionPayloadSchema = z.object({
|
||||||
session_id: z.string().max(128).optional(),
|
session_id: z.string().max(128).optional(),
|
||||||
parent_session_id: z.string().max(128).optional(),
|
parent_session_id: z.string().max(128).optional(),
|
||||||
@@ -64,6 +74,7 @@ type ActiveRun = {
|
|||||||
clientSessionId: string;
|
clientSessionId: string;
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
|
pendingPermissions: Map<string, PermissionRequestPayload>;
|
||||||
status: RunStatus;
|
status: RunStatus;
|
||||||
subscribers: Set<StreamSubscriber>;
|
subscribers: Set<StreamSubscriber>;
|
||||||
};
|
};
|
||||||
@@ -186,6 +197,46 @@ const updateLastAssistantMessage = (
|
|||||||
return messages;
|
return messages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateLastAssistantPermission = (
|
||||||
|
messages: unknown[],
|
||||||
|
requestId: string,
|
||||||
|
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
updateLastAssistantMessage(messages, (message) => {
|
||||||
|
const permissions = Array.isArray(message.permissions)
|
||||||
|
? message.permissions
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
permissions: permissions.map((permission) =>
|
||||||
|
isObjectRecord(permission) && permission.requestId === requestId
|
||||||
|
? updater(permission)
|
||||||
|
: permission,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const toFrontendPermission = (
|
||||||
|
payload: PermissionRequestPayload,
|
||||||
|
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
||||||
|
) => ({
|
||||||
|
requestId: payload.request_id,
|
||||||
|
sessionId: payload.session_id,
|
||||||
|
permission: payload.permission,
|
||||||
|
patterns: payload.patterns,
|
||||||
|
metadata: payload.metadata,
|
||||||
|
always: payload.always,
|
||||||
|
tool: payload.tool,
|
||||||
|
createdAt: payload.created_at,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toPermissionStatus = (reply: PermissionReply) => {
|
||||||
|
if (reply === "always") return "approved_always";
|
||||||
|
if (reply === "once") return "approved_once";
|
||||||
|
return "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
export const buildChatRouter = (
|
export const buildChatRouter = (
|
||||||
sessionBridge: ChatSessionBridge,
|
sessionBridge: ChatSessionBridge,
|
||||||
runtime: OpencodeRuntimeAdapter,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
@@ -636,6 +687,131 @@ export const buildChatRouter = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
|
||||||
|
const requestId = req.params.requestId?.trim();
|
||||||
|
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
|
||||||
|
if (!requestId) {
|
||||||
|
res.status(400).json({ message: "request_id is required" });
|
||||||
|
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 || run.status !== "running") {
|
||||||
|
res.status(409).json({ message: "session is not waiting for permissions" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingPermission = run.pendingPermissions.get(requestId);
|
||||||
|
if (!pendingPermission) {
|
||||||
|
res.status(404).json({ message: "permission request not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persistPermissionState = async () => {
|
||||||
|
const currentState = await sessionUiStateStore.read(
|
||||||
|
toSessionUiStateContext(sessionRecord),
|
||||||
|
);
|
||||||
|
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||||
|
messages: run.messages,
|
||||||
|
branchGroups: currentState?.branchGroups ?? [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.replyPermission({
|
||||||
|
requestId,
|
||||||
|
sessionId: sessionRecord.sessionId,
|
||||||
|
reply: parsed.data.reply,
|
||||||
|
message: parsed.data.message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
run.messages = updateLastAssistantPermission(
|
||||||
|
run.messages,
|
||||||
|
requestId,
|
||||||
|
(permission) => ({
|
||||||
|
...permission,
|
||||||
|
status: "error",
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "failed to reply permission",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await persistPermissionState().catch((persistError) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||||
|
"failed to persist permission error state",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
res.status(502).json({
|
||||||
|
message: "permission reply failed",
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.pendingPermissions.delete(requestId);
|
||||||
|
const status = toPermissionStatus(parsed.data.reply);
|
||||||
|
run.messages = updateLastAssistantPermission(
|
||||||
|
run.messages,
|
||||||
|
requestId,
|
||||||
|
(permission) => ({
|
||||||
|
...permission,
|
||||||
|
status,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await persistPermissionState().catch((persistError) => {
|
||||||
|
logger.warn(
|
||||||
|
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||||
|
"failed to persist permission reply state",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const subscriber of run.subscribers) {
|
||||||
|
subscriber.write("permission_response", {
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
request_id: requestId,
|
||||||
|
reply: parsed.data.reply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(202).json({
|
||||||
|
session_id: sessionRecord.sessionId,
|
||||||
|
request_id: requestId,
|
||||||
|
reply: parsed.data.reply,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error({ err: error }, "permission reply route failed");
|
||||||
|
res.status(500).json({
|
||||||
|
message: "permission reply route failed",
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
chatRouter.post("/fork", async (req, res) => {
|
chatRouter.post("/fork", async (req, res) => {
|
||||||
const parsed = forkPayloadSchema.safeParse(req.body);
|
const parsed = forkPayloadSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -818,6 +994,7 @@ export const buildChatRouter = (
|
|||||||
clientSessionId,
|
clientSessionId,
|
||||||
controller: abortController,
|
controller: abortController,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
|
pendingPermissions: new Map(),
|
||||||
status: "running",
|
status: "running",
|
||||||
subscribers: new Set(),
|
subscribers: new Set(),
|
||||||
};
|
};
|
||||||
@@ -903,6 +1080,35 @@ export const buildChatRouter = (
|
|||||||
isError: true,
|
isError: true,
|
||||||
progress: completeBackendProgress(message.progress),
|
progress: completeBackendProgress(message.progress),
|
||||||
}));
|
}));
|
||||||
|
} else if (event === "permission_request") {
|
||||||
|
const payload = data as PermissionRequestPayload;
|
||||||
|
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||||
|
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||||
|
...message,
|
||||||
|
permissions: [
|
||||||
|
...(Array.isArray(message.permissions) ? message.permissions : []),
|
||||||
|
toFrontendPermission(payload),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} else if (event === "permission_response") {
|
||||||
|
const requestId =
|
||||||
|
typeof data.request_id === "string" ? data.request_id : undefined;
|
||||||
|
const reply =
|
||||||
|
data.reply === "once" || data.reply === "always" || data.reply === "reject"
|
||||||
|
? data.reply
|
||||||
|
: undefined;
|
||||||
|
if (requestId && reply) {
|
||||||
|
activeRun.pendingPermissions.delete(requestId);
|
||||||
|
activeRun.messages = updateLastAssistantPermission(
|
||||||
|
activeRun.messages,
|
||||||
|
requestId,
|
||||||
|
(permission) => ({
|
||||||
|
...permission,
|
||||||
|
status: toPermissionStatus(reply),
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const subscriber of activeRun.subscribers) {
|
for (const subscriber of activeRun.subscribers) {
|
||||||
|
|||||||
+194
-2
@@ -2,7 +2,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
|||||||
|
|
||||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
import { type PermissionReply, type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||||
|
|
||||||
export const supportedModels = [
|
export const supportedModels = [
|
||||||
"deepseek/deepseek-v4-flash",
|
"deepseek/deepseek-v4-flash",
|
||||||
@@ -33,6 +33,20 @@ type ProgressPayload = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PermissionRequestPayload = {
|
||||||
|
session_id: string;
|
||||||
|
request_id: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
always: string[];
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
const toolLabels: Record<string, string> = {
|
||||||
@@ -158,6 +172,42 @@ const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
|||||||
"sessionID" in event.properties &&
|
"sessionID" in event.properties &&
|
||||||
event.properties.sessionID === sessionId;
|
event.properties.sessionID === sessionId;
|
||||||
|
|
||||||
|
const isPermissionAskedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
|
||||||
|
event.type === "permission.asked";
|
||||||
|
|
||||||
|
const isPermissionV2AskedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
|
||||||
|
event.type === "permission.v2.asked";
|
||||||
|
|
||||||
|
const isPermissionRepliedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
|
||||||
|
event.type === "permission.replied";
|
||||||
|
|
||||||
|
const isPermissionV2RepliedEvent = (
|
||||||
|
event: OpencodeEvent,
|
||||||
|
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
|
||||||
|
event.type === "permission.v2.replied";
|
||||||
|
|
||||||
|
const buildPermissionDetail = (event: Extract<OpencodeEvent, { type: "permission.asked" }>) => {
|
||||||
|
const patterns = event.properties.patterns.length
|
||||||
|
? event.properties.patterns.join(", ")
|
||||||
|
: event.properties.permission;
|
||||||
|
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPermissionV2Detail = (
|
||||||
|
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
|
||||||
|
) => {
|
||||||
|
const resources = event.properties.resources.length
|
||||||
|
? event.properties.resources.join(", ")
|
||||||
|
: event.properties.action;
|
||||||
|
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const collectTextContent = (parts: Part[]) =>
|
export const collectTextContent = (parts: Part[]) =>
|
||||||
parts
|
parts
|
||||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
||||||
@@ -323,6 +373,7 @@ export const streamPromptResponse = async ({
|
|||||||
let firstToolEventLogged = false;
|
let firstToolEventLogged = false;
|
||||||
let lastSessionStatus: string | null = null;
|
let lastSessionStatus: string | null = null;
|
||||||
let lastSessionStatusMessage: string | null = null;
|
let lastSessionStatusMessage: string | null = null;
|
||||||
|
let sawResponseActivity = false;
|
||||||
let emittedText = false;
|
let emittedText = false;
|
||||||
let toolCallCount = 0;
|
let toolCallCount = 0;
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -528,7 +579,128 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPermissionAskedEvent(event)) {
|
||||||
|
sawResponseActivity = true;
|
||||||
|
logDevelopmentDebug("permission request received", {
|
||||||
|
...debugContext,
|
||||||
|
requestId: event.properties.id,
|
||||||
|
permission: event.properties.permission,
|
||||||
|
patterns: event.properties.patterns,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: `permission-${event.properties.id}`,
|
||||||
|
phase: "permission",
|
||||||
|
status: "running",
|
||||||
|
title: "等待权限确认",
|
||||||
|
detail: buildPermissionDetail(event),
|
||||||
|
});
|
||||||
|
write("permission_request", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.properties.id,
|
||||||
|
permission: event.properties.permission,
|
||||||
|
patterns: event.properties.patterns,
|
||||||
|
metadata: event.properties.metadata,
|
||||||
|
always: event.properties.always,
|
||||||
|
tool: event.properties.tool,
|
||||||
|
created_at: Date.now(),
|
||||||
|
} satisfies PermissionRequestPayload);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermissionV2AskedEvent(event)) {
|
||||||
|
sawResponseActivity = true;
|
||||||
|
logDevelopmentDebug("permission v2 request received", {
|
||||||
|
...debugContext,
|
||||||
|
requestId: event.properties.id,
|
||||||
|
action: event.properties.action,
|
||||||
|
resources: event.properties.resources,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: `permission-${event.properties.id}`,
|
||||||
|
phase: "permission",
|
||||||
|
status: "running",
|
||||||
|
title: "等待权限确认",
|
||||||
|
detail: buildPermissionV2Detail(event),
|
||||||
|
});
|
||||||
|
write("permission_request", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.properties.id,
|
||||||
|
permission: event.properties.action,
|
||||||
|
patterns: event.properties.resources,
|
||||||
|
metadata: event.properties.metadata ?? {},
|
||||||
|
always: event.properties.save ?? [],
|
||||||
|
tool: undefined,
|
||||||
|
created_at: Date.now(),
|
||||||
|
} satisfies PermissionRequestPayload);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermissionRepliedEvent(event)) {
|
||||||
|
sawResponseActivity = true;
|
||||||
|
logDevelopmentDebug("permission request replied", {
|
||||||
|
...debugContext,
|
||||||
|
requestId: event.properties.requestID,
|
||||||
|
reply: event.properties.reply,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: `permission-${event.properties.requestID}`,
|
||||||
|
phase: "permission",
|
||||||
|
status: event.properties.reply === "reject" ? "error" : "completed",
|
||||||
|
title:
|
||||||
|
event.properties.reply === "reject"
|
||||||
|
? "权限请求已拒绝"
|
||||||
|
: "权限请求已允许",
|
||||||
|
detail:
|
||||||
|
event.properties.reply === "always"
|
||||||
|
? "已允许本次请求,并记住同类权限。"
|
||||||
|
: event.properties.reply === "once"
|
||||||
|
? "已允许本次请求。"
|
||||||
|
: "已拒绝本次请求。",
|
||||||
|
});
|
||||||
|
write("permission_response", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.properties.requestID,
|
||||||
|
reply: event.properties.reply satisfies PermissionReply,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermissionV2RepliedEvent(event)) {
|
||||||
|
sawResponseActivity = true;
|
||||||
|
logDevelopmentDebug("permission v2 request replied", {
|
||||||
|
...debugContext,
|
||||||
|
requestId: event.properties.requestID,
|
||||||
|
reply: event.properties.reply,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
emitProgress({
|
||||||
|
id: `permission-${event.properties.requestID}`,
|
||||||
|
phase: "permission",
|
||||||
|
status: event.properties.reply === "reject" ? "error" : "completed",
|
||||||
|
title:
|
||||||
|
event.properties.reply === "reject"
|
||||||
|
? "权限请求已拒绝"
|
||||||
|
: "权限请求已允许",
|
||||||
|
detail:
|
||||||
|
event.properties.reply === "always"
|
||||||
|
? "已允许本次请求,并记住同类权限。"
|
||||||
|
: event.properties.reply === "once"
|
||||||
|
? "已允许本次请求。"
|
||||||
|
: "已拒绝本次请求。",
|
||||||
|
});
|
||||||
|
write("permission_response", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
request_id: event.properties.requestID,
|
||||||
|
reply: event.properties.reply satisfies PermissionReply,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSkillEvent(event)) {
|
if (isSkillEvent(event)) {
|
||||||
|
sawResponseActivity = true;
|
||||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||||
logDevelopmentDebug("skill event received", {
|
logDevelopmentDebug("skill event received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
@@ -552,7 +724,15 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === "message.updated") {
|
||||||
|
if (event.properties.info.role === "assistant") {
|
||||||
|
sawResponseActivity = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
||||||
|
sawResponseActivity = true;
|
||||||
const partType = partTypes.get(event.properties.partID);
|
const partType = partTypes.get(event.properties.partID);
|
||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
if (!firstTokenLogged) {
|
if (!firstTokenLogged) {
|
||||||
@@ -591,6 +771,7 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message.part.updated") {
|
if (event.type === "message.part.updated") {
|
||||||
|
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") {
|
if (part.type === "text") {
|
||||||
@@ -720,6 +901,7 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "todo.updated") {
|
if (event.type === "todo.updated") {
|
||||||
|
sawResponseActivity = true;
|
||||||
const completed = event.properties.todos.filter(
|
const completed = event.properties.todos.filter(
|
||||||
(todo) => todo.status === "completed",
|
(todo) => todo.status === "completed",
|
||||||
).length;
|
).length;
|
||||||
@@ -736,6 +918,7 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
if (event.type === "session.error") {
|
||||||
|
sawResponseActivity = true;
|
||||||
logDevelopmentDebug("session error received", {
|
logDevelopmentDebug("session error received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
@@ -757,6 +940,13 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
if (event.type === "session.idle") {
|
||||||
|
if (!sawResponseActivity) {
|
||||||
|
logDevelopmentDebug("ignoring session idle before response activity", {
|
||||||
|
...debugContext,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
logDevelopmentDebug("session idle received", {
|
logDevelopmentDebug("session idle received", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
emittedText,
|
emittedText,
|
||||||
@@ -832,8 +1022,10 @@ export const streamPromptResponse = async ({
|
|||||||
return { aborted: false, failed: false, toolCallCount };
|
return { aborted: false, failed: false, toolCallCount };
|
||||||
} finally {
|
} finally {
|
||||||
await iterator.return?.(undefined);
|
await iterator.return?.(undefined);
|
||||||
if (!promptSettled) {
|
if (!promptSettled && !aborted) {
|
||||||
await promptPromise.catch(() => undefined);
|
await promptPromise.catch(() => undefined);
|
||||||
|
} else if (!promptSettled) {
|
||||||
|
void promptPromise.catch(() => undefined);
|
||||||
}
|
}
|
||||||
logDevelopmentDebug("chat stream cleanup finished", {
|
logDevelopmentDebug("chat stream cleanup finished", {
|
||||||
...debugContext,
|
...debugContext,
|
||||||
|
|||||||
+86
-3
@@ -3,6 +3,8 @@ import {
|
|||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
@@ -29,6 +31,8 @@ type RuntimeModelOverride = {
|
|||||||
modelID: string;
|
modelID: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
export class OpencodeRuntimeAdapter {
|
export class OpencodeRuntimeAdapter {
|
||||||
private clientPromise: Promise<OpencodeClient> | null = null;
|
private clientPromise: Promise<OpencodeClient> | null = null;
|
||||||
private closeServer: (() => void) | null = null;
|
private closeServer: (() => void) | null = null;
|
||||||
@@ -129,6 +133,34 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return response.stream;
|
return response.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async replyPermission(options: {
|
||||||
|
requestId: string;
|
||||||
|
sessionId?: string;
|
||||||
|
reply: PermissionReply;
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
if ("permission" in client && client.permission?.reply) {
|
||||||
|
const response = await client.permission.reply({
|
||||||
|
requestID: options.requestId,
|
||||||
|
reply: options.reply,
|
||||||
|
message: options.message,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||||||
|
const response = await client.permission.respond({
|
||||||
|
sessionID: options.sessionId,
|
||||||
|
permissionID: options.requestId,
|
||||||
|
response: options.reply,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("opencode permission reply API is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.closeServer?.();
|
this.closeServer?.();
|
||||||
this.closeServer = null;
|
this.closeServer = null;
|
||||||
@@ -174,9 +206,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
hostname: config.OPENCODE_HOSTNAME,
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
port: config.OPENCODE_PORT,
|
port: config.OPENCODE_PORT,
|
||||||
timeout: config.OPENCODE_TIMEOUT_MS,
|
timeout: config.OPENCODE_TIMEOUT_MS,
|
||||||
config: {
|
config: buildOpencodeConfig(),
|
||||||
model: config.OPENCODE_MODEL,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingOpencodeCli(error)) {
|
if (isMissingOpencodeCli(error)) {
|
||||||
@@ -207,6 +237,59 @@ export class OpencodeRuntimeAdapter {
|
|||||||
|
|
||||||
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
export const opencodeRuntime = new OpencodeRuntimeAdapter();
|
||||||
|
|
||||||
|
function buildOpencodeConfig(): Record<string, unknown> {
|
||||||
|
return deepMerge(
|
||||||
|
deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
|
||||||
|
{
|
||||||
|
model: config.OPENCODE_MODEL,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProjectOpencodeConfig(): Record<string, unknown> {
|
||||||
|
const path = resolve(process.cwd(), "opencode.json");
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseConfigJson(readFileSync(path, "utf8"), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvOpencodeConfig(): Record<string, unknown> {
|
||||||
|
const content = process.env.OPENCODE_CONFIG_CONTENT;
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigJson(content: string, source: string): Record<string, unknown> {
|
||||||
|
const parsed = JSON.parse(content) as unknown;
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${source} must contain a JSON object`);
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(
|
||||||
|
left: Record<string, unknown>,
|
||||||
|
right: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const next = { ...left };
|
||||||
|
for (const [key, value] of Object.entries(right)) {
|
||||||
|
const existing = next[key];
|
||||||
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
||||||
|
next[key] = deepMerge(existing, value);
|
||||||
|
} else {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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 { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
@@ -253,7 +252,6 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"/api/v1/agent/chat",
|
"/api/v1/agent/chat",
|
||||||
requireAgentAuth,
|
|
||||||
buildChatRouter(
|
buildChatRouter(
|
||||||
sessionBridge,
|
sessionBridge,
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
streamPromptResponse,
|
||||||
|
type PermissionRequestPayload,
|
||||||
|
} from "../../src/routes/chatStream.js";
|
||||||
|
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||||
|
|
||||||
|
const createEventStream = (events: unknown[]) => ({
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
for (const event of events) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streamPromptResponse", () => {
|
||||||
|
it("forwards opencode permission requests as SSE payloads", async () => {
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-1",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
metadata: { command: "rm tmp.txt" },
|
||||||
|
always: ["rm *"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "delete temp",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionEvent = events.find((item) => item.event === "permission_request");
|
||||||
|
expect(permissionEvent?.data).toMatchObject({
|
||||||
|
session_id: "client-session-1",
|
||||||
|
request_id: "perm-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
metadata: { command: "rm tmp.txt" },
|
||||||
|
always: ["rm *"],
|
||||||
|
} satisfies Partial<PermissionRequestPayload>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards opencode v2 permission requests as SSE payloads", async () => {
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "permission.v2.asked",
|
||||||
|
properties: {
|
||||||
|
id: "perm-v2-1",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
action: "external_directory",
|
||||||
|
resources: ["/tmp"],
|
||||||
|
save: ["/tmp"],
|
||||||
|
metadata: { path: "/tmp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: "read /tmp",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionEvent = events.find((item) => item.event === "permission_request");
|
||||||
|
expect(permissionEvent?.data).toMatchObject({
|
||||||
|
session_id: "client-session-1",
|
||||||
|
request_id: "perm-v2-1",
|
||||||
|
permission: "external_directory",
|
||||||
|
patterns: ["/tmp"],
|
||||||
|
metadata: { path: "/tmp" },
|
||||||
|
always: ["/tmp"],
|
||||||
|
} satisfies Partial<PermissionRequestPayload>);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"types": ["node", "bun-types"]
|
"types": ["node", "bun-types"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
"include": ["src/**/*.ts", "tests/**/*.ts", "cli/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user