diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 15e4512d..9a033950 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -74,6 +74,7 @@ JS - `web-probe script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file ` 提供脚本;只需要 repo-owned 标准 DOM probe 时使用 `web-probe run`。 - 自定义 `web-probe script` 仍运行在 UniDesk `trans` 60s 最外层短连接约束内;能在一轮内完成的 P4 验收优先把 `--command-timeout-seconds` 控制在 55 秒以内,并减少无界 selector/network 等待。确需等待更久时,改用 `web-probe run` 的异步 job/status 语义,或把动作拆成“提交/采样/截图/状态读取”多次短 probe。若输出出现 `UNIDESK_SSH_RUNTIME_TIMEOUT` 但同时恢复了 `reportPath`、`reportSha256`、screenshots 或 DOM steps,先按远端报告判断脚本/页面实际状态;最终关闭证据仍优先用一次未触发短连接超时的 bounded rerun。 +- issue closeout 优先引用 `web-probe script` 输出的顶层 `issueEvidence` 或 `summary.issueEvidence`;只有需要展开调查时才粘贴 `probe.script.result`、`probe.steps` 或完整 `reportPath`,避免 stdout、summary 和 report 多层重复同一证据。 - stdin heredoc 与 `--script-file` 都按 ES module 加载,脚本必须导出 `export default async ({ page, gotoStable, recordStep, ... }) => { ... }`;不要在模块顶层直接写 `return`。失败为 `Illegal return statement`、`does not provide an export named default` 或 finalUrl 仍是 `about:blank` 且 stepCount=0 时,先按 probe 脚本入口误用处理,不要归因成 Cloud Web 行为失败。 - web-probe 由 UniDesk CLI 从 YAML 声明的 bootstrap admin sourceRef 读取凭据并建立同源 `hwlab_session`;脚本不得自行读取、打印或复制 Web 登录凭据、cookie、token 或完整 API key。 - 脚本构造 URL 时使用 `new URL(path, baseUrl).toString()`;不要拼出 `//v1/...`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c6e6d45e..7de923d5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,12 +24,16 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期 `hwlab nodes web-probe run|script --node --lane ` 是 HWLAB Cloud Web 线上 DOM/Playwright 验收的受控入口;CLI 负责从 YAML 解析 workspace、public URL 和 bootstrap admin sourceRef,并只输出 redacted 凭据状态、artifact path/hash、readiness、`probe.summary` 和失败分类。`run` 使用 repo-owned 标准 DOM probe;`script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file ` 提供调用者脚本。`run --message ...` 未显式设置 trace 参数时会做轻量 trace 采样,`script` helper 可用 `recordStep` / `safeFetchJson` / `fetchApiMatrix` 保留失败前的结构化 partial evidence,完整 redacted 报告通过 `reportPath`/`reportSha256` 展开。具体 Web 开发、fake-server Playwright、fixture 脱敏、`web-probe script` helper、截图和 Workbench/Performance 判定口径统一见 `$unidesk-webdev`,本 CLI 参考不再维护第二套操作面。 +`web-probe script` 的 issue closeout 优先引用顶层 `issueEvidence` 或 `summary.issueEvidence`,其中包含 redacted `result`、最近 steps、lastStep、script/report SHA 和截图摘要;完整调查才展开 `probe.script.result`、`probe.steps` 或 `reportPath`,避免把同一证据在 stdout、summary 和 report 多层重复粘贴。 + `hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 k3s、CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target、host k3s node config 摘要和将渲染的 control-plane 对象;`status` 只读观察 k3s systemd drop-in 与 node `capacity/allocatable.pods`、D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 与 host config 摘要;`apply --confirm` 按 YAML 收敛 D601 host k3s drop-in 和 control-plane bootstrap 对象,只有 host k3s 配置或 live pod capacity 未收敛时才重启 k3s,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。D601 host 侧 k3s pre-start 修正也必须写成 YAML `execStartPre` argv,不做手工 systemd 热改;当 kube API 已不可用时,`apply` 可用同一 YAML 渲染出的 host 脚本经 node-local tools image/Docker fallback 恢复 systemd drop-in,输出仍只给对象名、SHA、exit code 和摘要。k3s pod capacity 等可调数值只以 YAML 为准,长期参考不复制具体数值;tools image 的 node-local registry 地址只能作为输出 artifact,输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。 `hwlab nodes git-mirror status|sync|flush --node --lane ` 是 node-scoped runtime lane 的 Git mirror 维护入口。`status` 的 `githubSource` / `githubGitops` 来自本地 mirror cache 的 `refs/mirror-stage/...`,不是实时 GitHub API;输出中的 `refSources.githubFieldsAreMirrorStageCache=true` 和 `refSources.cacheRefresh` 给出这一来源和刷新命令。`sync --confirm --wait` 的 k3s Job 遇到 GitHub SSH transient 时,应通过目标 workspace fallback 拉取 GitHub source/gitops 并写回 node-local mirror,输出只披露 commit、mirror write URL 和 fallback 状态。`flush --confirm --wait` 如果已经把 GitOps ref push 到 GitHub,但 post-push fetch/recheck 因 transient SSH 失败而无法刷新 mirror-stage,会标记 `partialSuccess=push-succeeded-fetch-failed`;CLI 应自动执行一次受控 sync 刷新 mirror-stage,若恢复后 `pendingFlush=false` 且 `githubInSync=true`,结果应为 `ok=true` 并输出 `partialSuccessRecovered` / `postPushRecovery`,否则才保留 `degradedReason=node-runtime-git-mirror-flush-post-push-fetch-failed` 和下一步 `sync --confirm --wait`。不要把这种 partial success 解读为需要连续盲目 flush。`hwlab nodes control-plane trigger-current --node --lane --confirm --wait` 会在 source sync 后自动执行必要的 pre-flush,在 PipelineRun terminal 后自动执行必要的 post-flush;progress 事件必须显式输出 `git-mirror-pre-flush` / `git-mirror-post-flush` 的 executed/skipped、jobName、local/github source、local/github GitOps、`pendingFlush` 和 `githubInSync`,且已恢复的 partial success 不能让顶层 trigger-current false-fail。`control-plane status` 仍是只读入口,只暴露 compact `gitMirror` 摘要和下一步 flush 命令,不隐式执行写操作。 PR 合并后触发 node-scoped runtime lane 时,`control-plane status --pipeline-run ` 是某次 PipelineRun 的定点观察入口,但同一输出中的 `sourceHead` / `summary.sourceCommit` 仍可能反映当前分支最新 head;如果触发后又有后续 PR 合并,当前 head 可能已经不是该 PipelineRun 名称中的短 SHA。closeout 证据必须同时写明:PR merge commit、定点 PipelineRun 名称和状态、最终 runtime/GitOps revision、当前 branch tip,以及当前 branch tip 是否包含本次 PR merge commit。不要只凭 `summary.sourceCommit` 反推某个旧 PipelineRun 的源码身份。 +`hwlab nodes control-plane status` 的 `publicProbe.ready` 表示控制面从公网用户入口访问 YAML 声明 public Web/API 成功;`publicProbe.targetHost` 只表示目标节点 host 自己访问同一公网 URL 的诊断结果。若 `publicProbe.ready=true` 且 `publicProbe.diagnostic.kind=target-host-public-egress-mismatch`,closeout 仍以 `publicProbe` 和 `web-probe` 用户入口证据为准,host 侧 `hwlab-cli` 访问公网失败应单独按目标 host egress/hairpin 问题跟踪。 + PipelineRun 失败或长时间未完成时,先按定点 `control-plane status --pipeline-run ` 和 bounded 只读 k3s 诊断定位失败 TaskRun/Pod/container。env-reuse service build 常见失败点是 `build-` 的 `step-publish` 日志,apt、npm、Go module 等外部依赖下载可能通过 lane YAML 注入的 egress proxy 出现瞬时 502、reset 或超时;先用 `platform-infra sub2api status|validate` 区分共享 proxy 整体故障和单个上游 transient。proxy 健康但单个依赖下载 transient 时,可以受控 `trigger-current --rerun`;重复失败应把对应 `artifact-publish`/envRecipe 下载步骤补成有限重试后重新合并发布。不要用原生 `kubectl delete/patch`、pod 内热补或盲目全量重跑替代持久化 recipe 修复。 `hwlab nodes control-plane infra tools-image status|build|logs --node D601 --lane v03` 是 D601 tools image 的受控入口。Dockerfile 必须由 `config/hwlab-node-control-plane.yaml` 的 `tekton.toolsImage.dockerfileInline` 声明,输入镜像必须列在 `publicBaseImages`,构建参数和网络模式也来自 YAML;confirmed build 只在 D601 后台异步构建并推送到 node-local registry,返回 status/logs 轮询命令。`hwlab nodes control-plane infra argo status|apply|logs --node D601 --lane v03` 是 D601 Argo CD 的声明式安装入口。Argo 版本、官方 manifest URL、镜像 rewrite/preload、field manager、imagePullPolicy、CRD 列表、期望 Deployment/StatefulSet 以及生成的 AppProject/Application 都必须来自同一个 YAML;`argo apply --confirm` 只执行可重复 server-side apply 和后台轮询,不把原生 `kubectl apply`、手工 Argo CLI 或临时 manifest 作为正式安装路径。 diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 53ac94cb..2552cf40 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -75,7 +75,7 @@ export function hwlabNodeWebProbeHelp(): Record { }, notes: [ "Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.", - "Issue-ready summary is available under probe.summary; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.", + "Issue-ready evidence is available under issueEvidence and summary.issueEvidence; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.", "Use recordStep(name, data) or fetchApiMatrix(paths) to keep structured partial evidence when a later step fails.", "Use reloadStable(), gotoCurrentStable(), or safeReload() for bounded retries around page reload/current-URL navigation jitter such as ERR_NETWORK_CHANGED.", "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 7955d427..5b01bb8a 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -3349,10 +3349,14 @@ function nullableInteger(value: string): number | null { function nodeRuntimePublicProbeStatus(spec: HwlabRuntimeLaneSpec): Record { const web = publicHttpProbe("web", spec.publicWebUrl); const apiHealth = publicHttpProbe("apiHealth", joinUrlPath(spec.publicApiUrl, "/health/live")); + const ready = web.ok === true && apiHealth.ok === true; + const targetHost = nodeRuntimeTargetHostPublicProbeStatus(spec); return { - ready: web.ok === true && apiHealth.ok === true, + ready, web, apiHealth, + targetHost, + diagnostic: nodeRuntimePublicProbeDiagnostic(ready, targetHost), }; } @@ -3368,6 +3372,103 @@ function publicHttpProbe(kind: string, url: string): Record { }; } +function nodeRuntimeTargetHostPublicProbeStatus(spec: HwlabRuntimeLaneSpec): Record { + const webUrl = spec.publicWebUrl; + const apiHealthUrl = joinUrlPath(spec.publicApiUrl, "/health/live"); + const script = [ + "set -eu", + `web_url=${shellQuote(webUrl)}`, + `api_url=${shellQuote(apiHealthUrl)}`, + "probe() {", + " name=\"$1\"", + " url=\"$2\"", + " err_file=$(mktemp)", + " set +e", + " http_status=$(curl -k -sS --connect-timeout 5 --max-time 12 -o /dev/null -w '%{http_code}' \"$url\" 2>\"$err_file\")", + " rc=$?", + " set -e", + " error_text=$(tr '\\r\\n\\t' ' ' <\"$err_file\" | tail -c 600)", + " rm -f \"$err_file\"", + " printf '%sUrl\\t%s\\n' \"$name\" \"$url\"", + " printf '%sExitCode\\t%s\\n' \"$name\" \"$rc\"", + " printf '%sHttpStatus\\t%s\\n' \"$name\" \"$http_status\"", + " printf '%sError\\t%s\\n' \"$name\" \"$error_text\"", + "}", + "probe web \"$web_url\"", + "probe apiHealth \"$api_url\"", + ].join("\n"); + const result = runTransHostScript(spec.nodeId, script, "", 35); + const fields = keyValueLinesFromText(result.stdout); + const web = targetHostPublicHttpProbeFromFields("web", fields, webUrl, result.exitCode === 0); + const apiHealth = targetHostPublicHttpProbeFromFields("apiHealth", fields, apiHealthUrl, result.exitCode === 0); + return { + node: spec.nodeId, + ready: web.ok === true && apiHealth.ok === true, + probeAvailable: result.exitCode === 0 && result.timedOut !== true, + web, + apiHealth, + result: compactRuntimeCommand(result), + }; +} + +function targetHostPublicHttpProbeFromFields(kind: string, fields: Record, fallbackUrl: string, transportOk: boolean): Record { + const exitCode = numericField(fields[`${kind}ExitCode`]); + const httpStatus = numericField(fields[`${kind}HttpStatus`]); + const error = fields[`${kind}Error`] ?? ""; + return { + kind, + url: fields[`${kind}Url`] ?? fallbackUrl, + ok: transportOk && exitCode === 0 && httpStatus !== null && httpStatus >= 200 && httpStatus < 400, + httpStatus, + exitCode, + error: error.length > 0 ? error.slice(0, 600) : null, + }; +} + +function nodeRuntimePublicProbeDiagnostic(publicReady: boolean, targetHost: Record): Record { + const targetWeb = record(targetHost.web); + const targetApiHealth = record(targetHost.apiHealth); + const targetReady = targetHost.ready === true; + if (!publicReady) { + return { + kind: "public-entry-probe-failed", + affectsUserEntry: true, + targetHostReady: targetReady, + message: "control-plane public probe failed; treat this as a public endpoint readiness failure before using web-probe closeout evidence.", + nextAction: "run hwlab nodes web-probe run for the same node/lane after checking publicProbe.web and publicProbe.apiHealth", + }; + } + if (targetHost.probeAvailable !== true) { + return { + kind: "target-host-public-probe-unavailable", + affectsUserEntry: false, + targetHostReady: false, + message: "control-plane public probe passed, but the target host diagnostic probe could not run; this does not invalidate user-entry evidence.", + nextAction: "use control-plane publicProbe and web-probe evidence for closeout; inspect target host SSH/trans health separately if host-side CLI must call the public URL", + }; + } + if (!targetReady) { + return { + kind: "target-host-public-egress-mismatch", + affectsUserEntry: false, + targetHostReady: false, + failed: { + web: targetWeb.ok === true ? null : { httpStatus: targetWeb.httpStatus ?? null, exitCode: targetWeb.exitCode ?? null, error: targetWeb.error ?? null }, + apiHealth: targetApiHealth.ok === true ? null : { httpStatus: targetApiHealth.httpStatus ?? null, exitCode: targetApiHealth.exitCode ?? null, error: targetApiHealth.error ?? null }, + }, + message: "control-plane public probe passed, but the target host cannot reach the same public URLs; classify this as target-host egress/hairpin diagnostics, not a public endpoint failure.", + nextAction: "use control-plane publicProbe and web-probe evidence for issue closeout; track host-side public URL access separately if hwlab-cli must run on that host", + }; + } + return { + kind: "public-entry-and-target-host-ok", + affectsUserEntry: false, + targetHostReady: true, + message: "control-plane public probe and target-host public URL probe both passed.", + nextAction: null, + }; +} + function joinUrlPath(baseUrl: string, suffix: string): string { return `${baseUrl.replace(/\/+$/u, "")}/${suffix.replace(/^\/+/u, "")}`; } @@ -3385,6 +3486,10 @@ function summarizeNodeRuntimeControlPlaneStatus(status: Record, const workloadCount = typeof runtime.workloadCount === "number" ? runtime.workloadCount : workloadReadiness.length; const webProbe = record(publicProbes.web); const apiProbe = record(publicProbes.apiHealth); + const targetHostProbe = record(publicProbes.targetHost); + const targetHostWebProbe = record(targetHostProbe.web); + const targetHostApiProbe = record(targetHostProbe.apiHealth); + const publicProbeDiagnostic = record(publicProbes.diagnostic); return { ok: status.ok === true, command: status.command, @@ -3437,6 +3542,26 @@ function summarizeNodeRuntimeControlPlaneStatus(status: Record, ready: publicProbes.ready === true, web: { url: webProbe.url ?? null, ok: webProbe.ok === true, httpStatus: webProbe.httpStatus ?? null }, apiHealth: { url: apiProbe.url ?? null, ok: apiProbe.ok === true, httpStatus: apiProbe.httpStatus ?? null }, + targetHost: Object.keys(targetHostProbe).length === 0 ? null : { + node: targetHostProbe.node ?? status.node ?? null, + ready: targetHostProbe.ready === true, + probeAvailable: targetHostProbe.probeAvailable === true, + web: { + url: targetHostWebProbe.url ?? null, + ok: targetHostWebProbe.ok === true, + httpStatus: targetHostWebProbe.httpStatus ?? null, + exitCode: targetHostWebProbe.exitCode ?? null, + error: targetHostWebProbe.error ?? null, + }, + apiHealth: { + url: targetHostApiProbe.url ?? null, + ok: targetHostApiProbe.ok === true, + httpStatus: targetHostApiProbe.httpStatus ?? null, + exitCode: targetHostApiProbe.exitCode ?? null, + error: targetHostApiProbe.error ?? null, + }, + }, + diagnostic: Object.keys(publicProbeDiagnostic).length === 0 ? null : publicProbeDiagnostic, }, gitMirror: { ready: gitMirror.ready === true, @@ -6212,6 +6337,7 @@ function runNodeWebProbeScript( stderrTail: result.stderr.trim().slice(-2000), valuesRedacted: true, }); + const issueEvidence = nullableRecord(report?.issueEvidence) ?? nullableRecord(effectiveSummary?.issueEvidence); const compactResult = compactCommandResultRedacted(result, [material.password ?? ""]); if (outputFailureKind !== null) { compactResult.stdoutTail = redactKnownSecrets(result.stdout.slice(-2000), [material.password ?? ""]); @@ -6230,6 +6356,7 @@ function runNodeWebProbeScript( degradedReason, failureKind, summary: effectiveSummary, + issueEvidence, probe: report, reportLoad: stdoutReport !== null ? { source: "stdout", path: report?.reportPath ?? null, degradedReason: null } : recoveredReport === null ? null : { source: recoveredReport.source, diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index 6522e5a0..a34e3587 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -1773,7 +1773,7 @@ function sanitize(value, depth = 0, seen = new WeakSet()) { if (typeof value === "number" || typeof value === "boolean") return value; if (typeof value === "bigint") return String(value); if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]"; - if (depth > 8) return "[max-depth]"; + if (depth > 12) return "[max-depth]"; if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitize(item, depth + 1, seen)); if (typeof value === "object") { if (seen.has(value)) return "[circular]"; @@ -1836,6 +1836,7 @@ async function emit(payload) { } function compactStdoutPayload(payload) { + const issueEvidence = compactIssueEvidenceForStdout(payload?.summary?.issueEvidence ?? issueEvidenceFromPayload(payload)); return { ok: payload?.ok === true, status: payload?.status ?? null, @@ -1859,6 +1860,7 @@ function compactStdoutPayload(payload) { lastScreenshot: compactArtifactForStdout(payload?.lastScreenshot), readiness: compactJsonForStdout(payload?.readiness), artifacts: compactArtifactsForStdout(payload?.artifacts), + issueEvidence, summary: compactSummaryForStdout(payload?.summary), safety: { valuesRedacted: true, @@ -1874,7 +1876,7 @@ function compactScriptForStdout(script) { const steps = Array.isArray(value.steps) ? value.steps : []; return { ok: value.ok === true, - result: compactJsonForStdout(value.result), + result: compactJsonForEvidence(value.result), stepCount: steps.length, }; } @@ -1932,6 +1934,7 @@ function compactSummaryForStdout(summary) { apiMatrix: compactApiMatrixForStdout(value.apiMatrix), stepCount: Number.isFinite(value.stepCount) ? value.stepCount : null, lastStep: compactStepForStdout(value.lastStep), + issueEvidence: compactIssueEvidenceForStdout(value.issueEvidence), valuesRedacted: true, }; } @@ -2000,6 +2003,98 @@ function compactJsonForStdout(value, depth = 0) { return compactText(String(value), 180); } +function issueEvidenceFromPayload(payload) { + const steps = publicSteps(); + const artifacts = Array.isArray(payload?.artifacts?.items) ? payload.artifacts.items : artifactRecords; + const screenshots = artifacts + .filter((item) => item && typeof item === "object" && item.kind === "screenshot") + .slice(-5) + .map((item) => compactArtifactForStdout(item)) + .filter(Boolean); + const ok = payload?.ok === true; + const degradedReason = ok ? null : payload?.error ?? payload?.failureKind ?? "web-probe-script-failed"; + const failureKind = ok ? null : classifyIssueFailureKind(payload?.failureKind ?? payload?.error ?? degradedReason, payload?.errorMessage); + const failedCondition = ok ? null : payload?.errorMessage ?? payload?.error ?? payload?.failureKind ?? "script did not pass"; + const script = payload?.script && typeof payload.script === "object" ? payload.script : {}; + return { + ok, + status: payload?.status ?? null, + degradedReason, + failureKind, + failedCondition, + nextAction: ok ? null : issueNextAction(failureKind, payload), + baseUrl: payload?.baseUrl ?? null, + finalUrl: payload?.finalUrl ?? payload?.lastUrl ?? null, + lastUrl: payload?.lastUrl ?? payload?.finalUrl ?? null, + scriptSha256: payload?.scriptSha256 ?? null, + runDir, + reportPath: payload?.reportPath ?? null, + reportSha256: payload?.reportSha256 ?? null, + result: compactJsonForEvidence(script.result), + apiMatrix: compactApiMatrixForStdout(latestApiMatrixFromSteps(steps)), + lastStep: steps.length > 0 ? compactStepForEvidence(steps[steps.length - 1]) : null, + steps: steps.slice(-3).map((step) => compactStepForEvidence(step)), + lastScreenshot: compactArtifactForStdout(payload?.lastScreenshot), + screenshots, + valuesRedacted: true, + }; +} + +function compactIssueEvidenceForStdout(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return { + ok: value.ok === true, + status: value.status ?? null, + degradedReason: typeof value.degradedReason === "string" ? compactText(value.degradedReason, 600) : value.degradedReason ?? null, + failureKind: typeof value.failureKind === "string" ? compactText(value.failureKind, 300) : value.failureKind ?? null, + failedCondition: typeof value.failedCondition === "string" ? compactText(value.failedCondition, 1200) : value.failedCondition ?? null, + nextAction: typeof value.nextAction === "string" ? compactText(value.nextAction, 1200) : value.nextAction ?? null, + baseUrl: value.baseUrl ?? null, + finalUrl: value.finalUrl ?? null, + lastUrl: value.lastUrl ?? null, + scriptSha256: value.scriptSha256 ?? null, + runDir: value.runDir ?? runDir, + reportPath: value.reportPath ?? null, + reportSha256: value.reportSha256 ?? null, + result: compactJsonForEvidence(value.result), + apiMatrix: compactApiMatrixForStdout(value.apiMatrix), + lastStep: compactStepForEvidence(value.lastStep), + steps: Array.isArray(value.steps) ? value.steps.slice(-3).map((step) => compactStepForEvidence(step)) : [], + lastScreenshot: compactArtifactForStdout(value.lastScreenshot), + screenshots: Array.isArray(value.screenshots) ? value.screenshots.slice(-5).map((item) => compactArtifactForStdout(item)).filter(Boolean) : [], + valuesRedacted: true, + }; +} + +function compactStepForEvidence(step) { + if (!step || typeof step !== "object" || Array.isArray(step)) return null; + return { + index: Number.isFinite(step.index) ? step.index : null, + name: typeof step.name === "string" ? step.name : null, + ok: typeof step.ok === "boolean" ? step.ok : null, + atMs: Number.isFinite(step.atMs) ? step.atMs : null, + data: compactJsonForEvidence(step.data), + }; +} + +function compactJsonForEvidence(value, depth = 0) { + if (value === null || value === undefined) return value ?? null; + if (typeof value === "string") return compactText(value, 600); + if (typeof value === "number" || typeof value === "boolean") return value; + if (typeof value === "bigint") return String(value); + if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]"; + if (depth >= 8) return "[max-depth]"; + if (Array.isArray(value)) return value.slice(0, 16).map((item) => compactJsonForEvidence(item, depth + 1)); + if (typeof value === "object") { + const out = {}; + for (const [key, nested] of Object.entries(value).slice(0, 32)) { + out[key] = compactJsonForEvidence(nested, depth + 1); + } + return out; + } + return compactText(String(value), 600); +} + function compactText(value, maxChars) { return redactString(String(value)).replace(/\s+/gu, " ").trim().slice(0, maxChars); } @@ -2037,6 +2132,7 @@ function scriptIssueSummary(payload) { apiMatrix, stepCount: stepRecords.length, lastStep: stepRecords.length > 0 ? deepClonePlain(stepRecords[stepRecords.length - 1]) : null, + issueEvidence: issueEvidenceFromPayload(payload), valuesRedacted: true, }; } diff --git a/scripts/src/hwlab-node-web-probe-summary.ts b/scripts/src/hwlab-node-web-probe-summary.ts index 64b98ac9..5cb7dd0e 100644 --- a/scripts/src/hwlab-node-web-probe-summary.ts +++ b/scripts/src/hwlab-node-web-probe-summary.ts @@ -12,10 +12,12 @@ function nullableRecord(value: unknown): Record | null { export function compactWebProbeScriptResult(report: Record | null): Record | null { if (report === null) return null; const summary = compactIssueSummary(record(report.summary)); + const issueEvidence = compactIssueEvidence(report.issueEvidence ?? summary.issueEvidence ?? fallbackIssueEvidence(report, summary)); return { ok: report.ok === true, status: typeof report.status === "string" ? report.status : null, summary, + issueEvidence, baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null, finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null, lastUrl: typeof report.lastUrl === "string" ? report.lastUrl : null, @@ -41,7 +43,7 @@ function compactWebProbeScriptBlock(value: unknown): Record { const script = record(value); return { ok: script.ok === true, - result: compactJsonForIssue(script.result), + result: compactJsonForEvidence(script.result), stepCount: Array.isArray(script.steps) ? script.steps.length : null, }; } @@ -80,6 +82,7 @@ function compactIssueSummary(value: Record): Record, summary: Record): Record { + const script = record(report.script); + return { + ok: report.ok === true, + status: typeof report.status === "string" ? report.status : summary.status ?? null, + degradedReason: summary.degradedReason ?? null, + failureKind: summary.failureKind ?? (typeof report.failureKind === "string" ? report.failureKind : null), + failedCondition: summary.failedCondition ?? (typeof report.errorMessage === "string" ? report.errorMessage : null), + nextAction: summary.nextAction ?? null, + baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null, + finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : summary.finalUrl ?? null, + lastUrl: typeof report.lastUrl === "string" ? report.lastUrl : summary.lastUrl ?? null, + scriptSha256: typeof report.scriptSha256 === "string" ? report.scriptSha256 : summary.scriptSha256 ?? null, + runDir: typeof report.runDir === "string" ? report.runDir : summary.runDir ?? null, + reportPath: typeof report.reportPath === "string" ? report.reportPath : summary.reportPath ?? null, + reportSha256: typeof report.reportSha256 === "string" ? report.reportSha256 : summary.reportSha256 ?? null, + result: script.result, + apiMatrix: summary.apiMatrix ?? null, + lastStep: summary.lastStep ?? null, + steps: Array.isArray(report.steps) ? report.steps.slice(-3) : [], + lastScreenshot: report.lastScreenshot ?? summary.lastScreenshot ?? null, + screenshots: summary.screenshots ?? [], + valuesRedacted: true, + }; +} + +function compactIssueEvidence(value: unknown): Record | null { + const evidence = nullableRecord(value); + if (evidence === null) return null; + return { + ok: evidence.ok === true, + status: typeof evidence.status === "string" ? evidence.status : null, + degradedReason: typeof evidence.degradedReason === "string" ? evidence.degradedReason : null, + failureKind: typeof evidence.failureKind === "string" ? evidence.failureKind : null, + failedCondition: typeof evidence.failedCondition === "string" ? evidence.failedCondition : null, + nextAction: typeof evidence.nextAction === "string" ? evidence.nextAction : null, + baseUrl: typeof evidence.baseUrl === "string" ? evidence.baseUrl : null, + finalUrl: typeof evidence.finalUrl === "string" ? evidence.finalUrl : null, + lastUrl: typeof evidence.lastUrl === "string" ? evidence.lastUrl : null, + scriptSha256: typeof evidence.scriptSha256 === "string" ? evidence.scriptSha256 : null, + runDir: typeof evidence.runDir === "string" ? evidence.runDir : null, + reportPath: typeof evidence.reportPath === "string" ? evidence.reportPath : null, + reportSha256: typeof evidence.reportSha256 === "string" ? evidence.reportSha256 : null, + result: compactJsonForEvidence(evidence.result), + apiMatrix: compactApiMatrixSummary(evidence.apiMatrix), + lastStep: compactStepForEvidence(evidence.lastStep), + steps: Array.isArray(evidence.steps) ? evidence.steps.slice(-3).map(compactStepForEvidence).filter((item): item is Record => item !== null) : [], + lastScreenshot: nullableRecord(evidence.lastScreenshot), + screenshots: Array.isArray(evidence.screenshots) ? evidence.screenshots.slice(-5).map(nullableRecord).filter((item): item is Record => item !== null) : [], + valuesRedacted: evidence.valuesRedacted === true, + }; +} + +function compactStepForEvidence(value: unknown): Record | null { + const step = nullableRecord(value); + if (step === null) return null; + return { + index: typeof step.index === "number" ? step.index : null, + name: typeof step.name === "string" ? step.name : null, + ok: typeof step.ok === "boolean" ? step.ok : null, + atMs: typeof step.atMs === "number" ? step.atMs : null, + data: compactJsonForEvidence(step.data), + }; +} + +function compactJsonForEvidence(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return value ?? null; + if (typeof value === "string") return value.replace(/\s+/gu, " ").trim().slice(0, 600); + if (typeof value === "number" || typeof value === "boolean") return value; + if (depth >= 8) return "[max-depth]"; + if (Array.isArray(value)) return value.slice(0, 16).map((item) => compactJsonForEvidence(item, depth + 1)); + if (typeof value === "object") { + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record).slice(0, 32)) { + out[key] = compactJsonForEvidence(nested, depth + 1); + } + return out; + } + return String(value).slice(0, 600); +} + function compactStepForIssue(value: unknown): Record | null { const step = nullableRecord(value); if (step === null) return null;