Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b41ba04c | ||
|
|
b33d950d2b | ||
|
|
f0bf667d02 | ||
|
|
e8a335d5ba | ||
|
|
45687bcf67 | ||
|
|
178e74b403 | ||
|
|
a37f06352c |
@@ -1,7 +1,15 @@
|
|||||||
.git
|
.git
|
||||||
|
.gitea
|
||||||
node_modules
|
node_modules
|
||||||
.opencode/node_modules
|
.opencode/node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
.local.env
|
.local.env
|
||||||
dist
|
dist
|
||||||
.vscode
|
.vscode
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
source
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ LOG_LEVEL=info
|
|||||||
OPENCODE_MODE=embedded
|
OPENCODE_MODE=embedded
|
||||||
OPENCODE_MODEL=deepseek/deepseek-v4-flash
|
OPENCODE_MODEL=deepseek/deepseek-v4-flash
|
||||||
OPENCODE_HOSTNAME=127.0.0.1
|
OPENCODE_HOSTNAME=127.0.0.1
|
||||||
OPENCODE_PORT=4096
|
OPENCODE_PORT=0
|
||||||
|
|
||||||
AGENT_LOCAL_USER_ID=local-user
|
AGENT_LOCAL_USER_ID=local-user
|
||||||
# 必须填写 Server /api/v1/projects 返回的项目 UUID。
|
# 必须填写 Server /api/v1/projects 返回的项目 UUID。
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Agent CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test-publish-and-deploy:
|
||||||
|
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||||
|
with:
|
||||||
|
image_name: gitea.waternetwork.cn/orgtjwater/next-tjwater-agent
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_context: .
|
||||||
|
test_target: build
|
||||||
|
deploy_service: next-agent
|
||||||
|
deploy_host: 192.168.1.114
|
||||||
|
secrets:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
@@ -32,8 +32,8 @@ Agent 负责:
|
|||||||
1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。
|
1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。
|
||||||
2. 每次普通工具调用必须填写具体 `reason`。
|
2. 每次普通工具调用必须填写具体 `reason`。
|
||||||
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
|
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
|
||||||
4. SCADA 地图结果只允许使用可信 `sensor_id` 和 `high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML。
|
4. SCADA 地图结果只允许使用后端 API 返回的可信 `device_id` 和 `high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML。
|
||||||
5. 同一批结果不得包含重复 `sensor_id`,每次最多 100 个点位。
|
5. 同一批结果不得包含重复 `device_id`,每次最多 100 个点位。
|
||||||
6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。
|
6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。
|
||||||
|
|
||||||
## 执行约束
|
## 执行约束
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
|||||||
|
|
||||||
import { executeFrontendAction } from "./frontend_action.js";
|
import { executeFrontendAction } from "./frontend_action.js";
|
||||||
|
|
||||||
const sensorId = tool.schema.string().trim().min(1).max(128);
|
const deviceId = tool.schema.string().trim().min(1).max(128);
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description:
|
description:
|
||||||
@@ -16,7 +16,7 @@ export default tool({
|
|||||||
items: tool.schema
|
items: tool.schema
|
||||||
.array(
|
.array(
|
||||||
tool.schema.object({
|
tool.schema.object({
|
||||||
sensor_id: sensorId.describe("Trusted SCADA sensor identifier."),
|
device_id: deviceId.describe("Trusted SCADA device identifier from the backend API."),
|
||||||
level: tool.schema
|
level: tool.schema
|
||||||
.enum(["high", "medium", "low", "unrated"])
|
.enum(["high", "medium", "low", "unrated"])
|
||||||
.describe("Completed analysis level; use unrated only when no level was concluded."),
|
.describe("Completed analysis level; use unrated only when no level was concluded."),
|
||||||
@@ -25,8 +25,8 @@ export default tool({
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(100)
|
||||||
.refine(
|
.refine(
|
||||||
(items) => new Set(items.map((item) => item.sensor_id)).size === items.length,
|
(items) => new Set(items.map((item) => item.device_id)).size === items.length,
|
||||||
"sensor_id values must be unique",
|
"device_id values must be unique",
|
||||||
)
|
)
|
||||||
.describe("One to one hundred unique SCADA analysis results."),
|
.describe("One to one hundred unique SCADA analysis results."),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,4 +45,13 @@ and UIEnvelope mapping. Tests must not depend on mutable files under `data/`.
|
|||||||
|
|
||||||
This experimental service has no user authentication. Do not add secrets,
|
This experimental service has no user authentication. Do not add secrets,
|
||||||
tokens, passwords, API keys, session dumps, or generated result references to
|
tokens, passwords, API keys, session dumps, or generated result references to
|
||||||
the repository.
|
the repository. Keep `.env`, `data/`, and `logs/` statically denied in
|
||||||
|
`opencode.json`; do not replace those denials with prompt-only guidance. Keep
|
||||||
|
arbitrary Bash and edit operations approval-gated and subagents disabled.
|
||||||
|
|
||||||
|
## Runtime Readiness
|
||||||
|
|
||||||
|
Complete the OpenCode health check, warmup session, and model tool discovery
|
||||||
|
before the HTTP server starts listening. A warmup failure must fail startup.
|
||||||
|
Once listening, `/health` must expose the `ready` and `warmed_up` readiness
|
||||||
|
fields used by deployment checks.
|
||||||
|
|||||||
+14
-15
@@ -1,15 +1,17 @@
|
|||||||
FROM oven/bun:canary-slim AS bun-bin
|
FROM oven/bun:canary-slim@sha256:24a8816a7388dd900e851b61b6d44a4a1908f464661a9ffe393e13e661f0ae3e AS bun-bin
|
||||||
|
|
||||||
FROM smanx/opencode:latest AS base
|
FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
|
||||||
USER root
|
USER root
|
||||||
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com
|
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com
|
||||||
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
ENV PATH="$VIRTUAL_ENV/bin:/root/.local/bin:$PATH"
|
ENV PATH="$VIRTUAL_ENV/bin:/root/.local/bin:$PATH"
|
||||||
ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
|
ENV PIP_INDEX_URL=${PYPI_INDEX_URL}
|
||||||
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
|
ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST}
|
||||||
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
|
ENV UV_INDEX_URL=${PYPI_INDEX_URL}
|
||||||
|
ENV BUN_CONFIG_REGISTRY=${NPM_REGISTRY}
|
||||||
|
|
||||||
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \
|
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \
|
||||||
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
|
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
|
||||||
@@ -19,14 +21,14 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
|
|||||||
unzip \
|
unzip \
|
||||||
python3 \
|
python3 \
|
||||||
python3-venv && \
|
python3-venv && \
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
|
||||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
|
||||||
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
||||||
mkdir -p /root/.config/pip && \
|
mkdir -p /root/.config/pip && \
|
||||||
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "$PIP_INDEX_URL" "$PIP_TRUSTED_HOST" > /root/.config/pip/pip.conf && \
|
printf "[global]\nindex-url = %s\ntrusted-host = %s\n" "$PIP_INDEX_URL" "$PIP_TRUSTED_HOST" > /root/.config/pip/pip.conf && \
|
||||||
uv venv "$VIRTUAL_ENV" && \
|
python3 -m venv "$VIRTUAL_ENV" && \
|
||||||
uv pip install --python "$VIRTUAL_ENV/bin/python" \
|
pip install \
|
||||||
--index-url "$UV_INDEX_URL" \
|
--index-url "$PIP_INDEX_URL" \
|
||||||
|
--trusted-host "$PIP_TRUSTED_HOST" \
|
||||||
|
uv \
|
||||||
pip \
|
pip \
|
||||||
setuptools \
|
setuptools \
|
||||||
wheel \
|
wheel \
|
||||||
@@ -37,6 +39,7 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
|
|||||||
rich \
|
rich \
|
||||||
ipython \
|
ipython \
|
||||||
pytest && \
|
pytest && \
|
||||||
|
ln -s "$VIRTUAL_ENV/bin/uv" /usr/local/bin/uv && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
|
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
|
||||||
@@ -54,29 +57,25 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||||
|
COPY package.json bun.lock ./
|
||||||
COPY tsconfig.json opencode.json README.md .gitignore ./
|
COPY tsconfig.json opencode.json README.md .gitignore ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY .opencode ./.opencode
|
COPY .opencode ./.opencode
|
||||||
RUN bun run check
|
RUN bun run check
|
||||||
|
|
||||||
FROM base AS runner
|
FROM build AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8787
|
ENV PORT=8787
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
COPY tsconfig.json opencode.json .gitignore ./
|
|
||||||
COPY src ./src
|
|
||||||
COPY .opencode ./.opencode
|
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
|
CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/health" >/dev/null || exit 1
|
||||||
CMD ["bun", "src/server.ts"]
|
CMD ["bun", "src/server.ts"]
|
||||||
|
|||||||
@@ -99,3 +99,14 @@ bun test tests/**/*.test.ts
|
|||||||
```
|
```
|
||||||
|
|
||||||
`bun run check` 会检查服务代码和 `.opencode` 工具的类型。
|
`bun run check` 会检查服务代码和 `.opencode` 工具的类型。
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Gitea Actions 通过 `.gitea/workflows/package.yml` 复用
|
||||||
|
`OrgTJWater/ci-templates` 的容器发布流程。手动触发只构建镜像并在 Dev
|
||||||
|
服务器运行候选验证;从 `main` 创建并推送 `v*` tag 后,候选验证通过才会
|
||||||
|
提升为正式 `agent` 服务。
|
||||||
|
|
||||||
|
仓库需要配置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD` 和
|
||||||
|
`DEV_DEPLOY_SSH_KEY` 三个 Gitea Secrets。`.env`、持久化数据、日志和
|
||||||
|
`source/` 业务数据不会写入镜像;它们由 Dev 服务器通过环境文件和卷挂载。
|
||||||
|
|||||||
+45
-6
@@ -8,15 +8,44 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"model": "deepseek/deepseek-v4-flash",
|
"model": "deepseek/deepseek-v4-flash",
|
||||||
|
"experimental": {
|
||||||
|
"continue_loop_on_deny": true
|
||||||
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"hostname": "127.0.0.1",
|
"hostname": "127.0.0.1",
|
||||||
"port": 4096
|
"port": 4096
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "allow",
|
"*": "ask",
|
||||||
"external_directory": "ask",
|
"external_directory": "deny",
|
||||||
"bash": {
|
"read": {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
|
".env": "deny",
|
||||||
|
".env.*": "deny",
|
||||||
|
"*.env": "deny",
|
||||||
|
"**/.env": "deny",
|
||||||
|
"**/.env.*": "deny",
|
||||||
|
"**/*.env": "deny",
|
||||||
|
"data/**": "deny",
|
||||||
|
"**/data/**": "deny",
|
||||||
|
"logs/**": "deny",
|
||||||
|
"**/logs/**": "deny"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"*": "ask",
|
||||||
|
".env": "deny",
|
||||||
|
".env.*": "deny",
|
||||||
|
"*.env": "deny",
|
||||||
|
"**/.env": "deny",
|
||||||
|
"**/.env.*": "deny",
|
||||||
|
"**/*.env": "deny",
|
||||||
|
"data/**": "deny",
|
||||||
|
"**/data/**": "deny",
|
||||||
|
"logs/**": "deny",
|
||||||
|
"**/logs/**": "deny"
|
||||||
|
},
|
||||||
|
"bash": {
|
||||||
|
"*": "ask",
|
||||||
"rm *": "ask",
|
"rm *": "ask",
|
||||||
"rmdir *": "ask",
|
"rmdir *": "ask",
|
||||||
"mv *": "ask",
|
"mv *": "ask",
|
||||||
@@ -24,10 +53,20 @@
|
|||||||
"chown *": "ask",
|
"chown *": "ask",
|
||||||
"sudo *": "ask",
|
"sudo *": "ask",
|
||||||
"curl *": "ask",
|
"curl *": "ask",
|
||||||
"wget *": "ask"
|
"wget *": "ask",
|
||||||
|
"*.env*": "deny",
|
||||||
|
"*data/*": "deny",
|
||||||
|
"* data": "deny",
|
||||||
|
"*/data": "deny",
|
||||||
|
"*logs/*": "deny",
|
||||||
|
"* logs": "deny",
|
||||||
|
"*/logs": "deny"
|
||||||
},
|
},
|
||||||
"edit": "ask",
|
"question": "allow",
|
||||||
"task": "deny"
|
"task": "deny",
|
||||||
|
"todo": "allow",
|
||||||
|
"todoread": "allow",
|
||||||
|
"todowrite": "allow"
|
||||||
},
|
},
|
||||||
"default_agent": "instruction"
|
"default_agent": "instruction"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -58,8 +58,8 @@ const envSchema = z
|
|||||||
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
|
||||||
// embedded opencode server 的监听地址。
|
// embedded opencode server 的监听地址。
|
||||||
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
|
||||||
// embedded opencode server 的监听端口。
|
// embedded opencode server 的监听端口;0 表示由系统动态分配。
|
||||||
OPENCODE_PORT: z.coerce.number().int().positive().default(4096),
|
OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0),
|
||||||
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。
|
||||||
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||||
// 默认使用的 opencode 模型标识。
|
// 默认使用的 opencode 模型标识。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
import type { FrontendActionManifest } from "./types.js";
|
import type { FrontendActionManifest } from "./types.js";
|
||||||
|
|
||||||
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
|
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
|
||||||
const sensorId = z.string().trim().min(1).max(128);
|
const deviceId = z.string().trim().min(1).max(128);
|
||||||
const scadaLevel = z.enum(["high", "medium", "low", "unrated"]);
|
const scadaLevel = z.enum(["high", "medium", "low", "unrated"]);
|
||||||
const levelCounts = z.object({
|
const levelCounts = z.object({
|
||||||
high: z.number().int().nonnegative(),
|
high: z.number().int().nonnegative(),
|
||||||
@@ -11,18 +11,18 @@ const levelCounts = z.object({
|
|||||||
unrated: z.number().int().nonnegative(),
|
unrated: z.number().int().nonnegative(),
|
||||||
}).strict();
|
}).strict();
|
||||||
const renderScadaAnalysisInput = z.object({
|
const renderScadaAnalysisInput = z.object({
|
||||||
items: z.array(z.object({ sensor_id: sensorId, level: scadaLevel }).strict()).min(1).max(100),
|
items: z.array(z.object({ device_id: deviceId, level: scadaLevel }).strict()).min(1).max(100),
|
||||||
}).strict().superRefine((value, context) => {
|
}).strict().superRefine((value, context) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
value.items.forEach((item, index) => {
|
value.items.forEach((item, index) => {
|
||||||
if (seen.has(item.sensor_id)) {
|
if (seen.has(item.device_id)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "sensor_id values must be unique",
|
message: "device_id values must be unique",
|
||||||
path: ["items", index, "sensor_id"],
|
path: ["items", index, "device_id"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
seen.add(item.sensor_id);
|
seen.add(item.device_id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const definitions: Definition[] = [
|
const definitions: Definition[] = [
|
||||||
@@ -36,8 +36,8 @@ const definitions: Definition[] = [
|
|||||||
},
|
},
|
||||||
inputSchema: renderScadaAnalysisInput,
|
inputSchema: renderScadaAnalysisInput,
|
||||||
outputSchema: z.object({
|
outputSchema: z.object({
|
||||||
rendered_ids: z.array(sensorId),
|
rendered_ids: z.array(deviceId),
|
||||||
missing_ids: z.array(sensorId),
|
missing_ids: z.array(deviceId),
|
||||||
level_counts: levelCounts,
|
level_counts: levelCounts,
|
||||||
fitted: z.literal(true),
|
fitted: z.literal(true),
|
||||||
}).strict(),
|
}).strict(),
|
||||||
|
|||||||
+104
-3
@@ -4,6 +4,7 @@ import {
|
|||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { createServer } from "node:net";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
@@ -44,6 +45,34 @@ type RuntimeMessage = {
|
|||||||
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
|
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
|
||||||
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
|
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
|
||||||
|
|
||||||
|
const isTcpPortAvailable = (hostname: string, port: number) =>
|
||||||
|
new Promise<boolean>((resolve, reject) => {
|
||||||
|
const server = createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === "EADDRINUSE") {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
server.listen({ host: hostname, port, exclusive: true }, () => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve(true)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resolveEmbeddedOpencodePort = async (
|
||||||
|
hostname: string,
|
||||||
|
configuredPort: number,
|
||||||
|
) => {
|
||||||
|
if (configuredPort === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (await isTcpPortAvailable(hostname, configuredPort))
|
||||||
|
? configuredPort
|
||||||
|
: 0;
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -64,6 +93,64 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return requireData(response.data, "global.health");
|
return requireData(response.data, "global.health");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async warmup(): Promise<void> {
|
||||||
|
const client = await this.ensureClient();
|
||||||
|
const healthStartedAt = Date.now();
|
||||||
|
const healthResponse = await client.global.health();
|
||||||
|
const health = requireData(healthResponse.data, "global.health");
|
||||||
|
logDevelopmentDebug("opencode warmup health check completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
|
||||||
|
healthy: health.healthy,
|
||||||
|
version: health.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionStartedAt = Date.now();
|
||||||
|
const sessionResponse = await client.session.create({
|
||||||
|
title: "tjwater-agent-warmup",
|
||||||
|
});
|
||||||
|
const session = requireData(sessionResponse.data, "session.create");
|
||||||
|
logDevelopmentDebug("opencode warmup session created", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [provider, model] = config.OPENCODE_MODEL.split("/");
|
||||||
|
if (!provider || !model) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const toolsStartedAt = Date.now();
|
||||||
|
const toolsResponse = await client.tool.list({ provider, model });
|
||||||
|
const tools = requireData(toolsResponse.data, "tool.list");
|
||||||
|
logDevelopmentDebug("opencode warmup tools loaded", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
|
||||||
|
model: config.OPENCODE_MODEL,
|
||||||
|
sessionId: session.id,
|
||||||
|
toolCount: tools.length,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
const cleanupStartedAt = Date.now();
|
||||||
|
let cleanupSucceeded = true;
|
||||||
|
await client.session.delete(
|
||||||
|
{ sessionID: session.id },
|
||||||
|
{ throwOnError: true },
|
||||||
|
).catch((error) => {
|
||||||
|
cleanupSucceeded = false;
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, sessionId: session.id },
|
||||||
|
"failed to remove opencode warmup session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logDevelopmentDebug("opencode warmup session cleanup completed", {
|
||||||
|
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
|
||||||
|
sessionId: session.id,
|
||||||
|
succeeded: cleanupSucceeded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(title?: string) {
|
async createSession(title?: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.session.create({
|
const response = await client.session.create({
|
||||||
@@ -350,10 +437,24 @@ export class OpencodeRuntimeAdapter {
|
|||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
|
||||||
"";
|
"";
|
||||||
|
|
||||||
|
const port = await resolveEmbeddedOpencodePort(
|
||||||
|
config.OPENCODE_HOSTNAME,
|
||||||
|
config.OPENCODE_PORT,
|
||||||
|
);
|
||||||
|
if (port !== config.OPENCODE_PORT) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
|
configuredPort: config.OPENCODE_PORT,
|
||||||
|
},
|
||||||
|
"configured opencode port is occupied; falling back to a dynamic port",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
hostname: config.OPENCODE_HOSTNAME,
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
port: config.OPENCODE_PORT,
|
port,
|
||||||
model: config.OPENCODE_MODEL,
|
model: config.OPENCODE_MODEL,
|
||||||
mode: config.OPENCODE_MODE,
|
mode: config.OPENCODE_MODE,
|
||||||
},
|
},
|
||||||
@@ -365,7 +466,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
try {
|
try {
|
||||||
runtime = await createOpencode({
|
runtime = await createOpencode({
|
||||||
hostname: config.OPENCODE_HOSTNAME,
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
port: config.OPENCODE_PORT,
|
port,
|
||||||
timeout: config.OPENCODE_TIMEOUT_MS,
|
timeout: config.OPENCODE_TIMEOUT_MS,
|
||||||
config: buildOpencodeConfig(),
|
config: buildOpencodeConfig(),
|
||||||
});
|
});
|
||||||
@@ -382,7 +483,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
{
|
{
|
||||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||||
hostname: config.OPENCODE_HOSTNAME,
|
hostname: config.OPENCODE_HOSTNAME,
|
||||||
port: config.OPENCODE_PORT,
|
baseUrl: runtime.server.url,
|
||||||
mode: config.OPENCODE_MODE,
|
mode: config.OPENCODE_MODE,
|
||||||
},
|
},
|
||||||
"opencode server started in embedded mode",
|
"opencode server started in embedded mode",
|
||||||
|
|||||||
+22
-12
@@ -75,6 +75,8 @@ app.get("/health", async (_req, res) => {
|
|||||||
const runtime = await opencodeRuntime.health();
|
const runtime = await opencodeRuntime.health();
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
ready: true,
|
||||||
|
warmed_up: true,
|
||||||
runtime,
|
runtime,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
});
|
});
|
||||||
@@ -82,6 +84,8 @@ app.get("/health", async (_req, res) => {
|
|||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
ready: false,
|
||||||
|
warmed_up: true,
|
||||||
message: "opencode runtime unavailable",
|
message: "opencode runtime unavailable",
|
||||||
detail,
|
detail,
|
||||||
sessions: sessionBridge.count(),
|
sessions: sessionBridge.count(),
|
||||||
@@ -148,23 +152,12 @@ const bootstrap = async () => {
|
|||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
sessionTranscriptStore.initialize(),
|
sessionTranscriptStore.initialize(),
|
||||||
]);
|
]);
|
||||||
resultReferenceStore.startCleanupLoop();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await bootstrap();
|
|
||||||
|
|
||||||
const server = app.listen(config.PORT, config.HOST, () => {
|
|
||||||
logger.info(
|
|
||||||
{ host: config.HOST, port: config.PORT },
|
|
||||||
"TJWaterAgent listening",
|
|
||||||
);
|
|
||||||
void warmupOpencodeRuntime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const warmupOpencodeRuntime = async () => {
|
const warmupOpencodeRuntime = async () => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
await opencodeRuntime.ensureClient();
|
await opencodeRuntime.warmup();
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||||||
@@ -181,9 +174,26 @@ const warmupOpencodeRuntime = async () => {
|
|||||||
},
|
},
|
||||||
"failed to warm up opencode runtime",
|
"failed to warm up opencode runtime",
|
||||||
);
|
);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await bootstrap();
|
||||||
|
await warmupOpencodeRuntime();
|
||||||
|
resultReferenceStore.startCleanupLoop();
|
||||||
|
|
||||||
|
const server = app.listen(config.PORT, config.HOST, () => {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
host: config.HOST,
|
||||||
|
port: config.PORT,
|
||||||
|
ready: true,
|
||||||
|
warmedUp: true,
|
||||||
|
},
|
||||||
|
"TJWaterAgent listening",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
logger.info("shutting down TJWaterAgent");
|
logger.info("shutting down TJWaterAgent");
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
|
|||||||
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
|
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ sensor_id: "MP01", level: "high" },
|
{ device_id: "MP01", level: "high" },
|
||||||
{ sensor_id: "MP02", level: "medium" },
|
{ device_id: "MP02", level: "medium" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const start = (
|
const start = (
|
||||||
@@ -119,10 +119,10 @@ describe("FrontendActionCoordinator", () => {
|
|||||||
params: value,
|
params: value,
|
||||||
});
|
});
|
||||||
expect(() => request({ items: [] })).toThrow(FrontendActionError);
|
expect(() => request({ items: [] })).toThrow(FrontendActionError);
|
||||||
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ sensor_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params");
|
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ device_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params");
|
||||||
expect(() => request({ items: [{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params");
|
expect(() => request({ items: [{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params");
|
||||||
expect(() => request({ items: [{ sensor_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params");
|
expect(() => request({ items: [{ device_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params");
|
||||||
expect(() => request({ items: [{ sensor_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params");
|
expect(() => request({ items: [{ device_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps terminal results idempotent and rejects conflicts", async () => {
|
it("keeps terminal results idempotent and rejects conflicts", async () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe("appendBackendToolArtifact", () => {
|
|||||||
const artifacts = appendBackendToolArtifact([], {
|
const artifacts = appendBackendToolArtifact([], {
|
||||||
tool: "render_scada_analysis",
|
tool: "render_scada_analysis",
|
||||||
reason: "显示已完成的分级结论",
|
reason: "显示已完成的分级结论",
|
||||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||||
}) as Array<Record<string, unknown>>;
|
}) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
expect(artifacts[0]).toMatchObject({
|
expect(artifacts[0]).toMatchObject({
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
|
||||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
import { config } from "../../src/config.js";
|
||||||
|
import {
|
||||||
|
OpencodeRuntimeAdapter,
|
||||||
|
resolveEmbeddedOpencodePort,
|
||||||
|
} from "../../src/runtime/opencode.js";
|
||||||
|
|
||||||
const createRuntimeAdapter = (
|
const createRuntimeAdapter = (
|
||||||
messages: unknown[],
|
messages: unknown[],
|
||||||
@@ -85,3 +90,73 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|||||||
expect(attempts).toBe(2);
|
expect(attempts).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveEmbeddedOpencodePort", () => {
|
||||||
|
it("falls back to a dynamic port when the configured port is occupied", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close();
|
||||||
|
throw new Error("expected a TCP server address");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
resolveEmbeddedOpencodePort("127.0.0.1", address.port),
|
||||||
|
).resolves.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||||
|
it("initializes the project session and model tools before reporting ready", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const client = {
|
||||||
|
global: {
|
||||||
|
health: async () => {
|
||||||
|
calls.push("health");
|
||||||
|
return { data: { healthy: true, version: "test" } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: async () => {
|
||||||
|
calls.push("session.create");
|
||||||
|
return { data: { id: "warmup-session" } };
|
||||||
|
},
|
||||||
|
delete: async ({ sessionID }: { sessionID: string }) => {
|
||||||
|
calls.push(`session.delete:${sessionID}`);
|
||||||
|
return { data: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
list: async (model: { provider: string; model: string }) => {
|
||||||
|
calls.push(`tool.list:${model.provider}/${model.model}`);
|
||||||
|
return { data: [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
clientPromise: null,
|
||||||
|
closeServer: null,
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await runtime.warmup();
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"health",
|
||||||
|
"session.create",
|
||||||
|
`tool.list:${config.OPENCODE_MODEL}`,
|
||||||
|
"session.delete:warmup-session",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it, test } from "bun:test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
describe("internal OpenCode permissions", () => {
|
||||||
|
it("keeps protected paths denied in every approval mode", async () => {
|
||||||
|
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
|
||||||
|
permission?: Record<string, string | Record<string, string>>;
|
||||||
|
};
|
||||||
|
const permission = config.permission ?? {};
|
||||||
|
const bash = permission.bash as Record<string, string> | undefined;
|
||||||
|
const edit = permission.edit as Record<string, string> | undefined;
|
||||||
|
const read = permission.read as Record<string, string> | undefined;
|
||||||
|
|
||||||
|
expect(permission["*"]).toBe("ask");
|
||||||
|
expect(permission.external_directory).toBe("deny");
|
||||||
|
expect(permission.task).toBe("deny");
|
||||||
|
expect(permission.question).toBe("allow");
|
||||||
|
expect(permission.todowrite).toBe("allow");
|
||||||
|
expect(read?.["*"]).toBe("allow");
|
||||||
|
expect(read?.["data/**"]).toBe("deny");
|
||||||
|
expect(read?.["**/logs/**"]).toBe("deny");
|
||||||
|
expect(edit?.["*"]).toBe("ask");
|
||||||
|
expect(edit?.["data/**"]).toBe("deny");
|
||||||
|
expect(edit?.["**/logs/**"]).toBe("deny");
|
||||||
|
expect(bash?.["*"]).toBe("ask");
|
||||||
|
expect(bash?.["*.env*"]).toBe("deny");
|
||||||
|
expect(bash?.["*data/*"]).toBe("deny");
|
||||||
|
expect(bash?.["*logs/*"]).toBe("deny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenCode runtime config", () => {
|
||||||
|
test("continues the agent loop after a question or permission is denied", async () => {
|
||||||
|
const config = await Bun.file(new URL("../../opencode.json", import.meta.url)).json();
|
||||||
|
|
||||||
|
expect(config.experimental?.continue_loop_on_deny).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ describe("toUiEnvelopeFromToolCall", () => {
|
|||||||
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
|
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
|
||||||
expect(toUiEnvelopeFromToolCall({
|
expect(toUiEnvelopeFromToolCall({
|
||||||
tool: "render_scada_analysis",
|
tool: "render_scada_analysis",
|
||||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||||
})).toBeNull();
|
})).toBeNull();
|
||||||
expect(toUiEnvelopeFromToolCall({
|
expect(toUiEnvelopeFromToolCall({
|
||||||
tool: "clear_scada_analysis",
|
tool: "clear_scada_analysis",
|
||||||
|
|||||||
Reference in New Issue
Block a user