feat(api): expose REST-only agent routes
Agent CI/CD / docker-image (push) Failing after 35s
Agent CI/CD / deploy-fallback-log (push) Successful in 1s

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent 2415f75841
commit 94529cb141
29 changed files with 3525 additions and 378 deletions
+5 -1
View File
@@ -4,7 +4,7 @@
## 主要能力 ## 主要能力
- 提供 `POST /api/v1/agent/chat/stream` SSE 聊天接口。 - 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。 - 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。
- 管理前端 `session_id` 与 OpenCode session 的映射。 - 管理前端 `session_id` 与 OpenCode session 的映射。
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。 - 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
@@ -43,6 +43,8 @@ bun run dev
```bash ```bash
bun run check bun run check
bun run contract:generate
bun run test:api
bun run test:cli bun run test:cli
bun run start bun run start
bun run start:prod bun run start:prod
@@ -50,6 +52,8 @@ docker build -t tjwater-agent:local .
``` ```
- `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型。 - `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型。
- `bun run contract:generate`:生成 `contracts/agent-v1.openapi.json`
- `bun run test:api`:验证公开 REST 契约和聊天路由。
- `bun run test:cli`:运行 `node-tests/cli/*.node.mjs` - `bun run test:cli`:运行 `node-tests/cli/*.node.mjs`
- `bun run start`:直接启动服务。 - `bun run start`:直接启动服务。
- `bun run start:prod`:先类型检查,再启动服务。 - `bun run start:prod`:先类型检查,再启动服务。
+7
View File
@@ -14,6 +14,7 @@
"zod": "^3.25.76", "zod": "^3.25.76",
}, },
"devDependencies": { "devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
@@ -23,6 +24,8 @@
}, },
}, },
"packages": { "packages": {
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], "@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=="],
@@ -175,6 +178,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -259,6 +264,8 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+43 -53
View File
@@ -2,7 +2,7 @@ import { CliError } from "../core/errors.js";
import { emitApi, requestJson } from "../core/http.js"; import { emitApi, requestJson } from "../core/http.js";
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js"; import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js"; import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js"; import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js"; import { parseTime } from "../core/time.js";
import { success } from "../core/output.js"; import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -12,17 +12,16 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file")); const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file"));
const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!; const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!;
return emitApi(ctx, "爆管分析执行成功", { return emitApi(ctx, "爆管分析执行成功", {
method: "GET", method: "POST",
path: "/burst-analysis", path: "/burst-analyses",
params: { params: {
network: requireNetwork(ctx),
modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"), modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
burst_ID: ids, burst_id: ids,
burst_size: sizes, burst_size: sizes,
modify_total_duration: requiredNumber(values, "duration"), modify_total_duration: requiredNumber(values, "duration"),
scheme_name: schemeName, scheme_name: schemeName,
}, },
requireNetworkCtx: true, requireProject: true,
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]); }, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]);
} }
@@ -34,25 +33,24 @@ function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> {
const startTime = optionalString(values, "start-time"); const startTime = optionalString(values, "start-time");
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2); if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
return emitApi(ctx, "阀门关闭分析执行成功", { return emitApi(ctx, "阀门关闭分析执行成功", {
method: "GET", method: "POST",
path: "/valve_close_analysis/", path: "/valve-isolation-analyses",
params: { params: {
network: requireNetwork(ctx),
start_time: parseTime(startTime, "--start-time"), start_time: parseTime(startTime, "--start-time"),
valves, valves,
duration: optionalNumber(values, "duration") || 900, duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
}, },
requireNetworkCtx: true, requireProject: true,
}); });
} }
const elements = optionalStringArray(values, "element"); const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2); if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2);
return emitApi(ctx, "阀门隔离分析执行成功", { return emitApi(ctx, "阀门隔离分析执行成功", {
method: "GET", method: "POST",
path: "/valve-isolation-analysis", path: "/valve-isolation-analyses",
params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") }, params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") },
requireNetworkCtx: true, requireProject: true,
}); });
} }
@@ -60,36 +58,34 @@ function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { flow: "number", duration: "integer" }); const { values } = parseOptions(argv, { flow: "number", duration: "integer" });
const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file")); const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file"));
return emitApi(ctx, "冲洗分析执行成功", { return emitApi(ctx, "冲洗分析执行成功", {
method: "GET", method: "POST",
path: "/flushing-analysis", path: "/flushing-analyses",
params: { params: {
network: requireNetwork(ctx),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"), start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
valves, valves,
valves_k: openings, valves_k: openings,
drainage_node_ID: requiredString(values, "drainage-node"), drainage_node_id: requiredString(values, "drainage-node"),
flush_flow: requiredNumber(values, "flow"), flush_flow: requiredNumber(values, "flow"),
duration: optionalNumber(values, "duration") || 900, duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
}, },
requireNetworkCtx: true, requireProject: true,
}); });
} }
function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisAge(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer" }); const { values } = parseOptions(argv, { duration: "integer" });
return emitApi(ctx, "水龄分析执行成功", { return emitApi(ctx, "水龄分析执行成功", {
method: "GET", method: "POST",
path: "/age_analysis/", path: "/water-age-analyses",
params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") }, params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") },
requireNetworkCtx: true, requireProject: true,
}); });
} }
function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { duration: "integer", concentration: "number" }); const { values } = parseOptions(argv, { duration: "integer", concentration: "number" });
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
network: requireNetwork(ctx),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"), start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
source: requiredString(values, "source-node"), source: requiredString(values, "source-node"),
concentration: requiredNumber(values, "concentration"), concentration: requiredNumber(values, "concentration"),
@@ -98,55 +94,50 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
}; };
const pattern = optionalString(values, "pattern"); const pattern = optionalString(values, "pattern");
if (pattern) params.pattern = pattern; if (pattern) params.pattern = pattern;
return emitApi(ctx, "污染物模拟执行成功", { method: "GET", path: "/contaminant-simulation", params, requireNetworkCtx: true }); return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
} }
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> { function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" }); const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", { return emitApi(ctx, "传感器选址执行成功", {
method: "POST", method: "POST",
path: "/pressure_sensor_placement_kmeans/", path: "/pressure-sensor-placement-kmeans",
body: { body: {
name: requireNetwork(ctx),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
sensor_number: requiredNumber(values, "count"), sensor_number: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0, min_diameter: optionalNumber(values, "min-diameter") || 0,
username: requireUsername(ctx),
}, },
requireNetworkCtx: true, requireProject: true,
requireUsernameCtx: true,
}); });
} }
function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise<void> { function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, startKey: string, endKey: string): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, summary, { return emitApi(ctx, summary, {
method: "POST", method: "POST",
path, path,
body: { body: {
[networkKey]: requireNetwork(ctx),
[startKey]: parseTime(requiredString(values, "start-time"), "--start-time"), [startKey]: parseTime(requiredString(values, "start-time"), "--start-time"),
[endKey]: parseTime(requiredString(values, "end-time"), "--end-time"), [endKey]: parseTime(requiredString(values, "end-time"), "--end-time"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
}, },
requireNetworkCtx: true, requireProject: true,
}); });
} }
function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise<void> { function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true });
} }
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> { function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> {
const { positionals } = parseOptions(argv); const { positionals } = parseOptions(argv);
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2); if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2);
return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true });
} }
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> { function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" }); const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" });
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
network: requireNetwork(ctx),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
data_source: optionalString(values, "data-source") || "monitoring", data_source: optionalString(values, "data-source") || "monitoring",
scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"), scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"),
@@ -162,18 +153,17 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const flowFile = optionalString(values, "flow-file"); const flowFile = optionalString(values, "flow-file");
if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure"); if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure");
if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow"); if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow");
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-location/locate/", body, requireNetworkCtx: true }); return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
} }
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> { function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), pipe_id: requiredString(values, "pipe") }, requireNetworkCtx: true }); return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
} }
async function riskNetwork(ctx: RuntimeContext): Promise<void> { async function riskNetwork(ctx: RuntimeContext): Promise<void> {
const network = requireNetwork(ctx); const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true }); const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true });
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b); success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
} }
@@ -184,16 +174,16 @@ export const analysisHandlers: HandlerMap = {
"analysis age": analysisAge, "analysis age": analysisAge,
"analysis contaminant": analysisContaminant, "analysis contaminant": analysisContaminant,
"analysis sensor-placement kmeans": sensorKmeans, "analysis sensor-placement kmeans": sensorKmeans,
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"), "analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"), "analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"), "analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"), "analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"), "analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"), "analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
"analysis burst-location locate": burstLocation, "analysis burst-location locate": burstLocation,
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"), "analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"), "analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"), "analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"), "analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
"analysis risk network": riskNetwork, "analysis risk network": riskNetwork,
}; };
+10 -11
View File
@@ -1,7 +1,6 @@
import { CliError } from "../core/errors.js"; import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js"; import { emitApi } from "../core/http.js";
import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js"; import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
type ComponentKind = "time" | "energy" | "pump-energy" | "network"; type ComponentKind = "time" | "energy" | "pump-energy" | "network";
@@ -10,22 +9,22 @@ function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean):
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind"); const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind");
const routes: Record<`${ComponentKind}:${boolean}`, string> = { const routes: Record<`${ComponentKind}:${boolean}`, string> = {
"time:true": "/gettimeschema", "time:true": "/network-schemas/time",
"time:false": "/gettimeproperties/", "time:false": "/network-options/time",
"energy:true": "/getenergyschema/", "energy:true": "/network-schemas/energy",
"energy:false": "/getenergyproperties/", "energy:false": "/network-options/energy",
"pump-energy:true": "/getpumpenergyschema/", "pump-energy:true": "/network-schemas/pump-energy",
"pump-energy:false": "/getpumpenergyproperties//", "pump-energy:false": "/network-options/pump-energy",
"network:true": "/getoptionschema/", "network:true": "/network-schemas/option",
"network:false": "/getoptionproperties/", "network:false": "/network-options",
}; };
const params: Record<string, unknown> = { network: requireNetwork(ctx) }; const params: Record<string, unknown> = {};
const pump = optionalString(values, "pump"); const pump = optionalString(values, "pump");
if (kind === "pump-energy") { if (kind === "pump-energy") {
if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2); if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2);
if (pump) params.pump = pump; if (pump) params.pump = pump;
} }
return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireNetworkCtx: true }); return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true });
} }
export const componentHandlers: HandlerMap = { export const componentHandlers: HandlerMap = {
+23 -19
View File
@@ -2,7 +2,7 @@ import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js"; import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js"; import { emitApi } from "../core/http.js";
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js"; import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { requireNetwork, resolveScheme } from "../core/runtime.js"; import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js"; import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -20,7 +20,7 @@ function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取实时模拟数据成功", { return emitApi(ctx, "读取实时模拟数据成功", {
method: "GET", method: "GET",
path: "/realtime/query/by-id-time", path: "/timeseries/realtime/simulation-results",
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") }, params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true, requireProject: true,
}); });
@@ -31,7 +31,7 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"); const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时属性聚合数据成功", { return emitApi(ctx, "读取实时属性聚合数据成功", {
method: "GET", method: "GET",
path: "/realtime/query/by-time-property", path: "/timeseries/realtime/records",
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") }, params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
requireProject: true, requireProject: true,
}); });
@@ -41,7 +41,7 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案管道数据成功", { return emitApi(ctx, "读取方案管道数据成功", {
method: "GET", method: "GET",
path: "/scheme/links", path: "/timeseries/schemes/links",
params: { params: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation", scheme_type: optionalString(values, "scheme-type") || "simulation",
@@ -56,7 +56,7 @@ function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案节点字段成功", { return emitApi(ctx, "读取方案节点字段成功", {
method: "GET", method: "GET",
path: `/scheme/nodes/${requiredString(values, "node")}/field`, path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`,
params: { params: {
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"), field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
@@ -80,10 +80,10 @@ function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
}; };
if (query === "by-id-time") { if (query === "by-id-time") {
params.id = requiredString(values, "id"); params.id = requiredString(values, "id");
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/scheme/query/by-id-time", params, requireProject: true }); return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
} }
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property"); params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/scheme/query/by-scheme-time-property", params, requireProject: true }); return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
} }
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> { function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -95,7 +95,7 @@ function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
}; };
const field = optionalString(values, "field"); const field = optionalString(values, "field");
if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field"); if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field");
return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true }); return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/timeseries/scada-readings/fields" : "/timeseries/scada-readings", params, requireProject: true });
} }
function composite(ctx: RuntimeContext, argv: string[]): Promise<void> { function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -115,7 +115,12 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
params.element_id = feature[0]; params.element_id = feature[0];
params.use_cleaned = Boolean(values["use-cleaned"]); params.use_cleaned = Boolean(values["use-cleaned"]);
} }
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true }); const paths = {
"scada-simulation": "/timeseries/views/scada-simulations",
"element-simulation": "/timeseries/views/element-simulations",
"element-scada": "/timeseries/views/element-scada-readings",
} as const;
return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: paths[kind], params, requireProject: true });
} }
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> { function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -124,33 +129,32 @@ function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
requiredString(values, "start-time"); requiredString(values, "start-time");
return emitApi(ctx, "读取管道健康预测成功", { return emitApi(ctx, "读取管道健康预测成功", {
method: "GET", method: "GET",
path: "/composite/pipeline-health-prediction", path: "/pipeline-health-predictions",
params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") }, params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") },
requireProject: true, requireProject: true,
requireNetworkCtx: true,
}); });
} }
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> { function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true }); return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
} }
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> { function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind");
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true });
} }
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> { function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/getscheme/", params: { network: requireNetwork(ctx), schema_name: requiredString(values, "name") }, requireNetworkCtx: true }); return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true });
} }
export const dataHandlers: HandlerMap = { export const dataHandlers: HandlerMap = {
"data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"), "data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"),
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"), "data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
"data timeseries realtime simulation-by-id-time": realtimeByIdTime, "data timeseries realtime simulation-by-id-time": realtimeByIdTime,
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty, "data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
"data timeseries scheme links": schemeLinks, "data timeseries scheme links": schemeLinks,
@@ -161,7 +165,7 @@ export const dataHandlers: HandlerMap = {
"data timeseries composite pipeline-health": pipelineHealth, "data timeseries composite pipeline-health": pipelineHealth,
"data scada get": dataScadaGet, "data scada get": dataScadaGet,
"data scada list": dataScadaList, "data scada list": dataScadaList,
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }), "data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }),
"data scheme get": dataSchemeGet, "data scheme get": dataSchemeGet,
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }), "data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
}; };
+15 -16
View File
@@ -1,27 +1,26 @@
import { emitApi } from "../core/http.js"; import { emitApi } from "../core/http.js";
import { parseOptions, requiredString } from "../core/options.js"; import { parseOptions, requiredString } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
function legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> { function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx), [key]: requiredString(values, key) }, requireNetworkCtx: true }); return emitApi(ctx, summary, { method: "GET", path, params: { [key]: requiredString(values, key) }, requireProject: true });
} }
function legacyGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> { function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); return emitApi(ctx, summary, { method: "GET", path, requireProject: true });
} }
export const networkHandlers: HandlerMap = { export const networkHandlers: HandlerMap = {
"network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"), "network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"),
"network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"), "network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"),
"network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"), "network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"),
"network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"), "network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"),
"network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"), "network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"),
"network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"), "network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"),
"network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"), "network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"),
"network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"), "network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"),
"network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"), "network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"),
"network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"), "network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"),
"network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"), "network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"),
}; };
+1 -3
View File
@@ -1,6 +1,5 @@
import { emitApi } from "../core/http.js"; import { emitApi } from "../core/http.js";
import { parseOptions, requiredNumber, requiredString } from "../core/options.js"; import { parseOptions, requiredNumber, requiredString } from "../core/options.js";
import { requireNetwork } from "../core/runtime.js";
import { addMinutesPreservingOffset, parseTime } from "../core/time.js"; import { addMinutesPreservingOffset, parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -9,11 +8,10 @@ function simulationRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const start = parseTime(requiredString(values, "start-time"), "--start-time"); const start = parseTime(requiredString(values, "start-time"), "--start-time");
const duration = requiredNumber(values, "duration"); const duration = requiredNumber(values, "duration");
const end = addMinutesPreservingOffset(start, duration); const end = addMinutesPreservingOffset(start, duration);
const network = requireNetwork(ctx);
return emitApi( return emitApi(
ctx, ctx,
"触发模拟成功", "触发模拟成功",
{ method: "POST", path: "/simulations/run-by-date", body: { name: network, start_time: start.replace(/\.\d+/, ""), duration }, requireNetworkCtx: true }, { method: "POST", path: "/simulation-runs", body: { start_time: start.replace(/\.\d+/, ""), duration }, requireProject: true },
[ [
`tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`, `tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${end}`,
`tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`, `tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${end}`,
+4 -5
View File
@@ -20,10 +20,10 @@ export function readJsonFile(path: string, label: string): unknown {
export function parseBurstFile(path: string): [string[], number[]] { export function parseBurstFile(path: string): [string[], number[]] {
let raw = readJsonFile(path, "burst"); let raw = readJsonFile(path, "burst");
if (isRecord(raw) && "bursts" in raw) raw = raw.bursts; if (isRecord(raw) && "bursts" in raw) raw = raw.bursts;
if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) { if (isRecord(raw) && "burst_id" in raw && "burst_size" in raw && Array.isArray(raw.burst_id) && Array.isArray(raw.burst_size)) {
const ids = raw.burst_ID.map(String); const ids = raw.burst_id.map(String);
const sizes = raw.burst_size.map(Number); const sizes = raw.burst_size.map(Number);
if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2); if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_id and burst_size must have the same length", 2);
return [ids, sizes]; return [ids, sizes];
} }
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
@@ -35,7 +35,7 @@ export function parseBurstFile(path: string): [string[], number[]] {
raw.map((item) => Number((item as Record<string, unknown>).size)), raw.map((item) => Number((item as Record<string, unknown>).size)),
]; ];
} }
throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_ID/burst_size", 2); throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file must be a JSON array or object with burst_id/burst_size", 2);
} }
export function parseValveSettingFile(path: string): [string[], number[]] { export function parseValveSettingFile(path: string): [string[], number[]] {
@@ -66,4 +66,3 @@ export function assignDatasetKeys(target: Record<string, unknown>, path: string,
} }
} }
} }
+2 -11
View File
@@ -1,5 +1,4 @@
import { CliError, errorMessage } from "./errors.js"; import { CliError, errorMessage } from "./errors.js";
import { requireNetwork, requireUsername } from "./runtime.js";
import { success } from "./output.js"; import { success } from "./output.js";
import type { RequestOptions, RuntimeContext } from "./types.js"; import type { RequestOptions, RuntimeContext } from "./types.js";
@@ -35,18 +34,10 @@ function appendParams(url: URL, params: Record<string, unknown> = {}): void {
} }
} }
function withNetworkParam(params: Record<string, unknown> | undefined, network: string): Record<string, unknown> {
if (params?.network !== undefined || params?.network_name !== undefined || params?.name !== undefined) return params ?? {};
return { ...(params ?? {}), network };
}
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> { export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request; const { method, path, params, body, requireAuth = true, requireProject = false } = request;
const network = requireNetworkCtx ? requireNetwork(ctx) : null;
if (requireUsernameCtx) requireUsername(ctx);
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, "")); const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
const requestParams = network && (params !== undefined || body === undefined) ? withNetworkParam(params, network) : params; appendParams(url, params);
appendParams(url, requestParams);
const started = performance.now(); const started = performance.now();
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000); const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
-14
View File
@@ -31,8 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: process.env.TJWATER_SERVER, server: process.env.TJWATER_SERVER,
access_token: process.env.TJWATER_ACCESS_TOKEN, access_token: process.env.TJWATER_ACCESS_TOKEN,
project_id: process.env.TJWATER_PROJECT_ID, project_id: process.env.TJWATER_PROJECT_ID,
username: process.env.TJWATER_USERNAME,
network: process.env.TJWATER_NETWORK,
headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {}, headers: process.env.TJWATER_EXTRA_HEADERS ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) : {},
}; };
const headers = (raw.headers ?? {}) as unknown; const headers = (raw.headers ?? {}) as unknown;
@@ -43,8 +41,6 @@ export async function loadAuthContext(authStdin: boolean): Promise<AuthContext>
server: pick(raw, "server", "base_url"), server: pick(raw, "server", "base_url"),
accessToken: pick(raw, "access_token", "token", "accessToken"), accessToken: pick(raw, "access_token", "token", "accessToken"),
projectId: pick(raw, "project_id", "projectId", "x_project_id"), projectId: pick(raw, "project_id", "projectId", "x_project_id"),
username: pick(raw, "username", "preferred_username"),
network: pick(raw, "network", "project_code", "projectCode", "project"),
headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])), headers: Object.fromEntries(Object.entries(headers as Record<string, unknown>).map(([key, value]) => [String(key), String(value)])),
}; };
} }
@@ -81,16 +77,6 @@ export async function buildRuntime(globals: GlobalArgs): Promise<RuntimeContext>
}; };
} }
export function requireNetwork(ctx: RuntimeContext): string {
if (ctx.auth.network) return ctx.auth.network;
throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]);
}
export function requireUsername(ctx: RuntimeContext): string {
if (ctx.auth.username) return ctx.auth.username;
throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]);
}
export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null { export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null {
const scheme = explicit || ctx.scheme; const scheme = explicit || ctx.scheme;
if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2);
-4
View File
@@ -6,8 +6,6 @@ export interface AuthContext {
server: string | null; server: string | null;
accessToken: string | null; accessToken: string | null;
projectId: string | null; projectId: string | null;
username: string | null;
network: string | null;
headers: Record<string, string>; headers: Record<string, string>;
} }
@@ -48,8 +46,6 @@ export interface RequestOptions {
body?: unknown; body?: unknown;
requireAuth?: boolean; requireAuth?: boolean;
requireProject?: boolean; requireProject?: boolean;
requireNetworkCtx?: boolean;
requireUsernameCtx?: boolean;
} }
export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void; export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise<void> | void;
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
}
}
}
+3 -4
View File
@@ -205,18 +205,17 @@ test("sends auth headers and simulation body through the backend API contract",
{ {
server: server.url, server: server.url,
access_token: "token-1", access_token: "token-1",
network: "tjwater", project_id: "project-1",
headers: { "x-extra": "extra" }, headers: { "x-extra": "extra" },
}, },
); );
assert.equal(result.exitCode, 0, result.stderr); assert.equal(result.exitCode, 0, result.stderr);
assert.equal(server.seen[0].method, "POST"); assert.equal(server.seen[0].method, "POST");
assert.equal(server.seen[0].url, "/api/v1/simulations/run-by-date"); assert.equal(server.seen[0].url, "/api/v1/simulation-runs");
assert.equal(server.seen[0].headers.authorization, "Bearer token-1"); assert.equal(server.seen[0].headers.authorization, "Bearer token-1");
assert.equal(server.seen[0].headers["x-extra"], "extra"); assert.equal(server.seen[0].headers["x-extra"], "extra");
assert.deepEqual(server.seen[0].body, { assert.deepEqual(server.seen[0].body, {
name: "tjwater",
start_time: "2025-01-02T03:00:00+08:00", start_time: "2025-01-02T03:00:00+08:00",
duration: 60, duration: 60,
}); });
@@ -254,7 +253,7 @@ test("uses project scoped headers for realtime data commands", async () => {
assert.equal(server.seen[0].method, "GET"); assert.equal(server.seen[0].method, "GET");
assert.equal( assert.equal(
server.seen[0].url, server.seen[0].url,
"/api/v1/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00", "/api/v1/timeseries/realtime/links?start_time=2025-01-02T03%3A00%3A00%2B08%3A00&end_time=2025-01-02T04%3A00%3A00%2B08%3A00",
); );
assert.equal(server.seen[0].headers.authorization, "Bearer token-2"); assert.equal(server.seen[0].headers.authorization, "Bearer token-2");
assert.equal(server.seen[0].headers["x-project-id"], "project-1"); assert.equal(server.seen[0].headers["x-project-id"], "project-1");
+4
View File
@@ -12,6 +12,9 @@
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"contract:generate": "bun scripts/generate-contract.ts",
"contract:check": "bun scripts/generate-contract.ts --check",
"test:api": "bun test tests/routes tests/contracts",
"migrate:session-identity": "bun scripts/migrate-session-identity.ts", "migrate:session-identity": "bun scripts/migrate-session-identity.ts",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh",
"start": "bun src/server.ts", "start": "bun src/server.ts",
@@ -27,6 +30,7 @@
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+37
View File
@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { generateAgentOpenApi } from "../src/contracts/openapi.js";
const canonicalJson = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const document = generateAgentOpenApi();
const payload = canonicalJson(document);
const sha256 = createHash("sha256").update(payload).digest("hex");
const manifestPayload = canonicalJson({
contract_version: "1.0.0",
contracts: {
agent: {
file: "agent-v1.openapi.json",
sha256,
},
},
});
if (process.argv.includes("--check")) {
const currentContract = await readFile(
"contracts/agent-v1.openapi.json",
"utf8",
);
const currentManifest = await readFile("contracts/manifest.json", "utf8");
if (currentContract !== payload || currentManifest !== manifestPayload) {
throw new Error(
"Agent OpenAPI contract is stale: run `bun run contract:generate`",
);
}
console.log(`validated Agent OpenAPI contract (${sha256})`);
} else {
await mkdir("contracts", { recursive: true });
await writeFile("contracts/agent-v1.openapi.json", payload, "utf8");
await writeFile("contracts/manifest.json", manifestPayload, "utf8");
console.log(`generated Agent OpenAPI contract (${sha256})`);
}
+424
View File
@@ -0,0 +1,424 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
type JsonRecord = Record<string, unknown>;
type Options = {
backupDir: string;
dataDir: string;
dryRun: boolean;
fromActorKey: string;
fromProjectId?: string;
fromProjectKey?: string;
fromUserId?: string;
metadataDir: string;
memoryDir: string;
resultRefsDir: string;
toActorKey: string;
toProjectId?: string;
toProjectKey?: string;
toUserId?: string;
transcriptsDir: string;
};
type Change = {
detail: string;
file: string;
kind: "metadata" | "transcript" | "result-ref" | "memory";
};
const usage = `Usage:
bun scripts/migrate-session-identity.ts --from-user-id <old> --to-user-id <new> [--write]
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
Optional:
--from-project-id <old> Match old project id
--to-project-id <new> Rewrite project id
--from-project-key <old> Match old project key
--to-project-key <new> Rewrite project key
--data-dir <dir> Default: ./data
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
--write Apply changes. Without this, dry-run only.
Examples:
bun scripts/migrate-session-identity.ts \\
--from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\
--to-actor-key actor-49a0275a85079f9d
bun scripts/migrate-session-identity.ts \\
--from-actor-key actor-da98d99c20c386e7 \\
--to-actor-key actor-49a0275a85079f9d \\
--write
`;
const main = async () => {
const options = parseOptions(process.argv.slice(2));
const changes: Change[] = [];
changes.push(...(await migrateMetadata(options)));
changes.push(...(await migrateTranscripts(options)));
changes.push(...(await migrateResultRefs(options)));
changes.push(...(await migrateUserMemory(options)));
printSummary(options, changes);
};
const parseOptions = (args: string[]): Options => {
const values = new Map<string, string | boolean>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
throw new Error(`unexpected argument: ${arg}\n\n${usage}`);
}
const key = arg.slice(2);
if (key === "help" || key === "h") {
console.log(usage);
process.exit(0);
}
if (key === "write") {
values.set(key, true);
continue;
}
const value = args[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`missing value for --${key}\n\n${usage}`);
}
values.set(key, value);
index += 1;
}
const dataDir = stringOption(values, "data-dir") ?? "./data";
const fromUserId = stringOption(values, "from-user-id");
const toUserId = stringOption(values, "to-user-id");
const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId);
const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId);
const fromProjectId = stringOption(values, "from-project-id");
const toProjectId = stringOption(values, "to-project-id");
const fromProjectKey =
stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId);
const toProjectKey =
stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId);
if (!fromActorKey || !toActorKey) {
throw new Error(
"provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" +
usage,
);
}
if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) {
throw new Error("source and target identity are identical");
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupDir =
stringOption(values, "backup-dir") ??
join(dataDir, "backup", "session-identity-migration", timestamp);
return {
backupDir,
dataDir,
dryRun: !values.has("write"),
fromActorKey,
fromProjectId,
fromProjectKey,
fromUserId,
metadataDir: join(dataDir, "session-metadata"),
memoryDir: join(dataDir, "memory"),
resultRefsDir: join(dataDir, "result-refs"),
toActorKey,
toProjectId,
toProjectKey,
toUserId,
transcriptsDir: join(dataDir, "session-transcripts"),
};
};
const stringOption = (values: Map<string, string | boolean>, key: string) => {
const value = values.get(key);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const actorKeyFromUserId = (userId?: string) =>
userId ? scopedKey("actor", userId) : undefined;
const projectKeyFromProjectId = (projectId?: string) =>
projectId ? scopedKey("project", projectId) : undefined;
const scopedKey = (prefix: string, value: string) =>
`${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`;
const migrateMetadata = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.metadataDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toUserId ? { ownerUserId: options.toUserId } : {}),
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "metadata",
});
}
return changes;
};
const migrateTranscripts = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.transcriptsDir)) {
const transcript = await readJson(file);
if (!transcript || transcript.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) {
continue;
}
const next: JsonRecord = {
...transcript,
actorKey: options.toActorKey,
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
const nextPath = join(
dirname(file),
`${next.actorKey}__${next.projectKey}__${next.sessionId}.json`,
);
if (nextPath !== file && (await exists(nextPath))) {
throw new Error(`refusing to overwrite existing transcript: ${nextPath}`);
}
await writeJsonWithBackup(options, file, next);
if (nextPath !== file) {
await renameWithBackup(options, file, nextPath);
}
changes.push({
detail: `${basename(file)} -> ${basename(nextPath)}`,
file,
kind: "transcript",
});
}
return changes;
};
const migrateResultRefs = async (options: Options): Promise<Change[]> => {
const changes: Change[] = [];
for (const file of await listJsonFiles(options.resultRefsDir)) {
const record = await readJson(file);
if (!record || record.actorKey !== options.fromActorKey) {
continue;
}
if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) {
continue;
}
if (options.fromProjectId && record.projectId !== options.fromProjectId) {
continue;
}
const next: JsonRecord = {
...record,
actorKey: options.toActorKey,
...(options.toProjectId ? { projectId: options.toProjectId } : {}),
...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}),
};
await writeJsonWithBackup(options, file, next);
changes.push({
detail: `${record.actorKey} -> ${next.actorKey}`,
file,
kind: "result-ref",
});
}
return changes;
};
const migrateUserMemory = async (options: Options): Promise<Change[]> => {
const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`);
if (!(await exists(fromFile))) {
return [];
}
const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`);
if (fromFile === toFile) {
return [];
}
if (await exists(toFile)) {
const [fromContent, toContent] = await Promise.all([
readFile(fromFile, "utf8"),
readFile(toFile, "utf8"),
]);
const merged = mergeMemoryMarkdown(toContent, fromContent);
await writeTextWithBackup(options, toFile, merged);
await removeWithBackup(options, fromFile);
} else {
await renameWithBackup(options, fromFile, toFile);
}
return [
{
detail: `${basename(fromFile)} -> ${basename(toFile)}`,
file: fromFile,
kind: "memory",
},
];
};
const mergeMemoryMarkdown = (target: string, source: string) => {
const lines = target.split("\n");
const existing = new Set(lines.map((line) => line.trim()).filter(Boolean));
for (const line of source.split("\n")) {
const normalized = line.trim();
if (!normalized || existing.has(normalized)) {
continue;
}
lines.push(line);
existing.add(normalized);
}
return `${lines.join("\n").replace(/\n+$/u, "")}\n`;
};
const listJsonFiles = async (dir: string) => {
try {
const names = await readdir(dir);
return names
.filter((name) => name.endsWith(".json"))
.map((name) => join(dir, name));
} catch (error) {
if (isErrno(error, "ENOENT")) {
return [];
}
throw error;
}
};
const readJson = async (file: string): Promise<JsonRecord | null> => {
try {
const value = JSON.parse(await readFile(file, "utf8")) as unknown;
return isRecord(value) ? value : null;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return null;
}
throw error;
}
};
const writeJsonWithBackup = async (
options: Options,
file: string,
value: JsonRecord,
) => {
await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`);
};
const writeTextWithBackup = async (
options: Options,
file: string,
content: string,
) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await writeFile(file, content, "utf8");
};
const renameWithBackup = async (options: Options, from: string, to: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, from);
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
};
const removeWithBackup = async (options: Options, file: string) => {
if (options.dryRun) {
return;
}
await backupFile(options, file);
await rm(file, { force: true });
};
const backupFile = async (options: Options, file: string) => {
const relativePath = relative(process.cwd(), file);
const target = join(options.backupDir, relativePath);
await mkdir(dirname(target), { recursive: true });
await copyFile(file, target);
};
const exists = async (file: string) => {
try {
await stat(file);
return true;
} catch (error) {
if (isErrno(error, "ENOENT")) {
return false;
}
throw error;
}
};
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isErrno = (error: unknown, code: string) =>
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === code;
const printSummary = (options: Options, changes: Change[]) => {
const counts = changes.reduce<Record<string, number>>((acc, change) => {
acc[change.kind] = (acc[change.kind] ?? 0) + 1;
return acc;
}, {});
console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied");
console.log(`from actor: ${options.fromActorKey}`);
console.log(`to actor: ${options.toActorKey}`);
if (options.fromProjectKey || options.toProjectKey) {
console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`);
}
if (!options.dryRun) {
console.log(`backup dir: ${options.backupDir}`);
}
console.log(`changes: ${changes.length}`);
for (const [kind, count] of Object.entries(counts)) {
console.log(` ${kind}: ${count}`);
}
for (const change of changes.slice(0, 40)) {
console.log(`- [${change.kind}] ${change.detail}`);
}
if (changes.length > 40) {
console.log(`... ${changes.length - 40} more`);
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+20 -11
View File
@@ -100,7 +100,7 @@ export const requireAgentAuth = async (
const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS);
try { try {
const response = await fetch(new URL("/api/v1/agent/auth/context", config.TJWATER_API_BASE_URL), { const response = await fetch(new URL("/api/v1/agent-auth-context", config.TJWATER_API_BASE_URL), {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
@@ -125,18 +125,27 @@ export const requireAgentAuth = async (
return; return;
} }
const detail = await response.text(); const status =
res.status(response.status === 403 ? 403 : 401).json({ response.status === 400 ||
message: response.status === 403 ? "forbidden" : "unauthorized", response.status === 401 ||
detail: detail || undefined, response.status === 403 ||
}); response.status === 404
? response.status
: response.status === 503
? 503
: 502;
const messages: Record<number, string> = {
400: "invalid authentication request",
401: "unauthorized",
403: "forbidden",
404: "authentication context not found",
502: "invalid authentication service response",
503: "authentication service unavailable",
};
res.status(status).json({ message: messages[status] });
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.warn({ err: error }, "agent auth validation failed"); logger.warn({ err: error }, "agent auth validation failed");
res.status(503).json({ res.status(503).json({ message: "authentication service unavailable" });
message: "authentication service unavailable",
detail,
});
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
+325
View File
@@ -0,0 +1,325 @@
import {
extendZodWithOpenApi,
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
const SessionId = z.object({ session_id: z.string().max(128) });
const RenderRef = z.object({ render_ref: z.string().min(1) });
const RenderReferenceQuery = z.object({
session_id: z.string().max(128).optional(),
});
const SessionCreate = z
.object({
session_id: z.string(),
title: z.string().nullable().optional(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
})
.openapi("AgentSessionCreate");
const SessionSummary = z
.object({
id: z.string(),
title: z.string(),
created_at: z.string(),
updated_at: z.string(),
status: z.string(),
parent_session_id: z.string().nullable().optional(),
is_streaming: z.boolean(),
run_status: z.string().nullable().optional(),
})
.openapi("AgentSessionSummary");
const SessionDetail = SessionSummary.extend({
session_id: z.string(),
is_title_manually_edited: z.boolean(),
messages: z.array(z.unknown()),
}).openapi("AgentSessionDetail");
const SessionUpdate = z
.object({
id: z.string(),
title: z.string(),
updated_at: z.string(),
})
.openapi("AgentSessionUpdate");
const SessionFork = z
.object({
session_id: z.string(),
})
.openapi("AgentSessionFork");
const Problem = z
.object({
type: z.string(),
title: z.string(),
status: z.number().int(),
detail: z.string(),
instance: z.string(),
code: z.string(),
trace_id: z.string(),
errors: z.array(z.unknown()),
})
.openapi("ProblemDetails");
const JsonObject = z.record(z.unknown());
const errorResponses = {
400: {
description: "Invalid request",
content: { "application/problem+json": { schema: Problem } },
},
401: {
description: "Authentication required",
content: { "application/problem+json": { schema: Problem } },
},
403: {
description: "Insufficient permission",
content: { "application/problem+json": { schema: Problem } },
},
404: {
description: "Resource not found",
content: { "application/problem+json": { schema: Problem } },
},
409: {
description: "Resource conflict",
content: { "application/problem+json": { schema: Problem } },
},
422: {
description: "Validation error",
content: { "application/problem+json": { schema: Problem } },
},
500: {
description: "Internal server error",
content: { "application/problem+json": { schema: Problem } },
},
502: {
description: "Upstream dependency error",
content: { "application/problem+json": { schema: Problem } },
},
503: {
description: "Dependency unavailable",
content: { "application/problem+json": { schema: Problem } },
},
};
const jsonResponse = (schema: z.ZodTypeAny, description = "Successful response") => ({
description,
content: { "application/json": { schema } },
});
type RegisterConfig = {
summary: string;
request?: Record<string, unknown>;
responses: Record<number, unknown>;
};
export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => {
const operations = new Set<string>();
return (
path: string,
method: "get" | "post" | "patch" | "delete",
config: RegisterConfig,
) => {
const key = `${method.toUpperCase()} ${path}`;
if (operations.has(key)) {
throw new Error(`Duplicate Agent OpenAPI operation: ${key}`);
}
operations.add(key);
targetRegistry.registerPath({
path,
method,
operationId: `${method}_${path
.replace(/^\/api\/v1\/agent\/?/, "")
.replaceAll(/[{}]/g, "")
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
.replaceAll(/^_|_$/g, "")}`,
tags: ["Agent"],
security: [{ bearerAuth: [] }],
...config,
responses: { ...config.responses, ...errorResponses },
});
};
};
const register = createRouteRegistrar(registry);
register("/api/v1/agent/models", "get", {
summary: "List available agent models",
responses: { 200: jsonResponse(JsonObject) },
});
register("/api/v1/agent/sessions", "get", {
summary: "List agent sessions",
responses: { 200: jsonResponse(z.object({ sessions: z.array(SessionSummary) })) },
});
register("/api/v1/agent/sessions", "post", {
summary: "Create an agent session",
request: {
body: {
content: {
"application/json": {
schema: z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
}),
},
},
},
},
responses: {
200: jsonResponse(SessionCreate, "Existing session returned"),
201: jsonResponse(SessionCreate, "Session created"),
},
});
register("/api/v1/agent/sessions/{session_id}", "get", {
summary: "Get an agent session",
request: { params: SessionId },
responses: { 200: jsonResponse(SessionDetail) },
});
register("/api/v1/agent/sessions/{session_id}", "patch", {
summary: "Update an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
title: z.string().min(1).max(120),
is_title_manually_edited: z.boolean().optional(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionUpdate) },
});
register("/api/v1/agent/sessions/{session_id}", "delete", {
summary: "Delete an agent session",
request: { params: SessionId },
responses: { 204: { description: "Session deleted" } },
});
register("/api/v1/agent/sessions/{session_id}/runs", "post", {
summary: "Run an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
message: z.string().min(1).max(10000),
model: z.string().optional(),
approval_mode: z.enum(["request", "always"]).optional(),
}),
},
},
},
},
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
});
register(
"/api/v1/agent/sessions/{session_id}/runs/current/events",
"get",
{
summary: "Resume the current agent event stream",
request: { params: SessionId },
responses: {
200: {
description: "Agent event stream",
content: { "text/event-stream": { schema: z.string() } },
},
},
},
);
register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
summary: "Abort the current agent run",
request: { params: SessionId },
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
});
register(
"/api/v1/agent/sessions/{session_id}/permission-responses",
"post",
{
summary: "Reply to an agent permission request",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register(
"/api/v1/agent/sessions/{session_id}/question-responses",
"post",
{
summary: "Reply to or reject an agent question",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
request_id: z.string(),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).optional(),
}),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register("/api/v1/agent/sessions/{session_id}/forks", "post", {
summary: "Fork an agent session",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({
keep_message_count: z.number().int().nonnegative(),
}),
},
},
},
},
responses: { 200: jsonResponse(SessionFork) },
});
register("/api/v1/agent/render-references/{render_ref}", "get", {
summary: "Resolve a render reference",
request: { params: RenderRef, query: RenderReferenceQuery },
responses: { 200: jsonResponse(JsonObject) },
});
export const generateAgentOpenApi = () => {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.3",
info: {
title: "TJWater Agent API",
version: "1.0.0",
description: "Public REST API for TJWater Agent sessions and runs",
},
});
};
+23 -15
View File
@@ -131,7 +131,7 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.post("/session", async (req, res) => { chatRouter.post("/sessions", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {}); const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
@@ -194,7 +194,7 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.get("/session/:session_id", async (req, res) => { chatRouter.get("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim(); const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req); const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
@@ -238,7 +238,7 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.get("/session/:session_id/stream", async (req, res) => { chatRouter.get("/sessions/:session_id/runs/current/events", async (req, res) => {
const sessionId = req.params.session_id?.trim(); const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req); const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
@@ -303,7 +303,7 @@ export const buildChatRouter = (
res.on("close", cleanup); res.on("close", cleanup);
}); });
chatRouter.patch("/session/:session_id/title", async (req, res) => { chatRouter.patch("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim(); const sessionId = req.params.session_id?.trim();
const title = const title =
typeof req.body?.title === "string" ? req.body.title.trim() : ""; typeof req.body?.title === "string" ? req.body.title.trim() : "";
@@ -349,7 +349,7 @@ export const buildChatRouter = (
}); });
}); });
chatRouter.delete("/session/:session_id", async (req, res) => { chatRouter.delete("/sessions/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim(); const sessionId = req.params.session_id?.trim();
const authContext = getAgentAuthContext(req); const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId; const projectId = authContext.projectId;
@@ -395,8 +395,11 @@ export const buildChatRouter = (
sessionUiStateStore, sessionUiStateStore,
}); });
chatRouter.post("/fork", async (req, res) => { chatRouter.post("/sessions/:session_id/forks", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body); const parsed = forkPayloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -425,6 +428,10 @@ export const buildChatRouter = (
sourceSessionId, sourceSessionId,
) )
: null; : null;
if (!sourceSessionId || !sourceSessionRecord) {
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession(); const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({ const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey, actorKey,
@@ -436,7 +443,6 @@ export const buildChatRouter = (
}); });
const nextSessionId = targetSessionRecord.sessionId; const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread( await sessionTranscriptStore.cloneThread(
{ {
actorKey, actorKey,
@@ -452,11 +458,10 @@ export const buildChatRouter = (
}, },
parsed.data.keep_message_count, parsed.data.keep_message_count,
); );
} const sourceState = await sessionUiStateStore.read(
const sourceState = sourceSessionRecord toSessionUiStateContext(sourceSessionRecord),
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord)) );
: null; const forkTitle = sourceSessionRecord.title
const forkTitle = sourceSessionRecord?.title
? `${sourceSessionRecord.title} 副本` ? `${sourceSessionRecord.title} 副本`
: "新对话副本"; : "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch( const titledTargetSessionRecord = await sessionMetadataStore.touch(
@@ -495,8 +500,11 @@ export const buildChatRouter = (
} }
}); });
chatRouter.post("/stream", async (req, res) => { chatRouter.post("/sessions/:session_id/runs", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body); const parsed = payloadSchema.safeParse({
...req.body,
session_id: req.params.session_id,
});
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
+4 -4
View File
@@ -17,7 +17,7 @@ import {
updateLastAssistantMessage, updateLastAssistantMessage,
} from "./chatUiState.js"; } from "./chatUiState.js";
const abortPayloadSchema = z.object({ const abortParamsSchema = z.object({
session_id: z.string().max(128), session_id: z.string().max(128),
}); });
@@ -45,7 +45,7 @@ export const registerChatAuxiliaryRoutes = (
sessionUiStateStore, sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions, }: RegisterAuxiliaryRoutesOptions,
) => { ) => {
chatRouter.get("/render-ref/:render_ref", async (req, res) => { chatRouter.get("/render-references/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim(); const renderRef = req.params.render_ref?.trim();
const authContext = getAgentAuthContext(req); const authContext = getAgentAuthContext(req);
const userId = authContext.userId; const userId = authContext.userId;
@@ -82,8 +82,8 @@ export const registerChatAuxiliaryRoutes = (
res.json(result); res.json(result);
}); });
chatRouter.post("/abort", async (req, res) => { chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body); const parsed = abortParamsSchema.safeParse(req.params);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
+29 -158
View File
@@ -15,20 +15,17 @@ import {
} from "./chatUiState.js"; } from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({ const permissionReplyPayloadSchema = z.object({
session_id: z.string().max(128), request_id: z.string().min(1),
reply: z.enum(["once", "always", "reject"]), reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(), message: z.string().max(1000).optional(),
}); });
const questionReplyPayloadSchema = z.object({ const questionReplyPayloadSchema = z.object({
session_id: z.string().max(128), request_id: z.string().min(1),
action: z.enum(["reply", "reject"]).default("reply"),
answers: z.array(z.array(z.string().max(2000))).default([]), answers: z.array(z.array(z.string().max(2000))).default([]),
}); });
const questionRejectPayloadSchema = z.object({
session_id: z.string().max(128),
});
type RegisterInteractionRoutesOptions = { type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>; activeRuns: Map<string, ActiveRun>;
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
@@ -49,13 +46,8 @@ export const registerChatInteractionRoutes = (
sessionUiStateStore, sessionUiStateStore,
}: RegisterInteractionRoutesOptions, }: RegisterInteractionRoutesOptions,
) => { ) => {
chatRouter.post("/permission/:request_id/reply", async (req, res) => { chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = permissionReplyPayloadSchema.safeParse(req.body); const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -70,9 +62,10 @@ export const registerChatInteractionRoutes = (
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, req.params.session_id,
); );
if (!sessionRecord) { if (!sessionRecord) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
@@ -174,13 +167,8 @@ export const registerChatInteractionRoutes = (
} }
}); });
chatRouter.post("/question/:request_id/reply", async (req, res) => { chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionReplyPayloadSchema.safeParse(req.body); const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ res.status(400).json({
message: "invalid request payload", message: "invalid request payload",
@@ -195,9 +183,10 @@ export const registerChatInteractionRoutes = (
const userId = authContext.userId; const userId = authContext.userId;
const actorKey = toActorKey(userId); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId); const projectKey = toProjectKey(projectId);
const requestId = parsed.data.request_id;
const sessionRecord = await sessionMetadataStore.get( const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
parsed.data.session_id, req.params.session_id,
); );
if (!sessionRecord) { if (!sessionRecord) {
res.status(404).json({ message: "session not found" }); res.status(404).json({ message: "session not found" });
@@ -227,11 +216,18 @@ export const registerChatInteractionRoutes = (
}; };
try { try {
if (parsed.data.action === "reject") {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} else {
await runtime.replyQuestion({ await runtime.replyQuestion({
requestId, requestId,
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
answers: parsed.data.answers, answers: parsed.data.answers,
}); });
}
} catch (error) { } catch (error) {
run.messages = updateLastAssistantQuestion( run.messages = updateLastAssistantQuestion(
run.messages, run.messages,
@@ -242,7 +238,7 @@ export const registerChatInteractionRoutes = (
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
: "failed to reply question", : `failed to ${parsed.data.action} question`,
}), }),
); );
await persistQuestionState().catch((persistError) => { await persistQuestionState().catch((persistError) => {
@@ -252,7 +248,7 @@ export const registerChatInteractionRoutes = (
); );
}); });
res.status(502).json({ res.status(502).json({
message: "question reply failed", message: `question ${parsed.data.action} failed`,
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
}); });
return; return;
@@ -264,8 +260,9 @@ export const registerChatInteractionRoutes = (
requestId, requestId,
(question) => ({ (question) => ({
...question, ...question,
status: "answered", status: parsed.data.action === "reject" ? "rejected" : "answered",
answers: parsed.data.answers, answers:
parsed.data.action === "reject" ? question.answers : parsed.data.answers,
repliedAt: Date.now(), repliedAt: Date.now(),
error: undefined, error: undefined,
}), }),
@@ -280,7 +277,9 @@ export const registerChatInteractionRoutes = (
subscriber.write("question_response", { subscriber.write("question_response", {
session_id: pendingQuestion.session_id, session_id: pendingQuestion.session_id,
request_id: requestId, request_id: requestId,
answers: parsed.data.answers, ...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
}); });
} }
if ( if (
@@ -294,143 +293,15 @@ export const registerChatInteractionRoutes = (
res.status(202).json({ res.status(202).json({
session_id: pendingQuestion.session_id, session_id: pendingQuestion.session_id,
request_id: requestId, request_id: requestId,
answers: parsed.data.answers, ...(parsed.data.action === "reject"
? { rejected: true }
: { answers: parsed.data.answers }),
}); });
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reply route failed"); logger.error({ err: error }, "question response route failed");
res.status(500).json({ res.status(500).json({
message: "question reply route failed", message: "question response route failed",
detail,
});
}
});
chatRouter.post("/question/:request_id/reject", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionRejectPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getAgentAuthContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
detail, detail,
}); });
} }
+55
View File
@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
import { type Request, type Response, Router } from "express";
const problemDetails = (req: Request, status: number, body: unknown) => {
const record =
typeof body === "object" && body !== null
? (body as Record<string, unknown>)
: {};
const detail =
typeof record.detail === "string"
? record.detail
: typeof record.message === "string"
? record.message
: `Request failed with status ${status}`;
const codeByStatus: Record<number, string> = {
400: "invalid_request",
401: "unauthenticated",
403: "forbidden",
404: "not_found",
409: "conflict",
422: "validation_error",
503: "dependency_unavailable",
};
const code = codeByStatus[status] ?? "request_error";
return {
type: `https://tjwater.example/problems/${code.replaceAll("_", "-")}`,
title: code.replaceAll("_", " "),
status,
detail,
instance: req.originalUrl.split("?")[0],
code,
trace_id: req.header("x-request-id") ?? randomUUID(),
errors: record.detail && typeof record.detail === "object" ? [record.detail] : [],
};
};
export const buildAgentPublicRouter = (chatRouter: Router) => {
const router = Router();
router.use((req, res, next) => {
const json = res.json.bind(res);
res.json = ((body: unknown) => {
if (res.statusCode >= 400) {
res.type("application/problem+json");
return json(problemDetails(req, res.statusCode, body));
}
return json(body);
}) as Response["json"];
next();
});
router.use(chatRouter);
return router;
};
+10 -8
View File
@@ -18,6 +18,7 @@ import {
ResultReferenceStore, ResultReferenceStore,
} from "./results/store.js"; } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { buildAgentPublicRouter } from "./routes/publicApi.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { import {
getRuntimeSessionContext, getRuntimeSessionContext,
@@ -113,7 +114,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
server: config.TJWATER_API_BASE_URL, server: config.TJWATER_API_BASE_URL,
access_token: context.accessToken, access_token: context.accessToken,
project_id: context.projectId, project_id: context.projectId,
network: context.network,
}); });
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)]; const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)];
@@ -402,7 +402,7 @@ app.post("/internal/tools/web-search", async (req, res) => {
try { try {
const response = await callBackendJson( const response = await callBackendJson(
"/api/v1/web-search", "/api/v1/web-searches",
context, context,
payload, payload,
); );
@@ -445,7 +445,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
try { try {
const response = await callBackendJson( const response = await callBackendJson(
"/api/v1/tianditu/geocode", "/api/v1/geocoding-requests",
context, context,
{ keyword }, { keyword },
); );
@@ -462,10 +462,7 @@ app.post("/internal/tools/geocode", async (req, res) => {
} }
}); });
app.use( const chatRouter = buildChatRouter(
"/api/v1/agent/chat",
requireAgentAuth,
buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,
sessionMetadataStore, sessionMetadataStore,
@@ -474,7 +471,12 @@ app.use(
sessionTranscriptStore, sessionTranscriptStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
), );
const authenticatedChatRouter = express.Router();
authenticatedChatRouter.use(requireAgentAuth, chatRouter);
app.use(
"/api/v1/agent",
buildAgentPublicRouter(authenticatedChatRouter),
); );
const bootstrap = async () => { const bootstrap = async () => {
+59 -14
View File
@@ -8,6 +8,7 @@ import {
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
slugify, slugify,
toStableId,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export type SessionStatus = "active" | "archived"; export type SessionStatus = "active" | "archived";
@@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
parentSessionId?: string; parentSessionId?: string;
}; };
export class SessionMetadataOwnershipError extends Error {
constructor(sessionId: string) {
super(`session metadata ownership mismatch: ${sessionId}`);
this.name = "SessionMetadataOwnershipError";
}
}
export class SessionMetadataStore { export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {} constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
@@ -49,10 +57,13 @@ export class SessionMetadataStore {
if (!sessionId) { if (!sessionId) {
throw new Error("sessionId is required"); throw new Error("sessionId is required");
} }
const existing = await readJsonFile<SessionRecord>( const existing = await this.readRecord(sessionId);
this.filePath(sessionId),
);
if (existing) { if (existing) {
if (!matchesContext(existing, input)) {
throw new SessionMetadataOwnershipError(sessionId);
}
await atomicWriteJson(this.filePath(sessionId), existing);
await removeFileIfExists(this.legacyFilePath(sessionId));
return { created: false, record: existing }; return { created: false, record: existing };
} }
@@ -80,9 +91,8 @@ export class SessionMetadataStore {
if (!normalizedSessionId) { if (!normalizedSessionId) {
return null; return null;
} }
return await readJsonFile<SessionRecord>( const record = await this.readRecord(normalizedSessionId);
this.filePath(normalizedSessionId), return record && matchesContext(record, context) ? record : null;
);
} }
async touch( async touch(
@@ -98,6 +108,7 @@ export class SessionMetadataStore {
this.filePath(record.sessionId), this.filePath(record.sessionId),
next, next,
); );
await removeFileIfExists(this.legacyFilePath(record.sessionId));
return next; return next;
} }
@@ -106,27 +117,61 @@ export class SessionMetadataStore {
const records = await Promise.all( const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)), files.map((file) => readJsonFile<SessionRecord>(file)),
); );
return records const uniqueRecords = new Map<string, SessionRecord>();
for (const record of records) {
if (record && matchesContext(record, context)) {
const previous = uniqueRecords.get(record.sessionId);
if (!previous || record.updatedAt > previous.updatedAt) {
uniqueRecords.set(record.sessionId, record);
}
}
}
return [...uniqueRecords.values()]
.filter((record): record is SessionRecord => Boolean(record)) .filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
} }
async remove(record: SessionRecord) { async remove(record: SessionRecord) {
await removeFileIfExists( await Promise.all(
this.filePath(record.sessionId), this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)),
); );
} }
private filePath(sessionId: string) { private filePath(sessionId: string) {
return join(
this.baseDir,
`${slugify(sessionId)}-${toStableId(sessionId)}.json`,
);
}
private legacyFilePath(sessionId: string) {
return join(this.baseDir, `${slugify(sessionId)}.json`); return join(this.baseDir, `${slugify(sessionId)}.json`);
} }
private filePaths(sessionId: string) {
return [this.filePath(sessionId), this.legacyFilePath(sessionId)];
} }
private async readRecord(sessionId: string) {
for (const path of this.filePaths(sessionId)) {
const record = await readJsonFile<SessionRecord>(path);
if (record?.sessionId === sessionId) {
return record;
}
}
return null;
}
}
const matchesContext = (
record: SessionRecord,
context: SessionMetadataContext,
) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey &&
(!record.ownerUserId || record.ownerUserId === context.userId?.trim()) &&
(!record.projectId || record.projectId === context.projectId);
const normalizeSessionId = (value?: string) => { const normalizeSessionId = (value?: string) => {
const normalized = value?.trim(); const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined; return normalized ? normalized.slice(0, 128) : undefined;
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "bun:test";
import type { NextFunction, Request, Response } from "express";
import { requireAgentAuth } from "../../src/auth/agentAuth.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("requireAgentAuth", () => {
it("preserves dependency outages without exposing upstream response text", async () => {
globalThis.fetch = (async () =>
new Response("postgresql://secret-host/internal-table", {
status: 503,
})) as unknown as typeof fetch;
const headers: Record<string, string> = {
authorization: "Bearer token",
"x-project-id": "project-id",
};
const req = {
header: (name: string) => headers[name.toLowerCase()],
} as Request;
let status = 200;
let body: unknown;
const res = {
status: (value: number) => {
status = value;
return res;
},
json: (value: unknown) => {
body = value;
return res;
},
} as unknown as Response;
let nextCalled = false;
await requireAgentAuth(req, res, (() => {
nextCalled = true;
}) as NextFunction);
expect(status).toBe(503);
expect(body).toEqual({ message: "authentication service unavailable" });
expect(JSON.stringify(body)).not.toContain("secret-host");
expect(nextCalled).toBe(false);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test";
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
import {
createRouteRegistrar,
generateAgentOpenApi,
} from "../../src/contracts/openapi.js";
const KEBAB_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
describe("Agent REST OpenAPI", () => {
test("rejects duplicate method and path registrations", () => {
const register = createRouteRegistrar(new OpenAPIRegistry());
const config = {
summary: "Test operation",
responses: { 200: { description: "Successful response" } },
};
register("/api/v1/agent/test", "get", config);
expect(() => register("/api/v1/agent/test", "get", config)).toThrow(
"Duplicate Agent OpenAPI operation: GET /api/v1/agent/test",
);
});
test("publishes only unique kebab-case public operations", () => {
const document = generateAgentOpenApi();
const operationIds = new Set<string>();
let operationCount = 0;
for (const [path, pathItem] of Object.entries(document.paths)) {
expect(path.endsWith("/")).toBe(false);
for (const segment of path.split("/").filter(Boolean)) {
if (!segment.startsWith("{")) {
expect(segment).toMatch(KEBAB_SEGMENT);
}
}
for (const operation of Object.values(pathItem ?? {})) {
if (!operation || typeof operation !== "object" || !("responses" in operation)) {
continue;
}
operationCount += 1;
expect(operation.operationId).toBeTruthy();
expect(operationIds.has(operation.operationId!)).toBe(false);
operationIds.add(operation.operationId!);
expect(operation.security).toEqual([{ bearerAuth: [] }]);
}
}
expect(operationCount).toBe(13);
});
test("models runs as session subresources", () => {
const document = generateAgentOpenApi();
expect(document.paths["/api/v1/agent/sessions/{session_id}/runs"]?.post).toBeTruthy();
expect(
document.paths[
"/api/v1/agent/sessions/{session_id}/runs/current/events"
]?.get,
).toBeTruthy();
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
});
test("matches the public session runtime response shapes", () => {
const document = generateAgentOpenApi();
const schemas = document.components?.schemas ?? {};
const createResponses =
document.paths["/api/v1/agent/sessions"]?.post?.responses ?? {};
const patchOperation =
document.paths["/api/v1/agent/sessions/{session_id}"]?.patch;
const patchRequestBody = patchOperation?.requestBody;
const forkResponses =
document.paths["/api/v1/agent/sessions/{session_id}/forks"]?.post
?.responses ?? {};
expect(Object.keys(createResponses)).toContain("200");
expect(Object.keys(createResponses)).toContain("201");
expect(schemas.AgentSessionSummary).toMatchObject({
required: expect.arrayContaining(["id", "created_at", "updated_at", "status"]),
});
expect(schemas.AgentSessionSummary).not.toMatchObject({
required: expect.arrayContaining(["session_id"]),
});
expect(
patchRequestBody && !("$ref" in patchRequestBody)
? patchRequestBody.content["application/json"]?.schema
: undefined,
).toMatchObject({
properties: {
is_title_manually_edited: { type: "boolean" },
},
});
expect(patchOperation?.responses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionUpdate" },
},
},
});
expect(forkResponses["200"]).toMatchObject({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AgentSessionFork" },
},
},
});
expect(forkResponses["201"]).toBeUndefined();
});
test("documents runtime query parameters and error statuses", () => {
const document = generateAgentOpenApi();
const renderOperation =
document.paths["/api/v1/agent/render-references/{render_ref}"]?.get;
expect(renderOperation?.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({
in: "query",
name: "session_id",
required: false,
}),
]),
);
expect(Object.keys(renderOperation?.responses ?? {})).toEqual(
expect.arrayContaining(["400", "500", "502"]),
);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { generateAgentOpenApi } from "../../src/contracts/openapi.js";
import { buildChatRouter } from "../../src/routes/chat.js";
import { buildAgentPublicRouter } from "../../src/routes/publicApi.js";
describe("Agent public REST router", () => {
let baseUrl = "";
let server: Server;
beforeAll(async () => {
const chatRouter = Router();
chatRouter.use(express.json());
chatRouter.post("/sessions/:session_id/runs", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.delete("/sessions/:session_id/runs/current", (req, res) => {
res.json({ method: req.method, path: req.path, body: req.body });
});
chatRouter.get("/auth-failure", (_req, res) => {
res.status(503).json({ message: "authentication service unavailable" });
});
const app = express();
app.use(express.json());
app.use("/api/v1/agent", buildAgentPublicRouter(chatRouter));
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
test("matches the published OpenAPI method and path set", () => {
type RouterLayer = {
route?: {
methods: Record<string, boolean>;
path: string;
};
};
const router = buildChatRouter(
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
undefined as never,
);
const layers = (router as unknown as { stack: RouterLayer[] }).stack;
const runtimeOperations = layers
.flatMap((layer) => {
if (!layer.route) {
return [];
}
const path = `/api/v1/agent${layer.route.path.replace(
/:([^/]+)/g,
"{$1}",
)}`;
return Object.entries(layer.route.methods)
.filter(([, enabled]) => enabled)
.map(([method]) => `${method.toUpperCase()} ${path}`);
})
.sort();
const document = generateAgentOpenApi();
const contractOperations = Object.entries(document.paths)
.flatMap(([path, pathItem]) =>
Object.entries(pathItem ?? {})
.filter(([, operation]) => {
return (
operation &&
typeof operation === "object" &&
"responses" in operation
);
})
.map(([method]) => `${method.toUpperCase()} ${path}`),
)
.sort();
expect(runtimeOperations).toEqual(contractOperations);
});
test("serves session runs without rewriting the REST request", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-1/runs`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hello" }),
},
);
expect(await response.json()).toEqual({
method: "POST",
path: "/sessions/session-1/runs",
body: { message: "hello" },
});
});
test("serves DELETE current run without rewriting the HTTP method", async () => {
const response = await fetch(
`${baseUrl}/api/v1/agent/sessions/session-2/runs/current`,
{ method: "DELETE" },
);
expect(await response.json()).toEqual({
method: "DELETE",
path: "/sessions/session-2/runs/current",
body: {},
});
});
test("wraps authentication middleware failures as Problem Details", async () => {
const response = await fetch(`${baseUrl}/api/v1/agent/auth-failure`);
const body = await response.json();
expect(response.status).toBe(503);
expect(response.headers.get("content-type")).toContain(
"application/problem+json",
);
expect(body).toMatchObject({
status: 503,
code: "dependency_unavailable",
detail: "authentication service unavailable",
});
});
});
+75
View File
@@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => {
); );
expect(fetched?.title).toBe("新标题"); expect(fetched?.title).toBe("新标题");
}); });
it("does not expose a session across users or projects", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "private-session",
userId: "user-a",
});
expect(
await store.get(
{
actorKey: "actor-b",
projectId: "project-a",
projectKey: "project-key-a",
userId: "user-b",
},
"private-session",
),
).toBeNull();
expect(
await store.get(
{
actorKey: "actor-a",
projectId: "project-b",
projectKey: "project-key-b",
userId: "user-a",
},
"private-session",
),
).toBeNull();
});
it("rejects idempotent creation when the session belongs to another scope", async () => {
await store.ensure({
actorKey: "actor-a",
projectId: "project-a",
projectKey: "project-key-a",
sessionId: "claimed-session",
userId: "user-a",
});
await expect(
store.ensure({
actorKey: "actor-b",
projectId: "project-b",
projectKey: "project-key-b",
sessionId: "claimed-session",
userId: "user-b",
}),
).rejects.toThrow("session metadata ownership mismatch");
});
it("keeps long session ids with the same slug prefix distinct", async () => {
const prefix = "s".repeat(64);
const firstSessionId = `${prefix}-first`;
const secondSessionId = `${prefix}-second`;
const context = {
actorKey: "actor-long",
projectId: "project-long",
projectKey: "project-key-long",
userId: "user-long",
};
await store.ensure({ ...context, sessionId: firstSessionId });
await store.ensure({ ...context, sessionId: secondSessionId });
expect((await store.get(context, firstSessionId))?.sessionId).toBe(
firstSessionId,
);
expect((await store.get(context, secondSessionId))?.sessionId).toBe(
secondSessionId,
);
});
}); });