7 Commits
Author SHA1 Message Date
jiang 88b41ba04c refactor(scada): use canonical device identifiers
Generic Container CI/CD / test-build-publish (push) Successful in 32s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 32s
2026-09-08 18:18:51 +08:00
jiang b33d950d2b fix: avoid embedded opencode port conflicts 2026-08-19 16:56:25 +08:00
jiang f0bf667d02 fix(agent): harden permissions and startup readiness 2026-08-18 17:10:48 +08:00
jiang e8a335d5ba fix: isolate Next agent deployment
Generic Container CI/CD / test-build-publish (push) Successful in 33s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 33s
2026-08-17 19:43:50 +08:00
jiang 45687bcf67 build: use mainland package mirrors
Generic Container CI/CD / test-build-publish (push) Successful in 31s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 31s
2026-08-17 19:25:38 +08:00
jiang 178e74b403 feat: add container CI/CD workflow 2026-08-17 19:02:02 +08:00
jiang a37f06352c fix: continue agent after denied interactions 2026-07-28 18:08:08 +08:00
18 changed files with 375 additions and 63 deletions
+8
View File
@@ -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
View File
@@ -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。
+22
View File
@@ -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 }}
+2 -2
View File
@@ -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 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。
## 执行约束 ## 执行约束
+4 -4
View File
@@ -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."),
}, },
+10 -1
View File
@@ -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
View File
@@ -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"]
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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 模型标识。
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+6 -6
View File
@@ -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 () => {
+1 -1
View File
@@ -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({
+76 -1
View File
@@ -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",
]);
});
});
+38
View File
@@ -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);
});
});
+1 -1
View File
@@ -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",