diff --git a/AGENTS.md b/AGENTS.md index 0b342af6..eed241dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 自有配置真相。 diff --git a/config/cli-output.yaml b/config/cli-output.yaml new file mode 100644 index 00000000..36737ac4 --- /dev/null +++ b/config/cli-output.yaml @@ -0,0 +1,6 @@ +output: + dump: + enabled: true + thresholdBytes: 10240 + previewChars: 2000 + dir: /tmp/unidesk-cli-output diff --git a/scripts/cli.ts b/scripts/cli.ts index 2351d367..296590ca 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -290,6 +290,11 @@ async function main(): Promise { 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 { 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; diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 4b83abb9..5ae7e5cf 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -4703,6 +4703,160 @@ function compactCommentSummary(comment: GitHubComment): Record }; } +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 ?? ""} --repo ${result.repo}`, + ` full: bun scripts/cli.ts gh issue view ${issue.number ?? ""} --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 ?? ""} --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 ?? ""} --repo ${result.repo}`, + ` preflight: bun scripts/cli.ts gh pr preflight ${pr.number ?? ""} --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 ?? ""} --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 ?? ""} --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>(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(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 { @@ -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(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 { @@ -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(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 { @@ -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 = { 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 { @@ -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(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 { @@ -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 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.", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index aec3c82d..93ffab52 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -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 { @@ -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 { @@ -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): string { @@ -6979,6 +6986,222 @@ function webObserveNextCommands(id: string): Record { }; } +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, renderedText: string): Record { + return { + ...result, + renderedText, + contentType: "text/plain", + }; +} + +function renderWebObserveStatusResult(result: Record): Record { + 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): Record { + 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): Record { + 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 | null, id: string | null): Record | null { if (value === null || id === null) return value; return { diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index ecebc644..9fec0dd0 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -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; } diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 4d906187..36f08b91 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -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 { 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(command: string, envelope: JsonEnvelope): 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(command: string, envelope: JsonEnvelope): 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): 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 { - 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 { + 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): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function recordString(source: Record, key: string): string | null { + const value = source[key]; + return typeof value === "string" ? value : null; +} + +function booleanField(source: Record, 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, 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, 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, keys: string[]): Record { const result: Record = {}; for (const key of keys) { diff --git a/scripts/src/platform-infra-observability.ts b/scripts/src/platform-infra-observability.ts index 97e1da13..f769bd69 100644 --- a/scripts/src/platform-infra-observability.ts +++ b/scripts/src/platform-infra-observability.ts @@ -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> { +async function trace(config: UniDeskConfig, options: TraceOptions): Promise | RenderedCliResult> { if (options.traceId === null) throw new Error("observability trace requires --trace-id "); 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> { +async function search(config: UniDeskConfig, options: SearchOptions): Promise | 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> { +async function diagnoseCodeAgent(config: UniDeskConfig, options: DiagnoseCodeAgentOptions): Promise | 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): 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) || ""} --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) || ""} --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): 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 --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 --grep --limit 80`); + return { + ok: response.ok === true, + command: "platform-infra observability search", + contentType: "text/plain", + renderedText: `${lines.join("\n")}\n`, + }; +} + +function renderTraceResult(response: Record): 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 || ""} --grep --limit 80`); + lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${textValue(target.id) || "D601"} --trace-id ${traceId || ""} --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 { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +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: diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 25b8f7c9..10448a02 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -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.", }; }