From 8a219087858c6c124817720cf0f7149ecbbf04e4 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 15 Jul 2026 10:44:07 +0800 Subject: [PATCH] feat(agent): add typed server API tools --- .env.example | 5 +- .opencode/agents/instruction.md | 22 +- .opencode/plugins/frontend-action-call-id.ts | 2 + .opencode/tools/get_job_status.ts | 3 + .opencode/tools/get_project_context.ts | 3 + .opencode/tools/list_monitoring_assets.ts | 3 + .opencode/tools/query_monitoring_readings.ts | 3 + .opencode/tools/server_api_shared.ts | 13 + .../tools/start_data_quality_analysis.ts | 3 + .opencode/tools/start_simulation.ts | 3 + .opencode/tools/view_history.ts | 10 +- .opencode/tools/view_scada.ts | 3 + README.md | 27 +- bun.lock | 65 + docs/agent-ui-protocol.md | 6 + openapi/openapi.json | 1579 +++++++++++++++++ opencode.json | 3 +- package.json | 3 + src/config.ts | 10 + src/frontendAction/registry.ts | 8 +- src/generated/server-api.d.ts | 1048 +++++++++++ src/results/store.ts | 2 + src/routes/chatAuxiliaryRoutes.ts | 25 + src/server.ts | 22 + src/serverApi/gateway.ts | 103 ++ src/serverApi/schemas.ts | 34 + tests/frontendAction/callIdPlugin.test.ts | 6 +- tests/frontendAction/coordinator.test.ts | 43 + tests/serverApi/gateway.test.ts | 53 + tests/serverApi/schemas.test.ts | 24 + 30 files changed, 3119 insertions(+), 15 deletions(-) create mode 100644 .opencode/tools/get_job_status.ts create mode 100644 .opencode/tools/get_project_context.ts create mode 100644 .opencode/tools/list_monitoring_assets.ts create mode 100644 .opencode/tools/query_monitoring_readings.ts create mode 100644 .opencode/tools/server_api_shared.ts create mode 100644 .opencode/tools/start_data_quality_analysis.ts create mode 100644 .opencode/tools/start_simulation.ts create mode 100644 openapi/openapi.json create mode 100644 src/generated/server-api.d.ts create mode 100644 src/serverApi/gateway.ts create mode 100644 src/serverApi/schemas.ts create mode 100644 tests/serverApi/gateway.test.ts create mode 100644 tests/serverApi/schemas.test.ts diff --git a/.env.example b/.env.example index 1f73a35..29eb3eb 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,10 @@ OPENCODE_HOSTNAME=127.0.0.1 OPENCODE_PORT=4096 AGENT_LOCAL_USER_ID=local-user -AGENT_LOCAL_PROJECT_ID=local-project +# 必须填写 Server /api/v1/projects 返回的项目 UUID。 +AGENT_LOCAL_PROJECT_ID=00000000-0000-0000-0000-000000000000 AGENT_LOCAL_NETWORK=local-network TJWATER_API_BASE_URL=http://127.0.0.1:8000 +TJWATER_API_ENABLED=true +TJWATER_AUTH_MODE=disabled diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 622b47b..047486e 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -33,8 +33,11 @@ Agent 负责: | 历史会话检索 | `session_search` | | 用户偏好和项目事实 | `memory_manager` | | 可复用流程沉淀 | `skill_manager` | - -当前实验版没有通用 TJWater 数据查询工具;如果缺少业务数据,应明确说明数据能力尚未接入,不得编造结果。 +| 项目背景 | `get_project_context` | +| 监测设备/点位 | `list_monitoring_assets` | +| 监测时序读数 | `query_monitoring_readings`(必须给出 `observed_from`、`observed_to`) | +| 数据质量分析 | `start_data_quality_analysis`、`get_job_status` | +| SWMM 仿真 | `start_simulation`、`get_job_status` | ## UI 约束 @@ -58,3 +61,18 @@ Agent 负责: ## 记忆持久化 `memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。 +## TJWater Server 受控领域工具 + +仅通过领域工具访问 Server;不得构造 URL、HTTP header、SQL、命令或项目 ID。 + +- 项目背景:`get_project_context` +- 设备/监测点:`list_monitoring_assets` +- 时序读数:`query_monitoring_readings` +- 数据质量任务:`start_data_quality_analysis`(写操作,必须等待审批) +- 仿真任务:`start_simulation`(写操作,必须等待审批) +- 任务状态:`get_job_status` + +查询历史监测数据时,必须先明确时间区间;不清楚区间时先追问,不得省略或自行扩大范围。需要数值结论时先调用 `query_monitoring_readings`,传入 `observed_from`、`observed_to`、`granularity`、`metrics` 和设备 ID,再调用 `show_chart` 或 `view_history` 展示。`view_history` 只负责打开前端历史面板,也必须传入 `start_time`、`end_time`。 +先查询数据再作判断。回答必须说明查询时间范围、粒度、指标,以及已创建任务的 ID 和状态。 +遇到空数据、拒绝、权限错误、超时或服务不可用时,明确说明事实,不得补全或猜测结果。 +工具返回 `result_ref` 时,说明完整结果已安全存储,并使用 preview 中的样本和统计作答。 diff --git a/.opencode/plugins/frontend-action-call-id.ts b/.opencode/plugins/frontend-action-call-id.ts index e11e638..6a9f5c3 100644 --- a/.opencode/plugins/frontend-action-call-id.ts +++ b/.opencode/plugins/frontend-action-call-id.ts @@ -9,4 +9,6 @@ export const frontendActionCallIdPlugin: Plugin = async () => ({ }, }); +export const server = frontendActionCallIdPlugin; + export default frontendActionCallIdPlugin; diff --git a/.opencode/tools/get_job_status.ts b/.opencode/tools/get_job_status.ts new file mode 100644 index 0000000..c2d8569 --- /dev/null +++ b/.opencode/tools/get_job_status.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "读取分析或仿真任务的状态。", args: { job_id: tool.schema.string().uuid(), job_type: tool.schema.enum(["analysis", "simulation"]) }, execute: (args, context) => callServerApiTool("get_job_status", args, context.sessionID) }); diff --git a/.opencode/tools/get_project_context.ts b/.opencode/tools/get_project_context.ts new file mode 100644 index 0000000..bbe9aa9 --- /dev/null +++ b/.opencode/tools/get_project_context.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "读取当前受控项目的基本信息。", args: {}, execute: (_args, context) => callServerApiTool("get_project_context", {}, context.sessionID) }); diff --git a/.opencode/tools/list_monitoring_assets.ts b/.opencode/tools/list_monitoring_assets.ts new file mode 100644 index 0000000..4a6adb6 --- /dev/null +++ b/.opencode/tools/list_monitoring_assets.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "列出当前项目的监测设备和监测点。", args: { include: tool.schema.enum(["devices", "points", "all"]).default("all") }, execute: (args, context) => callServerApiTool("list_monitoring_assets", args, context.sessionID) }); diff --git a/.opencode/tools/query_monitoring_readings.ts b/.opencode/tools/query_monitoring_readings.ts new file mode 100644 index 0000000..41378f5 --- /dev/null +++ b/.opencode/tools/query_monitoring_readings.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "按设备、时间范围、粒度和允许指标查询监测读数。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime(), granularity: tool.schema.enum(["5m", "1h", "1d"]).default("5m"), metrics: tool.schema.array(tool.schema.enum(["flow_mean", "flow_total", "conductivity_mean", "velocity_mean", "temperature_mean", "level_mean"])).min(1).max(6) }, execute: (args, context) => callServerApiTool("query_monitoring_readings", args, context.sessionID) }); diff --git a/.opencode/tools/server_api_shared.ts b/.opencode/tools/server_api_shared.ts new file mode 100644 index 0000000..977425e --- /dev/null +++ b/.opencode/tools/server_api_shared.ts @@ -0,0 +1,13 @@ +const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; +const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + +export const callServerApiTool = async (name: string, args: unknown, sessionID: string) => { + const response = await fetch(`${internalBaseUrl}/internal/tools/server-api/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken }, + body: JSON.stringify({ session_id: sessionID, args }), + }); + const text = await response.text(); + if (!response.ok) throw new Error("TJWater domain tool bridge rejected the request"); + return text; +}; diff --git a/.opencode/tools/start_data_quality_analysis.ts b/.opencode/tools/start_data_quality_analysis.ts new file mode 100644 index 0000000..10b49cc --- /dev/null +++ b/.opencode/tools/start_data_quality_analysis.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "创建数据质量分析任务。此操作会创建后台任务,需要用户审批。", args: { device_ids: tool.schema.array(tool.schema.string().uuid()).min(1).max(100), observed_from: tool.schema.string().datetime(), observed_to: tool.schema.string().datetime() }, execute: (args, context) => callServerApiTool("start_data_quality_analysis", args, context.sessionID) }); diff --git a/.opencode/tools/start_simulation.ts b/.opencode/tools/start_simulation.ts new file mode 100644 index 0000000..b24e08b --- /dev/null +++ b/.opencode/tools/start_simulation.ts @@ -0,0 +1,3 @@ +import { tool } from "@opencode-ai/plugin"; +import { callServerApiTool } from "./server_api_shared.js"; +export default tool({ description: "基于已注册模型版本创建仿真任务。此操作会创建后台任务,需要用户审批。", args: { model_version_id: tool.schema.string().uuid(), parameters: tool.schema.record(tool.schema.string(), tool.schema.unknown()).default({}) }, execute: (args, context) => callServerApiTool("start_simulation", args, context.sessionID) }); diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index cf01721..05d5822 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin"; import { executeFrontendAction } from "./frontend_action.js"; export default tool({ - description: "为选定的管网要素打开前端的历史记录或计算结果面板。", + description: "为选定的管网要素打开前端的历史记录或计算结果面板;必须提供 ISO8601 时间区间 start_time 和 end_time。", args: { reason: tool.schema .string() @@ -17,12 +17,12 @@ export default tool({ .describe("History data source type."), start_time: tool.schema .string() - .optional() - .describe("Optional ISO8601 start time."), + .datetime() + .describe("Required ISO8601 inclusive start time for the history query."), end_time: tool.schema .string() - .optional() - .describe("Optional ISO8601 end time."), + .datetime() + .describe("Required ISO8601 exclusive end time for the history query; must be after start_time."), }, async execute(args, context) { return executeFrontendAction("view_history", args, context); diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index 4e6ce4e..0bfa51c 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -23,6 +23,9 @@ export default tool({ .string() .optional() .describe("Optional ISO8601 end time."), + result_ref: tool.schema.string().optional().describe("Authorized large monitoring result reference."), + metrics: tool.schema.array(tool.schema.string()).optional().describe("Metrics represented by the result."), + granularity: tool.schema.enum(["5m", "1h", "1d"]).optional().describe("Reading aggregation granularity."), }, async execute(args, context) { return executeFrontendAction("view_scada", args, context); diff --git a/README.md b/README.md index 8a1411c..5abf44c 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ real auth provider through `src/context/`. - No required `Authorization` or `X-Project-Id` headers. - No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge. - Agent visual output is expressed through `agent-ui@1` UIEnvelope events. -- Backend data tools are not fully reintroduced yet; missing business data must - be reported honestly by the Agent. +- Backend business data is available through typed, allowlisted domain tools. + Arbitrary URLs, SQL, shell commands, and filesystem paths remain unavailable. ## Main API @@ -51,10 +51,26 @@ Defaults: ```text AGENT_LOCAL_USER_ID=local-user -AGENT_LOCAL_PROJECT_ID=local-project +AGENT_LOCAL_PROJECT_ID= AGENT_LOCAL_NETWORK=local-network ``` +Local domain tools are enabled by default. Set `TJWATER_API_BASE_URL` to the +Server address and `AGENT_LOCAL_PROJECT_ID` to an enabled project UUID. Setting +`TJWATER_API_ENABLED=false` disables all Server calls. Production startup rejects +enabled Server tools while `TJWATER_AUTH_MODE=disabled`. + +Available domain tools: + +- `get_project_context` +- `list_monitoring_assets` +- `query_monitoring_readings` with required `observed_from` and `observed_to` + ISO8601 timestamps. The server rejects reversed or unbounded historical + queries. +- `start_data_quality_analysis` (requires approval) +- `start_simulation` (requires approval) +- `get_job_status` + Optional request headers can override the local context during experiments: ```text @@ -78,6 +94,11 @@ Visual opencode tools are mapped by the service: - `locate_features`, `zoom_to_map`, `render_junctions`, `apply_layer_style` -> `map_action` - `view_history`, `view_scada` -> `registered_component` +`view_history` opens the historical data/result panel and must include +`start_time` and `end_time` ISO8601 timestamps. Use `query_monitoring_readings` +first when the answer needs actual monitoring values; use `view_history` only to +ask the frontend to display the selected feature history panel. + See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md). ## Development diff --git a/bun.lock b/bun.lock index 1bfc10b..7459ba8 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@types/express": "^5.0.3", "@types/node": "^24.7.2", "bun-types": "^1.3.3", + "openapi-typescript": "^7.10.1", "typescript": "^5.9.3", }, }, @@ -30,10 +31,20 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.2", "", { "dependencies": { "@ai-sdk/provider": "4.0.1", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EcmdjJb7yggsZPCbS3MFBpvAUnKaPW+QvanU5GzF00XCq0bqqAmvJ3MN19ejlmOETbW8sJNiq6qam48wTcbUNw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], + + "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], + + "@redocly/openapi-core": ["@redocly/openapi-core@1.34.17", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.2.0", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -64,14 +75,24 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ai": ["ai@7.0.8", "", { "dependencies": { "@ai-sdk/gateway": "4.0.6", "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vTEKl6fDBZ2IxBXTRaZOajf9W2Ev57Ju8iKtUvqlmDk8Z9BrEP4c22SWJsg1RcWHSFmJMSBa/s5dlUBHUq3YwA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + "brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -80,6 +101,8 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], @@ -128,6 +151,8 @@ "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], @@ -152,8 +177,12 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -162,8 +191,16 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], @@ -178,6 +215,8 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -194,12 +233,18 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], + + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], @@ -208,6 +253,8 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -224,6 +271,8 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], @@ -258,10 +307,14 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -270,6 +323,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -278,12 +333,22 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "body-parser/qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], } } diff --git a/docs/agent-ui-protocol.md b/docs/agent-ui-protocol.md index 21d8d0d..9289fad 100644 --- a/docs/agent-ui-protocol.md +++ b/docs/agent-ui-protocol.md @@ -87,6 +87,12 @@ Initial mappings: - `view_history` -> `registered_component: HistoryPanel` - `view_scada` -> `registered_component: ScadaPanel` +`view_history` parameters must include `start_time` and `end_time` ISO8601 +timestamps with `start_time < end_time`. When the Agent needs historical +monitoring values for analysis or a chart, it should call +`query_monitoring_readings` first, then use `view_history` only to open the +frontend panel. + `render_junctions` accepts only a `render_ref`. The frontend must resolve the full payload through: diff --git a/openapi/openapi.json b/openapi/openapi.json new file mode 100644 index 0000000..7554a5d --- /dev/null +++ b/openapi/openapi.json @@ -0,0 +1,1579 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Next TJWater Data API", + "version": "0.1.0" + }, + "paths": { + "/api/v1/meta/projects": { + "get": { + "tags": [ + "projects" + ], + "summary": "Projects", + "operationId": "projects_api_v1_meta_projects_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectSummaryResponse" + }, + "type": "array", + "title": "Response Projects Api V1 Meta Projects Get" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/v1/meta/project": { + "get": { + "tags": [ + "projects" + ], + "summary": "Project", + "operationId": "project_api_v1_meta_project_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectMetaResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/meta/db/health": { + "get": { + "tags": [ + "projects" + ], + "summary": "Health", + "operationId": "health_api_v1_meta_db_health_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatabaseHealthResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/monitoring/devices": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "Devices", + "operationId": "devices_api_v1_monitoring_devices_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeviceOut" + }, + "title": "Response Devices Api V1 Monitoring Devices Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/monitoring/points": { + "get": { + "tags": [ + "monitoring" + ], + "summary": "Points", + "operationId": "points_api_v1_monitoring_points_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MonitoringPointOut" + }, + "title": "Response Points Api V1 Monitoring Points Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/monitoring/readings/query": { + "post": { + "tags": [ + "monitoring" + ], + "summary": "Readings", + "operationId": "readings_api_v1_monitoring_readings_query_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadingQuery" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadingQueryResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/analyses": { + "post": { + "tags": [ + "analyses" + ], + "summary": "Create", + "operationId": "create_api_v1_analyses_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalysisCreate" + } + } + } + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/analyses/{job_id}": { + "get": { + "tags": [ + "analyses" + ], + "summary": "Get", + "operationId": "get_api_v1_analyses__job_id__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Job Id" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/analyses/{job_id}/cancel": { + "post": { + "tags": [ + "analyses" + ], + "summary": "Cancel", + "operationId": "cancel_api_v1_analyses__job_id__cancel_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Job Id" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/simulation/models": { + "post": { + "tags": [ + "simulations" + ], + "summary": "Upload", + "operationId": "upload_api_v1_simulation_models_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_api_v1_simulation_models_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelUploadOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/simulations": { + "post": { + "tags": [ + "simulations" + ], + "summary": "Create", + "operationId": "create_api_v1_simulations_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimulationCreate" + } + } + } + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/simulations/{job_id}": { + "get": { + "tags": [ + "simulations" + ], + "summary": "Get", + "operationId": "get_api_v1_simulations__job_id__get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Job Id" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jobs/{job_id}/events": { + "get": { + "tags": [ + "jobs" + ], + "summary": "Events", + "operationId": "events_api_v1_jobs__job_id__events_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Job Id" + } + }, + { + "name": "X-Project-Id", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "X-Project-Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tianditu/geocode": { + "post": { + "tags": [ + "tianditu" + ], + "summary": "Geocode", + "operationId": "geocode_api_v1_tianditu_geocode_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TiandituGeocodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TiandituGeocodeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/health/live": { + "get": { + "summary": "Live", + "operationId": "live_health_live_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Live Health Live Get" + } + } + } + } + } + } + }, + "/health/ready": { + "get": { + "summary": "Ready", + "operationId": "ready_health_ready_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Ready Health Ready Get" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AnalysisCreate": { + "properties": { + "algorithm": { + "type": "string", + "const": "data-quality", + "title": "Algorithm" + }, + "device_ids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Device Ids" + }, + "observed_from": { + "type": "string", + "format": "date-time", + "title": "Observed From" + }, + "observed_to": { + "type": "string", + "format": "date-time", + "title": "Observed To" + } + }, + "type": "object", + "required": [ + "algorithm", + "device_ids", + "observed_from", + "observed_to" + ], + "title": "AnalysisCreate" + }, + "Body_upload_api_v1_simulation_models_post": { + "properties": { + "name": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Name" + }, + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "name", + "file" + ], + "title": "Body_upload_api_v1_simulation_models_post" + }, + "DatabaseHealthResponse": { + "properties": { + "postgres": { + "type": "string", + "title": "Postgres" + }, + "timescale": { + "type": "string", + "title": "Timescale" + } + }, + "type": "object", + "required": [ + "postgres", + "timescale" + ], + "title": "DatabaseHealthResponse" + }, + "DeviceOut": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "monitoring_point_id": { + "type": "string", + "format": "uuid", + "title": "Monitoring Point Id" + }, + "external_id": { + "type": "string", + "title": "External Id" + }, + "active": { + "type": "boolean", + "title": "Active" + } + }, + "type": "object", + "required": [ + "id", + "monitoring_point_id", + "external_id", + "active" + ], + "title": "DeviceOut" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "JobOut": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "progress": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Progress" + }, + "result": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Result" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "id", + "status" + ], + "title": "JobOut" + }, + "MapExtent": { + "properties": { + "xmin": { + "type": "number", + "title": "Xmin" + }, + "ymin": { + "type": "number", + "title": "Ymin" + }, + "xmax": { + "type": "number", + "title": "Xmax" + }, + "ymax": { + "type": "number", + "title": "Ymax" + } + }, + "type": "object", + "required": [ + "xmin", + "ymin", + "xmax", + "ymax" + ], + "title": "MapExtent" + }, + "ModelUploadOut": { + "properties": { + "model_id": { + "type": "string", + "format": "uuid", + "title": "Model Id" + }, + "model_version_id": { + "type": "string", + "format": "uuid", + "title": "Model Version Id" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "sha256": { + "type": "string", + "title": "Sha256" + } + }, + "type": "object", + "required": [ + "model_id", + "model_version_id", + "version", + "sha256" + ], + "title": "ModelUploadOut" + }, + "MonitoringPointOut": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "external_id": { + "type": "string", + "title": "External Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "id", + "external_id", + "name" + ], + "title": "MonitoringPointOut" + }, + "ProjectMetaResponse": { + "properties": { + "project_id": { + "type": "string", + "format": "uuid", + "title": "Project Id" + }, + "code": { + "type": "string", + "title": "Code" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "title": "Status" + }, + "project_role": { + "type": "string", + "title": "Project Role" + }, + "gs_workspace": { + "type": "string", + "title": "Gs Workspace" + }, + "map_extent": { + "anyOf": [ + { + "$ref": "#/components/schemas/MapExtent" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "project_id", + "code", + "name", + "description", + "status", + "project_role", + "gs_workspace", + "map_extent" + ], + "title": "ProjectMetaResponse" + }, + "ProjectSummaryResponse": { + "properties": { + "project_id": { + "type": "string", + "format": "uuid", + "title": "Project Id" + }, + "code": { + "type": "string", + "title": "Code" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ], + "title": "Status" + }, + "project_role": { + "type": "string", + "title": "Project Role" + } + }, + "type": "object", + "required": [ + "project_id", + "code", + "name", + "description", + "status", + "project_role" + ], + "title": "ProjectSummaryResponse" + }, + "ReadingQuery": { + "properties": { + "device_ids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "maxItems": 100, + "minItems": 1, + "title": "Device Ids" + }, + "observed_from": { + "type": "string", + "format": "date-time", + "title": "Observed From" + }, + "observed_to": { + "type": "string", + "format": "date-time", + "title": "Observed To" + }, + "granularity": { + "type": "string", + "enum": [ + "5m", + "1h", + "1d" + ], + "title": "Granularity", + "default": "5m" + }, + "metrics": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 6, + "minItems": 1, + "title": "Metrics" + } + }, + "type": "object", + "required": [ + "device_ids", + "observed_from", + "observed_to", + "metrics" + ], + "title": "ReadingQuery" + }, + "ReadingQueryResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/ReadingRow" + }, + "type": "array", + "title": "Data" + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta" + } + }, + "type": "object", + "required": [ + "data", + "meta" + ], + "title": "ReadingQueryResponse" + }, + "ReadingRow": { + "properties": { + "device_id": { + "type": "string", + "format": "uuid", + "title": "Device Id" + }, + "observed_at": { + "type": "string", + "format": "date-time", + "title": "Observed At" + }, + "values": { + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "type": "object", + "title": "Values" + } + }, + "type": "object", + "required": [ + "device_id", + "observed_at", + "values" + ], + "title": "ReadingRow" + }, + "SimulationCreate": { + "properties": { + "model_version_id": { + "type": "string", + "format": "uuid", + "title": "Model Version Id" + }, + "parameters": { + "additionalProperties": true, + "type": "object", + "title": "Parameters" + } + }, + "type": "object", + "required": [ + "model_version_id" + ], + "title": "SimulationCreate" + }, + "TiandituGeocodeLocation": { + "properties": { + "lon": { + "type": "number", + "title": "Lon" + }, + "lat": { + "type": "number", + "title": "Lat" + }, + "level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Level" + }, + "score": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Score" + }, + "keyWord": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keyword" + } + }, + "type": "object", + "required": [ + "lon", + "lat" + ], + "title": "TiandituGeocodeLocation" + }, + "TiandituGeocodeRequest": { + "properties": { + "keyword": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Keyword" + } + }, + "type": "object", + "required": [ + "keyword" + ], + "title": "TiandituGeocodeRequest" + }, + "TiandituGeocodeResponse": { + "properties": { + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "title": "Status" + }, + "msg": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Msg" + }, + "location": { + "anyOf": [ + { + "$ref": "#/components/schemas/TiandituGeocodeLocation" + }, + { + "type": "null" + } + ] + }, + "searchVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Searchversion" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "TiandituGeocodeResponse" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "securitySchemes": { + "OAuth2PasswordBearer": { + "type": "oauth2", + "flows": { + "password": { + "scopes": {}, + "tokenUrl": "unused" + } + } + } + } + } +} diff --git a/opencode.json b/opencode.json index d1aea3d..9e19ffc 100644 --- a/opencode.json +++ b/opencode.json @@ -26,7 +26,8 @@ "curl *": "ask", "wget *": "ask" }, - "edit": "ask" + "edit": "ask", + "task": "deny" }, "plugin": [ "./.opencode/plugins/frontend-action-call-id.ts" diff --git a/package.json b/package.json index b2f5d24..5a91f99 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "typecheck:opencode": "bun run --cwd .opencode typecheck", "dev": "bun --watch src/server.ts", "build": "bun run check", + "generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts", + "check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts", "check": "bun run typecheck && bun run typecheck:opencode", "start": "bun src/server.ts", "start:prod": "bun run check && bun src/server.ts" @@ -30,5 +32,6 @@ "@types/node": "^24.7.2", "bun-types": "^1.3.3", "typescript": "^5.9.3" + ,"openapi-typescript": "^7.10.1" } } diff --git a/src/config.ts b/src/config.ts index 68a9599..8024e77 100644 --- a/src/config.ts +++ b/src/config.ts @@ -84,6 +84,9 @@ const envSchema = z OPENCODE_BASE_URL: z.string().url().optional(), // TJWater 后端 API 的基础地址。 TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"), + // 本地实验环境默认开放受控领域工具;生产环境仍由下方鉴权校验保护。 + TJWATER_API_ENABLED: optionalBoolean(true), + TJWATER_AUTH_MODE: z.enum(["disabled"]).default("disabled"), // 代理调用 TJWater 后端 API 的超时时间(毫秒)。 TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), // 后端结果在直接内联返回给模型前允许的最大字节数。 @@ -138,6 +141,13 @@ const envSchema = z .default(3600000), }) .superRefine((env, ctx) => { + if (env.NODE_ENV === "production" && env.TJWATER_API_ENABLED && env.TJWATER_AUTH_MODE === "disabled") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["TJWATER_AUTH_MODE"], + message: "TJWATER API tools cannot run without authentication in production", + }); + } if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/frontendAction/registry.ts b/src/frontendAction/registry.ts index d140a70..0f694d9 100644 --- a/src/frontendAction/registry.ts +++ b/src/frontendAction/registry.ts @@ -9,6 +9,10 @@ const timeRange = { start_time: isoTime.optional(), end_time: isoTime.optional(), }; +const requiredTimeRange = { + start_time: isoTime, + end_time: isoTime, +}; const locateInput = z.object({ ids: z.array(id).min(1).max(100), feature_type: z.enum(["junction", "pipe", "valve", "reservoir", "pump", "tank", "conduit", "orifice", "outfall"]), @@ -16,8 +20,8 @@ const locateInput = z.object({ const historyInput = z.object({ feature_infos: z.array(z.tuple([id, id])).min(1).max(100), data_type: z.enum(["realtime", "scheme", "none"]), - ...timeRange, -}).strict().refine((value) => !value.start_time || !value.end_time || Date.parse(value.start_time) <= Date.parse(value.end_time), "start_time must not be after end_time"); + ...requiredTimeRange, +}).strict().refine((value) => Date.parse(value.start_time) < Date.parse(value.end_time), "start_time must precede end_time"); const scadaInput = z.object({ device_id: id.optional(), device_ids: z.array(id).min(1).max(100).optional(), diff --git a/src/generated/server-api.d.ts b/src/generated/server-api.d.ts new file mode 100644 index 0000000..a469c10 --- /dev/null +++ b/src/generated/server-api.d.ts @@ -0,0 +1,1048 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/v1/meta/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Projects */ + get: operations["projects_api_v1_meta_projects_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/meta/project": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Project */ + get: operations["project_api_v1_meta_project_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/meta/db/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health_api_v1_meta_db_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/monitoring/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Devices */ + get: operations["devices_api_v1_monitoring_devices_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/monitoring/points": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Points */ + get: operations["points_api_v1_monitoring_points_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/monitoring/readings/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Readings */ + post: operations["readings_api_v1_monitoring_readings_query_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analyses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create */ + post: operations["create_api_v1_analyses_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analyses/{job_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get */ + get: operations["get_api_v1_analyses__job_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/analyses/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel */ + post: operations["cancel_api_v1_analyses__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/simulation/models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload */ + post: operations["upload_api_v1_simulation_models_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/simulations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create */ + post: operations["create_api_v1_simulations_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/simulations/{job_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get */ + get: operations["get_api_v1_simulations__job_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/jobs/{job_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Events */ + get: operations["events_api_v1_jobs__job_id__events_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tianditu/geocode": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Geocode */ + post: operations["geocode_api_v1_tianditu_geocode_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Live */ + get: operations["live_health_live_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health/ready": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ready */ + get: operations["ready_health_ready_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AnalysisCreate */ + AnalysisCreate: { + /** + * Algorithm + * @constant + */ + algorithm: "data-quality"; + /** Device Ids */ + device_ids: string[]; + /** + * Observed From + * Format: date-time + */ + observed_from: string; + /** + * Observed To + * Format: date-time + */ + observed_to: string; + }; + /** Body_upload_api_v1_simulation_models_post */ + Body_upload_api_v1_simulation_models_post: { + /** Name */ + name: string; + /** File */ + file: string; + }; + /** DatabaseHealthResponse */ + DatabaseHealthResponse: { + /** Postgres */ + postgres: string; + /** Timescale */ + timescale: string; + }; + /** DeviceOut */ + DeviceOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Monitoring Point Id + * Format: uuid + */ + monitoring_point_id: string; + /** External Id */ + external_id: string; + /** Active */ + active: boolean; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** JobOut */ + JobOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Status */ + status: string; + /** Progress */ + progress?: number | null; + /** Result */ + result?: { + [key: string]: unknown; + } | null; + /** Error */ + error?: string | null; + }; + /** MapExtent */ + MapExtent: { + /** Xmin */ + xmin: number; + /** Ymin */ + ymin: number; + /** Xmax */ + xmax: number; + /** Ymax */ + ymax: number; + }; + /** ModelUploadOut */ + ModelUploadOut: { + /** + * Model Id + * Format: uuid + */ + model_id: string; + /** + * Model Version Id + * Format: uuid + */ + model_version_id: string; + /** Version */ + version: number; + /** Sha256 */ + sha256: string; + }; + /** MonitoringPointOut */ + MonitoringPointOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** External Id */ + external_id: string; + /** Name */ + name: string; + }; + /** ProjectMetaResponse */ + ProjectMetaResponse: { + /** + * Project Id + * Format: uuid + */ + project_id: string; + /** Code */ + code: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + /** + * Status + * @enum {string} + */ + status: "active" | "inactive"; + /** Project Role */ + project_role: string; + /** Gs Workspace */ + gs_workspace: string; + map_extent: components["schemas"]["MapExtent"] | null; + }; + /** ProjectSummaryResponse */ + ProjectSummaryResponse: { + /** + * Project Id + * Format: uuid + */ + project_id: string; + /** Code */ + code: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + /** + * Status + * @enum {string} + */ + status: "active" | "inactive"; + /** Project Role */ + project_role: string; + }; + /** ReadingQuery */ + ReadingQuery: { + /** Device Ids */ + device_ids: string[]; + /** + * Observed From + * Format: date-time + */ + observed_from: string; + /** + * Observed To + * Format: date-time + */ + observed_to: string; + /** + * Granularity + * @default 5m + * @enum {string} + */ + granularity: "5m" | "1h" | "1d"; + /** Metrics */ + metrics: string[]; + }; + /** ReadingQueryResponse */ + ReadingQueryResponse: { + /** Data */ + data: components["schemas"]["ReadingRow"][]; + /** Meta */ + meta: { + [key: string]: unknown; + }; + }; + /** ReadingRow */ + ReadingRow: { + /** + * Device Id + * Format: uuid + */ + device_id: string; + /** + * Observed At + * Format: date-time + */ + observed_at: string; + /** Values */ + values: { + [key: string]: number | null; + }; + }; + /** SimulationCreate */ + SimulationCreate: { + /** + * Model Version Id + * Format: uuid + */ + model_version_id: string; + /** Parameters */ + parameters?: { + [key: string]: unknown; + }; + }; + /** TiandituGeocodeLocation */ + TiandituGeocodeLocation: { + /** Lon */ + lon: number; + /** Lat */ + lat: number; + /** Level */ + level?: string | null; + /** Score */ + score?: number | null; + /** Keyword */ + keyWord?: string | null; + }; + /** TiandituGeocodeRequest */ + TiandituGeocodeRequest: { + /** Keyword */ + keyword: string; + }; + /** TiandituGeocodeResponse */ + TiandituGeocodeResponse: { + /** Status */ + status: string | number; + /** Msg */ + msg?: string | null; + location?: components["schemas"]["TiandituGeocodeLocation"] | null; + /** Searchversion */ + searchVersion?: string | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + projects_api_v1_meta_projects_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectSummaryResponse"][]; + }; + }; + }; + }; + project_api_v1_meta_project_get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectMetaResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health_api_v1_meta_db_health_get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DatabaseHealthResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + devices_api_v1_monitoring_devices_get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeviceOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + points_api_v1_monitoring_points_get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MonitoringPointOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + readings_api_v1_monitoring_readings_query_post: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReadingQuery"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadingQueryResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_api_v1_analyses_post: { + parameters: { + query?: never; + header?: { + "Idempotency-Key"?: string | null; + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AnalysisCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_api_v1_analyses__job_id__get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_api_v1_analyses__job_id__cancel_post: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_api_v1_simulation_models_post: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_api_v1_simulation_models_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelUploadOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_api_v1_simulations_post: { + parameters: { + query?: never; + header?: { + "Idempotency-Key"?: string | null; + "X-Project-Id"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SimulationCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_api_v1_simulations__job_id__get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + events_api_v1_jobs__job_id__events_get: { + parameters: { + query?: never; + header?: { + "X-Project-Id"?: string | null; + }; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + geocode_api_v1_tianditu_geocode_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TiandituGeocodeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TiandituGeocodeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + live_health_live_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + ready_health_ready_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; +} diff --git a/src/results/store.ts b/src/results/store.ts index 8566eea..2113a1b 100644 --- a/src/results/store.ts +++ b/src/results/store.ts @@ -17,10 +17,12 @@ const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/; export const RESULT_REFERENCE_KIND = { renderJunctionsPayload: "render-junctions-payload", + serverApiPayload: "server_api_payload", } as const; export const RESULT_REFERENCE_SOURCE = { agentGenerated: "agent_generated", + serverApi: "server_api", } as const; export type ResultReferenceKind = diff --git a/src/routes/chatAuxiliaryRoutes.ts b/src/routes/chatAuxiliaryRoutes.ts index da8c207..c3a91c0 100644 --- a/src/routes/chatAuxiliaryRoutes.ts +++ b/src/routes/chatAuxiliaryRoutes.ts @@ -85,6 +85,31 @@ export const registerChatAuxiliaryRoutes = ( res.json(result); }); + chatRouter.get("/result-ref/:result_ref", async (req, res) => { + const resultRef = req.params.result_ref?.trim(); + const context = getLocalAgentContext(req); + const clientSessionId = typeof req.query.session_id === "string" ? req.query.session_id.trim() : undefined; + const offset = Math.max(0, Number.parseInt(String(req.query.offset ?? "0"), 10) || 0); + const limit = Math.min(500, Math.max(1, Number.parseInt(String(req.query.limit ?? "100"), 10) || 100)); + if (!resultRef || !clientSessionId) { + res.status(400).json({ message: "result_ref and session_id are required" }); + return; + } + const result = await resultReferenceResolver.getFullAuthorized(resultRef, { + actorKey: toActorKey(context.userId), clientSessionId, projectId: context.projectId, + }, { expectedKind: RESULT_REFERENCE_KIND.serverApiPayload }); + if (!result) { + res.status(404).json({ message: "result_ref not found" }); + return; + } + const rows = Array.isArray(result.data) + ? result.data + : result.data && typeof result.data === "object" && Array.isArray((result.data as { data?: unknown }).data) + ? (result.data as { data: unknown[] }).data + : [result.data]; + res.json({ ...result, data: rows.slice(offset, offset + limit), page: { offset, limit, total: rows.length, has_more: offset + limit < rows.length } }); + }); + chatRouter.post("/abort", async (req, res) => { const parsed = abortPayloadSchema.safeParse(req.body); if (!parsed.success) { diff --git a/src/server.ts b/src/server.ts index 6d44715..902d373 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,8 @@ import { import { buildChatRouter } from "./routes/chat.js"; import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js"; import { opencodeRuntime } from "./runtime/opencode.js"; +import { ServerApiGateway } from "./serverApi/gateway.js"; +import { isDomainToolName } from "./serverApi/schemas.js"; import { getRuntimeSessionContext, type RuntimeSessionContext, @@ -38,6 +40,7 @@ const learningOrchestrator = new LearningOrchestrator( ); const resultReferenceStore = new ResultReferenceStore(); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); +const serverApiGateway = new ServerApiGateway(resultReferenceResolver); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const frontendActionCoordinator = new FrontendActionCoordinator(); @@ -47,6 +50,25 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; app.use(cors()); app.use(express.json({ limit: "1mb" })); +app.post("/internal/tools/server-api/:tool", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + const name = req.params.tool; + if (!isDomainToolName(name)) { + res.status(404).json({ message: "unknown domain tool" }); + return; + } + const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? getRuntimeSessionContext(sessionId) : null; + if (!context) { + res.status(404).json({ message: "session context not found" }); + return; + } + res.json(await serverApiGateway.execute(name, req.body?.args ?? {}, context)); +}); + app.post("/internal/frontend-actions/request", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); diff --git a/src/serverApi/gateway.ts b/src/serverApi/gateway.ts new file mode 100644 index 0000000..7cbfe43 --- /dev/null +++ b/src/serverApi/gateway.ts @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +import { config } from "../config.js"; +import { ResultReferenceResolver } from "../results/resolver.js"; +import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js"; +import type { RuntimeSessionContext } from "../runtime/sessionContext.js"; +import { domainToolSchemas, type DomainToolName } from "./schemas.js"; + +export type GatewayErrorCode = "VALIDATION_FAILED" | "NOT_FOUND" | "UPSTREAM_UNAVAILABLE" | "TIMEOUT" | "PROJECT_CONTEXT_INVALID"; +type Fetch = typeof fetch; + +const responseSchemas: Record = { + get_project_context: z.object({ + project_id: z.string().uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + status: z.enum(["active", "inactive"]), + project_role: z.string(), + gs_workspace: z.string(), + map_extent: z.object({ xmin: z.number(), ymin: z.number(), xmax: z.number(), ymax: z.number() }).nullable(), + }), + list_monitoring_assets: z.object({ devices: z.array(z.unknown()).optional(), points: z.array(z.unknown()).optional() }), + query_monitoring_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }), + start_data_quality_analysis: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(), + start_simulation: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(), + get_job_status: z.object({ id: z.string().uuid(), status: z.string() }).passthrough(), +}; + +export class ServerApiGateway { + constructor(private readonly resolver: ResultReferenceResolver, private readonly fetchImpl: Fetch = fetch) {} + + async execute(name: DomainToolName, rawArgs: unknown, context: RuntimeSessionContext) { + if (!config.TJWATER_API_ENABLED) return failure("UPSTREAM_UNAVAILABLE", "TJWater API tools are disabled"); + if (!context.projectId || !z.string().uuid().safeParse(context.projectId).success) { + return failure("PROJECT_CONTEXT_INVALID", "project context is unavailable or invalid"); + } + const parsed = domainToolSchemas[name].safeParse(rawArgs); + if (!parsed.success) return failure("VALIDATION_FAILED", "tool parameters are invalid", parsed.error.flatten()); + try { + const data = await this.dispatch(name, parsed.data, context); + const validated = responseSchemas[name].safeParse(data); + if (!validated.success) return failure("UPSTREAM_UNAVAILABLE", "upstream returned an invalid response"); + const bytes = Buffer.byteLength(JSON.stringify(validated.data)); + if (bytes <= config.MAX_INLINE_RESULT_BYTES) return { ok: true, data: validated.data, truncated: false }; + const record = await this.resolver.register({ + actorKey: context.actorKey, clientSessionId: context.clientSessionId, data: validated.data, + kind: RESULT_REFERENCE_KIND.serverApiPayload, projectId: context.projectId, + projectKey: context.projectKey, schemaVersion: 1, sessionId: context.clientSessionId, + source: RESULT_REFERENCE_SOURCE.serverApi, traceId: context.traceId, + }); + return { ok: true, result_ref: record.resultRef, preview: record.preview, result_size_bytes: bytes, truncated: true }; + } catch (error) { + if (error instanceof GatewayHttpError) { + if (error.status === 404) return failure("NOT_FOUND", "requested resource was not found"); + return failure("UPSTREAM_UNAVAILABLE", `upstream request failed (${error.status})`); + } + if (error instanceof DOMException && error.name === "AbortError") return failure("TIMEOUT", "upstream request timed out"); + return failure("UPSTREAM_UNAVAILABLE", "upstream service is unavailable"); + } + } + + private async dispatch(name: DomainToolName, args: any, context: RuntimeSessionContext): Promise { + if (name === "list_monitoring_assets") { + const result: Record = {}; + if (args.include !== "points") result.devices = await this.request("GET", "/api/v1/monitoring/devices", undefined, context); + if (args.include !== "devices") result.points = await this.request("GET", "/api/v1/monitoring/points", undefined, context); + return result; + } + const routes = { + get_project_context: ["GET", "/api/v1/meta/project"], + query_monitoring_readings: ["POST", "/api/v1/monitoring/readings/query"], + start_data_quality_analysis: ["POST", "/api/v1/analyses"], + start_simulation: ["POST", "/api/v1/simulations"], + get_job_status: ["GET", args.job_type === "analysis" ? `/api/v1/analyses/${args.job_id}` : `/api/v1/simulations/${args.job_id}`], + } as const; + const [method, path] = routes[name]; + const body = name === "start_data_quality_analysis" ? { ...args, algorithm: "data-quality" } : method === "POST" ? args : undefined; + const idempotencyKey = name.startsWith("start_") ? createIdempotencyKey(context, name, body) : undefined; + return this.request(method, path, body, context, idempotencyKey); + } + + private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext, idempotencyKey?: string) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS); + try { + const response = await this.fetchImpl(new URL(path, config.TJWATER_API_BASE_URL), { + method, signal: controller.signal, headers: { + Accept: "application/json", "Content-Type": "application/json", "X-Project-Id": context.projectId!, + "X-Request-Id": context.traceId, ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), + }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) throw new GatewayHttpError(response.status); + return await response.json(); + } finally { clearTimeout(timer); } + } +} + +class GatewayHttpError extends Error { constructor(readonly status: number) { super("upstream request failed"); } } +const failure = (code: GatewayErrorCode, message: string, detail?: unknown) => ({ ok: false, error: { code, message, ...(detail ? { detail } : {}) } }); +export const createIdempotencyKey = (context: RuntimeSessionContext, name: string, args: unknown) => createHash("sha256").update(JSON.stringify([context.clientSessionId, name, canonicalize(args)])).digest("hex"); +const canonicalize = (value: unknown): unknown => Array.isArray(value) ? value.map(canonicalize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, canonicalize(entry)])) : value; diff --git a/src/serverApi/schemas.ts b/src/serverApi/schemas.ts new file mode 100644 index 0000000..0cc3fef --- /dev/null +++ b/src/serverApi/schemas.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const uuid = z.string().uuid(); +const dateTime = z.string().datetime({ offset: true }); +const metrics = z.enum([ + "flow_mean", "flow_total", "conductivity_mean", "velocity_mean", + "temperature_mean", "level_mean", +]); + +export const domainToolSchemas = { + get_project_context: z.object({}), + list_monitoring_assets: z.object({ include: z.enum(["devices", "points", "all"]).default("all") }), + query_monitoring_readings: z.object({ + device_ids: z.array(uuid).min(1).max(100), + observed_from: dateTime, + observed_to: dateTime, + granularity: z.enum(["5m", "1h", "1d"]).default("5m"), + metrics: z.array(metrics).min(1).max(6), + }).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), { + message: "observed_from must precede observed_to", + }), + start_data_quality_analysis: z.object({ + device_ids: z.array(uuid).min(1).max(100), + observed_from: dateTime, + observed_to: dateTime, + }).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), { + message: "observed_from must precede observed_to", + }), + start_simulation: z.object({ model_version_id: uuid, parameters: z.record(z.unknown()).default({}) }), + get_job_status: z.object({ job_id: uuid, job_type: z.enum(["analysis", "simulation"]) }), +} as const; + +export type DomainToolName = keyof typeof domainToolSchemas; +export const isDomainToolName = (value: string): value is DomainToolName => value in domainToolSchemas; diff --git a/tests/frontendAction/callIdPlugin.test.ts b/tests/frontendAction/callIdPlugin.test.ts index 23d9b2e..f20439a 100644 --- a/tests/frontendAction/callIdPlugin.test.ts +++ b/tests/frontendAction/callIdPlugin.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js"; +import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin, server } from "../../.opencode/plugins/frontend-action-call-id.js"; import { frontendActionRegistry } from "../../src/frontendAction/registry.js"; const frontendActionToolNames = () => readdirSync(".opencode/tools") @@ -17,6 +17,10 @@ describe("frontend action call ID plugin", () => { expect(config.plugin).toContain("./.opencode/plugins/frontend-action-call-id.ts"); }); + it("exposes the server export required by OpenCode", () => { + expect(server).toBe(frontendActionCallIdPlugin); + }); + it("overwrites forged IDs only for allowlisted tools", async () => { const hooks = await frontendActionCallIdPlugin({} as never); const before = hooks["tool.execute.before"]; diff --git a/tests/frontendAction/coordinator.test.ts b/tests/frontendAction/coordinator.test.ts index c67494f..998c7cf 100644 --- a/tests/frontendAction/coordinator.test.ts +++ b/tests/frontendAction/coordinator.test.ts @@ -44,4 +44,47 @@ describe("FrontendActionCoordinator", () => { coordinator.registerSink("s", () => undefined); expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action"); }); + + it("requires a valid time range when opening history data", async () => { + const coordinator = new FrontendActionCoordinator(); + let emitted: FrontendActionRequest | undefined; + coordinator.registerSink("s", (request) => { emitted = request; }); + expect(() => coordinator.request({ + sessionId: "s", + toolCallId: "c", + name: "view_history", + params: { feature_infos: [["node-1", "junction"]], data_type: "realtime" }, + })).toThrow("invalid frontend action params"); + expect(() => coordinator.request({ + sessionId: "s", + toolCallId: "c", + name: "view_history", + params: { + feature_infos: [["node-1", "junction"]], + data_type: "realtime", + start_time: "2026-07-14T00:00:00Z", + end_time: "2026-07-14T00:00:00Z", + }, + })).toThrow("invalid frontend action params"); + const pending = coordinator.request({ + sessionId: "s", + toolCallId: "c", + name: "view_history", + params: { + feature_infos: [["node-1", "junction"]], + data_type: "realtime", + start_time: "2026-07-14T00:00:00Z", + end_time: "2026-07-14T01:00:00Z", + }, + }); + if (!emitted) throw new Error("request was not emitted"); + coordinator.submitResult("s", { + version: "frontend-action-result@1", + actionId: emitted.actionId, + status: "succeeded", + output: { component: "HistoryPanel", rendered: true, itemCount: 1 }, + completedAt: Date.now(), + }); + await expect(pending).resolves.toMatchObject({ status: "succeeded" }); + }); }); diff --git a/tests/serverApi/gateway.test.ts b/tests/serverApi/gateway.test.ts new file mode 100644 index 0000000..dfaaaf3 --- /dev/null +++ b/tests/serverApi/gateway.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import { ServerApiGateway } from "../../src/serverApi/gateway.js"; + +const context = { + actorKey: "user", + clientSessionId: "session", + projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73", + projectKey: "project", + sessionId: "runtime", + traceId: "trace", +}; + +describe("ServerApiGateway project context", () => { + test("uses the metadata path and accepts project_id responses", async () => { + let requestedPath = ""; + const fetchImpl = (async (input: URL | RequestInfo) => { + requestedPath = new URL(input.toString()).pathname; + return Response.json({ + project_id: context.projectId, + code: "lingang", + name: "临港排水", + description: null, + status: "active", + project_role: "owner", + gs_workspace: "lingang", + map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 }, + }); + }) as typeof fetch; + const gateway = new ServerApiGateway({} as never, fetchImpl); + + const result = await gateway.execute("get_project_context", {}, context); + + expect(requestedPath).toBe("/api/v1/meta/project"); + expect(result).toMatchObject({ + ok: true, + data: { project_id: context.projectId, status: "active", project_role: "owner" }, + }); + }); + + test("rejects the removed id response shape", async () => { + const fetchImpl = (async () => + Response.json({ id: context.projectId, code: "lingang", name: "临港排水" })) as unknown as typeof fetch; + const gateway = new ServerApiGateway({} as never, fetchImpl); + + const result = await gateway.execute("get_project_context", {}, context); + + expect(result).toMatchObject({ + ok: false, + error: { code: "UPSTREAM_UNAVAILABLE" }, + }); + }); +}); diff --git a/tests/serverApi/schemas.test.ts b/tests/serverApi/schemas.test.ts new file mode 100644 index 0000000..66ee038 --- /dev/null +++ b/tests/serverApi/schemas.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { domainToolSchemas } from "../../src/serverApi/schemas.js"; +import { createIdempotencyKey } from "../../src/serverApi/gateway.js"; + +const context = { + actorKey: "user", clientSessionId: "session", projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73", + projectKey: "project", sessionId: "runtime", traceId: "trace", +}; + +describe("domain tool validation", () => { + test("rejects unknown metrics and reversed time", () => { + const base = { device_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" }; + expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["pressure"] }).success).toBeFalse(); + expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse(); + }); + + test("rejects invalid UUID before dispatch", () => { + expect(domainToolSchemas.start_simulation.safeParse({ model_version_id: "bad", parameters: {} }).success).toBeFalse(); + }); + + test("idempotency key ignores object key order", () => { + expect(createIdempotencyKey(context, "start_simulation", { b: 2, a: 1 })).toBe(createIdempotencyKey(context, "start_simulation", { a: 1, b: 2 })); + }); +});