fix cli output dumping and otel web-probe diagnostics

This commit is contained in:
Codex
2026-06-21 16:46:17 +00:00
parent da3efa4c6e
commit 06bffaf74a
9 changed files with 959 additions and 76 deletions
+7
View File
@@ -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 自有配置真相。
+6
View File
@@ -0,0 +1,6 @@
output:
dump:
enabled: true
thresholdBytes: 10240
previewChars: 2000
dir: /tmp/unidesk-cli-output
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+364 -14
View File
@@ -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
View File
@@ -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.",
};
}