diff --git a/.gitignore b/.gitignore index 59d0cc3..a4d32b1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,5 @@ __pycache__/ docker-compose.yml data/ logs/ -cli/ AGENT_HARNESS_REPORT.md HARNESS_INTRODUCTION.md diff --git a/Dockerfile b/Dockerfile index 5577775..bb22a14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,7 @@ WORKDIR /app ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=8787 -ENV TJWATER_CLI_PATH=./cli/tjwater-cli/tjwater-cli +ENV TJWATER_CLI_PATH=./cli/tjwater-cli COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules @@ -76,7 +76,7 @@ COPY .opencode ./.opencode COPY cli ./cli COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh ./cli/tjwater-cli/tjwater-cli +RUN chmod +x /entrypoint.sh ./cli/tjwater-cli ENTRYPOINT ["/entrypoint.sh"] diff --git a/cli/tjwater-cli b/cli/tjwater-cli new file mode 100755 index 0000000..c9fe518 --- /dev/null +++ b/cli/tjwater-cli @@ -0,0 +1,656 @@ +#!/usr/bin/env bun + +const SCHEMA_VERSION = "tjwater-cli/v1"; +const DEFAULT_SERVER = "http://192.168.1.114:8000"; +const DEFAULT_TIMEOUT = 180; + +const GROUPS = { + network: "管网节点、管线等基础属性查询命令。", + component: "组件选项与配置读取命令。", + simulation: "模拟运行与调度相关命令。", + analysis: "分析计算与诊断相关命令。", + data: "时序、SCADA 和方案数据查询命令。", +}; + +const NETWORK_COMMANDS = { + "get-junction-properties": { + summary: "读取节点属性成功", + path: "/getjunctionproperties/", + options: { junction: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), junction: args.junction }), + }, + "get-pipe-properties": { + summary: "读取管道属性成功", + path: "/getpipeproperties/", + options: { pipe: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pipe: args.pipe }), + }, + "get-all-pipes-properties": { + summary: "读取全部管道属性成功", + path: "/getallpipeproperties/", + params: ({ ctx }) => ({ network: requireNetwork(ctx) }), + }, + "get-reservoir-properties": { + summary: "读取水库属性成功", + path: "/getreservoirproperties/", + options: { reservoir: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), reservoir: args.reservoir }), + }, + "get-all-reservoirs-properties": { + summary: "读取全部水库属性成功", + path: "/getallreservoirproperties/", + params: ({ ctx }) => ({ network: requireNetwork(ctx) }), + }, + "get-tank-properties": { + summary: "读取水箱属性成功", + path: "/gettankproperties/", + options: { tank: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), tank: args.tank }), + }, + "get-all-tanks-properties": { + summary: "读取全部水箱属性成功", + path: "/getalltankproperties/", + params: ({ ctx }) => ({ network: requireNetwork(ctx) }), + }, + "get-pump-properties": { + summary: "读取水泵属性成功", + path: "/getpumpproperties/", + options: { pump: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), pump: args.pump }), + }, + "get-all-pumps-properties": { + summary: "读取全部水泵属性成功", + path: "/getallpumpproperties/", + params: ({ ctx }) => ({ network: requireNetwork(ctx) }), + }, + "get-valve-properties": { + summary: "读取阀门属性成功", + path: "/getvalveproperties/", + options: { valve: { required: true } }, + params: ({ args, ctx }) => ({ network: requireNetwork(ctx), valve: args.valve }), + }, + "get-all-valves-properties": { + summary: "读取全部阀门属性成功", + path: "/getallvalveproperties/", + params: ({ ctx }) => ({ network: requireNetwork(ctx) }), + }, +}; + +const DATA_REALTIME_COMMANDS = { + links: { + summary: "读取实时管道数据成功", + path: "/realtime/links", + options: { "start-time": { required: true }, "end-time": { required: true } }, + requireProject: true, + params: ({ args }) => ({ + start_time: parseTime(args["start-time"], "--start-time"), + end_time: parseTime(args["end-time"], "--end-time"), + }), + }, + nodes: { + summary: "读取实时节点数据成功", + path: "/realtime/nodes", + options: { "start-time": { required: true }, "end-time": { required: true } }, + requireProject: true, + params: ({ args }) => ({ + start_time: parseTime(args["start-time"], "--start-time"), + end_time: parseTime(args["end-time"], "--end-time"), + }), + }, + "simulation-by-id-time": { + summary: "读取实时模拟数据成功", + path: "/realtime/query/by-id-time", + options: { id: { required: true }, type: { required: true }, time: { required: true } }, + requireProject: true, + params: ({ args }) => ({ + id: args.id, + type: args.type, + query_time: parseTime(args.time, "--time"), + }), + }, + "simulation-by-time-property": { + summary: "读取实时属性聚合数据成功", + path: "/realtime/query/by-time-property", + options: { type: { required: true }, time: { required: true }, property: { required: true } }, + requireProject: true, + params: ({ args }) => ({ + type: args.type, + query_time: parseTime(args.time, "--time"), + property: args.property, + }), + }, +}; + +const DATA_SCHEME_COMMANDS = { + list: { + summary: "读取方案列表成功", + path: "/schemes", + requireProject: true, + params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }), + }, + get: { + summary: "读取方案详情成功", + path: ({ positionals }) => `/schemes/${encodeURIComponent(requirePos(positionals, 0, "scheme name"))}`, + requireProject: true, + params: ({ args }) => maybe({ scheme_type: args["scheme-type"] }), + }, + links: { + summary: "读取方案管道数据成功", + path: "/scheme/links", + options: { "start-time": { required: true }, "end-time": { required: true } }, + requireProject: true, + params: ({ args, ctx }) => ({ + scheme_name: resolveScheme(ctx, args.scheme, true), + scheme_type: args["scheme-type"] ?? "simulation", + start_time: parseTime(args["start-time"], "--start-time"), + end_time: parseTime(args["end-time"], "--end-time"), + }), + }, +}; + +const DATA_SCADA_COMMANDS = { + list: { + summary: "读取 SCADA 元数据成功", + path: "/scada/devices", + requireProject: true, + params: ({ args }) => maybe({ kind: args.kind }), + }, + query: { + summary: "读取 SCADA 时序成功", + path: ({ args }) => (args.field ? "/scada/by-ids-field-time-range" : "/scada/by-ids-time-range"), + options: { "device-id": { required: true, repeated: true }, "start-time": { required: true }, "end-time": { required: true } }, + requireProject: true, + params: ({ args }) => ({ + device_ids: toArray(args["device-id"]).join(","), + start_time: parseTime(args["start-time"], "--start-time"), + end_time: parseTime(args["end-time"], "--end-time"), + ...maybe({ field: args.field }), + }), + }, +}; + +async function main() { + const parsed = parseArgs(Bun.argv.slice(2)); + if (parsed.help || parsed.command.length === 0) { + emitHelp(parsed.command); + return 0; + } + if (parsed.command[0] === "help") { + emitHelp(parsed.command.slice(1)); + return 0; + } + + const ctx = await buildContext(parsed.global); + const startedAt = Date.now(); + const result = await dispatch(parsed, ctx); + const data = await requestJson(ctx, result); + emitSuccess({ + summary: result.summary, + data, + ctx, + durationMs: Date.now() - startedAt, + nextCommands: result.nextCommands, + }); + return 0; +} + +async function dispatch(parsed, ctx) { + const [group, ...rest] = parsed.command; + if (group === "network") { + return commandFromMap(NETWORK_COMMANDS, rest, parsed, ctx); + } + if (group === "component" && rest[0] === "option") { + return componentOption(rest.slice(1), parsed, ctx); + } + if (group === "simulation" && rest[0] === "run") { + requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } }); + const start = parseTime(parsed.args["start-time"], "--start-time"); + const duration = Number(parsed.args.duration); + const endTime = new Date(Date.parse(start) + duration * 60_000).toISOString(); + return { + summary: "触发模拟成功", + method: "POST", + path: "/runsimulationmanuallybydate/", + body: { name: requireNetwork(ctx), start_time: start, duration }, + nextCommands: [ + `tjwater-cli data timeseries realtime links --start-time ${start} --end-time ${endTime}`, + `tjwater-cli data timeseries realtime nodes --start-time ${start} --end-time ${endTime}`, + ], + }; + } + if (group === "analysis") { + return analysis(rest, parsed, ctx); + } + if (group === "data") { + return dataCommand(rest, parsed, ctx); + } + throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, { + next_commands: ["tjwater-cli help"], + }); +} + +function commandFromMap(map, commandPath, parsed, ctx) { + const name = commandPath[0]; + const spec = map[name]; + if (!spec) { + throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown command: ${parsed.command.join(" ")}`, 2, { + next_commands: ["tjwater-cli help"], + }); + } + requireOptions(parsed.args, spec.options ?? {}); + const positionals = [...commandPath.slice(1), ...parsed.positionals]; + return { + summary: spec.summary, + method: spec.method ?? "GET", + path: valueOf(spec.path, { args: parsed.args, positionals, ctx }), + params: spec.params?.({ args: parsed.args, positionals, ctx }) ?? {}, + body: spec.body?.({ args: parsed.args, positionals, ctx }), + requireProject: spec.requireProject, + nextCommands: spec.nextCommands, + }; +} + +function componentOption(rest, parsed, ctx) { + const action = rest[0]; + if (action !== "schema" && action !== "get") { + throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown component option command", 2); + } + requireOptions(parsed.args, { kind: { required: true } }); + const kind = parsed.args.kind; + const routes = { + "time:schema": "/gettimeschema", + "time:get": "/gettimeproperties/", + "energy:schema": "/getenergyschema/", + "energy:get": "/getenergyproperties/", + "pump-energy:schema": "/getpumpenergyschema/", + "pump-energy:get": "/getpumpenergyproperties//", + "network:schema": "/getoptionschema/", + "network:get": "/getoptionproperties/", + }; + const path = routes[`${kind}:${action}`]; + if (!path) { + throw cliError("CLI 参数错误", "INVALID_KIND", "--kind must be one of time, energy, pump-energy, network", 2); + } + if (kind === "pump-energy" && action === "get" && !parsed.args.pump) { + throw cliError("CLI 参数错误", "PUMP_REQUIRED", "--pump is required when --kind pump-energy", 2); + } + return { + summary: action === "schema" ? "读取选项 schema 成功" : "读取选项属性成功", + method: "GET", + path, + params: { network: requireNetwork(ctx), ...maybe({ pump: parsed.args.pump }) }, + }; +} + +function analysis(rest, parsed, ctx) { + const [domain, sub, leaf] = rest; + if (domain === "age") { + requireOptions(parsed.args, { "start-time": { required: true }, duration: { required: true } }); + return { + summary: "水龄分析执行成功", + method: "GET", + path: "/age_analysis/", + params: { + network: requireNetwork(ctx), + start_time: parseTime(parsed.args["start-time"], "--start-time"), + duration: Number(parsed.args.duration), + }, + }; + } + if (domain === "leakage" && sub === "schemes") { + return schemesCommand("漏损", "/leakage/schemes", leaf, rest[3], parsed, ctx); + } + if (domain === "burst-detection" && sub === "schemes") { + return schemesCommand("爆管检测", "/burst-detection/schemes", leaf, rest[3], parsed, ctx); + } + if (domain === "leakage" && sub === "identify") { + return detectionCommand("漏损识别执行成功", "/leakage/identify/", parsed, ctx); + } + if (domain === "burst-detection" && sub === "detect") { + return detectionCommand("爆管检测执行成功", "/burst-detection/detect/", parsed, ctx); + } + if (domain === "sensor-placement" && sub === "kmeans") { + requireOptions(parsed.args, { count: { required: true } }); + return { + summary: "传感器选址执行成功", + method: "POST", + path: "/pressure_sensor_placement_kmeans/", + body: { + name: requireNetwork(ctx), + scheme_name: resolveScheme(ctx, parsed.args.scheme, true), + sensor_number: Number(parsed.args.count), + min_diameter: Number(parsed.args["min-diameter"] ?? 0), + username: requireUsername(ctx), + }, + }; + } + throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown analysis command: ${rest.join(" ")}`, 2); +} + +function schemesCommand(label, basePath, action, inlineName, parsed, ctx) { + if (action === "list") { + return { + summary: `读取${label}方案列表成功`, + method: "GET", + path: `${basePath}/`, + params: { network: requireNetwork(ctx) }, + }; + } + if (action === "get") { + const name = inlineName ?? requirePos(parsed.positionals, 0, "scheme name"); + return { + summary: `读取${label}方案详情成功`, + method: "GET", + path: `${basePath}/${encodeURIComponent(name)}`, + params: { network: requireNetwork(ctx) }, + }; + } + throw cliError("未找到命令", "COMMAND_NOT_FOUND", "unknown schemes command", 2); +} + +function detectionCommand(summary, path, parsed, ctx) { + requireOptions(parsed.args, { "start-time": { required: true }, "end-time": { required: true } }); + return { + summary, + method: "POST", + path, + body: { + network: requireNetwork(ctx), + scada_start: parseTime(parsed.args["start-time"], "--start-time"), + scada_end: parseTime(parsed.args["end-time"], "--end-time"), + scheme_name: resolveScheme(ctx, parsed.args.scheme, true), + }, + }; +} + +function dataCommand(rest, parsed, ctx) { + if (rest[0] === "timeseries" && rest[1] === "realtime") { + return commandFromMap(DATA_REALTIME_COMMANDS, rest.slice(2), parsed, ctx); + } + if (rest[0] === "timeseries" && rest[1] === "scheme") { + return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(2), parsed, ctx); + } + if (rest[0] === "timeseries" && rest[1] === "scada") { + return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(2), parsed, ctx); + } + if (rest[0] === "scada") { + return commandFromMap(DATA_SCADA_COMMANDS, rest.slice(1), parsed, ctx); + } + if (rest[0] === "scheme") { + return commandFromMap(DATA_SCHEME_COMMANDS, rest.slice(1), parsed, ctx); + } + throw cliError("未找到命令", "COMMAND_NOT_FOUND", `unknown data command: ${rest.join(" ")}`, 2); +} + +async function requestJson(ctx, request) { + if (request.requireProject) { + requireProject(ctx); + } + const url = new URL(valueOf(request.path, { args: {}, positionals: [], ctx }), ctx.server); + for (const [key, value] of Object.entries(request.params ?? {})) { + if (value === undefined || value === null || value === "") continue; + if (Array.isArray(value)) { + for (const item of value) url.searchParams.append(key, String(item)); + continue; + } + url.searchParams.set(key, String(value)); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ctx.timeout * 1000); + const headers = { + Accept: "application/json", + "X-Request-Id": ctx.requestId, + ...ctx.auth.headers, + }; + if (ctx.auth.accessToken) headers.Authorization = `Bearer ${ctx.auth.accessToken}`; + if (ctx.auth.projectId) headers["X-Project-Id"] = ctx.auth.projectId; + if (ctx.auth.userId) headers["X-User-Id"] = ctx.auth.userId; + if (request.body !== undefined) headers["Content-Type"] = "application/json"; + + try { + const response = await fetch(url, { + method: request.method ?? "GET", + headers, + body: request.body === undefined ? undefined : JSON.stringify(request.body), + signal: controller.signal, + }); + const text = await response.text(); + let data = text; + try { + data = text ? JSON.parse(text) : null; + } catch {} + if (!response.ok) { + throw cliError("服务端请求失败", "HTTP_ERROR", `server returned HTTP ${response.status}`, response.status >= 500 ? 4 : 2, { + retryable: response.status >= 500, + data: { status: response.status, body: data }, + }); + } + return data; + } catch (error) { + if (error?.name === "AbortError") { + throw cliError("命令超时", "TIMEOUT", `request timed out after ${ctx.timeout}s`, 4, { retryable: true }); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +async function buildContext(global) { + const auth = await loadAuth(global.authStdin); + return { + server: (global.server ?? auth.server ?? DEFAULT_SERVER).replace(/\/+$/, ""), + auth, + scheme: global.scheme, + timeout: Number(global.timeout ?? DEFAULT_TIMEOUT), + requestId: global.requestId ?? crypto.randomUUID(), + }; +} + +async function loadAuth(authStdin) { + if (authStdin) { + const raw = await new Response(Bun.stdin.stream()).text(); + return normalizeAuth(JSON.parse(raw || "{}")); + } + const extraHeaders = process.env.TJWATER_EXTRA_HEADERS + ? JSON.parse(process.env.TJWATER_EXTRA_HEADERS) + : {}; + return normalizeAuth({ + server: process.env.TJWATER_SERVER, + access_token: process.env.TJWATER_ACCESS_TOKEN, + project_id: process.env.TJWATER_PROJECT_ID, + user_id: process.env.TJWATER_USER_ID, + username: process.env.TJWATER_USERNAME, + network: process.env.TJWATER_NETWORK, + headers: extraHeaders, + }); +} + +function normalizeAuth(raw) { + return { + server: pick(raw, "server", "base_url"), + accessToken: pick(raw, "access_token", "token", "accessToken"), + projectId: pick(raw, "project_id", "projectId", "x_project_id"), + userId: pick(raw, "user_id", "userId", "x_user_id"), + username: pick(raw, "username", "preferred_username"), + network: pick(raw, "network", "project_code", "projectCode", "project"), + headers: typeof raw.headers === "object" && raw.headers !== null ? raw.headers : {}, + }; +} + +function parseArgs(argv) { + const global = {}; + const command = []; + const args = {}; + const positionals = []; + let help = false; + let commandStarted = false; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--help" || token === "-h") { + help = true; + continue; + } + if (!commandStarted && token.startsWith("--")) { + const key = camel(token.slice(2)); + if (["authStdin"].includes(key)) { + global[key] = true; + } else { + global[key] = argv[++i]; + } + continue; + } + commandStarted = true; + if (token.startsWith("--")) { + const key = token.slice(2); + const next = argv[i + 1]; + const value = next && !next.startsWith("--") ? argv[++i] : true; + if (args[key] === undefined) { + args[key] = value; + } else if (Array.isArray(args[key])) { + args[key].push(value); + } else { + args[key] = [args[key], value]; + } + continue; + } + if (Object.keys(args).length === 0 && positionals.length === 0) { + command.push(token); + } else { + positionals.push(token); + } + } + return { global, command, args, positionals, help }; +} + +function requireOptions(args, spec) { + for (const [name, option] of Object.entries(spec)) { + if (option.required && (args[name] === undefined || args[name] === "")) { + throw cliError("CLI 参数错误", "MISSING_OPTION", `missing required option --${name}`, 2); + } + } +} + +function emitHelp(command = []) { + const payload = { + ok: true, + schema_version: SCHEMA_VERSION, + summary: command.length ? `命令帮助:${command.join(" ")}` : "TJWater agent CLI", + data: { + command: command.join(" "), + groups: GROUPS, + examples: [ + "tjwater-cli help", + "tjwater-cli network get-all-pipes-properties", + "tjwater-cli data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00", + "tjwater-cli analysis leakage schemes list", + ], + }, + }; + console.log(JSON.stringify(payload, null, 2)); +} + +function emitSuccess({ summary, data, ctx, durationMs, nextCommands }) { + console.log(JSON.stringify({ + ok: true, + schema_version: SCHEMA_VERSION, + summary, + data, + metadata: { + server: ctx.server, + request_id: ctx.requestId, + duration_ms: durationMs, + }, + next_commands: nextCommands, + })); +} + +function emitFailure(error) { + const payload = { + ok: false, + schema_version: SCHEMA_VERSION, + summary: error.summary ?? "命令失败", + error: { + code: error.code ?? "UNKNOWN", + message: error.message ?? String(error), + retryable: Boolean(error.retryable), + }, + data: error.data, + next_commands: error.next_commands, + }; + console.error(JSON.stringify(payload)); + return error.exitCode ?? 1; +} + +function cliError(summary, code, message, exitCode, extra = {}) { + const error = new Error(message); + error.summary = summary; + error.code = code; + error.exitCode = exitCode; + Object.assign(error, extra); + return error; +} + +function requireNetwork(ctx) { + if (ctx.auth.network) return ctx.auth.network; + throw cliError("认证失败", "NETWORK_CONTEXT_REQUIRED", "missing network in auth context", 3); +} + +function requireProject(ctx) { + if (ctx.auth.projectId) return ctx.auth.projectId; + throw cliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3); +} + +function requireUsername(ctx) { + if (ctx.auth.username) return ctx.auth.username; + throw cliError("认证失败", "USERNAME_CONTEXT_REQUIRED", "missing username in auth context", 3); +} + +function resolveScheme(ctx, explicit, required) { + const scheme = explicit ?? ctx.scheme; + if (required && !scheme) { + throw cliError("CLI 参数错误", "SCHEME_REQUIRED", "missing scheme; use --scheme", 2); + } + return scheme; +} + +function parseTime(value, optionName) { + if (!value || Number.isNaN(Date.parse(value)) || !/[zZ]|[+-]\d\d:\d\d$/.test(value)) { + throw cliError("CLI 参数错误", "INVALID_TIME", `${optionName} must be RFC3339 with timezone`, 2); + } + return value; +} + +function requirePos(values, index, name) { + const value = values[index]; + if (!value) throw cliError("CLI 参数错误", "MISSING_ARGUMENT", `missing ${name}`, 2); + return value; +} + +function pick(raw, ...keys) { + for (const key of keys) { + if (raw?.[key] !== undefined && raw[key] !== "") return raw[key]; + } + return undefined; +} + +function valueOf(value, args) { + return typeof value === "function" ? value(args) : value; +} + +function maybe(values) { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== "")); +} + +function toArray(value) { + return Array.isArray(value) ? value : value === undefined ? [] : [value]; +} + +function camel(value) { + return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +main() + .then((code) => process.exit(code)) + .catch((error) => process.exit(emitFailure(error)));