fix: render observability diagnostics as tables
This commit is contained in:
@@ -7,6 +7,12 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- P0: `scripts/cli.ts`、`scripts/src/**`、`trans`、`tran` 和轻量 helper 的 CLI 改动,除非用户明确要求,否则不做单元测试、合同测试或新增测试脚本;默认最多只做语法检查和必要的帮助/命令形态人工确认。
|
||||
- P0: 历史合同测试和 prompt lint 属于过度门禁,不再作为 CLI 变更的默认验证、派单前置或关闭依据;发现旧入口阻碍最新目标时直接删除,不做兼容保留。
|
||||
|
||||
## P0 最高优先级:CLI 输出渐进披露规则
|
||||
|
||||
- P0: CLI 默认输出必须优先采用类似 Kubernetes CLI 的简洁表格、短摘要和可复制的 drill-down 命令;JSON 只作为 `--json`、`--raw`、`--full` 或明确机器消费模式输出,禁止把大段 JSON 当作面向人的默认阅读界面。
|
||||
- P0: 看到高频 CLI 默认输出仍是 JSON、长对象、长数组或容易超过 YAML 配置阈值的噪声输出时,必须见到一个改一个,优先把该命令改成表格优先、可再展开的渐进式披露形态,而不是反复依赖 dump 后再人工抽取。
|
||||
- P0: 自动 dump 只作为防止终端爆炸的兜底能力;一旦某个常用命令反复触发 dump,必须把 warning 视为 CLI 可用性缺陷并改进命令自身输出,不能把 dump 文件路径变成长期交互入口。
|
||||
|
||||
## P0 最高优先级:自有配置 YAML 优先规则
|
||||
|
||||
- P0: UniDesk 自有配置一律优先使用 YAML(`.yaml`/`.yml`),包括 `config/` 下的运行面、平台基础设施、节点/lane、部署参数和可调版本配置;除非外部工具硬性要求 JSON/TOML/ENV 等格式,禁止新增 JSON 作为 UniDesk 自有配置真相。
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Buffer } from "node:buffer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import {
|
||||
compactCapture,
|
||||
createYamlFieldReader,
|
||||
@@ -151,7 +152,7 @@ interface DiagnoseCodeAgentOptions extends CommonOptions {
|
||||
export function observabilityHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent",
|
||||
output: "json",
|
||||
output: "text/table by default for search and diagnose-code-agent; use --full or --raw for structured JSON",
|
||||
configTruth: "config/platform-infra/observability.yaml",
|
||||
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
|
||||
usage: [
|
||||
@@ -169,7 +170,7 @@ export function observabilityHelp(): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [action = "plan"] = args;
|
||||
if (action === "plan") return plan(parseCommonOptions(args.slice(1)));
|
||||
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1)));
|
||||
@@ -638,15 +639,30 @@ async function validate(config: UniDeskConfig, options: CommonOptions): Promise<
|
||||
};
|
||||
}
|
||||
|
||||
async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown>> {
|
||||
async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
if (options.traceId === null) throw new Error("observability trace requires --trace-id <traceId>");
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const tracePath = observability.probes.traceQueryPathTemplate.replaceAll("{{traceId}}", encodeURIComponent(options.traceId));
|
||||
const result = await capture(config, target.route, ["sh"], traceScript(observability, target, tracePath, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
if (!options.full && !options.raw) {
|
||||
return renderTraceTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
tracePath,
|
||||
query: {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: tracePath,
|
||||
},
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : asPlainRecord(redactSensitiveUnknown(parsed)) ?? {},
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
ok,
|
||||
action: "platform-infra-observability-trace",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
@@ -660,7 +676,7 @@ async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Reco
|
||||
};
|
||||
}
|
||||
|
||||
async function search(config: UniDeskConfig, options: SearchOptions): Promise<Record<string, unknown>> {
|
||||
async function search(config: UniDeskConfig, options: SearchOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const endSeconds = Math.floor(Date.now() / 1000);
|
||||
@@ -675,29 +691,38 @@ async function search(config: UniDeskConfig, options: SearchOptions): Promise<Re
|
||||
const searchPath = `/api/search?${params.toString()}`;
|
||||
const result = await capture(config, target.route, ["sh"], searchScript(observability, target, searchPath, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
const query = {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: searchPath,
|
||||
grep: options.grep,
|
||||
tempoQuery,
|
||||
explicitTempoQuery: options.query,
|
||||
pathFilter: options.path,
|
||||
statusFilter: options.status,
|
||||
lookbackMinutes: options.lookbackMinutes,
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
};
|
||||
if (!options.full && !options.raw) {
|
||||
return renderSearchTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
query,
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : compactSearchResult(parsed),
|
||||
});
|
||||
}
|
||||
const exposedResult = parsed === null
|
||||
? compactCapture(result, { full: true })
|
||||
: options.full || options.raw
|
||||
? redactSensitiveUnknown(parsed)
|
||||
: redactSensitiveUnknown(compactSearchResult(parsed));
|
||||
: redactSensitiveUnknown(parsed);
|
||||
return {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
ok,
|
||||
action: "platform-infra-observability-search",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
query: {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
path: searchPath,
|
||||
grep: options.grep,
|
||||
tempoQuery,
|
||||
explicitTempoQuery: options.query,
|
||||
pathFilter: options.path,
|
||||
statusFilter: options.status,
|
||||
lookbackMinutes: options.lookbackMinutes,
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
},
|
||||
query,
|
||||
result: exposedResult,
|
||||
};
|
||||
}
|
||||
@@ -708,7 +733,7 @@ function inferSearchTempoQuery(options: SearchOptions): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAgentOptions): Promise<Record<string, unknown>> {
|
||||
async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAgentOptions): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const observability = readObservabilityConfig();
|
||||
const target = resolveTarget(observability, options.targetId);
|
||||
const endSeconds = Math.floor(Date.now() / 1000);
|
||||
@@ -725,30 +750,386 @@ async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAge
|
||||
}
|
||||
const result = await capture(config, target.route, ["sh"], diagnoseCodeAgentScript(observability, target, searchPath, options));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const ok = result.exitCode === 0 && parsed?.ok === true;
|
||||
const query = {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
businessTraceId: options.businessTraceId,
|
||||
traceId: options.traceId,
|
||||
path: searchPath,
|
||||
lookbackMinutes: options.lookbackMinutes,
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
};
|
||||
if (!options.full && !options.raw) {
|
||||
return renderDiagnoseCodeAgentTable({
|
||||
ok,
|
||||
target,
|
||||
options,
|
||||
query,
|
||||
result: parsed === null ? { ok: false, remote: compactCapture(result, { full: false }) } : compactDiagnoseCodeAgentResult(parsed),
|
||||
});
|
||||
}
|
||||
const exposedResult = parsed === null
|
||||
? compactCapture(result, { full: true })
|
||||
: options.full || options.raw
|
||||
? redactSensitiveUnknown(parsed)
|
||||
: redactSensitiveUnknown(compactDiagnoseCodeAgentResult(parsed));
|
||||
: redactSensitiveUnknown(parsed);
|
||||
return {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
ok,
|
||||
action: "platform-infra-observability-diagnose-code-agent",
|
||||
mutation: false,
|
||||
target: targetSummary(target),
|
||||
query: {
|
||||
backend: observability.traceBackend.type,
|
||||
service: observability.traceBackend.serviceName,
|
||||
businessTraceId: options.businessTraceId,
|
||||
traceId: options.traceId,
|
||||
path: searchPath,
|
||||
lookbackMinutes: options.lookbackMinutes,
|
||||
candidateLimit: options.candidateLimit,
|
||||
limit: options.limit,
|
||||
},
|
||||
query,
|
||||
result: exposedResult,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTraceTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: TraceOptions;
|
||||
tracePath: string;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const spanRows = uniqueSpanRecords([...asArray(input.result.errorSpans), ...asArray(input.result.spans)])
|
||||
.slice(0, 10)
|
||||
.map((span) => {
|
||||
return [
|
||||
shortenEnd(textValue(span.name), 48),
|
||||
shortenEnd(textValue(span.service), 28),
|
||||
spanDetail(span, 140),
|
||||
];
|
||||
});
|
||||
const countRows = asArray(input.result.spanNameCounts).slice(0, 8).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [shortenEnd(textValue(row.name), 64), textValue(row.count)];
|
||||
});
|
||||
const next = asPlainRecord(input.result.next);
|
||||
const lines = [
|
||||
`platform-infra observability trace (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "SPANS", "ERR", "MATCH", "SERVICES", "BUSINESS_TRACE"], [[
|
||||
shortenMiddle(input.options.traceId ?? "-", 20),
|
||||
textValue(input.result.spanCount),
|
||||
textValue(input.result.errorSpanCount),
|
||||
textValue(input.result.matchedSpanCount),
|
||||
joinValues(input.result.services, 44),
|
||||
joinValues(input.result.businessTraceIds, 34),
|
||||
]]),
|
||||
"",
|
||||
"Key spans:",
|
||||
formatTable(["NAME", "SERVICE", "DETAIL"], spanRows.length > 0 ? spanRows : [["-", "-", "-"]]),
|
||||
"",
|
||||
"Span name counts:",
|
||||
formatTable(["NAME", "COUNT"], countRows.length > 0 ? countRows : [["-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} backend=${textValue(input.query.backend)} service=${textValue(input.query.service)} path=${input.tracePath}`,
|
||||
` grep=${textValue(input.result.grep)} limit=${textValue(input.result.limit)} traceFound=${textValue(input.result.traceFound)} parseOk=${textValue(input.result.parseOk)}`,
|
||||
"",
|
||||
"Next:",
|
||||
];
|
||||
const nextCommands = [
|
||||
textValue(next?.fullSummary),
|
||||
textValue(next?.grepProviderStream),
|
||||
textValue(next?.raw),
|
||||
].filter((item) => item !== "-");
|
||||
if (nextCommands.length > 0) {
|
||||
for (const command of nextCommands.slice(0, 3)) lines.push(` ${command}`);
|
||||
} else {
|
||||
lines.push(` ${buildTraceCommand(input.target, input.options, true)}`);
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured span summary JSON or --raw for backend response.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability trace",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function renderSearchTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: SearchOptions;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const traces = asArray(input.result.traces).map((item) => asPlainRecord(item) ?? {});
|
||||
const rows = traces.slice(0, 12).map((trace) => [
|
||||
shortenMiddle(textValue(trace.traceId), 20),
|
||||
searchTraceStatus(trace),
|
||||
numberValue(trace.spanCount),
|
||||
numberValue(trace.errorSpanCount),
|
||||
numberValue(trace.matchedSpanCount),
|
||||
joinValues(trace.services, 46),
|
||||
]);
|
||||
const lines = [
|
||||
`platform-infra observability search (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "STATUS", "SPANS", "ERR", "MATCH", "SERVICES"], rows.length > 0 ? rows : [["-", "no-match", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} backend=${textValue(input.query.backend)} service=${textValue(input.query.service)}`,
|
||||
` grep=${textValue(input.query.grep)} path=${textValue(input.query.pathFilter)} status=${textValue(input.query.statusFilter)} tempoQuery=${shortenMiddle(textValue(input.query.tempoQuery), 80)}`,
|
||||
` lookbackMinutes=${textValue(input.query.lookbackMinutes)} candidateLimit=${textValue(input.query.candidateLimit)} limit=${textValue(input.query.limit)}`,
|
||||
` candidates=${textValue(input.result.candidateTraceCount)} scanned=${textValue(input.result.scannedTraceCount)} matched=${textValue(input.result.matchedTraceCount)} stopped=${textValue(input.result.scanStopped)}`,
|
||||
];
|
||||
const firstTraceId = traces.length > 0 ? textValue(traces[0].traceId) : "";
|
||||
lines.push("", "Next:");
|
||||
if (firstTraceId.length > 0 && firstTraceId !== "-") {
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${input.target.id} --trace-id ${firstTraceId}`);
|
||||
}
|
||||
lines.push(` ${buildSearchCommand(input.target, input.options, true)}`);
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured diagnosis JSON or trace --trace-id for one trace.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability search",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDiagnoseCodeAgentTable(input: {
|
||||
ok: boolean;
|
||||
target: ObservabilityTarget;
|
||||
options: DiagnoseCodeAgentOptions;
|
||||
query: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}): RenderedCliResult {
|
||||
const mapping = asPlainRecord(input.result.mapping);
|
||||
const identity = asPlainRecord(input.result.identity);
|
||||
const agentrun = asPlainRecord(input.result.agentrun);
|
||||
const projectionLag = asPlainRecord(input.result.projectionLag);
|
||||
const summary = asPlainRecord(input.result.summary);
|
||||
const evidence = asPlainRecord(input.result.evidence);
|
||||
const rootCauses = asArray(input.result.rootCauseCandidates).map((item) => asPlainRecord(item) ?? {});
|
||||
const http = asPlainRecord(input.result.http);
|
||||
const services = joinValues(input.result.services, 54);
|
||||
const traceId = textValue(mapping?.otelTraceId ?? input.query.traceId);
|
||||
const rootCause = textValue(summary?.rootCause ?? rootCauses[0]?.code);
|
||||
const rows = [[
|
||||
shortenMiddle(traceId, 20),
|
||||
shortenEnd(rootCause, 34),
|
||||
textValue(agentrun?.terminalStatus),
|
||||
textValue(agentrun?.runnerProviderClassification),
|
||||
textValue(projectionLag?.status),
|
||||
textValue(evidence?.errorSpanCount),
|
||||
services,
|
||||
]];
|
||||
const rootRows = rootCauses.slice(0, 5).map((candidate) => [
|
||||
shortenEnd(textValue(candidate.code), 40),
|
||||
textValue(candidate.confidence),
|
||||
shortenEnd(textValue(candidate.summary ?? candidate.label), 80),
|
||||
]);
|
||||
const httpRows = httpTableRows(http);
|
||||
const lines = [
|
||||
`platform-infra observability diagnose-code-agent (${input.ok ? "ok" : "not-ok"})`,
|
||||
"",
|
||||
formatTable(["TRACE", "ROOT_CAUSE", "TERMINAL", "RUNNER", "PROJECTION", "ERR", "SERVICES"], rows),
|
||||
"",
|
||||
"Identity:",
|
||||
` businessTraceId=${textValue(mapping?.businessTraceId ?? input.query.businessTraceId)} otelTraceId=${traceId}`,
|
||||
` runId=${textValue(identity?.runId)} commandId=${textValue(identity?.commandId)} runnerJobId=${textValue(identity?.runnerJobId)} runnerId=${textValue(identity?.runnerId)}`,
|
||||
` backendProfile=${textValue(identity?.backendProfile)} sourceCommit=${shortenMiddle(textValue(identity?.sourceCommit), 20)}`,
|
||||
"",
|
||||
"Root causes:",
|
||||
formatTable(["CODE", "CONF", "SUMMARY"], rootRows.length > 0 ? rootRows : [["-", "-", "-"]]),
|
||||
"",
|
||||
"HTTP:",
|
||||
formatTable(["METHOD", "ROUTE", "STATUS", "COUNT"], httpRows.length > 0 ? httpRows : [["-", "-", "-", "-"]]),
|
||||
"",
|
||||
"Summary:",
|
||||
` target=${input.target.id} spanCount=${textValue(input.result.spanCount)} servicePath=${joinValues(input.result.servicePath, 60)}`,
|
||||
` readModel=${shortenEnd(JSON.stringify(input.result.hwlabReadModel ?? null), 140)}`,
|
||||
];
|
||||
const next = asPlainRecord(input.result.next);
|
||||
lines.push("", "Next:");
|
||||
const nextCommands = [
|
||||
textValue(next?.diagnoseFull),
|
||||
textValue(next?.traceSummary),
|
||||
textValue(next?.traceReads),
|
||||
].filter((item) => item !== "-");
|
||||
if (nextCommands.length > 0) {
|
||||
for (const command of nextCommands.slice(0, 3)) lines.push(` ${command}`);
|
||||
} else {
|
||||
lines.push(` ${buildDiagnoseCommand(input.target, input.options, true)}`);
|
||||
if (traceId.length > 0 && traceId !== "-") lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${input.target.id} --trace-id ${traceId}`);
|
||||
}
|
||||
lines.push("", "Disclosure:");
|
||||
lines.push(" default view is a bounded table; use --full for structured diagnosis JSON or trace --grep for span-level drill-down.");
|
||||
return {
|
||||
ok: input.ok,
|
||||
command: "platform-infra observability diagnose-code-agent",
|
||||
contentType: "text/plain",
|
||||
renderedText: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function searchTraceStatus(trace: Record<string, unknown>): string {
|
||||
if (trace.queryOk === false) return "query-bad";
|
||||
if (trace.parseOk === false) return "parse-bad";
|
||||
const errorCount = numberFromUnknown(trace.errorSpanCount);
|
||||
if (errorCount > 0) return "error";
|
||||
const matchedCount = numberFromUnknown(trace.matchedSpanCount);
|
||||
if (matchedCount > 0 || trace.rawMatched === true) return "matched";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function uniqueSpanRecords(values: unknown[]): Record<string, unknown>[] {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const span = asPlainRecord(value) ?? {};
|
||||
const key = `${textValue(span.name)}\n${textValue(span.service)}\n${spanDetail(span, 200)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push(span);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function spanDetail(span: Record<string, unknown>, maxLength: number): string {
|
||||
const attrs = asPlainRecord(span.attributes);
|
||||
const parts = [
|
||||
attrPart(attrs, "failureKind"),
|
||||
attrPart(attrs, "errorSummary"),
|
||||
attrPart(attrs, "error.message"),
|
||||
attrPart(attrs, "exception.message"),
|
||||
attrPart(attrs, "terminalStatus"),
|
||||
attrPart(attrs, "status"),
|
||||
attrPart(attrs, "toolName"),
|
||||
attrPart(attrs, "exitCode"),
|
||||
attrPart(attrs, "outputSummary"),
|
||||
attrPart(attrs, "outputBytes"),
|
||||
attrPart(attrs, "durationMs"),
|
||||
attrPart(attrs, "cwd"),
|
||||
attrPart(attrs, "waitingFor"),
|
||||
attrPart(attrs, "lastEventLabel"),
|
||||
attrPart(attrs, "idleMs"),
|
||||
attrPart(attrs, "idleSeconds"),
|
||||
attrPart(attrs, "commandFingerprint"),
|
||||
attrPart(attrs, "http.route"),
|
||||
attrPart(attrs, "http.response.status_code"),
|
||||
].filter((item) => item !== null) as string[];
|
||||
if (parts.length === 0) return "-";
|
||||
return shortenEnd(parts.join(" "), maxLength);
|
||||
}
|
||||
|
||||
function attrPart(attrs: Record<string, unknown> | null, key: string): string | null {
|
||||
if (attrs === null) return null;
|
||||
const value = attrs[key];
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return `${key}=${textValue(value)}`;
|
||||
}
|
||||
|
||||
function httpTableRows(http: Record<string, unknown> | null): string[][] {
|
||||
if (http === null) return [];
|
||||
const problemRows = asArray(http.problemCounts).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [
|
||||
textValue(row.method),
|
||||
shortenEnd(textValue(row.route ?? row.path), 44),
|
||||
textValue(row.status ?? row.statusCode),
|
||||
textValue(row.count),
|
||||
];
|
||||
});
|
||||
const statusRows = asArray(http.statusCounts).map((item) => {
|
||||
const row = asPlainRecord(item) ?? {};
|
||||
return [
|
||||
textValue(row.method),
|
||||
shortenEnd(textValue(row.route ?? row.path), 44),
|
||||
textValue(row.status ?? row.statusCode),
|
||||
textValue(row.count),
|
||||
];
|
||||
});
|
||||
return [...problemRows, ...statusRows].slice(0, 8);
|
||||
}
|
||||
|
||||
function buildTraceCommand(target: ObservabilityTarget, options: TraceOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "trace", "--target", target.id];
|
||||
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
||||
if (options.grep !== null) parts.push("--grep", options.grep);
|
||||
parts.push("--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
function buildSearchCommand(target: ObservabilityTarget, options: SearchOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "search", "--target", target.id];
|
||||
if (options.grep !== null) parts.push("--grep", options.grep);
|
||||
if (options.query !== null) parts.push("--query", options.query);
|
||||
if (options.path !== null) parts.push("--path", options.path);
|
||||
if (options.status !== null) parts.push("--status", String(options.status));
|
||||
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
function buildDiagnoseCommand(target: ObservabilityTarget, options: DiagnoseCodeAgentOptions, full: boolean): string {
|
||||
const parts = ["bun", "scripts/cli.ts", "platform-infra", "observability", "diagnose-code-agent", "--target", target.id];
|
||||
if (options.businessTraceId !== null) parts.push("--business-trace-id", options.businessTraceId);
|
||||
if (options.traceId !== null) parts.push("--trace-id", options.traceId);
|
||||
parts.push("--lookback-minutes", String(options.lookbackMinutes), "--candidate-limit", String(options.candidateLimit), "--limit", String(options.limit));
|
||||
if (full) parts.push("--full");
|
||||
return parts.map(cliArg).join(" ");
|
||||
}
|
||||
|
||||
function cliArg(value: string): string {
|
||||
return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shQuote(value);
|
||||
}
|
||||
|
||||
function formatTable(headers: string[], rows: string[][]): string {
|
||||
const normalizedRows = rows.map((row) => headers.map((_, index) => row[index] ?? ""));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...normalizedRows.map((row) => row[index].length)));
|
||||
return [
|
||||
headers.map((header, index) => header.padEnd(widths[index])).join(" ").trimEnd(),
|
||||
...normalizedRows.map((row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd()),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): string {
|
||||
const number = numberFromUnknown(value);
|
||||
return Number.isFinite(number) ? String(number) : "-";
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
function joinValues(value: unknown, maxLength: number): string {
|
||||
const joined = asArray(value).map(textValue).filter((item) => item !== "-").join(",");
|
||||
return shortenEnd(joined.length > 0 ? joined : textValue(value), maxLength);
|
||||
}
|
||||
|
||||
function shortenEnd(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 1) return value.slice(0, maxLength);
|
||||
return `${value.slice(0, maxLength - 1)}~`;
|
||||
}
|
||||
|
||||
function shortenMiddle(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 3) return value.slice(0, maxLength);
|
||||
const left = Math.ceil((maxLength - 1) / 2);
|
||||
const right = Math.floor((maxLength - 1) / 2);
|
||||
return `${value.slice(0, left)}~${value.slice(value.length - right)}`;
|
||||
}
|
||||
|
||||
function compactSearchResult(value: unknown): Record<string, unknown> {
|
||||
const source = asPlainRecord(value) ?? {};
|
||||
const traces = asArray(source.traces).map(compactSearchTrace);
|
||||
@@ -822,9 +1203,9 @@ function compactDiagnoseCodeAgentResult(value: unknown): Record<string, unknown>
|
||||
selectedQuality: mapping.selectedQuality ?? null,
|
||||
selectedLowConfidence: mapping.selectedLowConfidence ?? null,
|
||||
selectedRejectedReason: mapping.selectedRejectedReason ?? null,
|
||||
selectedReasons: limitArray(mapping.selectedReasons, 8),
|
||||
selectedReasons: limitArray(mapping.selectedReasons, 4),
|
||||
selectedMeta: compactMetaRecord(mapping.selectedMeta),
|
||||
candidatePreview: asArray(mapping.candidatePreview).slice(0, 3).map(compactDiagnoseCandidate),
|
||||
bestCandidate: asArray(mapping.candidatePreview).slice(0, 1).map(compactDiagnoseCandidate)[0] ?? null,
|
||||
searchStderrTail: mapping.searchStderrTail ?? null,
|
||||
},
|
||||
tracePath: source.tracePath ?? null,
|
||||
@@ -841,7 +1222,7 @@ function compactDiagnoseCodeAgentResult(value: unknown): Record<string, unknown>
|
||||
projectionLag: source.projectionLag ?? null,
|
||||
summary: source.summary ?? null,
|
||||
rootCauseCandidates: asArray(source.rootCauseCandidates).slice(0, 5).map(compactRootCauseCandidate),
|
||||
spanNameCounts: compactNameCounts(source.spanNameCounts, 10),
|
||||
spanNameCounts: compactNameCounts(source.spanNameCounts, 6),
|
||||
evidence: evidence === null ? null : {
|
||||
httpProblemSpanCount: evidence.httpProblemSpanCount ?? null,
|
||||
terminalSpanCount: evidence.terminalSpanCount ?? null,
|
||||
@@ -852,7 +1233,7 @@ function compactDiagnoseCodeAgentResult(value: unknown): Record<string, unknown>
|
||||
idleWarningSpanCount: evidence.idleWarningSpanCount ?? null,
|
||||
idleWarningSpanTail: compactSpanList(evidence.idleWarningSpanTail, 3),
|
||||
},
|
||||
next: source.next ?? null,
|
||||
next: compactDiagnoseNext(source.next),
|
||||
stderrTail: source.stderrTail ?? null,
|
||||
disclosure: {
|
||||
defaultView: "compact diagnosis only; detailed span evidence is omitted to keep stdout below config/unidesk-cli.yaml output.maxStdoutBytes",
|
||||
@@ -866,7 +1247,7 @@ function compactDiagnoseCandidate(value: unknown): Record<string, unknown> {
|
||||
return {
|
||||
traceId: candidate.traceId ?? null,
|
||||
score: candidate.score ?? null,
|
||||
reasons: limitArray(candidate.reasons, 6),
|
||||
reasons: limitArray(candidate.reasons, 3),
|
||||
parseOk: candidate.parseOk ?? null,
|
||||
queryOk: candidate.queryOk ?? null,
|
||||
spanCount: candidate.spanCount ?? null,
|
||||
@@ -877,13 +1258,21 @@ function compactDiagnoseCandidate(value: unknown): Record<string, unknown> {
|
||||
errorSpanCount: candidate.errorSpanCount ?? null,
|
||||
candidateQuality: candidate.candidateQuality ?? null,
|
||||
lowConfidence: candidate.lowConfidence ?? null,
|
||||
rootTraceName: candidate.rootTraceName ?? null,
|
||||
rootServiceName: candidate.rootServiceName ?? null,
|
||||
spanNamePreview: limitArray(candidate.spanNamePreview, 8),
|
||||
spanNamePreview: limitArray(candidate.spanNamePreview, 5),
|
||||
traceCommand: candidate.traceCommand ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function compactDiagnoseNext(value: unknown): Record<string, unknown> | null {
|
||||
const next = asPlainRecord(value);
|
||||
if (next === null) return null;
|
||||
return {
|
||||
diagnoseFull: next.diagnoseFull ?? null,
|
||||
traceSummary: next.traceSummary ?? null,
|
||||
traceReads: next.traceReads ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function compactRootCauseCandidate(value: unknown): Record<string, unknown> {
|
||||
const candidate = asPlainRecord(value) ?? {};
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user