fix cli output dumping and otel web-probe diagnostics
This commit is contained in:
@@ -7,6 +7,13 @@ 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: 超过 YAML 配置阈值的 CLI stdout 没有被自动 dump 是比单个命令输出 JSON 更严重的全局可见性事故;dump 兜底必须对所有 `emitJson`/`emitText` 全局生效,不能按命令白名单选择性启用,也不能让超长输出无缓冲直冲上下文。
|
||||
- P0: 自动 dump 只作为防止终端爆炸的兜底能力;一旦某个常用命令反复触发 dump,必须把 warning 视为 CLI 可用性缺陷并改进命令自身输出,不能把 dump 文件路径变成长期交互入口。
|
||||
|
||||
## P0 最高优先级:自有配置 YAML 优先规则
|
||||
|
||||
- P0: UniDesk 自有配置一律优先使用 YAML(`.yaml`/`.yml`),包括 `config/` 下的运行面、平台基础设施、节点/lane、部署参数和可调版本配置;除非外部工具硬性要求 JSON/TOML/ENV 等格式,禁止新增 JSON 作为 UniDesk 自有配置真相。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
output:
|
||||
dump:
|
||||
enabled: true
|
||||
thresholdBytes: 10240
|
||||
previewChars: 2000
|
||||
dir: /tmp/unidesk-cli-output
|
||||
@@ -290,6 +290,11 @@ async function main(): Promise<void> {
|
||||
if (top === "gh") {
|
||||
const result = await runGhCommand(args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
@@ -316,6 +321,11 @@ async function main(): Promise<void> {
|
||||
const { runHwlabNodeCommand } = await import("./src/hwlab-node");
|
||||
const result = await runHwlabNodeCommand(readConfig(), args.slice(2));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
|
||||
+176
-22
@@ -4703,6 +4703,160 @@ function compactCommentSummary(comment: GitHubComment): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function compactGhValue(value: unknown, maxLength = 96): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
const singleLine = text.replace(/\s+/gu, " ").trim();
|
||||
if (singleLine.length <= maxLength) return singleLine.length === 0 ? "-" : singleLine;
|
||||
return `${singleLine.slice(0, Math.max(1, maxLength - 1))}...`;
|
||||
}
|
||||
|
||||
function shortGhSha(value: unknown): string {
|
||||
const text = typeof value === "string" ? value : "";
|
||||
if (text.length <= 12) return text || "-";
|
||||
return text.slice(0, 12);
|
||||
}
|
||||
|
||||
function renderGhTable(headers: string[], rows: unknown[][]): string {
|
||||
const stringRows = rows.map((row) => row.map((value) => compactGhValue(value)));
|
||||
const widths = headers.map((header, index) => Math.max(
|
||||
header.length,
|
||||
...stringRows.map((row) => row[index]?.length ?? 0),
|
||||
));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...stringRows.map(renderRow)].join("\n");
|
||||
}
|
||||
|
||||
function withRenderedGhResult(result: GitHubCommandResult, renderedText: string): GitHubCommandResult {
|
||||
return {
|
||||
...result,
|
||||
renderedText,
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function renderIssueCreateResult(result: GitHubCommandResult): GitHubCommandResult {
|
||||
const issue = isRecord(result.issue) ? result.issue : {};
|
||||
const bodySource = isRecord(result.bodySource) ? result.bodySource : {};
|
||||
const status = result.dryRun === true ? "planned" : "created";
|
||||
const renderedText = [
|
||||
renderGhTable(
|
||||
["COMMAND", "REPO", "STATUS", "ISSUE", "TITLE", "URL"],
|
||||
[[result.command, result.repo, status, issue.number ?? "-", issue.title ?? result.title ?? "-", issue.url ?? "-"]],
|
||||
),
|
||||
"",
|
||||
renderGhTable(
|
||||
["BODY", "CHARS", "SHA", "SOURCE"],
|
||||
[["body", issue.bodyChars ?? result.bodyChars ?? "-", shortGhSha(issue.bodySha ?? result.bodySha), bodySource.kind ?? result.source ?? "-"]],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` read: bun scripts/cli.ts gh issue view ${issue.number ?? "<issue>"} --repo ${result.repo}`,
|
||||
` full: bun scripts/cli.ts gh issue view ${issue.number ?? "<issue>"} --repo ${result.repo} --full`,
|
||||
].join("\n");
|
||||
return withRenderedGhResult(result, renderedText);
|
||||
}
|
||||
|
||||
function renderIssueCommentCreateResult(result: GitHubCommandResult): GitHubCommandResult {
|
||||
const comment = isRecord(result.comment) ? result.comment : {};
|
||||
const status = result.dryRun === true ? "planned" : "created";
|
||||
const target = result.issueNumber === undefined ? "-" : `#${result.issueNumber}`;
|
||||
const renderedText = [
|
||||
renderGhTable(
|
||||
["COMMAND", "REPO", "TARGET", "STATUS", "COMMENT_ID", "URL"],
|
||||
[[result.command, result.repo, target, status, comment.id ?? "-", comment.url ?? "-"]],
|
||||
),
|
||||
"",
|
||||
renderGhTable(
|
||||
["BODY", "CHARS", "SHA", "SOURCE"],
|
||||
[["body", result.bodyChars ?? comment.bodyChars ?? "-", shortGhSha(result.bodySha ?? comment.bodySha), result.source ?? "-"]],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` comments: bun scripts/cli.ts gh issue view ${result.issueNumber ?? "<issue>"} --repo ${result.repo} --json comments`,
|
||||
].join("\n");
|
||||
return withRenderedGhResult(result, renderedText);
|
||||
}
|
||||
|
||||
function renderPrCreateResult(result: GitHubCommandResult): GitHubCommandResult {
|
||||
const pr = isRecord(result.pr) ? result.pr : {};
|
||||
const validation = isRecord(result.validation) ? result.validation : {};
|
||||
const compare = isRecord(validation.compare) ? validation.compare : {};
|
||||
const status = result.dryRun === true ? "planned" : "created";
|
||||
const renderedText = [
|
||||
renderGhTable(
|
||||
["COMMAND", "REPO", "STATUS", "PR", "TITLE", "BASE", "HEAD", "URL"],
|
||||
[[result.command, result.repo, status, pr.number ?? "-", pr.title ?? result.title ?? "-", pr.baseRefName ?? result.base ?? "-", pr.headRefName ?? result.head ?? "-", pr.url ?? "-"]],
|
||||
),
|
||||
"",
|
||||
renderGhTable(
|
||||
["CHECK", "VALUE"],
|
||||
[
|
||||
["aheadBy", compare.aheadBy ?? "-"],
|
||||
["draft", result.draft ?? pr.draft ?? "-"],
|
||||
["bodySource", isRecord(validation.bodySource) ? validation.bodySource.kind ?? "-" : "-"],
|
||||
],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` view: bun scripts/cli.ts gh pr view ${pr.number ?? "<pr>"} --repo ${result.repo}`,
|
||||
` preflight: bun scripts/cli.ts gh pr preflight ${pr.number ?? "<pr>"} --repo ${result.repo}`,
|
||||
].join("\n");
|
||||
return withRenderedGhResult(result, renderedText);
|
||||
}
|
||||
|
||||
function renderPrCommentCreateResult(result: GitHubCommandResult): GitHubCommandResult {
|
||||
const comment = isRecord(result.comment) ? result.comment : {};
|
||||
const pr = isRecord(result.pr) ? result.pr : {};
|
||||
const status = result.dryRun === true ? "planned" : "created";
|
||||
const target = result.issueNumber === undefined ? "-" : `#${result.issueNumber}`;
|
||||
const renderedText = [
|
||||
renderGhTable(
|
||||
["COMMAND", "REPO", "PR", "STATUS", "COMMENT_ID", "URL"],
|
||||
[[result.command, result.repo, target, status, comment.id ?? "-", comment.url ?? "-"]],
|
||||
),
|
||||
"",
|
||||
renderGhTable(
|
||||
["BODY", "CHARS", "SHA", "SOURCE"],
|
||||
[["body", result.bodyChars ?? "-", shortGhSha(result.bodySha), result.source ?? "-"]],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` view: bun scripts/cli.ts gh pr view ${pr.number ?? result.issueNumber ?? "<pr>"} --repo ${result.repo}`,
|
||||
].join("\n");
|
||||
return withRenderedGhResult(result, renderedText);
|
||||
}
|
||||
|
||||
function renderPrMergeResult(result: GitHubCommandResult): GitHubCommandResult {
|
||||
const pr = isRecord(result.pullRequest) ? result.pullRequest : {};
|
||||
const mergeability = isRecord(result.mergeability) ? result.mergeability : {};
|
||||
const branchDeletion = isRecord(result.branchDeletion) ? result.branchDeletion : {};
|
||||
const status = result.alreadyMerged === true
|
||||
? "already-merged"
|
||||
: result.dryRun === true
|
||||
? "planned"
|
||||
: "merged";
|
||||
const renderedText = [
|
||||
renderGhTable(
|
||||
["COMMAND", "REPO", "PR", "STATUS", "METHOD", "TITLE", "URL"],
|
||||
[[result.command, result.repo, result.number ?? pr.number ?? "-", status, result.method ?? "-", pr.title ?? "-", pr.url ?? "-"]],
|
||||
),
|
||||
"",
|
||||
renderGhTable(
|
||||
["CHECK", "VALUE"],
|
||||
[
|
||||
["mergeability", mergeability.conclusion ?? (result.alreadyMerged === true ? "already-merged" : "-")],
|
||||
["deleteBranch", branchDeletion.attempted === true ? "attempted" : branchDeletion.skippedReason ?? result.deleteBranch ?? "-"],
|
||||
["mergedAt", pr.mergedAt ?? "-"],
|
||||
],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` view: bun scripts/cli.ts gh pr view ${result.number ?? pr.number ?? "<pr>"} --repo ${result.repo}`,
|
||||
].join("\n");
|
||||
return withRenderedGhResult(result, renderedText);
|
||||
}
|
||||
|
||||
function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
|
||||
if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
|
||||
return pr.state === "closed" ? "closed" : "open";
|
||||
@@ -5131,7 +5285,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
if (isGitHubError(pr)) return commandError("pr merge", repo, pr, { number, phase: "fetch-pr" });
|
||||
const summary = prSummary(pr);
|
||||
if (summary.merged === true) {
|
||||
return {
|
||||
return renderPrMergeResult({
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
@@ -5141,7 +5295,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
pullRequest: summary,
|
||||
branchDeletion: { attempted: false, skippedReason: "already-merged" },
|
||||
rest: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
const metadata = await prGraphqlMetadata(repo, token, number);
|
||||
if (isGitHubError(metadata)) return commandError("pr merge", repo, metadata, { number, phase: "fetch-pr-closeout-metadata", pullRequest: summary });
|
||||
@@ -5157,7 +5311,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
});
|
||||
}
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
return renderPrMergeResult({
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
@@ -5169,7 +5323,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
pullRequest: preflightPullRequestSummary(summary),
|
||||
mergeability,
|
||||
statusChecks,
|
||||
};
|
||||
});
|
||||
}
|
||||
const merged = await githubRequest<Record<string, unknown>>(token, "PUT", `/repos/${owner}/${name}/pulls/${number}/merge`, {
|
||||
merge_method: options.mergeMethod,
|
||||
@@ -5178,7 +5332,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
const after = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
||||
if (isGitHubError(after)) return commandError("pr merge", repo, after, { number, phase: "fetch-after-merge", mergeResult: merged });
|
||||
const branchDeletion = options.deleteBranch ? await deleteHeadBranchAfterMerge(repo, token, after) : { attempted: false, skippedReason: "delete-branch-not-requested" };
|
||||
return {
|
||||
return renderPrMergeResult({
|
||||
ok: true,
|
||||
command: "pr merge",
|
||||
repo,
|
||||
@@ -5188,7 +5342,7 @@ async function prMerge(repo: string, token: string, number: number, options: Git
|
||||
pullRequest: prSummary(after),
|
||||
branchDeletion,
|
||||
rest: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function prPreflight(repo: string, number: number, commandName: "preflight" | "pr preflight" | "pr closeout", includeRaw: boolean): Promise<GitHubCommandResult> {
|
||||
@@ -5407,7 +5561,7 @@ async function prCreate(repo: string, token: string, options: GitHubOptions): Pr
|
||||
const body = bodySource.body;
|
||||
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
return renderPrCreateResult({
|
||||
ok: true,
|
||||
command: "pr create",
|
||||
repo,
|
||||
@@ -5415,7 +5569,7 @@ async function prCreate(repo: string, token: string, options: GitHubOptions): Pr
|
||||
planned: true,
|
||||
draft: options.draft,
|
||||
...planned,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const repoResult = await repoInfo(token, repo);
|
||||
@@ -5445,7 +5599,7 @@ async function prCreate(repo: string, token: string, options: GitHubOptions): Pr
|
||||
};
|
||||
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
|
||||
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
|
||||
return {
|
||||
return renderPrCreateResult({
|
||||
ok: true,
|
||||
command: "pr create",
|
||||
repo,
|
||||
@@ -5467,7 +5621,7 @@ async function prCreate(repo: string, token: string, options: GitHubOptions): Pr
|
||||
bodyChars: body.length,
|
||||
},
|
||||
rest: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
@@ -5480,7 +5634,7 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
const body = bodySource.body;
|
||||
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
return renderPrCommentCreateResult({
|
||||
ok: true,
|
||||
command: "pr comment",
|
||||
repo,
|
||||
@@ -5488,7 +5642,7 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
planned: true,
|
||||
issueNumber,
|
||||
...planned,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const repoResult = await repoInfo(token, repo);
|
||||
@@ -5498,7 +5652,7 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
|
||||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||||
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
|
||||
return {
|
||||
return renderPrCommentCreateResult({
|
||||
ok: true,
|
||||
command: "pr comment create",
|
||||
repo,
|
||||
@@ -5521,7 +5675,7 @@ async function prComment(repo: string, token: string, issueNumber: number, optio
|
||||
bodySource: bodySource.bodySource,
|
||||
},
|
||||
rest: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function prUpdate(repo: string, token: string, number: number, options: GitHubOptions, commandName = "pr update"): Promise<GitHubCommandResult> {
|
||||
@@ -6375,7 +6529,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
|
||||
}, "issue create");
|
||||
const labels = options.labels;
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
return renderIssueCreateResult({
|
||||
ok: true,
|
||||
command: "issue create",
|
||||
repo,
|
||||
@@ -6384,7 +6538,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
|
||||
title: options.title,
|
||||
labels,
|
||||
...writeBodyPlan("issue create", repo, body, bodySource, { title: options.title, labels }),
|
||||
};
|
||||
});
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const payload: Record<string, unknown> = { title: options.title, body };
|
||||
@@ -6401,7 +6555,7 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions):
|
||||
missingLabels,
|
||||
});
|
||||
}
|
||||
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true };
|
||||
return renderIssueCreateResult({ ok: true, command: "issue create", repo, issue: issueSummary(issue), labels, bodySource, rest: true });
|
||||
}
|
||||
|
||||
async function issuePatch(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
@@ -6712,7 +6866,7 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
|
||||
}
|
||||
const { body, bodySource } = bodyInput;
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
return renderIssueCommentCreateResult({
|
||||
ok: true,
|
||||
command: "issue comment create",
|
||||
repo,
|
||||
@@ -6721,12 +6875,12 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
|
||||
issueNumber,
|
||||
readCommands: issueCommentReadCommands(repo, issueNumber),
|
||||
...writeBodyPlan("issue comment create", repo, body, bodySource, { issueNumber }),
|
||||
};
|
||||
});
|
||||
}
|
||||
const { owner, name } = repoParts(repo);
|
||||
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
||||
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
|
||||
return {
|
||||
return renderIssueCommentCreateResult({
|
||||
ok: true,
|
||||
command: "issue comment create",
|
||||
repo,
|
||||
@@ -6739,7 +6893,7 @@ async function issueComment(repo: string, token: string, issueNumber: number, op
|
||||
source: String(bodySource.kind ?? "unknown"),
|
||||
readCommands: issueCommentReadCommands(repo, issueNumber),
|
||||
rest: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function commentPatch(repo: string, token: string, ownerKind: "issue" | "pr", commentId: number, options: GitHubOptions, commandName?: string): Promise<GitHubCommandResult> {
|
||||
@@ -7741,7 +7895,7 @@ export function ghHelp(): unknown {
|
||||
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; full body is included only when requested with --json body, --full, or --raw, and unsupported fields fail structurally.",
|
||||
"issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||||
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
|
||||
"All CLI stdout larger than the YAML-configured dump threshold is automatically written to /tmp/unidesk-cli-output/*; stdout stays bounded with outputTruncated=true or UNIDESK_CLI_OUTPUT_TRUNCATED, the dump path, total bytes/lines, and head/tail commands.",
|
||||
"issue create accepts --body-stdin or --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
|
||||
"--body-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.",
|
||||
"update defaults to --mode replace; --mode append reads the current body and appends file bytes so real newlines, backticks, and Markdown tables are preserved.",
|
||||
|
||||
+235
-12
@@ -6610,12 +6610,19 @@ function runNodeWebProbeObserveStatus(options: NodeWebProbeObserveOptions, spec:
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const status = parseJsonObject(result.stdout);
|
||||
const observerId = webObserveIdFromStatus(status, options);
|
||||
const index = status?.ok !== false && observerId !== null && options.stateDir !== null
|
||||
const statusOk = status !== null && status.ok !== false;
|
||||
const commandResult = compactCommandResult(result);
|
||||
if (status === null) {
|
||||
commandResult.failureKind = "web-observe-status-parse-failed";
|
||||
commandResult.stdoutTail = result.stdout.slice(-2000);
|
||||
commandResult.stderrTail = result.stderr.slice(-2000);
|
||||
}
|
||||
const index = statusOk && observerId !== null && options.stateDir !== null
|
||||
? upsertWebObserveIndexEntry(webObserveIndexEntryFromOptions(options, spec, observerId, status))
|
||||
: null;
|
||||
return {
|
||||
ok: result.exitCode === 0 && status?.ok !== false,
|
||||
status: result.exitCode === 0 ? "observed" : "blocked",
|
||||
return renderWebObserveStatusResult({
|
||||
ok: result.exitCode === 0 && statusOk,
|
||||
status: result.exitCode === 0 && statusOk ? "observed" : "blocked",
|
||||
command: webObserveCommandLabel("status", options),
|
||||
id: observerId,
|
||||
node: options.node,
|
||||
@@ -6624,9 +6631,9 @@ function runNodeWebProbeObserveStatus(options: NodeWebProbeObserveOptions, spec:
|
||||
observer: withWebObserveShortcuts(status, observerId),
|
||||
index,
|
||||
next: observerId === null ? null : webObserveNextCommands(observerId),
|
||||
result: compactCommandResult(result),
|
||||
result: commandResult,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record<string, unknown> {
|
||||
@@ -6661,7 +6668,7 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
|
||||
"if [ -f \"$state_dir/pid\" ]; then kill \"$(cat \"$state_dir/pid\")\" >/dev/null 2>&1 || true; fi",
|
||||
"printf '{\"ok\":true,\"forced\":true,\"stateDir\":\"%s\"}\\n' \"$state_dir\"",
|
||||
].join("\n"), 55);
|
||||
return {
|
||||
return renderWebObserveCommandResult({
|
||||
ok: killResult.exitCode === 0,
|
||||
status: killResult.exitCode === 0 ? "forced-stop-requested" : "blocked",
|
||||
command: webObserveCommandLabel("stop", options),
|
||||
@@ -6674,9 +6681,9 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
|
||||
gracefulResult: compactCommandResult(result),
|
||||
forceResult: compactCommandResult(killResult),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
return renderWebObserveCommandResult({
|
||||
ok: result.exitCode === 0 && commandResult?.ok !== false,
|
||||
status: result.exitCode === 0 ? (options.waitMs > 0 ? "completed-or-queued" : "queued") : "blocked",
|
||||
command: webObserveCommandLabel(stopCommand ? "stop" : "command", options),
|
||||
@@ -6689,7 +6696,7 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
|
||||
observer: commandResult,
|
||||
result: compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||
@@ -6726,7 +6733,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
].join("\n");
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const analysis = parseJsonObject(result.stdout);
|
||||
return {
|
||||
return renderWebObserveAnalyzeResult({
|
||||
ok: result.exitCode === 0 && analysis?.ok === true,
|
||||
status: result.exitCode === 0 && analysis?.ok === true ? "analyzed" : "blocked",
|
||||
command: webObserveCommandLabel("analyze", options),
|
||||
@@ -6737,7 +6744,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
analysis,
|
||||
result: compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function nodeWebObserveResolveStateDirShell(options: Pick<NodeWebProbeObserveOptions, "stateDir" | "jobId" | "node" | "lane">): string {
|
||||
@@ -6979,6 +6986,222 @@ function webObserveNextCommands(id: string): Record<string, string> {
|
||||
};
|
||||
}
|
||||
|
||||
function webObserveCell(value: unknown, maxLength = 96): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
const compact = text.replace(/\s+/gu, " ").trim();
|
||||
if (compact.length === 0) return "-";
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, Math.max(1, maxLength - 1))}...`;
|
||||
}
|
||||
|
||||
function webObserveTable(headers: string[], rows: unknown[][]): string {
|
||||
const stringRows = rows.map((row) => row.map((value) => webObserveCell(value)));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...stringRows.map((row) => row[index]?.length ?? 0)));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...stringRows.map(renderRow)].join("\n");
|
||||
}
|
||||
|
||||
function webObserveArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function withWebObserveRendered(result: Record<string, unknown>, renderedText: string): Record<string, unknown> {
|
||||
return {
|
||||
...result,
|
||||
renderedText,
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function renderWebObserveStatusResult(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const observer = record(result.observer);
|
||||
const commandResult = record(result.result);
|
||||
const manifest = record(observer.manifest);
|
||||
const heartbeat = record(observer.heartbeat);
|
||||
const tails = record(observer.tails);
|
||||
const samples = webObserveArray(tails.samples);
|
||||
const network = webObserveArray(tails.network);
|
||||
const control = webObserveArray(tails.control);
|
||||
const artifacts = webObserveArray(tails.artifacts);
|
||||
const lastSample = record(samples[samples.length - 1]);
|
||||
const failedNetwork = network
|
||||
.map((item) => record(item))
|
||||
.filter((item) => item.type === "requestfailed" || typeof item.status === "number" && item.status >= 500);
|
||||
const id = result.id ?? observer.id ?? manifest.jobId ?? heartbeat.jobId ?? "-";
|
||||
const resultSection = result.ok === true ? [] : [
|
||||
"",
|
||||
webObserveTable(
|
||||
["RESULT", "VALUE"],
|
||||
[
|
||||
["exitCode", commandResult.exitCode ?? "-"],
|
||||
["timedOut", commandResult.timedOut ?? "-"],
|
||||
["stdoutTail", commandResult.stdoutTail ?? commandResult.stdout ?? "-"],
|
||||
["stderrTail", commandResult.stderrTail ?? commandResult.stderr ?? "-"],
|
||||
],
|
||||
),
|
||||
];
|
||||
const renderedText = [
|
||||
webObserveTable(
|
||||
["OBSERVER", "NODE", "LANE", "STATUS", "ALIVE", "PID", "SAMPLE", "COMMAND", "UPDATED"],
|
||||
[[id, result.node, result.lane, manifest.status ?? heartbeat.status ?? result.status, observer.processAlive, observer.pid, heartbeat.sampleSeq, heartbeat.commandSeq, heartbeat.updatedAt]],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["URL", "ROUTE_SESSION", "ACTIVE_SESSION", "MESSAGES", "TRACE_ROWS"],
|
||||
[[heartbeat.currentUrl, lastSample.routeSessionId, lastSample.activeSessionId, lastSample.messageCount, lastSample.traceRowCount]],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["TAIL", "COUNT", "DETAIL"],
|
||||
[
|
||||
["control", control.length, record(control[control.length - 1]).type ?? "-"],
|
||||
["samples", samples.length, record(samples[samples.length - 1]).ts ?? "-"],
|
||||
["network", network.length, failedNetwork.length === 0 ? "no recent failed/5xx tail" : `${failedNetwork.length} recent failed/5xx`],
|
||||
["artifacts", artifacts.length, record(artifacts[artifacts.length - 1]).sha256 ?? "-"],
|
||||
],
|
||||
),
|
||||
...resultSection,
|
||||
"",
|
||||
"NEXT",
|
||||
` status: bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
|
||||
` analyze: bun scripts/cli.ts hwlab nodes web-probe observe analyze ${id}`,
|
||||
` command: bun scripts/cli.ts hwlab nodes web-probe observe command ${id} --type mark --label checkpoint`,
|
||||
].join("\n");
|
||||
return withWebObserveRendered(result, renderedText);
|
||||
}
|
||||
|
||||
function renderWebObserveCommandResult(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const observerCommand = record(result.observerCommand);
|
||||
const observer = record(result.observer);
|
||||
const id = result.id ?? "-";
|
||||
const renderedText = [
|
||||
webObserveTable(
|
||||
["OBSERVER", "NODE", "LANE", "COMMAND_ID", "TYPE", "STATUS", "DETAIL"],
|
||||
[[id, result.node, result.lane, result.commandId, observerCommand.type, result.status, observer.queued === true ? "queued" : observer.phase ?? observer.status ?? "-"]],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["INPUT", "VALUE"],
|
||||
[
|
||||
["label", observerCommand.label ?? "-"],
|
||||
["path", observerCommand.path ?? "-"],
|
||||
["provider", observerCommand.provider ?? "-"],
|
||||
["sessionId", observerCommand.sessionId ?? "-"],
|
||||
["text", observerCommand.textHash === null || observerCommand.textHash === undefined ? "-" : `${observerCommand.textBytes ?? "-"}B ${observerCommand.textHash}`],
|
||||
],
|
||||
),
|
||||
"",
|
||||
"NEXT",
|
||||
` status: bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
|
||||
` analyze: bun scripts/cli.ts hwlab nodes web-probe observe analyze ${id}`,
|
||||
].join("\n");
|
||||
return withWebObserveRendered(result, renderedText);
|
||||
}
|
||||
|
||||
function renderWebObserveAnalyzeResult(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const analysis = record(result.analysis);
|
||||
const counts = record(analysis.counts);
|
||||
const sampleMetrics = record(analysis.sampleMetrics);
|
||||
const runtimeAlerts = record(analysis.runtimeAlerts);
|
||||
const pagePerformance = record(analysis.pagePerformance);
|
||||
const rounds = webObserveArray(sampleMetrics.rounds).slice(-10).map((item) => record(item));
|
||||
const turnColumns = webObserveArray(sampleMetrics.turnColumns).slice(-12).map((item) => record(item));
|
||||
const slowApis = webObserveArray(analysis.pagePerformanceSlowApi).slice(0, 8).map((item) => record(item));
|
||||
const findings = webObserveArray(analysis.findings).slice(0, 8).map((item) => record(item));
|
||||
const id = result.id ?? "-";
|
||||
const renderedText = [
|
||||
webObserveTable(
|
||||
["OBSERVER", "NODE", "LANE", "STATUS", "REPORT_JSON", "REPORT_MD"],
|
||||
[[id, result.node, result.lane, result.status, analysis.reportJsonSha256, analysis.reportMdSha256]],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["SAMPLES", "CONTROL", "NETWORK", "CONSOLE", "ARTIFACTS", "ROUNDS", "ROWS"],
|
||||
[[counts.samples, counts.control, counts.network, counts.console, counts.artifacts, rounds.length || sampleMetrics.rounds, sampleMetrics.turnTimingRows]],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["TURN_TIMING", "VALUE"],
|
||||
[
|
||||
["nonMonotonic", sampleMetrics.turnTimingNonMonotonicCount],
|
||||
["elapsedDecrease", sampleMetrics.turnTimingTotalElapsedDecreaseCount],
|
||||
["recentJump", sampleMetrics.turnTimingRecentUpdateJumpCount],
|
||||
["maxRecentStepSec", sampleMetrics.turnTimingRecentUpdateMaxIncreaseSeconds],
|
||||
],
|
||||
),
|
||||
"",
|
||||
rounds.length === 0
|
||||
? "ROUNDS\n-"
|
||||
: webObserveTable(
|
||||
["ROUND", "COMMAND", "SAMPLES", "TOTAL_MAX", "TOTAL_LAST", "RECENT_MAX", "RECENT_LAST", "DIAG", "TERM", "NONMONO", "JUMP"],
|
||||
rounds.map((item) => [
|
||||
item.promptIndex,
|
||||
item.promptCommandId,
|
||||
item.sampleCount,
|
||||
item.maxTotalElapsedSeconds,
|
||||
item.lastTotalElapsedSeconds,
|
||||
item.maxRecentUpdateSeconds,
|
||||
item.lastRecentUpdateSeconds,
|
||||
item.diagnosticSamples,
|
||||
item.terminalSamples,
|
||||
item.turnTimingNonMonotonicCount,
|
||||
item.turnTimingRecentUpdateJumpCount,
|
||||
]),
|
||||
),
|
||||
"",
|
||||
turnColumns.length === 0
|
||||
? "TURN_COLUMNS\n-"
|
||||
: webObserveTable(
|
||||
["TURN", "PROMPT", "TRACE", "MESSAGE", "FIRST_SEQ", "LAST_SEQ", "LAST_TS"],
|
||||
turnColumns.map((item) => [item.label ?? item.id, item.lastPromptIndex ?? item.promptIndex, item.traceId, item.messageId, item.firstSeq, item.lastSeq, item.lastTs]),
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["ALERT", "COUNT"],
|
||||
[
|
||||
["httpError", runtimeAlerts.httpErrorCount],
|
||||
["requestFailed", runtimeAlerts.requestFailedCount],
|
||||
["domDiagnosticSamples", runtimeAlerts.domDiagnosticSampleCount],
|
||||
["consoleAlerts", runtimeAlerts.consoleAlertCount],
|
||||
["executionErrors", runtimeAlerts.executionErrorCount],
|
||||
],
|
||||
),
|
||||
"",
|
||||
webObserveTable(
|
||||
["PERF", "VALUE"],
|
||||
[
|
||||
["sameOriginApiPaths", pagePerformance.sameOriginApiPathCount],
|
||||
["slowPathCount", pagePerformance.slowPathCount],
|
||||
["slowSampleCount", pagePerformance.slowSampleCount],
|
||||
["worstP95Ms", pagePerformance.worstP95Ms],
|
||||
],
|
||||
),
|
||||
"",
|
||||
slowApis.length === 0
|
||||
? "SLOW_API\n-"
|
||||
: webObserveTable(
|
||||
["SLOW_API", "P50", "P75", "P95", ">5S", "COUNT"],
|
||||
slowApis.map((item) => [item.path, item.p50Ms, item.p75Ms, item.p95Ms, item.overFiveSecondCount, item.sampleCount]),
|
||||
),
|
||||
"",
|
||||
findings.length === 0
|
||||
? "FINDINGS\n-"
|
||||
: webObserveTable(
|
||||
["FINDING", "SEVERITY", "COUNT", "SUMMARY"],
|
||||
findings.map((item) => [item.id, item.severity, item.count, item.summary]),
|
||||
),
|
||||
"",
|
||||
"REPORTS",
|
||||
` json: ${analysis.reportJsonPath ?? "-"}`,
|
||||
` md: ${analysis.reportMdPath ?? "-"}`,
|
||||
"",
|
||||
"NEXT",
|
||||
` status: bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
|
||||
].join("\n");
|
||||
return withWebObserveRendered(result, renderedText);
|
||||
}
|
||||
|
||||
function withWebObserveShortcuts(value: Record<string, unknown> | null, id: string | null): Record<string, unknown> | null {
|
||||
if (value === null || id === null) return value;
|
||||
return {
|
||||
|
||||
@@ -1029,7 +1029,7 @@ const report = {
|
||||
await writeFile(reportJsonPath, JSON.stringify(report, null, 2) + "\n", { mode: 0o600 });
|
||||
await writeFile(reportMdPath, renderMarkdown(report), { mode: 0o600 });
|
||||
const [jsonMeta, mdMeta] = await Promise.all([fileMeta(reportJsonPath), fileMeta(reportMdPath)]);
|
||||
console.log(JSON.stringify({ ok: true, command: "web-probe-observe analyze", stateDir, reportJsonPath, reportJsonSha256: jsonMeta.sha256, reportMdPath, reportMdSha256: mdMeta.sha256, counts: report.counts, sampleMetrics: sampleMetrics.summary, pageProvenance: pageProvenance.summary, pagePerformance: pagePerformance.summary, promptNetwork: promptNetwork.summary, runtimeAlerts: runtimeAlerts.summary, turnTimingRecentUpdateSawtoothJumps: sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 20), turnTimingRecentUpdateLargestSteps: sampleMetrics.turnTimingRecentUpdateLargestSteps.slice(0, 20), pagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0).slice(0, 20), pagePerformanceLongLivedStreams: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream).slice(0, 20), findings: findings.slice(0, 20), valuesRedacted: true }, null, 2));
|
||||
console.log(JSON.stringify({ ok: true, command: "web-probe-observe analyze", stateDir, reportJsonPath, reportJsonSha256: jsonMeta.sha256, reportMdPath, reportMdSha256: mdMeta.sha256, counts: report.counts, sampleMetrics: { ...sampleMetrics.summary, rounds: sampleMetrics.rounds.slice(-20), turnColumns: sampleMetrics.turnColumns.slice(-50) }, pageProvenance: pageProvenance.summary, pagePerformance: pagePerformance.summary, promptNetwork: promptNetwork.summary, runtimeAlerts: runtimeAlerts.summary, turnTimingRecentUpdateSawtoothJumps: sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 20), turnTimingRecentUpdateLargestSteps: sampleMetrics.turnTimingRecentUpdateLargestSteps.slice(0, 20), pagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0).slice(0, 20), pagePerformanceLongLivedStreams: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream).slice(0, 20), findings: findings.slice(0, 20), valuesRedacted: true }, null, 2));
|
||||
|
||||
async function readJson(file) {
|
||||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||||
|
||||
+148
-22
@@ -1,7 +1,8 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface JsonEnvelope<T> {
|
||||
ok: boolean;
|
||||
@@ -17,9 +18,21 @@ export interface RenderedCliResult {
|
||||
contentType: "text/plain" | "application/json" | "application/yaml";
|
||||
}
|
||||
|
||||
const GH_OUTPUT_DUMP_THRESHOLD_BYTES = 20 * 1024;
|
||||
const OUTPUT_DUMP_PREVIEW_CHARS = 2000;
|
||||
const OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
|
||||
export interface CliOutputDumpConfig {
|
||||
enabled: boolean;
|
||||
thresholdBytes: number;
|
||||
previewChars: number;
|
||||
dir: string;
|
||||
source: string;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
const FALLBACK_OUTPUT_DUMP_THRESHOLD_BYTES = 10 * 1024;
|
||||
const FALLBACK_OUTPUT_DUMP_PREVIEW_CHARS = 2000;
|
||||
const FALLBACK_OUTPUT_DUMP_DIR = join(tmpdir(), "unidesk-cli-output");
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_OUTPUT_CONFIG_PATH = join(moduleDir, "..", "..", "config", "cli-output.yaml");
|
||||
let cachedOutputDumpConfig: CliOutputDumpConfig | null = null;
|
||||
|
||||
function isEpipe(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE";
|
||||
@@ -49,7 +62,8 @@ export function isRenderedCliResult(value: unknown): value is RenderedCliResult
|
||||
}
|
||||
|
||||
export function emitText(text: string): void {
|
||||
safeStdoutWrite(text.endsWith("\n") ? text : `${text}\n`);
|
||||
const output = text.endsWith("\n") ? text : `${text}\n`;
|
||||
safeStdoutWrite(renderPlainTextOutput("text", output));
|
||||
}
|
||||
|
||||
export function emitError(command: string, error: unknown): void {
|
||||
@@ -130,10 +144,11 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
const fullText = `${JSON.stringify(envelope, null, 2)}\n`;
|
||||
if (!shouldDumpLargeOutput(command, fullText, envelope)) return fullText;
|
||||
|
||||
const dump = dumpLargeOutput(command, fullText);
|
||||
const dump = dumpLargeOutput(command, fullText, "json");
|
||||
const compactPayload = {
|
||||
outputTruncated: true,
|
||||
reason: "stdout-json-exceeded-threshold",
|
||||
warning: "CLI output exceeded the global dump threshold. Fix this command's default output instead of repeatedly relying on dump.",
|
||||
message: "Full JSON output was written to a temporary file; stdout contains only bounded head/tail previews.",
|
||||
dump,
|
||||
summary: summarizeEnvelope(envelope),
|
||||
@@ -144,48 +159,132 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
return `${JSON.stringify(compactEnvelope, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function renderPlainTextOutput(command: string, text: string): string {
|
||||
if (!shouldDumpLargeText(command, text)) return text;
|
||||
const dump = dumpLargeOutput(command, text, "txt");
|
||||
return [
|
||||
"UNIDESK_CLI_OUTPUT_TRUNCATED",
|
||||
"warning: CLI text output exceeded the global dump threshold. Fix this command's default output instead of repeatedly relying on dump.",
|
||||
`dumpPath: ${dump.path}`,
|
||||
`bytes: ${dump.bytes}`,
|
||||
`lines: ${dump.lines}`,
|
||||
`thresholdBytes: ${dump.thresholdBytes}`,
|
||||
`headCommand: ${recordString(record(dump.readCommands), "head") ?? "-"}`,
|
||||
`tailCommand: ${recordString(record(dump.readCommands), "tail") ?? "-"}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope<unknown>): boolean {
|
||||
if (!isLargeOutputDumpCommand(command)) return false;
|
||||
if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1" || process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return false;
|
||||
if (typeof envelope.data === "object" && envelope.data !== null && (envelope.data as { noDump?: unknown }).noDump === true) return false;
|
||||
const threshold = configuredDumpThresholdBytes();
|
||||
const config = outputDumpConfig();
|
||||
if (!config.enabled) return false;
|
||||
const threshold = configuredDumpThresholdBytes(config);
|
||||
return Buffer.byteLength(text, "utf8") > threshold;
|
||||
}
|
||||
|
||||
function isLargeOutputDumpCommand(command: string): boolean {
|
||||
return command === "gh" || command.startsWith("gh ") || command === "agentrun" || command.startsWith("agentrun ");
|
||||
function shouldDumpLargeText(command: string, text: string): boolean {
|
||||
if (process.env.UNIDESK_CLI_OUTPUT_DUMP_DISABLED === "1") return false;
|
||||
const config = outputDumpConfig();
|
||||
if (!config.enabled) return false;
|
||||
const threshold = configuredDumpThresholdBytes(config);
|
||||
return Buffer.byteLength(text, "utf8") > threshold;
|
||||
}
|
||||
|
||||
function configuredDumpThresholdBytes(): number {
|
||||
function configuredDumpThresholdBytes(config = outputDumpConfig()): number {
|
||||
const genericRaw = process.env.UNIDESK_CLI_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
if (genericRaw !== undefined && genericRaw.trim().length > 0) {
|
||||
const genericValue = Number(genericRaw);
|
||||
if (Number.isInteger(genericValue) && genericValue > 0) return genericValue;
|
||||
}
|
||||
const raw = process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
if (raw === undefined || raw.trim().length === 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
if (raw === undefined || raw.trim().length === 0) return config.thresholdBytes;
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) return GH_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
if (!Number.isInteger(value) || value <= 0) return config.thresholdBytes;
|
||||
return value;
|
||||
}
|
||||
|
||||
function dumpLargeOutput(command: string, text: string): Record<string, unknown> {
|
||||
mkdirSync(OUTPUT_DUMP_DIR, { recursive: true, mode: 0o700 });
|
||||
function configuredDumpPreviewChars(config = outputDumpConfig()): number {
|
||||
const threshold = configuredDumpThresholdBytes(config);
|
||||
return Math.max(1, Math.min(config.previewChars, Math.floor(threshold / 4)));
|
||||
}
|
||||
|
||||
function configuredDumpDir(config = outputDumpConfig()): string {
|
||||
return config.dir;
|
||||
}
|
||||
|
||||
export function cliOutputDumpRuntimeConfig(): CliOutputDumpConfig {
|
||||
const config = outputDumpConfig();
|
||||
return {
|
||||
...config,
|
||||
thresholdBytes: configuredDumpThresholdBytes(config),
|
||||
previewChars: configuredDumpPreviewChars(config),
|
||||
dir: configuredDumpDir(config),
|
||||
};
|
||||
}
|
||||
|
||||
function outputDumpConfig(): CliOutputDumpConfig {
|
||||
if (cachedOutputDumpConfig !== null) return cachedOutputDumpConfig;
|
||||
cachedOutputDumpConfig = readOutputDumpConfig();
|
||||
return cachedOutputDumpConfig;
|
||||
}
|
||||
|
||||
function readOutputDumpConfig(): CliOutputDumpConfig {
|
||||
const fallback: CliOutputDumpConfig = {
|
||||
enabled: true,
|
||||
thresholdBytes: FALLBACK_OUTPUT_DUMP_THRESHOLD_BYTES,
|
||||
previewChars: FALLBACK_OUTPUT_DUMP_PREVIEW_CHARS,
|
||||
dir: FALLBACK_OUTPUT_DUMP_DIR,
|
||||
source: "fallback",
|
||||
warning: null,
|
||||
};
|
||||
if (!existsSync(CLI_OUTPUT_CONFIG_PATH)) {
|
||||
return { ...fallback, warning: `config missing: ${CLI_OUTPUT_CONFIG_PATH}` };
|
||||
}
|
||||
try {
|
||||
const parsed = Bun.YAML.parse(readFileSync(CLI_OUTPUT_CONFIG_PATH, "utf8")) as unknown;
|
||||
const root = record(parsed);
|
||||
const output = record(root.output);
|
||||
const dump = record(output.dump);
|
||||
return {
|
||||
enabled: booleanField(dump, "enabled", `${CLI_OUTPUT_CONFIG_PATH}.output.dump`),
|
||||
thresholdBytes: positiveIntegerField(dump, "thresholdBytes", `${CLI_OUTPUT_CONFIG_PATH}.output.dump`),
|
||||
previewChars: positiveIntegerField(dump, "previewChars", `${CLI_OUTPUT_CONFIG_PATH}.output.dump`),
|
||||
dir: nonEmptyStringField(dump, "dir", `${CLI_OUTPUT_CONFIG_PATH}.output.dump`),
|
||||
source: CLI_OUTPUT_CONFIG_PATH,
|
||||
warning: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...fallback,
|
||||
warning: `config invalid: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dumpLargeOutput(command: string, text: string, extension: "json" | "txt"): Record<string, unknown> {
|
||||
const config = outputDumpConfig();
|
||||
const outputDir = configuredDumpDir(config);
|
||||
mkdirSync(outputDir, { recursive: true, mode: 0o700 });
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
|
||||
const suffix = randomBytes(4).toString("hex");
|
||||
const slug = command.replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 80) || "command";
|
||||
const path = join(OUTPUT_DUMP_DIR, `${timestamp}-${process.pid}-${suffix}-${slug}.json`);
|
||||
const path = join(outputDir, `${timestamp}-${process.pid}-${suffix}-${slug}.${extension}`);
|
||||
writeFileSync(path, text, { encoding: "utf8", mode: 0o600 });
|
||||
const previewChars = configuredDumpPreviewChars(config);
|
||||
return {
|
||||
path,
|
||||
thresholdBytes: configuredDumpThresholdBytes(),
|
||||
thresholdBytes: configuredDumpThresholdBytes(config),
|
||||
configSource: config.source,
|
||||
configWarning: config.warning,
|
||||
bytes: Buffer.byteLength(text, "utf8"),
|
||||
chars: text.length,
|
||||
lines: countLines(text),
|
||||
headChars: OUTPUT_DUMP_PREVIEW_CHARS,
|
||||
tailChars: OUTPUT_DUMP_PREVIEW_CHARS,
|
||||
head: text.slice(0, OUTPUT_DUMP_PREVIEW_CHARS),
|
||||
tail: text.slice(Math.max(0, text.length - OUTPUT_DUMP_PREVIEW_CHARS)),
|
||||
headChars: previewChars,
|
||||
tailChars: previewChars,
|
||||
head: text.slice(0, previewChars),
|
||||
tail: text.slice(Math.max(0, text.length - previewChars)),
|
||||
readCommands: {
|
||||
full: `cat ${JSON.stringify(path)}`,
|
||||
head: `sed -n '1,80p' ${JSON.stringify(path)}`,
|
||||
@@ -240,6 +339,33 @@ function summarizeEnvelope(envelope: JsonEnvelope<unknown>): Record<string, unkn
|
||||
return summary;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function recordString(source: Record<string, unknown>, key: string): string | null {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function booleanField(source: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = source[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerField(source: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = source[key];
|
||||
if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function nonEmptyStringField(source: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = source[key];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function pickSummary(source: Record<string, unknown>, keys: string[]): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
|
||||
@@ -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,
|
||||
@@ -269,9 +270,9 @@ function parseSearchOptions(args: string[]): SearchOptions {
|
||||
let lookbackMinutes = 360;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--grep") {
|
||||
if (arg === "--grep" || arg === "--path") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--grep requires a value");
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
grep = value;
|
||||
index += 1;
|
||||
} else if (arg === "--query" || arg === "--q") {
|
||||
@@ -620,14 +621,14 @@ 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);
|
||||
return {
|
||||
const response = {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-observability-trace",
|
||||
mutation: false,
|
||||
@@ -640,9 +641,11 @@ async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Reco
|
||||
},
|
||||
result: redactSensitiveUnknown(parsed),
|
||||
};
|
||||
if (options.raw || options.full) return response;
|
||||
return renderTraceResult(response);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -656,7 +659,7 @@ 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);
|
||||
return {
|
||||
const response = {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-observability-search",
|
||||
mutation: false,
|
||||
@@ -673,9 +676,11 @@ async function search(config: UniDeskConfig, options: SearchOptions): Promise<Re
|
||||
},
|
||||
result: redactSensitiveUnknown(parsed) ?? compactCapture(result, { full: true }),
|
||||
};
|
||||
if (options.raw || options.full) return response;
|
||||
return renderSearchResult(response);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -692,7 +697,7 @@ 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);
|
||||
return {
|
||||
const response = {
|
||||
ok: result.exitCode === 0 && parsed?.ok === true,
|
||||
action: "platform-infra-observability-diagnose-code-agent",
|
||||
mutation: false,
|
||||
@@ -709,6 +714,220 @@ async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAge
|
||||
},
|
||||
result: redactSensitiveUnknown(parsed) ?? compactCapture(result, { full: true }),
|
||||
};
|
||||
if (options.raw || options.full) return response;
|
||||
return renderDiagnoseCodeAgentResult(response);
|
||||
}
|
||||
|
||||
function renderDiagnoseCodeAgentResult(response: Record<string, unknown>): RenderedCliResult {
|
||||
const target = recordValue(response.target);
|
||||
const query = recordValue(response.query);
|
||||
const result = recordValue(response.result);
|
||||
const mapping = recordValue(result.mapping);
|
||||
const summary = recordValue(result.summary);
|
||||
const identity = recordValue(result.identity);
|
||||
const agentrun = recordValue(result.agentrun);
|
||||
const http = recordValue(result.http);
|
||||
const servicePath = recordValue(result.servicePath);
|
||||
const selection = recordValue(mapping.selection);
|
||||
const candidates = arrayValue(mapping.candidateScores);
|
||||
const rootCandidates = arrayValue(summary.rootCauseCandidates);
|
||||
const problemCounts = arrayValue(http.problemCounts);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("COMMAND TARGET OK BUSINESS_TRACE OTEL_TRACE");
|
||||
lines.push(`${pad("platform-infra observability diagnose", 44)} ${pad(textValue(target.id) || "-", 7)} ${pad(response.ok === true ? "true" : "false", 6)} ${pad(textValue(query.businessTraceId) || "-", 27)} ${textValue(mapping.otelTraceId) || "-"}`);
|
||||
lines.push("");
|
||||
lines.push("TRACE_SELECTION");
|
||||
lines.push(`mode: ${textValue(selection.mode) || textValue(mapping.mode) || "-"}`);
|
||||
lines.push(`candidateTraceCount: ${textValue(mapping.candidateTraceCount) || "-"}`);
|
||||
lines.push(`selectedScore: ${textValue(selection.score) || "-"}`);
|
||||
lines.push(`selectedReason: ${arrayValue(selection.reasons).map((item) => textValue(item)).filter(Boolean).join(", ") || "-"}`);
|
||||
if (textValue(mapping.selectionWarning)) lines.push(`warning: ${textValue(mapping.selectionWarning)}`);
|
||||
lines.push("");
|
||||
lines.push("SERVICE_PATH");
|
||||
for (const service of ["hwlab-cloud-api", "agentrun-manager", "agentrun-runner"]) {
|
||||
lines.push(`${pad(service, 22)} ${textValue(servicePath[service]) || "missing"}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("AGENTRUN");
|
||||
lines.push(`terminalStatus: ${textValue(agentrun.terminalStatus) || "-"}`);
|
||||
lines.push(`terminalEventType: ${textValue(agentrun.terminalEventType) || "-"}`);
|
||||
lines.push(`runnerProviderClassification: ${textValue(agentrun.runnerProviderClassification) || "-"}`);
|
||||
lines.push("");
|
||||
lines.push("IDENTITY");
|
||||
for (const key of ["runId", "commandId", "runnerJobId", "runnerId", "sessionId", "turnId", "backendProfile", "sourceCommit"]) {
|
||||
const value = textValue(identity[key]);
|
||||
if (value) lines.push(`${pad(key, 16)} ${value}`);
|
||||
}
|
||||
if (lines[lines.length - 1] === "IDENTITY") lines.push("-");
|
||||
lines.push("");
|
||||
lines.push("ROOT_CAUSE");
|
||||
lines.push(textValue(summary.rootCause) || "-");
|
||||
for (const item of rootCandidates.slice(0, 5)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`- ${textValue(row.code) || textValue(row.label) || "candidate"} confidence=${textValue(row.confidence) || "-"} ${textValue(row.summary) || ""}`.trim());
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("HTTP_PROBLEMS");
|
||||
if (problemCounts.length === 0) {
|
||||
lines.push("-");
|
||||
} else {
|
||||
lines.push("METHOD ROUTE STATUS COUNT");
|
||||
for (const item of problemCounts.slice(0, 8)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`${pad(textValue(row.method) || "-", 7)} ${pad(truncate(textValue(row.route) || "-", 29), 29)} ${pad(textValue(row.status) || "-", 7)} ${textValue(row.count) || "-"}`);
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("CANDIDATE_TRACE_SCORES");
|
||||
lines.push("SCORE SPANS SERVICES TRACE");
|
||||
for (const item of candidates.slice(0, 8)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`${pad(textValue(row.score) || "0", 6)} ${pad(textValue(row.spanCount) || "-", 6)} ${pad(truncate(arrayValue(row.services).map((value) => textValue(value)).filter(Boolean).join(","), 32), 32)} ${textValue(row.traceId) || "-"}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Next:");
|
||||
if (textValue(mapping.otelTraceId)) {
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${textValue(target.id) || textValue(query.target) || "D601"} --trace-id ${textValue(mapping.otelTraceId)} --limit 40`);
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability diagnose-code-agent --target ${textValue(target.id) || "D601"} --business-trace-id ${textValue(query.businessTraceId) || "<trc_...>"} --full`);
|
||||
} else {
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability diagnose-code-agent --target ${textValue(target.id) || "D601"} --business-trace-id ${textValue(query.businessTraceId) || "<trc_...>"} --lookback-minutes 10080 --candidate-limit 100`);
|
||||
}
|
||||
return {
|
||||
ok: response.ok === true,
|
||||
command: "platform-infra observability diagnose-code-agent",
|
||||
contentType: "text/plain",
|
||||
renderedText: `${lines.join("\n")}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSearchResult(response: Record<string, unknown>): RenderedCliResult {
|
||||
const target = recordValue(response.target);
|
||||
const query = recordValue(response.query);
|
||||
const result = recordValue(response.result);
|
||||
const traces = arrayValue(result.traces);
|
||||
const lines: string[] = [];
|
||||
lines.push("COMMAND TARGET OK GREP_OR_QUERY MATCHED SCANNED CANDIDATES");
|
||||
lines.push(`${pad("platform-infra observability search", 38)} ${pad(textValue(target.id) || "-", 7)} ${pad(response.ok === true ? "true" : "false", 6)} ${pad(truncate(textValue(query.grep) || textValue(query.tempoQuery) || "-", 35), 35)} ${pad(textValue(result.matchedTraceCount) || "-", 8)} ${pad(textValue(result.scannedTraceCount) || "-", 8)} ${textValue(result.candidateTraceCount) || "-"}`);
|
||||
if (textValue(result.scanStopped)) lines.push(`warning: scanStopped=${textValue(result.scanStopped)}`);
|
||||
const truncated = recordValue(result.truncated);
|
||||
if (truncated.candidateTraces === true || truncated.matchedTraces === true) {
|
||||
lines.push(`warning: truncated candidateTraces=${textValue(truncated.candidateTraces)} matchedTraces=${textValue(truncated.matchedTraces)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("TRACES");
|
||||
if (traces.length === 0) {
|
||||
lines.push("-");
|
||||
} else {
|
||||
lines.push("TRACE SPANS ERRORS MATCH SERVICES ROOT");
|
||||
for (const item of traces.slice(0, 20)) {
|
||||
const row = recordValue(item);
|
||||
const meta = recordValue(row.meta);
|
||||
const services = arrayValue(row.services).map((value) => textValue(value)).filter(Boolean).join(",");
|
||||
lines.push(`${pad(textValue(row.traceId) || "-", 34)} ${pad(textValue(row.spanCount) || "-", 6)} ${pad(textValue(row.errorSpanCount) || "-", 7)} ${pad(textValue(row.matchedSpanCount) || "-", 6)} ${pad(truncate(services || textValue(meta.rootServiceName) || "-", 32), 32)} ${truncate(textValue(meta.rootTraceName) || "-", 48)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Next:");
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability search --target ${textValue(target.id) || "D601"} --grep <text> --lookback-minutes ${textValue(query.lookbackMinutes) || "360"} --candidate-limit ${textValue(query.candidateLimit) || "80"} --limit ${textValue(query.limit) || "20"} --full`);
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${textValue(target.id) || "D601"} --trace-id <traceId> --grep <text> --limit 80`);
|
||||
return {
|
||||
ok: response.ok === true,
|
||||
command: "platform-infra observability search",
|
||||
contentType: "text/plain",
|
||||
renderedText: `${lines.join("\n")}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTraceResult(response: Record<string, unknown>): RenderedCliResult {
|
||||
const target = recordValue(response.target);
|
||||
const traceId = textValue(response.traceId);
|
||||
const result = recordValue(response.result);
|
||||
const services = arrayValue(result.services).map((value) => textValue(value)).filter(Boolean);
|
||||
const businessTraceIds = arrayValue(result.businessTraceIds).map((value) => textValue(value)).filter(Boolean);
|
||||
const spanNameCounts = arrayValue(result.spanNameCounts);
|
||||
const errorSpans = arrayValue(result.errorSpans);
|
||||
const matchedSpans = arrayValue(result.spans).length > 0 ? arrayValue(result.spans) : arrayValue(result.matchedSpans);
|
||||
const lines: string[] = [];
|
||||
lines.push("COMMAND TARGET OK TRACE SPANS ERRORS MATCH");
|
||||
lines.push(`${pad("platform-infra observability trace", 37)} ${pad(textValue(target.id) || "-", 7)} ${pad(response.ok === true ? "true" : "false", 6)} ${pad(traceId || "-", 34)} ${pad(textValue(result.spanCount) || "-", 6)} ${pad(textValue(result.errorSpanCount) || "-", 7)} ${textValue(result.matchedSpanCount) || "-"}`);
|
||||
lines.push("");
|
||||
lines.push(`services: ${services.join(", ") || "-"}`);
|
||||
lines.push(`businessTraceIds: ${businessTraceIds.join(", ") || "-"}`);
|
||||
if (textValue(result.grep)) lines.push(`grep: ${textValue(result.grep)}`);
|
||||
const truncated = recordValue(result.truncated);
|
||||
if (truncated.errorSpans === true || truncated.spans === true) {
|
||||
lines.push(`warning: truncated errorSpans=${textValue(truncated.errorSpans)} spans=${textValue(truncated.spans)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("SPAN_NAME_COUNTS");
|
||||
if (spanNameCounts.length === 0) {
|
||||
lines.push("-");
|
||||
} else {
|
||||
lines.push("COUNT NAME");
|
||||
for (const item of spanNameCounts.slice(0, 12)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`${pad(textValue(row.count) || "-", 6)} ${truncate(textValue(row.name) || "-", 80)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("ERROR_SPANS");
|
||||
if (errorSpans.length === 0) {
|
||||
lines.push("-");
|
||||
} else {
|
||||
lines.push("SERVICE DURATION_MS NAME");
|
||||
for (const item of errorSpans.slice(0, 8)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`${pad(truncate(textValue(row.service) || "-", 23), 23)} ${pad(textValue(row.durationMs) || "-", 12)} ${truncate(textValue(row.name) || "-", 80)}`);
|
||||
}
|
||||
}
|
||||
if (matchedSpans.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("MATCHED_SPANS");
|
||||
lines.push("SERVICE DURATION_MS NAME");
|
||||
for (const item of matchedSpans.slice(0, 8)) {
|
||||
const row = recordValue(item);
|
||||
lines.push(`${pad(truncate(textValue(row.service) || "-", 23), 23)} ${pad(textValue(row.durationMs) || "-", 12)} ${truncate(textValue(row.name) || "-", 80)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Next:");
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${textValue(target.id) || "D601"} --trace-id ${traceId || "<otelTraceId>"} --grep <text> --limit 80`);
|
||||
lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${textValue(target.id) || "D601"} --trace-id ${traceId || "<otelTraceId>"} --full --limit 200`);
|
||||
return {
|
||||
ok: response.ok === true,
|
||||
command: "platform-infra observability trace",
|
||||
contentType: "text/plain",
|
||||
renderedText: `${lines.join("\n")}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function pad(value: string, width: number): string {
|
||||
return value.length >= width ? value : `${value}${" ".repeat(width - value.length)}`;
|
||||
}
|
||||
|
||||
function truncate(value: string, width: number): string {
|
||||
if (value.length <= width) return value;
|
||||
if (width <= 1) return value.slice(0, width);
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return `${value.slice(0, width - 3)}...`;
|
||||
}
|
||||
|
||||
function renderManifest(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
||||
@@ -2416,6 +2635,101 @@ def root_cause_candidates(http_summary, agentrun, read_model, lag_summary, error
|
||||
candidates.sort(key=lambda item: item.get("confidence", 0), reverse=True)
|
||||
return candidates
|
||||
|
||||
def score_trace_candidate(trace_id, meta, index):
|
||||
trace_path = TRACE_PATH_TEMPLATE.replace("{{traceId}}", trace_id)
|
||||
trace_proxy_path = TRACE_PROXY_PREFIX + trace_path
|
||||
trace_rc, trace_body, trace_err = run_kubectl(trace_proxy_path, timeout=5)
|
||||
trace_parsed = parse_json(trace_body)
|
||||
score = 0
|
||||
reasons = []
|
||||
services = []
|
||||
span_count = 0
|
||||
error_span_count = 0
|
||||
business_trace_ids = []
|
||||
root_name = None
|
||||
fetch_ok = trace_rc == 0 and isinstance(trace_parsed, dict)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
"traceId": trace_id,
|
||||
"index": index,
|
||||
"score": -100,
|
||||
"reasons": ["trace_fetch_failed"],
|
||||
"services": services,
|
||||
"spanCount": span_count,
|
||||
"errorSpanCount": error_span_count,
|
||||
"businessTraceIds": business_trace_ids,
|
||||
"rootName": root_name,
|
||||
"fetchOk": False,
|
||||
"selectedMeta": compact_meta(meta),
|
||||
"stderrTail": (trace_err or "")[-1000:],
|
||||
}
|
||||
flat = flatten_trace(trace_parsed)
|
||||
spans = flat.get("spans", [])
|
||||
services = flat.get("services", [])
|
||||
business_trace_ids = flat.get("businessTraceIds", [])
|
||||
error_span_count = len(flat.get("errorSpans", []))
|
||||
span_count = len(spans)
|
||||
if spans:
|
||||
root_name = spans[0].get("name")
|
||||
service_set = set(str(service) for service in services)
|
||||
span_names = [str(item.get("name") or "") for item in spans]
|
||||
attr_fragments = []
|
||||
for item in spans:
|
||||
attrs = item.get("attributes", {}) if isinstance(item.get("attributes"), dict) else {}
|
||||
for key in ("runId", "commandId", "runnerJobId", "terminalStatus", "eventType", "failureKind", "traceId"):
|
||||
value = attrs.get(key)
|
||||
if value not in (None, ""):
|
||||
attr_fragments.append("%s=%s" % (key, value))
|
||||
haystack = " ".join(span_names + attr_fragments).lower()
|
||||
if BUSINESS_TRACE_ID and BUSINESS_TRACE_ID in business_trace_ids:
|
||||
score += 20
|
||||
reasons.append("business_trace_id_matched")
|
||||
if "hwlab-cloud-api" in service_set:
|
||||
score += 20
|
||||
reasons.append("hwlab_cloud_api_reached")
|
||||
if "agentrun-manager" in service_set:
|
||||
score += 80
|
||||
reasons.append("agentrun_manager_reached")
|
||||
if "agentrun-runner" in service_set:
|
||||
score += 120
|
||||
reasons.append("agentrun_runner_reached")
|
||||
if "codex_stdio" in haystack:
|
||||
score += 80
|
||||
reasons.append("codex_stdio_spans_present")
|
||||
if "runner_terminal" in haystack or "terminalstatus=" in haystack:
|
||||
score += 60
|
||||
reasons.append("runner_terminal_present")
|
||||
if "runid=" in haystack:
|
||||
score += 30
|
||||
reasons.append("run_id_present")
|
||||
if "commandid=" in haystack:
|
||||
score += 30
|
||||
reasons.append("command_id_present")
|
||||
if error_span_count > 0:
|
||||
score += min(30, error_span_count * 5)
|
||||
reasons.append("error_spans_present")
|
||||
if span_count > 1:
|
||||
score += min(40, span_count)
|
||||
if service_set == {"hwlab-cloud-api"} and span_count <= 2 and any(name.startswith("GET /v1/workbench/events") for name in span_names):
|
||||
score -= 120
|
||||
reasons.append("penalized_workbench_events_sse_only")
|
||||
if any(name.startswith("GET /v1/workbench/events") for name in span_names):
|
||||
score -= 20
|
||||
reasons.append("workbench_events_candidate")
|
||||
return {
|
||||
"traceId": trace_id,
|
||||
"index": index,
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"services": services,
|
||||
"spanCount": span_count,
|
||||
"errorSpanCount": error_span_count,
|
||||
"businessTraceIds": business_trace_ids,
|
||||
"rootName": root_name,
|
||||
"fetchOk": True,
|
||||
"selectedMeta": compact_meta(meta),
|
||||
}
|
||||
|
||||
def resolve_trace():
|
||||
if TRACE_ID is not None:
|
||||
return TRACE_ID, {
|
||||
@@ -2433,14 +2747,36 @@ def resolve_trace():
|
||||
search_rc, search_body, search_err = run_kubectl(SEARCH_PROXY_PATH, timeout=15)
|
||||
search_parsed = parse_json(search_body)
|
||||
metas = extract_traces(search_parsed)
|
||||
selected = None
|
||||
selected_trace_id = None
|
||||
for meta in metas:
|
||||
raw_candidates = []
|
||||
for index, meta in enumerate(metas):
|
||||
trace_id = trace_id_from_meta(meta)
|
||||
if trace_id:
|
||||
selected = meta
|
||||
selected_trace_id = trace_id
|
||||
raw_candidates.append((index, trace_id, meta))
|
||||
candidate_probe_limit = 60
|
||||
probe_deadline_seconds = 45
|
||||
probe_deadline = time.time() + probe_deadline_seconds
|
||||
candidate_probe_stopped = None
|
||||
candidate_scores = []
|
||||
for index, trace_id, meta in raw_candidates[:candidate_probe_limit]:
|
||||
if time.time() > probe_deadline:
|
||||
candidate_probe_stopped = "deadline"
|
||||
break
|
||||
scored = score_trace_candidate(trace_id, meta, index)
|
||||
candidate_scores.append(scored)
|
||||
if scored.get("score", 0) >= 260 and len(candidate_scores) >= 12:
|
||||
candidate_probe_stopped = "strong-candidate-found"
|
||||
break
|
||||
candidate_scores.sort(key=lambda item: (item.get("score", -9999), item.get("spanCount", 0), -item.get("index", 0)), reverse=True)
|
||||
selected_score = candidate_scores[0] if candidate_scores else None
|
||||
selected_trace_id = selected_score.get("traceId") if isinstance(selected_score, dict) else None
|
||||
selected = selected_score.get("selectedMeta") if isinstance(selected_score, dict) else None
|
||||
selection_warning = None
|
||||
if selected_score is not None:
|
||||
selected_services = set(str(service) for service in selected_score.get("services", []))
|
||||
if "agentrun-runner" not in selected_services:
|
||||
selection_warning = "no candidate contained agentrun-runner; selected best available trace"
|
||||
if selected_score.get("score", 0) < 0:
|
||||
selection_warning = "candidate fetch/scoring did not find a strong trace; selected best available trace"
|
||||
mapping = {
|
||||
"mode": "business-trace-id",
|
||||
"businessTraceId": BUSINESS_TRACE_ID,
|
||||
@@ -2450,7 +2786,21 @@ def resolve_trace():
|
||||
"searchOk": search_rc == 0,
|
||||
"searchParseOk": isinstance(search_parsed, dict),
|
||||
"candidateTraceCount": len(metas),
|
||||
"selectedMeta": compact_meta(selected),
|
||||
"candidateProbeLimit": candidate_probe_limit,
|
||||
"candidateProbeDeadlineSeconds": probe_deadline_seconds,
|
||||
"candidateProbeStopped": candidate_probe_stopped,
|
||||
"probedCandidateCount": len(candidate_scores),
|
||||
"selectedMeta": selected,
|
||||
"selection": {
|
||||
"mode": "scored-candidates",
|
||||
"score": selected_score.get("score") if isinstance(selected_score, dict) else None,
|
||||
"reasons": selected_score.get("reasons") if isinstance(selected_score, dict) else [],
|
||||
"rootName": selected_score.get("rootName") if isinstance(selected_score, dict) else None,
|
||||
"services": selected_score.get("services") if isinstance(selected_score, dict) else [],
|
||||
"spanCount": selected_score.get("spanCount") if isinstance(selected_score, dict) else None,
|
||||
},
|
||||
"selectionWarning": selection_warning,
|
||||
"candidateScores": candidate_scores[:12],
|
||||
"searchStderrTail": (search_err or "")[-2000:],
|
||||
}
|
||||
if RAW:
|
||||
|
||||
+12
-5
@@ -4,6 +4,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { cliOutputDumpRuntimeConfig } from "./output";
|
||||
import {
|
||||
decodeApplyPatchV2BulkRead,
|
||||
formatApplyPatchV2BulkReplacementPayload,
|
||||
@@ -196,7 +197,7 @@ const windowsCmdExeNativePath = "C:\\Windows\\System32\\cmd.exe";
|
||||
const defaultSshSlowWarningMs = 10_000;
|
||||
const defaultSshRuntimeTimeoutMs = 60_000;
|
||||
const maxSshRuntimeTimeoutMs = 60_000;
|
||||
const defaultSshStdoutStreamMaxBytes = 256 * 1024;
|
||||
const defaultSshStdoutStreamMaxBytes = 10 * 1024;
|
||||
const minSshStdoutStreamMaxBytes = 4 * 1024;
|
||||
const maxSshStdoutStreamMaxBytes = 16 * 1024 * 1024;
|
||||
const sshStdoutDumpDir = join(tmpdir(), "unidesk-cli-output");
|
||||
@@ -2513,9 +2514,15 @@ export function formatSshRuntimeTimeoutHint(hint: SshRuntimeTimeoutHint): string
|
||||
}
|
||||
|
||||
export function sshStdoutStreamMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES ?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES;
|
||||
const raw = env.UNIDESK_SSH_STDOUT_STREAM_MAX_BYTES
|
||||
?? env.UNIDESK_TRAN_STDOUT_STREAM_MAX_BYTES
|
||||
?? env.UNIDESK_CLI_OUTPUT_DUMP_THRESHOLD_BYTES;
|
||||
const parsed = raw === undefined ? NaN : Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return defaultSshStdoutStreamMaxBytes;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
const config = cliOutputDumpRuntimeConfig();
|
||||
if (config.enabled) return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(config.thresholdBytes)));
|
||||
return defaultSshStdoutStreamMaxBytes;
|
||||
}
|
||||
return Math.min(maxSshStdoutStreamMaxBytes, Math.max(minSshStdoutStreamMaxBytes, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
@@ -2538,8 +2545,8 @@ export function sshStdoutTruncationHint(options: {
|
||||
observedBytesAtTruncation: options.observedBytesAtTruncation,
|
||||
dumpPath: options.dumpPath,
|
||||
dumpError: options.dumpError ?? null,
|
||||
message: `ssh stdout exceeded ${options.thresholdBytes} bytes; stdout is bounded and the complete stream is written to a local dump when possible.`,
|
||||
action: "Inspect the dump path, or rerun a narrower remote command with tail/paging instead of emitting full logs or huge JSON.",
|
||||
message: `ssh/trans stdout exceeded the global dump threshold (${options.thresholdBytes} bytes); stdout is bounded and the complete stream is written to a local dump when possible.`,
|
||||
action: "Fix the called CLI default output to be concise/table-like; use the dump only as a fallback, or rerun a narrower remote command with tail/paging.",
|
||||
note: "This hint is written to stderr and intentionally does not echo the original remote command.",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user