From 94529cb1410aaf21cce88fd982f98e14782380ab Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 30 Jul 2026 20:38:52 +0800 Subject: [PATCH] feat(api): expose REST-only agent routes --- README.md | 6 +- bun.lock | 7 + cli/src/commands/analysis.ts | 96 +- cli/src/commands/component.ts | 21 +- cli/src/commands/data.ts | 42 +- cli/src/commands/network.ts | 31 +- cli/src/commands/simulation.ts | 4 +- cli/src/core/files.ts | 9 +- cli/src/core/http.ts | 13 +- cli/src/core/runtime.ts | 14 - cli/src/core/types.ts | 4 - contracts/agent-v1.openapi.json | 1999 ++++++++++++++++++++++++++ contracts/manifest.json | 9 + node-tests/cli/tjwaterCli.node.mjs | 7 +- package.json | 4 + scripts/generate-contract.ts | 37 + scripts/migrate-session-identity.ts | 424 ++++++ src/auth/agentAuth.ts | 31 +- src/contracts/openapi.ts | 325 +++++ src/routes/chat.ts | 68 +- src/routes/chatAuxiliaryRoutes.ts | 8 +- src/routes/chatInteractionRoutes.ts | 197 +-- src/routes/publicApi.ts | 55 + src/server.ts | 32 +- src/sessions/metadataStore.ts | 73 +- tests/auth/agentAuth.test.ts | 48 + tests/contracts/openapi.test.ts | 128 ++ tests/routes/publicApi.test.ts | 136 ++ tests/sessions/metadataStore.test.ts | 75 + 29 files changed, 3525 insertions(+), 378 deletions(-) create mode 100644 contracts/agent-v1.openapi.json create mode 100644 contracts/manifest.json create mode 100644 scripts/generate-contract.ts create mode 100644 scripts/migrate-session-identity.ts create mode 100644 src/contracts/openapi.ts create mode 100644 src/routes/publicApi.ts create mode 100644 tests/auth/agentAuth.test.ts create mode 100644 tests/contracts/openapi.test.ts create mode 100644 tests/routes/publicApi.test.ts diff --git a/README.md b/README.md index aaeb5b5..c57a45f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 主要能力 -- 提供 `POST /api/v1/agent/chat/stream` SSE 聊天接口。 +- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。 - 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。 - 管理前端 `session_id` 与 OpenCode session 的映射。 - 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。 @@ -43,6 +43,8 @@ bun run dev ```bash bun run check +bun run contract:generate +bun run test:api bun run test:cli bun run start bun run start:prod @@ -50,6 +52,8 @@ docker build -t tjwater-agent:local . ``` - `bun run check`:检查主项目和 `.opencode` 的 TypeScript 类型。 +- `bun run contract:generate`:生成 `contracts/agent-v1.openapi.json`。 +- `bun run test:api`:验证公开 REST 契约和聊天路由。 - `bun run test:cli`:运行 `node-tests/cli/*.node.mjs`。 - `bun run start`:直接启动服务。 - `bun run start:prod`:先类型检查,再启动服务。 diff --git a/bun.lock b/bun.lock index 9d21e2e..cad1fb8 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "zod": "^3.25.76", }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "7.3.4", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^24.7.2", @@ -23,6 +24,8 @@ }, }, "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=="], "@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=="], + "openapi3-ts": ["openapi3-ts@4.6.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "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=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "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=="], diff --git a/cli/src/commands/analysis.ts b/cli/src/commands/analysis.ts index e6c249d..6463301 100644 --- a/cli/src/commands/analysis.ts +++ b/cli/src/commands/analysis.ts @@ -2,7 +2,7 @@ import { CliError } from "../core/errors.js"; import { emitApi, requestJson } from "../core/http.js"; import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js"; import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js"; -import { requireNetwork, requireUsername, resolveScheme } from "../core/runtime.js"; +import { resolveScheme } from "../core/runtime.js"; import { parseTime } from "../core/time.js"; import { success } from "../core/output.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; @@ -12,17 +12,16 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise { const [ids, sizes] = parseBurstFile(requiredString(values, "burst-file")); const schemeName = resolveScheme(ctx, optionalString(values, "scheme"), true)!; return emitApi(ctx, "爆管分析执行成功", { - method: "GET", - path: "/burst-analysis", + method: "POST", + path: "/burst-analyses", params: { - network: requireNetwork(ctx), modify_pattern_start_time: parseTime(requiredString(values, "start-time"), "--start-time"), - burst_ID: ids, + burst_id: ids, burst_size: sizes, modify_total_duration: requiredNumber(values, "duration"), scheme_name: schemeName, }, - requireNetworkCtx: true, + requireProject: true, }, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]); } @@ -34,25 +33,24 @@ function analysisValve(ctx: RuntimeContext, argv: string[]): Promise { const startTime = optionalString(values, "start-time"); if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2); return emitApi(ctx, "阀门关闭分析执行成功", { - method: "GET", - path: "/valve_close_analysis/", + method: "POST", + path: "/valve-isolation-analyses", params: { - network: requireNetwork(ctx), start_time: parseTime(startTime, "--start-time"), valves, duration: optionalNumber(values, "duration") || 900, scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), }, - requireNetworkCtx: true, + requireProject: true, }); } const elements = optionalStringArray(values, "element"); if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2); return emitApi(ctx, "阀门隔离分析执行成功", { - method: "GET", - path: "/valve-isolation-analysis", - params: { network: requireNetwork(ctx), accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") }, - requireNetworkCtx: true, + method: "POST", + path: "/valve-isolation-analyses", + params: { accident_element: elements, disabled_valves: optionalStringArray(values, "disabled-valve") }, + requireProject: true, }); } @@ -60,36 +58,34 @@ function analysisFlushing(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv, { flow: "number", duration: "integer" }); const [valves, openings] = parseValveSettingFile(requiredString(values, "valve-setting-file")); return emitApi(ctx, "冲洗分析执行成功", { - method: "GET", - path: "/flushing-analysis", + method: "POST", + path: "/flushing-analyses", params: { - network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), valves, valves_k: openings, - drainage_node_ID: requiredString(values, "drainage-node"), + drainage_node_id: requiredString(values, "drainage-node"), flush_flow: requiredNumber(values, "flow"), duration: optionalNumber(values, "duration") || 900, scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), }, - requireNetworkCtx: true, + requireProject: true, }); } function analysisAge(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv, { duration: "integer" }); return emitApi(ctx, "水龄分析执行成功", { - method: "GET", - path: "/age_analysis/", - params: { network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") }, - requireNetworkCtx: true, + method: "POST", + path: "/water-age-analyses", + params: { start_time: parseTime(requiredString(values, "start-time"), "--start-time"), duration: requiredNumber(values, "duration") }, + requireProject: true, }); } function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv, { duration: "integer", concentration: "number" }); const params: Record = { - network: requireNetwork(ctx), start_time: parseTime(requiredString(values, "start-time"), "--start-time"), source: requiredString(values, "source-node"), concentration: requiredNumber(values, "concentration"), @@ -98,55 +94,50 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise }; const pattern = optionalString(values, "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 { const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" }); return emitApi(ctx, "传感器选址执行成功", { method: "POST", - path: "/pressure_sensor_placement_kmeans/", + path: "/pressure-sensor-placement-kmeans", body: { - name: requireNetwork(ctx), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), sensor_number: requiredNumber(values, "count"), min_diameter: optionalNumber(values, "min-diameter") || 0, - username: requireUsername(ctx), }, - requireNetworkCtx: true, - requireUsernameCtx: true, + requireProject: true, }); } -function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, networkKey: string, startKey: string, endKey: string): Promise { +function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, path: string, startKey: string, endKey: string): Promise { const { values } = parseOptions(argv); return emitApi(ctx, summary, { method: "POST", path, body: { - [networkKey]: requireNetwork(ctx), [startKey]: parseTime(requiredString(values, "start-time"), "--start-time"), [endKey]: parseTime(requiredString(values, "end-time"), "--end-time"), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), }, - requireNetworkCtx: true, + requireProject: true, }); } -function schemeList(ctx: RuntimeContext, summary: string, path: string): Promise { - return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise { + 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 { +function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise { const { positionals } = parseOptions(argv); if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2); - return emitApi(ctx, summary, { method: "GET", path: `${path}${positionals[0]}`, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); + return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true }); } function burstLocation(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv, { "burst-leakage": "number", "pressure-scada-id": "repeat", "flow-scada-id": "repeat", "use-scada-flow": "boolean" }); const body: Record = { - network: requireNetwork(ctx), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), data_source: optionalString(values, "data-source") || "monitoring", scada_burst_start: parseTime(requiredString(values, "start-time"), "--start-time"), @@ -162,18 +153,17 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise { const flowFile = optionalString(values, "flow-file"); if (pressureFile) assignDatasetKeys(body, pressureFile, ["burst_pressure", "normal_pressure"], "pressure"); if (flowFile) assignDatasetKeys(body, flowFile, ["burst_flow", "normal_flow"], "flow"); - return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-location/locate/", body, requireNetworkCtx: true }); + return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true }); } function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise { 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 { - const network = requireNetwork(ctx); - const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/getnetworkpiperiskprobabilitynow/", params: { network }, requireNetworkCtx: true }); - const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/getpiperiskprobabilitygeometries/", params: { network }, requireNetworkCtx: true }); + const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true }); + const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true }); success("读取全网风险成功", { probabilities, geometries }, ctx, a + b); } @@ -184,16 +174,16 @@ export const analysisHandlers: HandlerMap = { "analysis age": analysisAge, "analysis contaminant": analysisContaminant, "analysis sensor-placement kmeans": sensorKmeans, - "analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage/identify/", "network", "scada_start", "scada_end"), - "analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "/leakage/schemes/"), - "analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "/leakage/schemes/"), - "analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detection/detect/", "network", "scada_start", "scada_end"), - "analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "/burst-detection/schemes/"), - "analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "/burst-detection/schemes/"), + "analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"), + "analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"), + "analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"), + "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"), + "analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"), "analysis burst-location locate": burstLocation, - "analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "/burst-location/schemes/"), - "analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "/burst-location/schemes/"), - "analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/getpiperiskprobabilitynow/"), - "analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/getpiperiskprobability/"), + "analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"), + "analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"), + "analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"), + "analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"), "analysis risk network": riskNetwork, }; diff --git a/cli/src/commands/component.ts b/cli/src/commands/component.ts index c98c25e..106b034 100644 --- a/cli/src/commands/component.ts +++ b/cli/src/commands/component.ts @@ -1,7 +1,6 @@ import { CliError } from "../core/errors.js"; import { emitApi } from "../core/http.js"; import { optionalString, parseOptions, requiredString, validateChoice } from "../core/options.js"; -import { requireNetwork } from "../core/runtime.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; type ComponentKind = "time" | "energy" | "pump-energy" | "network"; @@ -10,22 +9,22 @@ function componentOption(ctx: RuntimeContext, argv: string[], schema: boolean): const { values } = parseOptions(argv); const kind = validateChoice(requiredString(values, "kind"), ["time", "energy", "pump-energy", "network"] as const, "--kind"); const routes: Record<`${ComponentKind}:${boolean}`, string> = { - "time:true": "/gettimeschema", - "time:false": "/gettimeproperties/", - "energy:true": "/getenergyschema/", - "energy:false": "/getenergyproperties/", - "pump-energy:true": "/getpumpenergyschema/", - "pump-energy:false": "/getpumpenergyproperties//", - "network:true": "/getoptionschema/", - "network:false": "/getoptionproperties/", + "time:true": "/network-schemas/time", + "time:false": "/network-options/time", + "energy:true": "/network-schemas/energy", + "energy:false": "/network-options/energy", + "pump-energy:true": "/network-schemas/pump-energy", + "pump-energy:false": "/network-options/pump-energy", + "network:true": "/network-schemas/option", + "network:false": "/network-options", }; - const params: Record = { network: requireNetwork(ctx) }; + const params: Record = {}; const pump = optionalString(values, "pump"); if (kind === "pump-energy") { if (!schema && !pump) throw new CliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2); if (pump) params.pump = pump; } - return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireNetworkCtx: true }); + return emitApi(ctx, schema ? "读取选项 schema 成功" : "读取选项属性成功", { method: "GET", path: routes[`${kind}:${schema}`], params, requireProject: true }); } export const componentHandlers: HandlerMap = { diff --git a/cli/src/commands/data.ts b/cli/src/commands/data.ts index 00b8807..5cf56f9 100644 --- a/cli/src/commands/data.ts +++ b/cli/src/commands/data.ts @@ -2,7 +2,7 @@ import { SCADA_FIELDS, type ElementType } from "../core/constants.js"; import { CliError } from "../core/errors.js"; import { emitApi } from "../core/http.js"; import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js"; -import { requireNetwork, resolveScheme } from "../core/runtime.js"; +import { resolveScheme } from "../core/runtime.js"; import { parseTime } from "../core/time.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; @@ -20,7 +20,7 @@ function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv); return emitApi(ctx, "读取实时模拟数据成功", { 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") }, requireProject: true, }); @@ -31,7 +31,7 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv); return emitApi(ctx, "读取方案管道数据成功", { method: "GET", - path: "/scheme/links", + path: "/timeseries/schemes/links", params: { scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_type: optionalString(values, "scheme-type") || "simulation", @@ -56,7 +56,7 @@ function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv); return emitApi(ctx, "读取方案节点字段成功", { method: "GET", - path: `/scheme/nodes/${requiredString(values, "node")}/field`, + path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`, params: { field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), @@ -80,10 +80,10 @@ function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise { }; if (query === "by-id-time") { 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"); - 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 { @@ -95,7 +95,7 @@ function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise { }; const field = optionalString(values, "field"); if (field) params.field = validateChoice(field, SCADA_FIELDS, "--field"); - return emitApi(ctx, "读取 SCADA 时序成功", { method: "GET", path: field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range", params, requireProject: true }); + 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 { @@ -115,7 +115,12 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise { params.element_id = feature[0]; params.use_cleaned = Boolean(values["use-cleaned"]); } - return emitApi(ctx, kind === "scada-simulation" ? "读取复合 SCADA-模拟数据成功" : kind === "element-simulation" ? "读取复合元素模拟数据成功" : "读取元素关联 SCADA 数据成功", { method: "GET", path: `/composite/${kind}`, params, requireProject: true }); + 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 { @@ -124,33 +129,32 @@ function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise { requiredString(values, "start-time"); return emitApi(ctx, "读取管道健康预测成功", { method: "GET", - path: "/composite/pipeline-health-prediction", - params: { network_name: requireNetwork(ctx), query_time: parseTime(requiredString(values, "end-time"), "--end-time") }, + path: "/pipeline-health-predictions", + params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") }, requireProject: true, - requireNetworkCtx: true, }); } function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv); validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); - return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/getscadainfo/", params: { network: requireNetwork(ctx), id: requiredString(values, "id") }, requireNetworkCtx: true }); + return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true }); } function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise { const { values } = parseOptions(argv); validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); - return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/getallscadainfo/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); + return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true }); } function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise { 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 = { - "data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/realtime/links"), - "data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/realtime/nodes"), + "data timeseries realtime links": (ctx, argv) => rangeGet(ctx, argv, "读取实时管道数据成功", "/timeseries/realtime/links"), + "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-time-property": realtimeByTimeProperty, "data timeseries scheme links": schemeLinks, @@ -161,7 +165,7 @@ export const dataHandlers: HandlerMap = { "data timeseries composite pipeline-health": pipelineHealth, "data scada get": dataScadaGet, "data scada list": dataScadaList, - "data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/getschemeschema/", params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }), + "data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }), "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 }), }; diff --git a/cli/src/commands/network.ts b/cli/src/commands/network.ts index aaf4963..1321d5e 100644 --- a/cli/src/commands/network.ts +++ b/cli/src/commands/network.ts @@ -1,27 +1,26 @@ import { emitApi } from "../core/http.js"; import { parseOptions, requiredString } from "../core/options.js"; -import { requireNetwork } from "../core/runtime.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; -function legacyGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise { +function apiGet(ctx: RuntimeContext, argv: string[], summary: string, path: string, key: string): Promise { 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 { - return emitApi(ctx, summary, { method: "GET", path, params: { network: requireNetwork(ctx) }, requireNetworkCtx: true }); +function apiGetAll(ctx: RuntimeContext, summary: string, path: string): Promise { + return emitApi(ctx, summary, { method: "GET", path, requireProject: true }); } export const networkHandlers: HandlerMap = { - "network get-junction-properties": (ctx, argv) => legacyGet(ctx, argv, "读取节点属性成功", "/getjunctionproperties/", "junction"), - "network get-pipe-properties": (ctx, argv) => legacyGet(ctx, argv, "读取管道属性成功", "/getpipeproperties/", "pipe"), - "network get-all-pipes-properties": (ctx) => legacyGetAll(ctx, "读取全部管道属性成功", "/getallpipeproperties/"), - "network get-reservoir-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水库属性成功", "/getreservoirproperties/", "reservoir"), - "network get-all-reservoirs-properties": (ctx) => legacyGetAll(ctx, "读取全部水库属性成功", "/getallreservoirproperties/"), - "network get-tank-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水箱属性成功", "/gettankproperties/", "tank"), - "network get-all-tanks-properties": (ctx) => legacyGetAll(ctx, "读取全部水箱属性成功", "/getalltankproperties/"), - "network get-pump-properties": (ctx, argv) => legacyGet(ctx, argv, "读取水泵属性成功", "/getpumpproperties/", "pump"), - "network get-all-pumps-properties": (ctx) => legacyGetAll(ctx, "读取全部水泵属性成功", "/getallpumpproperties/"), - "network get-valve-properties": (ctx, argv) => legacyGet(ctx, argv, "读取阀门属性成功", "/getvalveproperties/", "valve"), - "network get-all-valves-properties": (ctx) => legacyGetAll(ctx, "读取全部阀门属性成功", "/getallvalveproperties/"), + "network get-junction-properties": (ctx, argv) => apiGet(ctx, argv, "读取节点属性成功", "/junctions/properties", "junction"), + "network get-pipe-properties": (ctx, argv) => apiGet(ctx, argv, "读取管道属性成功", "/pipes/properties", "pipe"), + "network get-all-pipes-properties": (ctx) => apiGetAll(ctx, "读取全部管道属性成功", "/pipes"), + "network get-reservoir-properties": (ctx, argv) => apiGet(ctx, argv, "读取水库属性成功", "/reservoirs/properties", "reservoir"), + "network get-all-reservoirs-properties": (ctx) => apiGetAll(ctx, "读取全部水库属性成功", "/reservoirs"), + "network get-tank-properties": (ctx, argv) => apiGet(ctx, argv, "读取水箱属性成功", "/tanks/properties", "tank"), + "network get-all-tanks-properties": (ctx) => apiGetAll(ctx, "读取全部水箱属性成功", "/tanks"), + "network get-pump-properties": (ctx, argv) => apiGet(ctx, argv, "读取水泵属性成功", "/pumps/properties", "pump"), + "network get-all-pumps-properties": (ctx) => apiGetAll(ctx, "读取全部水泵属性成功", "/pumps"), + "network get-valve-properties": (ctx, argv) => apiGet(ctx, argv, "读取阀门属性成功", "/valves/properties", "valve"), + "network get-all-valves-properties": (ctx) => apiGetAll(ctx, "读取全部阀门属性成功", "/valves"), }; diff --git a/cli/src/commands/simulation.ts b/cli/src/commands/simulation.ts index 6d36481..503b652 100644 --- a/cli/src/commands/simulation.ts +++ b/cli/src/commands/simulation.ts @@ -1,6 +1,5 @@ import { emitApi } from "../core/http.js"; import { parseOptions, requiredNumber, requiredString } from "../core/options.js"; -import { requireNetwork } from "../core/runtime.js"; import { addMinutesPreservingOffset, parseTime } from "../core/time.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js"; @@ -9,11 +8,10 @@ function simulationRun(ctx: RuntimeContext, argv: string[]): Promise { const start = parseTime(requiredString(values, "start-time"), "--start-time"); const duration = requiredNumber(values, "duration"); const end = addMinutesPreservingOffset(start, duration); - const network = requireNetwork(ctx); return emitApi( ctx, "触发模拟成功", - { method: "POST", path: "/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 nodes --start-time ${start} --end-time ${end}`, diff --git a/cli/src/core/files.ts b/cli/src/core/files.ts index 4b40da3..ed79e74 100644 --- a/cli/src/core/files.ts +++ b/cli/src/core/files.ts @@ -20,10 +20,10 @@ export function readJsonFile(path: string, label: string): unknown { export function parseBurstFile(path: string): [string[], number[]] { let raw = readJsonFile(path, "burst"); if (isRecord(raw) && "bursts" in raw) raw = raw.bursts; - if (isRecord(raw) && "burst_ID" in raw && "burst_size" in raw && Array.isArray(raw.burst_ID) && Array.isArray(raw.burst_size)) { - const ids = raw.burst_ID.map(String); + if (isRecord(raw) && "burst_id" in raw && "burst_size" in raw && Array.isArray(raw.burst_id) && Array.isArray(raw.burst_size)) { + const ids = raw.burst_id.map(String); const sizes = raw.burst_size.map(Number); - if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_ID and burst_size must have the same length", 2); + if (ids.length !== sizes.length) throw new CliError("CLI 参数错误", "BURST_FILE_INVALID", "burst file burst_id and burst_size must have the same length", 2); return [ids, sizes]; } if (Array.isArray(raw)) { @@ -35,7 +35,7 @@ export function parseBurstFile(path: string): [string[], number[]] { raw.map((item) => Number((item as Record).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[]] { @@ -66,4 +66,3 @@ export function assignDatasetKeys(target: Record, path: string, } } } - diff --git a/cli/src/core/http.ts b/cli/src/core/http.ts index 8872764..27b2af0 100644 --- a/cli/src/core/http.ts +++ b/cli/src/core/http.ts @@ -1,5 +1,4 @@ import { CliError, errorMessage } from "./errors.js"; -import { requireNetwork, requireUsername } from "./runtime.js"; import { success } from "./output.js"; import type { RequestOptions, RuntimeContext } from "./types.js"; @@ -35,18 +34,10 @@ function appendParams(url: URL, params: Record = {}): void { } } -function withNetworkParam(params: Record | undefined, network: string): Record { - 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]> { - const { method, path, params, body, requireAuth = true, requireProject = false, requireNetworkCtx = false, requireUsernameCtx = false } = request; - const network = requireNetworkCtx ? requireNetwork(ctx) : null; - if (requireUsernameCtx) requireUsername(ctx); + const { method, path, params, body, requireAuth = true, requireProject = false } = request; const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, "")); - const requestParams = network && (params !== undefined || body === undefined) ? withNetworkParam(params, network) : params; - appendParams(url, requestParams); + appendParams(url, params); const started = performance.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000); diff --git a/cli/src/core/runtime.ts b/cli/src/core/runtime.ts index 4c44343..cf9fed9 100644 --- a/cli/src/core/runtime.ts +++ b/cli/src/core/runtime.ts @@ -31,8 +31,6 @@ export async function loadAuthContext(authStdin: boolean): Promise server: process.env.TJWATER_SERVER, access_token: process.env.TJWATER_ACCESS_TOKEN, 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) : {}, }; const headers = (raw.headers ?? {}) as unknown; @@ -43,8 +41,6 @@ export async function loadAuthContext(authStdin: boolean): Promise server: pick(raw, "server", "base_url"), accessToken: pick(raw, "access_token", "token", "accessToken"), 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).map(([key, value]) => [String(key), String(value)])), }; } @@ -81,16 +77,6 @@ export async function buildRuntime(globals: GlobalArgs): Promise }; } -export function requireNetwork(ctx: RuntimeContext): string { - if (ctx.auth.network) return ctx.auth.network; - throw new CliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context for legacy network-based endpoints", 3, false, null, ["add network to auth context"]); -} - -export function requireUsername(ctx: RuntimeContext): string { - if (ctx.auth.username) return ctx.auth.username; - throw new CliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3, false, null, ["add username to auth context"]); -} - export function resolveScheme(ctx: RuntimeContext, explicit: string | undefined, must = false): string | null { const scheme = explicit || ctx.scheme; if (must && !scheme) throw new CliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); diff --git a/cli/src/core/types.ts b/cli/src/core/types.ts index f20fd89..9f6adb7 100644 --- a/cli/src/core/types.ts +++ b/cli/src/core/types.ts @@ -6,8 +6,6 @@ export interface AuthContext { server: string | null; accessToken: string | null; projectId: string | null; - username: string | null; - network: string | null; headers: Record; } @@ -48,8 +46,6 @@ export interface RequestOptions { body?: unknown; requireAuth?: boolean; requireProject?: boolean; - requireNetworkCtx?: boolean; - requireUsernameCtx?: boolean; } export type Handler = (ctx: RuntimeContext, argv: string[]) => Promise | void; diff --git a/contracts/agent-v1.openapi.json b/contracts/agent-v1.openapi.json new file mode 100644 index 0000000..0d76880 --- /dev/null +++ b/contracts/agent-v1.openapi.json @@ -0,0 +1,1999 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "TJWater Agent API", + "version": "1.0.0", + "description": "Public REST API for TJWater Agent sessions and runs" + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { + "type": "string" + }, + "trace_id": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "nullable": true + } + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "instance", + "code", + "trace_id", + "errors" + ] + }, + "AgentSessionSummary": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "parent_session_id": { + "type": "string", + "nullable": true + }, + "is_streaming": { + "type": "boolean" + }, + "run_status": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "title", + "created_at", + "updated_at", + "status", + "is_streaming" + ] + }, + "AgentSessionCreate": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "parent_session_id": { + "type": "string", + "nullable": true + } + }, + "required": [ + "session_id", + "created_at", + "updated_at", + "status" + ] + }, + "AgentSessionDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/AgentSessionSummary" + }, + { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "is_title_manually_edited": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "nullable": true + } + } + }, + "required": [ + "session_id", + "is_title_manually_edited", + "messages" + ] + } + ] + }, + "AgentSessionUpdate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "updated_at" + ] + }, + "AgentSessionFork": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ] + } + }, + "parameters": {} + }, + "paths": { + "/api/v1/agent/models": { + "get": { + "operationId": "get_models", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List available agent models", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions": { + "get": { + "operationId": "get_sessions", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List agent sessions", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentSessionSummary" + } + } + }, + "required": [ + "sessions" + ] + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "post": { + "operationId": "post_sessions", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Create an agent session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "maxLength": 128 + }, + "parent_session_id": { + "type": "string", + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Existing session returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentSessionCreate" + } + } + } + }, + "201": { + "description": "Session created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentSessionCreate" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}": { + "get": { + "operationId": "get_sessions_session_id", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Get an agent session", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentSessionDetail" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "patch": { + "operationId": "patch_sessions_session_id", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Update an agent session", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "is_title_manually_edited": { + "type": "boolean" + } + }, + "required": [ + "title" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentSessionUpdate" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "delete": { + "operationId": "delete_sessions_session_id", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Delete an agent session", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "Session deleted" + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/runs": { + "post": { + "operationId": "post_sessions_session_id_runs", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Run an agent session", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "model": { + "type": "string" + }, + "approval_mode": { + "type": "string", + "enum": [ + "request", + "always" + ] + } + }, + "required": [ + "message" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Agent event stream", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/runs/current/events": { + "get": { + "operationId": "get_sessions_session_id_runs_current_events", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Resume the current agent event stream", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Agent event stream", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/runs/current": { + "delete": { + "operationId": "delete_sessions_session_id_runs_current", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Abort the current agent run", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "204": { + "description": "No active run" + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/permission-responses": { + "post": { + "operationId": "post_sessions_session_id_permission_responses", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reply to an agent permission request", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request_id": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": [ + "once", + "always", + "reject" + ] + }, + "message": { + "type": "string", + "maxLength": 1000 + } + }, + "required": [ + "request_id", + "reply" + ] + } + } + } + }, + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/question-responses": { + "post": { + "operationId": "post_sessions_session_id_question_responses", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reply to or reject an agent question", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request_id": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "reply", + "reject" + ], + "default": "reply" + }, + "answers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 2000 + } + } + } + }, + "required": [ + "request_id" + ] + } + } + } + }, + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/sessions/{session_id}/forks": { + "post": { + "operationId": "post_sessions_session_id_forks", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Fork an agent session", + "parameters": [ + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": true, + "name": "session_id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "keep_message_count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "keep_message_count" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentSessionFork" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/agent/render-references/{render_ref}": { + "get": { + "operationId": "get_render_references_render_ref", + "tags": [ + "Agent" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Resolve a render reference", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "render_ref", + "in": "path" + }, + { + "schema": { + "type": "string", + "maxLength": 128 + }, + "required": false, + "name": "session_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Insufficient permission", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "409": { + "description": "Resource conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Validation error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "502": { + "description": "Upstream dependency error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Dependency unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + } + } +} diff --git a/contracts/manifest.json b/contracts/manifest.json new file mode 100644 index 0000000..87fc2f0 --- /dev/null +++ b/contracts/manifest.json @@ -0,0 +1,9 @@ +{ + "contract_version": "1.0.0", + "contracts": { + "agent": { + "file": "agent-v1.openapi.json", + "sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259" + } + } +} diff --git a/node-tests/cli/tjwaterCli.node.mjs b/node-tests/cli/tjwaterCli.node.mjs index ee6fd06..5b48bc4 100644 --- a/node-tests/cli/tjwaterCli.node.mjs +++ b/node-tests/cli/tjwaterCli.node.mjs @@ -205,18 +205,17 @@ test("sends auth headers and simulation body through the backend API contract", { server: server.url, access_token: "token-1", - network: "tjwater", + project_id: "project-1", headers: { "x-extra": "extra" }, }, ); assert.equal(result.exitCode, 0, result.stderr); assert.equal(server.seen[0].method, "POST"); - assert.equal(server.seen[0].url, "/api/v1/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["x-extra"], "extra"); assert.deepEqual(server.seen[0].body, { - name: "tjwater", start_time: "2025-01-02T03:00:00+08:00", 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].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["x-project-id"], "project-1"); diff --git a/package.json b/package.json index f75136d..8ca0d0f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "dev": "bun --watch src/server.ts", "build": "bun run check", "check": "bun run typecheck && bun run typecheck:opencode", + "contract:generate": "bun scripts/generate-contract.ts", + "contract:check": "bun scripts/generate-contract.ts --check", + "test:api": "bun test tests/routes tests/contracts", "migrate:session-identity": "bun scripts/migrate-session-identity.ts", "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh", "start": "bun src/server.ts", @@ -27,6 +30,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "7.3.4", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^24.7.2", diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts new file mode 100644 index 0000000..a6c81f7 --- /dev/null +++ b/scripts/generate-contract.ts @@ -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})`); +} diff --git a/scripts/migrate-session-identity.ts b/scripts/migrate-session-identity.ts new file mode 100644 index 0000000..244f7fd --- /dev/null +++ b/scripts/migrate-session-identity.ts @@ -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; + +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 --to-user-id [--write] + bun scripts/migrate-session-identity.ts --from-actor-key --to-actor-key [--write] + +Optional: + --from-project-id Match old project id + --to-project-id Rewrite project id + --from-project-key Match old project key + --to-project-key Rewrite project key + --data-dir Default: ./data + --backup-dir Default: /backup/session-identity-migration/ + --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(); + 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, 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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>((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); +}); diff --git a/src/auth/agentAuth.ts b/src/auth/agentAuth.ts index 791ebcd..3e56ac5 100644 --- a/src/auth/agentAuth.ts +++ b/src/auth/agentAuth.ts @@ -100,7 +100,7 @@ export const requireAgentAuth = async ( const timer = setTimeout(() => controller.abort(), config.AGENT_AUTH_TIMEOUT_MS); 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", headers: { Accept: "application/json", @@ -125,18 +125,27 @@ export const requireAgentAuth = async ( return; } - const detail = await response.text(); - res.status(response.status === 403 ? 403 : 401).json({ - message: response.status === 403 ? "forbidden" : "unauthorized", - detail: detail || undefined, - }); + const status = + response.status === 400 || + response.status === 401 || + response.status === 403 || + response.status === 404 + ? response.status + : response.status === 503 + ? 503 + : 502; + const messages: Record = { + 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) { - const detail = error instanceof Error ? error.message : String(error); logger.warn({ err: error }, "agent auth validation failed"); - res.status(503).json({ - message: "authentication service unavailable", - detail, - }); + res.status(503).json({ message: "authentication service unavailable" }); } finally { clearTimeout(timer); } diff --git a/src/contracts/openapi.ts b/src/contracts/openapi.ts new file mode 100644 index 0000000..0cde29a --- /dev/null +++ b/src/contracts/openapi.ts @@ -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; + responses: Record; +}; + +export const createRouteRegistrar = (targetRegistry: OpenAPIRegistry) => { + const operations = new Set(); + + 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", + }, + }); +}; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 946deca..526d696 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -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 ?? {}); if (!parsed.success) { 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 authContext = getAgentAuthContext(req); 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 authContext = getAgentAuthContext(req); const projectId = authContext.projectId; @@ -303,7 +303,7 @@ export const buildChatRouter = ( 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 title = 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 authContext = getAgentAuthContext(req); const projectId = authContext.projectId; @@ -395,8 +395,11 @@ export const buildChatRouter = ( sessionUiStateStore, }); - chatRouter.post("/fork", async (req, res) => { - const parsed = forkPayloadSchema.safeParse(req.body); + chatRouter.post("/sessions/:session_id/forks", async (req, res) => { + const parsed = forkPayloadSchema.safeParse({ + ...req.body, + session_id: req.params.session_id, + }); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", @@ -425,6 +428,10 @@ export const buildChatRouter = ( sourceSessionId, ) : null; + if (!sourceSessionId || !sourceSessionRecord) { + res.status(404).json({ message: "source session not found" }); + return; + } const forkSession = await runtime.createSession(); const { record: targetSessionRecord } = await sessionMetadataStore.ensure({ actorKey, @@ -436,27 +443,25 @@ export const buildChatRouter = ( }); const nextSessionId = targetSessionRecord.sessionId; - if (sourceSessionId) { - await sessionTranscriptStore.cloneThread( - { - actorKey, - clientSessionId: sourceSessionId, - projectKey, - sessionId: sourceSessionId, - }, - { - actorKey, - clientSessionId: nextSessionId, - projectKey, - sessionId: nextSessionId, - }, - parsed.data.keep_message_count, - ); - } - const sourceState = sourceSessionRecord - ? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord)) - : null; - const forkTitle = sourceSessionRecord?.title + await sessionTranscriptStore.cloneThread( + { + actorKey, + clientSessionId: sourceSessionId, + projectKey, + sessionId: sourceSessionId, + }, + { + actorKey, + clientSessionId: nextSessionId, + projectKey, + sessionId: nextSessionId, + }, + parsed.data.keep_message_count, + ); + const sourceState = await sessionUiStateStore.read( + toSessionUiStateContext(sourceSessionRecord), + ); + const forkTitle = sourceSessionRecord.title ? `${sourceSessionRecord.title} 副本` : "新对话副本"; const titledTargetSessionRecord = await sessionMetadataStore.touch( @@ -495,8 +500,11 @@ export const buildChatRouter = ( } }); - chatRouter.post("/stream", async (req, res) => { - const parsed = payloadSchema.safeParse(req.body); + chatRouter.post("/sessions/:session_id/runs", async (req, res) => { + const parsed = payloadSchema.safeParse({ + ...req.body, + session_id: req.params.session_id, + }); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", diff --git a/src/routes/chatAuxiliaryRoutes.ts b/src/routes/chatAuxiliaryRoutes.ts index 2411c0b..3ae8b4b 100644 --- a/src/routes/chatAuxiliaryRoutes.ts +++ b/src/routes/chatAuxiliaryRoutes.ts @@ -17,7 +17,7 @@ import { updateLastAssistantMessage, } from "./chatUiState.js"; -const abortPayloadSchema = z.object({ +const abortParamsSchema = z.object({ session_id: z.string().max(128), }); @@ -45,7 +45,7 @@ export const registerChatAuxiliaryRoutes = ( sessionUiStateStore, }: 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 authContext = getAgentAuthContext(req); const userId = authContext.userId; @@ -82,8 +82,8 @@ export const registerChatAuxiliaryRoutes = ( res.json(result); }); - chatRouter.post("/abort", async (req, res) => { - const parsed = abortPayloadSchema.safeParse(req.body); + chatRouter.delete("/sessions/:session_id/runs/current", async (req, res) => { + const parsed = abortParamsSchema.safeParse(req.params); if (!parsed.success) { res.status(400).json({ message: "invalid request payload", diff --git a/src/routes/chatInteractionRoutes.ts b/src/routes/chatInteractionRoutes.ts index b5b95fe..079c49a 100644 --- a/src/routes/chatInteractionRoutes.ts +++ b/src/routes/chatInteractionRoutes.ts @@ -15,20 +15,17 @@ import { } from "./chatUiState.js"; const permissionReplyPayloadSchema = z.object({ - session_id: z.string().max(128), + request_id: z.string().min(1), reply: z.enum(["once", "always", "reject"]), message: z.string().max(1000).optional(), }); 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([]), }); -const questionRejectPayloadSchema = z.object({ - session_id: z.string().max(128), -}); - type RegisterInteractionRoutesOptions = { activeRuns: Map; runtime: OpencodeRuntimeAdapter; @@ -49,13 +46,8 @@ export const registerChatInteractionRoutes = ( sessionUiStateStore, }: RegisterInteractionRoutesOptions, ) => { - chatRouter.post("/permission/:request_id/reply", async (req, res) => { - const requestId = req.params.request_id?.trim(); + chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => { const parsed = permissionReplyPayloadSchema.safeParse(req.body); - if (!requestId) { - res.status(400).json({ message: "request_id is required" }); - return; - } if (!parsed.success) { res.status(400).json({ message: "invalid request payload", @@ -70,9 +62,10 @@ export const registerChatInteractionRoutes = ( const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); + const requestId = parsed.data.request_id; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, - parsed.data.session_id, + req.params.session_id, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); @@ -174,13 +167,8 @@ export const registerChatInteractionRoutes = ( } }); - chatRouter.post("/question/:request_id/reply", async (req, res) => { - const requestId = req.params.request_id?.trim(); + chatRouter.post("/sessions/:session_id/question-responses", async (req, res) => { const parsed = questionReplyPayloadSchema.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", @@ -195,9 +183,10 @@ export const registerChatInteractionRoutes = ( const userId = authContext.userId; const actorKey = toActorKey(userId); const projectKey = toProjectKey(projectId); + const requestId = parsed.data.request_id; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, - parsed.data.session_id, + req.params.session_id, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); @@ -227,11 +216,18 @@ export const registerChatInteractionRoutes = ( }; try { - await runtime.replyQuestion({ - requestId, - sessionId: sessionRecord.sessionId, - answers: parsed.data.answers, - }); + if (parsed.data.action === "reject") { + await runtime.rejectQuestion({ + requestId, + sessionId: sessionRecord.sessionId, + }); + } else { + await runtime.replyQuestion({ + requestId, + sessionId: sessionRecord.sessionId, + answers: parsed.data.answers, + }); + } } catch (error) { run.messages = updateLastAssistantQuestion( run.messages, @@ -242,7 +238,7 @@ export const registerChatInteractionRoutes = ( error: error instanceof Error ? error.message - : "failed to reply question", + : `failed to ${parsed.data.action} question`, }), ); await persistQuestionState().catch((persistError) => { @@ -252,7 +248,7 @@ export const registerChatInteractionRoutes = ( ); }); res.status(502).json({ - message: "question reply failed", + message: `question ${parsed.data.action} failed`, detail: error instanceof Error ? error.message : String(error), }); return; @@ -264,8 +260,9 @@ export const registerChatInteractionRoutes = ( requestId, (question) => ({ ...question, - status: "answered", - answers: parsed.data.answers, + status: parsed.data.action === "reject" ? "rejected" : "answered", + answers: + parsed.data.action === "reject" ? question.answers : parsed.data.answers, repliedAt: Date.now(), error: undefined, }), @@ -280,7 +277,9 @@ export const registerChatInteractionRoutes = ( subscriber.write("question_response", { session_id: pendingQuestion.session_id, request_id: requestId, - answers: parsed.data.answers, + ...(parsed.data.action === "reject" + ? { rejected: true } + : { answers: parsed.data.answers }), }); } if ( @@ -294,143 +293,15 @@ export const registerChatInteractionRoutes = ( res.status(202).json({ session_id: pendingQuestion.session_id, request_id: requestId, - answers: parsed.data.answers, + ...(parsed.data.action === "reject" + ? { rejected: true } + : { answers: parsed.data.answers }), }); } catch (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({ - message: "question reply 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", + message: "question response route failed", detail, }); } diff --git a/src/routes/publicApi.ts b/src/routes/publicApi.ts new file mode 100644 index 0000000..31b925d --- /dev/null +++ b/src/routes/publicApi.ts @@ -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) + : {}; + const detail = + typeof record.detail === "string" + ? record.detail + : typeof record.message === "string" + ? record.message + : `Request failed with status ${status}`; + const codeByStatus: Record = { + 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; +}; diff --git a/src/server.ts b/src/server.ts index 35f4d1a..a7a8bd6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { ResultReferenceStore, } from "./results/store.js"; import { buildChatRouter } from "./routes/chat.js"; +import { buildAgentPublicRouter } from "./routes/publicApi.js"; import { opencodeRuntime } from "./runtime/opencode.js"; import { getRuntimeSessionContext, @@ -113,7 +114,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { server: config.TJWATER_API_BASE_URL, access_token: context.accessToken, project_id: context.projectId, - network: context.network, }); const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)]; @@ -402,7 +402,7 @@ app.post("/internal/tools/web-search", async (req, res) => { try { const response = await callBackendJson( - "/api/v1/web-search", + "/api/v1/web-searches", context, payload, ); @@ -445,7 +445,7 @@ app.post("/internal/tools/geocode", async (req, res) => { try { const response = await callBackendJson( - "/api/v1/tianditu/geocode", + "/api/v1/geocoding-requests", context, { keyword }, ); @@ -462,19 +462,21 @@ app.post("/internal/tools/geocode", async (req, res) => { } }); +const chatRouter = buildChatRouter( + sessionBridge, + opencodeRuntime, + sessionMetadataStore, + sessionUiStateStore, + memoryStore, + sessionTranscriptStore, + learningOrchestrator, + resultReferenceResolver, +); +const authenticatedChatRouter = express.Router(); +authenticatedChatRouter.use(requireAgentAuth, chatRouter); app.use( - "/api/v1/agent/chat", - requireAgentAuth, - buildChatRouter( - sessionBridge, - opencodeRuntime, - sessionMetadataStore, - sessionUiStateStore, - memoryStore, - sessionTranscriptStore, - learningOrchestrator, - resultReferenceResolver, - ), + "/api/v1/agent", + buildAgentPublicRouter(authenticatedChatRouter), ); const bootstrap = async () => { diff --git a/src/sessions/metadataStore.ts b/src/sessions/metadataStore.ts index 220086c..665937f 100644 --- a/src/sessions/metadataStore.ts +++ b/src/sessions/metadataStore.ts @@ -8,6 +8,7 @@ import { readJsonFile, removeFileIfExists, slugify, + toStableId, } from "../utils/fileStore.js"; export type SessionStatus = "active" | "archived"; @@ -37,6 +38,13 @@ type EnsureSessionMetadataInput = SessionMetadataContext & { parentSessionId?: string; }; +export class SessionMetadataOwnershipError extends Error { + constructor(sessionId: string) { + super(`session metadata ownership mismatch: ${sessionId}`); + this.name = "SessionMetadataOwnershipError"; + } +} + export class SessionMetadataStore { constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {} @@ -49,10 +57,13 @@ export class SessionMetadataStore { if (!sessionId) { throw new Error("sessionId is required"); } - const existing = await readJsonFile( - this.filePath(sessionId), - ); + const existing = await this.readRecord(sessionId); 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 }; } @@ -80,9 +91,8 @@ export class SessionMetadataStore { if (!normalizedSessionId) { return null; } - return await readJsonFile( - this.filePath(normalizedSessionId), - ); + const record = await this.readRecord(normalizedSessionId); + return record && matchesContext(record, context) ? record : null; } async touch( @@ -98,6 +108,7 @@ export class SessionMetadataStore { this.filePath(record.sessionId), next, ); + await removeFileIfExists(this.legacyFilePath(record.sessionId)); return next; } @@ -106,27 +117,61 @@ export class SessionMetadataStore { const records = await Promise.all( files.map((file) => readJsonFile(file)), ); - return records + const uniqueRecords = new Map(); + 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.actorKey === context.actorKey && - record.projectKey === context.projectKey, - ) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } async remove(record: SessionRecord) { - await removeFileIfExists( - this.filePath(record.sessionId), + await Promise.all( + this.filePaths(record.sessionId).map((path) => removeFileIfExists(path)), ); } private filePath(sessionId: string) { + return join( + this.baseDir, + `${slugify(sessionId)}-${toStableId(sessionId)}.json`, + ); + } + + private legacyFilePath(sessionId: string) { 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(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 normalized = value?.trim(); return normalized ? normalized.slice(0, 128) : undefined; diff --git a/tests/auth/agentAuth.test.ts b/tests/auth/agentAuth.test.ts new file mode 100644 index 0000000..55fb2f2 --- /dev/null +++ b/tests/auth/agentAuth.test.ts @@ -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 = { + 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); + }); +}); diff --git a/tests/contracts/openapi.test.ts b/tests/contracts/openapi.test.ts new file mode 100644 index 0000000..5a828d6 --- /dev/null +++ b/tests/contracts/openapi.test.ts @@ -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(); + 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"]), + ); + }); +}); diff --git a/tests/routes/publicApi.test.ts b/tests/routes/publicApi.test.ts new file mode 100644 index 0000000..a55da7f --- /dev/null +++ b/tests/routes/publicApi.test.ts @@ -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((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; + 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", + }); + }); +}); diff --git a/tests/sessions/metadataStore.test.ts b/tests/sessions/metadataStore.test.ts index 2b1436c..b6e4119 100644 --- a/tests/sessions/metadataStore.test.ts +++ b/tests/sessions/metadataStore.test.ts @@ -61,4 +61,79 @@ describe("SessionMetadataStore", () => { ); 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, + ); + }); });