diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 0a927ba2..b1fe2040 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -991,6 +991,12 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./platform-infra-observability")).runPlatformObservabilityCommand({} as never, args.slice(2)), + platformInfraHelpSummary(), + ); + } if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); if (top === "platform-db") return platformDbHelp(); if (top === "secrets") return secretsHelp(); diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts new file mode 100644 index 00000000..064b904c --- /dev/null +++ b/scripts/src/platform-infra-observability.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { rootPath } from "./config"; +import { isRenderedCliResult } from "./output"; +import { runPlatformObservabilityCommand } from "./platform-infra-observability"; +import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; + +describe("platform-infra observability progressive disclosure", () => { + test("default plan is a bounded table while full and raw preserve the complete plan", async () => { + const compact = await runPlatformObservabilityCommand({} as never, ["plan", "--target", "NC01"]); + expect(isRenderedCliResult(compact)).toBe(true); + if (!isRenderedCliResult(compact)) throw new Error("expected rendered plan"); + expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); + expect(compact.renderedText).toContain("serviceConnections=8"); + expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); + expect(compact.renderedText).toContain("--full"); + expect(compact.renderedText).toContain("--raw"); + + for (const mode of ["--full", "--raw"]) { + const expanded = await runPlatformObservabilityCommand({} as never, ["plan", "--target", "NC01", mode]); + expect(isRenderedCliResult(expanded)).toBe(false); + expect(expanded).toMatchObject({ + action: "platform-infra-observability-plan", + mutation: false, + renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } }, + }); + expect((expanded as Record).renderPlan.instrumentation).toHaveLength(8); + } + }); + + test("real outer CLI keeps plan below the stdout dump threshold", () => { + const result = cli(["platform-infra", "observability", "plan", "--target", "NC01"]); + expect(result.status).toBe(0); + expect(Buffer.byteLength(result.stdout, "utf8")).toBeLessThan(10_240); + expect(result.stdout).toContain("platform-infra observability plan (ok)"); + expect(result.stdout).toContain("configRefs=6/6 missing=0"); + expect(result.stdout).not.toContain("outputTruncated"); + expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); + }); + + test("search, trace and diagnose-code-agent expose only scoped help", () => { + for (const operation of ["search", "trace", "diagnose-code-agent"]) { + const result = cli(["platform-infra", "observability", operation, "--help"]); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as Record; + expect(payload.data.scope).toBe(operation); + expect(payload.data.command).toBe(`platform-infra observability ${operation}`); + expect(payload.data.usage.every((line: string) => line.includes(`observability ${operation}`))).toBe(true); + expect(result.stdout).not.toContain("platform-infra sub2api"); + expect(result.stdout).not.toContain("platform-infra langbot"); + expect(result.stdout).not.toContain("outputTruncated"); + } + }); + + test("top observability help is only an operation index", () => { + const result = cli(["platform-infra", "observability", "--help"]); + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as Record; + expect(payload.data.operations.map((item: Record) => item.name)).toEqual([ + "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", + ]); + expect(payload.data.scopedHelp).toContain(" --help"); + expect(payload.data.usage).toBeUndefined(); + expect(result.stdout).not.toContain("platform-infra sub2api"); + }); + + test("configRef failures name the source and a bounded next command", () => { + const rendered = renderObservabilityPlanFailure( + new Error("config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.runtime.namespace is missing segment namespace"), + "NC01", + ); + expect(rendered.ok).toBe(false); + expect(rendered.renderedText).toContain("config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.runtime.namespace"); + expect(rendered.renderedText).toContain("rg -n lanes config/hwlab-node-lanes.yaml"); + expect(rendered.renderedText).not.toContain("/tmp/unidesk-cli-output"); + }); +}); + +function cli(args: string[]): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync("bun", ["scripts/cli.ts", ...args], { + cwd: rootPath(), + encoding: "utf8", + timeout: 30_000, + env: { ...process.env, NO_COLOR: "1" }, + }); + expect(result.stdout).not.toBe(""); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} diff --git a/scripts/src/platform-infra-observability/index.ts b/scripts/src/platform-infra-observability/index.ts index 9d441bcc..540492df 100644 --- a/scripts/src/platform-infra-observability/index.ts +++ b/scripts/src/platform-infra-observability/index.ts @@ -10,3 +10,4 @@ export * from "./trace-script"; export * from "./search-script"; export * from "./diagnose-code-agent-script"; export * from "./summary"; +export * from "./plan-render"; diff --git a/scripts/src/platform-infra-observability/options.ts b/scripts/src/platform-infra-observability/options.ts index 2fc345bb..46800fc9 100644 --- a/scripts/src/platform-infra-observability/options.ts +++ b/scripts/src/platform-infra-observability/options.ts @@ -22,37 +22,40 @@ import { import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types"; import { apply, plan, status, validate } from "./actions"; import { diagnoseCodeAgent, search, trace } from "./render"; +import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render"; -export function observabilityHelp(): Record { +const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const; + +export function observabilityHelp(scope: string | null = null): Record { + const scoped = scope === null ? null : observabilityOperationHelp(scope); + if (scoped !== null) return scoped; return { command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent", - output: "text/table by default; trace --full expands bounded diagnostic tables, --raw returns backend JSON", + output: "默认输出为有界摘要;--full/--raw 显式披露完整结构", configTruth: "config/platform-infra/observability.yaml", spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0", - usage: [ - "bun scripts/cli.ts platform-infra observability plan --target D601", - "bun scripts/cli.ts platform-infra observability plan --target D518", - "bun scripts/cli.ts platform-infra observability apply --target D601 --dry-run", - "bun scripts/cli.ts platform-infra observability apply --target D518 --confirm", - "bun scripts/cli.ts platform-infra observability status --target D601 [--full|--raw]", - "bun scripts/cli.ts platform-infra observability status --target D518 [--full|--raw]", - "bun scripts/cli.ts platform-infra observability validate --target D518 [--full|--raw]", - "bun scripts/cli.ts platform-infra observability trace --target D518 [--grep provider-stream-disconnected] [--limit 40] [--full|--raw]", - "bun scripts/cli.ts platform-infra observability search --target D518 --grep 'no rollout found' [--lookback-minutes 360] [--candidate-limit 80] [--limit 20] [--full|--raw]", - "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.stream.start --lookback-minutes 30 --limit 20", - "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.sse.directory_rewrite_enabled --lookback-minutes 30 --limit 20", - "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode-provider-proxy --lookback-minutes 30 --limit 20", - "bun scripts/cli.ts platform-infra observability search --target D518 --path /v1/workbench/sessions --status 502 [--lookback-minutes 120] [--full|--raw]", - "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --business-trace-id [--full|--raw]", - "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --run-id [--command-id ] [--session-id ] [--runner-job-id ] [--full|--raw]", - ], - boundary: "Prometheus remains the metrics source; this command owns only platform-infra OTel Collector, trace backend readiness, and trace lookup.", + operations: observabilityOperations.map((name) => ({ + name, + help: `bun scripts/cli.ts platform-infra observability ${name} --help`, + })), + scopedHelp: "bun scripts/cli.ts platform-infra observability --help", + boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel Collector、Tempo readiness 与 trace 查询。", }; } export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan"] = args; - if (action === "plan") return plan(parseCommonOptions(args.slice(1))); + if (args.some(isHelpToken)) return observabilityHelp(args.find((item) => !isHelpToken(item)) ?? null); + if (action === "plan") { + const options = parseCommonOptions(args.slice(1)); + try { + const result = plan(options); + return options.full || options.raw ? result : renderObservabilityPlan(result); + } catch (error) { + if (options.full || options.raw) throw error; + return renderObservabilityPlanFailure(error, options.targetId); + } + } if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1))); if (action === "status") return await status(config, parseCommonOptions(args.slice(1))); if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1))); @@ -62,6 +65,67 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() }; } +function isHelpToken(value: string): boolean { + return value === "help" || value === "--help" || value === "-h"; +} + +function observabilityOperationHelp(scope: string): Record | null { + const common = { + scope, + configTruth: "config/platform-infra/observability.yaml", + disclosure: "默认有界;使用 --full 查看完整计划或诊断结构,--raw 仅用于后端/API 解析排障。", + }; + if (scope === "plan") return { + ...common, + command: "platform-infra observability plan", + usage: ["bun scripts/cli.ts platform-infra observability plan --target [--full|--raw]"], + options: ["--target ", "--full", "--raw"], + }; + if (scope === "apply") return { + ...common, + command: "platform-infra observability apply", + usage: [ + "bun scripts/cli.ts platform-infra observability apply --target --dry-run", + "bun scripts/cli.ts platform-infra observability apply --target --confirm [--wait]", + ], + options: ["--target ", "--dry-run", "--confirm", "--wait", "--full", "--raw"], + }; + if (scope === "status" || scope === "validate") return { + ...common, + command: `platform-infra observability ${scope}`, + usage: [`bun scripts/cli.ts platform-infra observability ${scope} --target [--full|--raw]`], + options: ["--target ", "--full", "--raw"], + }; + if (scope === "trace") return { + ...common, + command: "platform-infra observability trace", + usage: [ + "bun scripts/cli.ts platform-infra observability trace --target [--grep ] [--limit 40] [--full|--raw]", + "bun scripts/cli.ts platform-infra observability trace --trace-id --target --full", + ], + options: ["--trace-id <32-hex>", "--target ", "--grep ", "--limit <1..500>", "--full", "--raw"], + }; + if (scope === "search") return { + ...common, + command: "platform-infra observability search", + usage: [ + "bun scripts/cli.ts platform-infra observability search --target --grep [--lookback-minutes 360] [--candidate-limit 80] [--limit 20]", + "bun scripts/cli.ts platform-infra observability search --target --path --status [--full|--raw]", + ], + options: ["--grep |--query |--path |--status ", "--target ", "--lookback-minutes ", "--end-at ", "--candidate-limit ", "--limit ", "--full", "--raw"], + }; + if (scope === "diagnose-code-agent") return { + ...common, + command: "platform-infra observability diagnose-code-agent", + usage: [ + "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target --business-trace-id [--full|--raw]", + "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target --run-id [--command-id ] [--session-id ] [--runner-job-id ]", + ], + options: ["--business-trace-id |--trace-id <32-hex>|--run-id |--command-id |--runner-job-id ", "--session-id ", "--target ", "--lookback-minutes ", "--candidate-limit ", "--limit ", "--full", "--raw"], + }; + return null; +} + export function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts new file mode 100644 index 00000000..cac6c86f --- /dev/null +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -0,0 +1,146 @@ +import type { RenderedCliResult } from "../output"; +import { formatTable, shortenEnd, textValue } from "./manifest"; +import { configLabel } from "./types"; + +export function renderObservabilityPlan(result: Record): RenderedCliResult { + const config = record(result.config); + const target = record(config.target); + const images = record(config.images); + const collector = record(config.collector); + const traceBackend = record(config.traceBackend); + const renderPlan = record(result.renderPlan); + const otlp = record(renderPlan.otlp); + const connections = records(config.serviceConnections); + const objects = records(renderPlan.objects); + const policy = records(result.policy); + const configRefs = connections.flatMap((connection) => records(connection.configRefs)); + const presentConfigRefs = configRefs.filter((item) => item.present === true); + const missingConfigRefs = configRefs.filter((item) => item.present !== true); + const failedPolicy = policy.filter((item) => item.ok === false); + const serviceNames = new Set(connections.map((item) => textValue(item.serviceName)).filter((item) => item !== "-")); + const nodes = new Set(connections.map((item) => textValue(record(item.resolved).targetNode)).filter((item) => item !== "-")); + const connectionRows = connections.map((item) => { + const resolved = record(item.resolved); + const refs = records(item.configRefs); + const present = refs.filter((ref) => ref.present === true).length; + return [ + shortenEnd(textValue(item.serviceName), 28), + textValue(resolved.targetNode), + textValue(resolved.lane), + textValue(resolved.namespace), + refs.length === 0 ? "inline" : `${present}/${refs.length}`, + String(values(item.requiredSpans).length), + ]; + }); + const objectRows = objects.map((item) => [textValue(item.kind), textValue(item.name)]); + const lines = [ + `platform-infra observability plan (${result.ok === false ? "not-ok" : "ok"})`, + "", + `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, + `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, + `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, + `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, + "", + "Service connections:", + formatTable( + ["SERVICE", "NODE", "LANE", "NAMESPACE", "CONFIG_REFS", "SPANS"], + connectionRows.length > 0 ? connectionRows : [["-", "-", "-", "-", "-", "-"]], + ), + "", + "Render objects:", + formatTable(["KIND", "NAME"], objectRows.length > 0 ? objectRows : [["-", "-"]]), + ]; + if (missingConfigRefs.length > 0) { + lines.push( + "", + "Missing configRefs:", + formatTable( + ["ID", "FILE", "PATH"], + missingConfigRefs.map((item) => [ + shortenEnd(textValue(item.id), 48), + shortenEnd(textValue(item.file), 48), + shortenEnd(textValue(item.path), 64), + ]), + ), + ); + } + if (failedPolicy.length > 0) { + lines.push( + "", + "Failed policy:", + formatTable(["NAME", "DETAIL"], failedPolicy.map((item) => [textValue(item.name), shortenEnd(textValue(item.detail), 100)])), + ); + } + lines.push( + "", + "Endpoints:", + ` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`, + ` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`, + ` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`, + "", + "Next:", + ` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`, + ` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --raw`, + ` bun scripts/cli.ts platform-infra observability status --target ${textValue(target.id)}`, + "", + "Disclosure:", + " 默认视图只展示计划摘要;--full/--raw 显式披露完整 render plan。", + ); + return { + ok: result.ok !== false, + command: "platform-infra observability plan", + contentType: "text/plain", + renderedText: lines.join("\n"), + }; +} + +export function renderObservabilityPlanFailure(error: unknown, targetId: string | null): RenderedCliResult { + const message = error instanceof Error ? error.message : String(error); + const configRef = extractConfigRef(message); + const sourcePath = configRef?.split("#")[0] ?? configLabel; + const inspectPattern = configRef?.split("#")[1]?.split(/[.[]/u)[0] ?? "instrumentation"; + const target = targetId ?? "YAML-default"; + const targetArg = targetId === null ? "" : ` --target ${targetId}`; + const lines = [ + "platform-infra observability plan (not-ok)", + "", + `target=${target}`, + `config=${configLabel}`, + `configRef=${configRef ?? "unresolved"}`, + `reason=${shortenEnd(message.replace(/\s+/gu, " ").trim(), 500)}`, + "", + "Next:", + ` rg -n ${quoteArg(inspectPattern)} ${quoteArg(sourcePath)}`, + ` bun scripts/cli.ts platform-infra observability plan${targetArg} --full`, + "", + "Disclosure:", + " 配置或 configRef 失败会直接显示来源与检查入口,不依赖 stdout dump。", + ]; + return { + ok: false, + command: "platform-infra observability plan", + contentType: "text/plain", + renderedText: lines.join("\n"), + }; +} + +function extractConfigRef(message: string): string | null { + const match = message.match(/config\/[A-Za-z0-9_./-]+[.]yaml(?:#[A-Za-z0-9_.\-[\]]+)?/u); + return match?.[0] ?? null; +} + +function quoteArg(value: string): string { + return /^[A-Za-z0-9_./:[\]-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function values(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +}