+
-
count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))} · latest=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}
+
次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}
run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}
-
${escapeHtml(shortText(item.summary || "", 180))}
-
+
${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}
+
${escapeHtml(findingNextAction(code))}
+
`;
}).join("");
for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) {
@@ -398,17 +400,17 @@ function renderFindings() {
function renderFindingAggregation() {
if (state.findings.length === 0) {
- refs.findingAggregation.innerHTML = '
No finding aggregation
';
+ refs.findingAggregation.innerHTML = '
暂无发现聚合
';
return;
}
const severityEntries = aggregateFindings((item) => item.severity || "unknown").slice(0, 5);
const codeEntries = aggregateFindings((item) => item.code || item.findingId || "unknown").slice(0, 5);
const scenarioEntries = aggregateFindings((item) => item.scenarioId || "unknown").slice(0, 5);
refs.findingAggregation.innerHTML = [
- aggregationGroup("Severity", severityEntries, "severity"),
- aggregationGroup("Code", codeEntries, "code"),
- aggregationGroup("Scenario", scenarioEntries, "scenario"),
- `
Window
`,
+ aggregationGroup("严重级别", severityEntries.map((item) => ({ ...item, label: displaySeverity(item.key) })), "severity"),
+ aggregationGroup("代码", codeEntries.map((item) => ({ ...item, label: displayFindingCode(item.key) })), "code"),
+ aggregationGroup("场景", scenarioEntries, "scenario"),
+ `
窗口
`,
].join("");
for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) {
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
@@ -418,7 +420,7 @@ function renderFindingAggregation() {
function aggregationGroup(label, entries, filterKey) {
const chips = entries.length === 0
? '
-'
- : entries.map((entry) => `
`).join("");
+ : entries.map((entry) => `
`).join("");
return `
${escapeHtml(label)}${chips}
`;
}
@@ -445,8 +447,8 @@ function applyFindingFilter(key, value) {
function renderDetail() {
if (!state.selectedRunId || !state.runDetail) {
- refs.detailSubtitle.textContent = "No run selected";
- refs.detailContent.innerHTML = '
Select a run
';
+ refs.detailSubtitle.textContent = "未选择运行";
+ refs.detailContent.innerHTML = '
请选择一条运行
';
return;
}
const detail = state.runDetail;
@@ -457,29 +459,29 @@ function renderDetail() {
const turnSummaryView = selectedView(state.runViews, "turn-summary");
refs.detailSubtitle.textContent = run.runId || state.selectedRunId;
refs.detailContent.innerHTML = [
- detailBlock("Traceability", [
+ detailBlock("追溯信息", [
["run", run.runId || "-"],
["observer", run.observerId || "-"],
["stateDir", run.stateDir || "-"],
["report", run.reportJsonSha256 || "-"],
]),
- detailBlock("Summary", [
- ["status", run.status || "-"],
+ detailBlock("摘要", [
+ ["status", displayStatus(run.status)],
["scenario", run.scenarioId || "-"],
["findings", String(run.findingCount ?? 0)],
["artifacts", String(run.artifactCount ?? 0)],
["updated", run.updatedAt || "-"],
["views", Array.isArray(detail.viewsAvailable) ? detail.viewsAvailable.join(", ") : "-"],
]),
- detailBlock("Report Summary", safeSummaryRows(detail.summary), "detail-block-wide"),
+ detailBlock("报告摘要", safeSummaryRows(detail.summary), "detail-block-wide"),
detailFindings(findings),
detailArtifacts(artifacts),
detailCommands(commands),
detailTurnSummary(turnSummaryView),
detailTraceReader(state.runViews, commands),
detailEvidence(detail, artifacts),
- detailBlock("Redaction", [
- ["values", detail.valuesRedacted === true ? "redacted" : "-"],
+ detailBlock("脱敏", [
+ ["values", detail.valuesRedacted === true ? "已脱敏" : "-"],
["prompt", detail.redaction?.prompt || "-"],
["assistant", detail.redaction?.assistantFinal || "-"],
]),
@@ -495,16 +497,16 @@ function detailBlock(title, rows, className = "") {
}
function detailFindings(findings) {
- if (findings.length === 0) return detailBlock("Run Findings", [["status", "none"]], "detail-block-wide");
- return `
Run Findings
+ if (findings.length === 0) return detailBlock("运行发现项", [["status", "无"]], "detail-block-wide");
+ return `运行发现项
- | Severity | Code | Count | Summary | Report |
+ | 严重级别 | 代码 | 次数 | 摘要 | 报告 |
${findings.map((item) => `
- | ${escapeHtml(item.severity || "-")} |
+ ${escapeHtml(displaySeverity(item.severity))} |
${escapeHtml(item.finding_id || item.findingId || "-")} |
${escapeHtml(String(item.count ?? 0))} |
- ${escapeHtml(shortText(item.summary || "", 220))} |
+ ${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))} |
${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))} |
`).join("")}
@@ -514,7 +516,7 @@ function detailFindings(findings) {
function detailArtifacts(artifacts) {
const screenshot = artifacts.screenshot || {};
- return detailBlock("Artifacts", [
+ return detailBlock("产物", [
["artifactCount", String(artifacts.artifactCount ?? "-")],
["reportJsonSha256", artifacts.reportJsonSha256 || "-"],
["screenshotPath", screenshot.path || "-"],
@@ -529,17 +531,17 @@ function detailCommands(commands) {
["turn-summary", commands.turnSummary || "-"],
["trace-frame", commands.traceFrame || "-"],
];
- return `
CLI Commands
+ return `CLI 对照命令
${rows.map(([label, command]) => `
${escapeHtml(label)}${escapeHtml(command)}
`).join("")}
`;
}
function detailTurnSummary(view) {
- if (!view) return detailBlock("Turn Summary - Layer 1", [["status", "not indexed"]], "detail-block-wide");
- if (view.ok === false) return detailBlock("Turn Summary - Layer 1", [["status", view.error || "unavailable"]], "detail-block-wide");
+ if (!view) return detailBlock("多轮摘要 - 第一层", [["status", "未索引"]], "detail-block-wide");
+ if (view.ok === false) return detailBlock("多轮摘要 - 第一层", [["status", view.error || "不可用"]], "detail-block-wide");
const text = redactDisplayText(view.renderedText || "");
- const note = `${formatNumber(view.renderedTextBytes || text.length)} bytes${view.truncated ? " truncated" : ""}`;
- return `Turn Summary - Layer 1
+ const note = `${formatNumber(view.renderedTextBytes || text.length)} bytes${view.truncated ? " 已截断" : ""}`;
+ return `多轮摘要 - 第一层
${escapeHtml(note)}
${escapeHtml(text || "-")}
`;
@@ -550,25 +552,25 @@ function detailTraceReader(response, commands) {
const traceView = selectedView(response, "trace-frame");
const choices = traceChoices(turnView?.renderedText || "", traceView?.renderedText || "");
const selectedIndex = Math.min(state.selectedTraceChoiceIndex, Math.max(0, choices.length - 1));
- const selected = choices[selectedIndex] || { label: "stored trace-frame", meta: "current run", key: "stored" };
+ const selected = choices[selectedIndex] || { label: "已保存 trace-frame", meta: "当前运行", key: "stored" };
const traceText = traceView?.ok === false ? "" : redactDisplayText(traceView?.renderedText || "");
const traceNote = traceView
- ? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " truncated" : ""}`
- : "trace-frame view not indexed";
- return `Trace Frame - Layer 2
+ ? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " 已截断" : ""}`
+ : "trace-frame 未索引";
+ return `Trace Frame - 第二层
-
- Turn / trace / sample choices
+
+ 选择 turn / trace / sample
${choices.map((choice, index) => ``).join("")}
-
- Selected: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}
- ${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame unavailable" : "trace-frame view not indexed"))}
+
+ 已选择: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}
+ ${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame 不可用" : "trace-frame 未索引"))}
${finalResponseBlock(traceView)}
- source=${escapeHtml(commands.traceFrame || "-")} · analyzer findings do not rewrite this text
+ source=${escapeHtml(commands.traceFrame || "-")} · analyzer finding 不改写此文字证据
`;
@@ -576,17 +578,17 @@ function detailTraceReader(response, commands) {
function finalResponseBlock(traceView) {
if (!traceView) {
- return `Final Responsetrace-frame view not indexed
`;
+ return `Final Responsetrace-frame 未索引
`;
}
if (traceView.ok === false) {
- return `Final Response${escapeHtml(traceView.error || "trace-frame unavailable")}
`;
+ return `Final Response${escapeHtml(traceView.error || "trace-frame 不可用")}
`;
}
const block = traceView.finalResponse || {};
const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || "");
const bytes = Number(block.byteCount || 0);
return `
Final Response
- ${block.empty === true ? "empty" : "available"} · ${formatNumber(bytes)} bytes · values redacted
+ ${block.empty === true ? "空内容" : "有内容"} · ${formatNumber(bytes)} bytes · 已脱敏
${escapeHtml(text || "(空内容)")}
`;
}
@@ -602,19 +604,19 @@ function traceChoices(turnSummaryText, traceFrameText) {
.map((line) => line.trim())
.filter((line) => /(trace|sample|total=|final response)/iu.test(line))
.slice(0, 12);
- if (lines.length === 0) return [{ label: "stored trace-frame", meta: "current run", key: "stored" }];
+ if (lines.length === 0) return [{ label: "已保存 trace-frame", meta: "当前运行", key: "stored" }];
return lines.map((line, index) => {
const trace = line.match(/trace(?:Id)?[=: ]+([A-Za-z0-9_.:-]+)/u)?.[1] || line.match(/\btrc_[A-Za-z0-9_.:-]+/u)?.[0] || null;
const sample = line.match(/sample(?:Seq)?[=: ]+([0-9]+)/u)?.[1] || null;
const turn = line.match(/turn[=: #]+([0-9A-Za-z_.:-]+)/iu)?.[1] || null;
- const meta = [turn ? `turn ${turn}` : null, trace ? `trace ${trace}` : null, sample ? `sample ${sample}` : null].filter(Boolean).join(" · ") || `line ${index + 1}`;
+ const meta = [turn ? `turn ${turn}` : null, trace ? `trace ${trace}` : null, sample ? `sample ${sample}` : null].filter(Boolean).join(" · ") || `第 ${index + 1} 行`;
return { label: shortText(redactDisplayText(line), 120), meta, key: `${trace || "line"}-${sample || index}` };
});
}
function detailEvidence(detail, artifacts) {
const traceability = detail.traceability || {};
- return detailBlock("Evidence", [
+ return detailBlock("证据", [
["source", traceability.source || "-"],
["stateRoot", traceability.stateRoot || "-"],
["stateDir", traceability.stateDir || "-"],
@@ -625,7 +627,7 @@ function detailEvidence(detail, artifacts) {
}
function selectedView(response, viewName) {
- if (response?.ok === false) return { ok: false, error: response.error || "unavailable", view: viewName };
+ if (response?.ok === false) return { ok: false, error: response.error || "不可用", view: viewName };
const views = Array.isArray(response?.views) ? response.views : [];
return views.find((item) => item.view === viewName) || null;
}
@@ -645,7 +647,7 @@ function renderLoading(show) {
}
function renderCheckChip(element, label, ok, detail) {
- const status = ok ? "ok" : "blocked";
+ const status = ok ? "正常" : "阻塞";
element.textContent = `${label} ${status}${detail ? ` · ${shortText(detail, 34)}` : ""}`;
element.className = `check-chip ${ok ? "check-ok" : "check-blocked"}`;
}
@@ -772,7 +774,7 @@ function safeSummaryRows(summary) {
if (rows.length >= 8) break;
rows.push([key, safeDisplayValue(value)]);
}
- return rows.length === 0 ? [["status", "not indexed"]] : rows;
+ return rows.length === 0 ? [["status", "未索引"]] : rows;
}
function safeDisplayKey(key) {
@@ -782,25 +784,25 @@ function safeDisplayKey(key) {
function safeDisplayValue(value) {
if (value === null || typeof value === "undefined") return "-";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
- if (Array.isArray(value)) return `${value.length} items`;
+ if (Array.isArray(value)) return `${value.length} 项`;
const entries = Object.entries(value).filter(([key]) => safeDisplayKey(key)).slice(0, 4);
- if (entries.length === 0) return "redacted object";
+ if (entries.length === 0) return "已脱敏对象";
return entries.map(([key, item]) => `${key}=${primitiveValue(item)}`).join(", ");
}
function primitiveValue(value) {
if (value === null || typeof value === "undefined") return "-";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return shortText(String(value), 80);
- if (Array.isArray(value)) return `${value.length} items`;
- return "object";
+ if (Array.isArray(value)) return `${value.length} 项`;
+ return "对象";
}
function redactDisplayText(value) {
return String(value || "")
.split("\n")
.map((line) => {
- if (/(prompt|cookie|token|authorization|provider|payload|api[_-]?key|password|secret|stdout|stderr)/iu.test(line)) return "[redacted]";
- if (/assistant/iu.test(line) && line.length > 180) return "[assistant text redacted]";
+ if (/(prompt|cookie|token|authorization|provider|payload|api[_-]?key|password|secret|stdout|stderr)/iu.test(line)) return "[已脱敏]";
+ if (/assistant/iu.test(line) && line.length > 180) return "[assistant 正文已脱敏]";
return shortText(line, 360);
})
.join("\n");
@@ -827,14 +829,97 @@ function severityClass(value) {
return "severity-unknown";
}
+function displayStatus(value) {
+ const normalized = String(value || "idle").toLowerCase();
+ const labels = {
+ healthy: "健康",
+ observed: "已观察",
+ analyzed: "已分析",
+ pass: "通过",
+ ok: "正常",
+ warning: "警告",
+ degraded: "降级",
+ planned: "已计划",
+ running: "运行中",
+ queued: "排队中",
+ blocked: "阻塞",
+ failed: "失败",
+ error: "错误",
+ timeout: "超时",
+ interrupted: "已中断",
+ idle: "空闲",
+ };
+ return labels[normalized] || String(value || "-");
+}
+
+function displaySeverity(value) {
+ const normalized = String(value || "unknown").toLowerCase();
+ const labels = {
+ red: "红色",
+ critical: "严重",
+ error: "错误",
+ warning: "警告",
+ amber: "警告",
+ info: "信息",
+ unknown: "未知",
+ };
+ return labels[normalized] || String(value || "-");
+}
+
+function displayFindingCode(code) {
+ const normalized = String(code || "").toLowerCase();
+ const labels = {
+ "quick-verify-no-business-turn": "quick verify 未触达业务 turn",
+ "observer-command-failed": "观察器控制命令失败",
+ "runtime-requestfailed": "运行时请求失败",
+ "runtime-console-alerts": "运行时控制台告警",
+ "browser-console-or-page-errors": "浏览器控制台或页面错误",
+ "browser-timeout": "页面 readiness 超时",
+ "target-page-readiness-timeout": "页面 readiness 超时",
+ "network-or-api-fetch-bug": "网络或 API 请求异常",
+ };
+ return labels[normalized] || String(code || "finding");
+}
+
+function displayFindingSummary(code, summary) {
+ const normalized = String(code || "").toLowerCase();
+ const summaries = {
+ "quick-verify-no-business-turn": "quick verify 没有形成 sendPrompt、session、trace rows 或 Final Response;不能把公开 dashboard 200 当作 HWLAB 恢复证据。",
+ "observer-command-failed": "observe control command 失败,需要查看 observer timeline 和 failed command 文件确认是 readiness、session API、超时还是 runner shutdown。",
+ "runtime-requestfailed": "页面运行时存在请求失败,需要按路径聚合确认是 asset/provenance 噪声、public origin、auth/session 还是 Workbench API。",
+ "runtime-console-alerts": "页面控制台出现告警,需要结合 run detail 的 network/console 证据判断是否影响业务 turn。",
+ "browser-console-or-page-errors": "浏览器页面报错,需要先看 finalUrl、readiness、session create 和 API 证据,再判断是否是前端缺陷。",
+ "browser-timeout": "页面或 Workbench readiness 超时,不应默认判断为浏览器安装环境问题。",
+ "target-page-readiness-timeout": "页面或 Workbench readiness 超时,不应默认判断为浏览器安装环境问题。",
+ };
+ const translated = summaries[normalized];
+ if (!translated) return String(summary || "");
+ const source = String(summary || "").trim();
+ return source && !source.includes(translated) ? `${translated} 原始摘要: ${source}` : translated;
+}
+
+function findingNextAction(code) {
+ const normalized = String(code || "").toLowerCase();
+ const actions = {
+ "quick-verify-no-business-turn": "下一步: 打开该 run 的 turn-summary/trace-frame,并用 CLI 对照命令确认没有业务 turn。",
+ "observer-command-failed": "下一步: 查看 observe collect timeline 和 failed command 文件,定位失败阶段。",
+ "runtime-requestfailed": "下一步: 按请求路径聚合失败,区分网络、auth/session、API 或静态资源问题。",
+ "runtime-console-alerts": "下一步: 结合 console 样本与业务 trace 判断是否为阻塞级。",
+ "browser-console-or-page-errors": "下一步: 先查 Workbench readiness 和 session create,再决定是否修前端。",
+ "browser-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath,不先改浏览器安装。",
+ "target-page-readiness-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath,不先改浏览器安装。",
+ };
+ return actions[normalized] || "下一步: 打开最近运行详情,并用 CLI 对照命令复核同一 run/observer/report。";
+}
+
function formatSeveritySummary(counts) {
const entries = Object.entries(counts || {}).filter(([, value]) => Number(value || 0) > 0);
- if (entries.length === 0) return "none";
- return entries.map(([key, value]) => `${key} ${formatNumber(Number(value || 0))}`).join(" · ");
+ if (entries.length === 0) return "无";
+ return entries.map(([key, value]) => `${displaySeverity(key)} ${formatNumber(Number(value || 0))}`).join(" · ");
}
function formatNumber(value) {
- return new Intl.NumberFormat("en-US").format(Number(value || 0));
+ return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function shortText(value, limit) {
@@ -846,12 +931,13 @@ function formatRelative(iso) {
const ms = Date.parse(String(iso || ""));
if (!Number.isFinite(ms)) return "-";
const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
- if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 5) return "刚刚";
+ if (seconds < 60) return `${seconds} 秒前`;
const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
+ if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- return `${Math.floor(hours / 24)}d ago`;
+ if (hours < 24) return `${hours} 小时前`;
+ return `${Math.floor(hours / 24)} 天前`;
}
function escapeHtml(value) {
diff --git a/scripts/src/hwlab-node-web-probe-summary.ts b/scripts/src/hwlab-node-web-probe-summary.ts
index 5cb7dd0e..89efbc18 100644
--- a/scripts/src/hwlab-node-web-probe-summary.ts
+++ b/scripts/src/hwlab-node-web-probe-summary.ts
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// Responsibility: Redacted web-probe summaries and issue-ready compaction helpers.
function record(value: unknown): Record {
@@ -392,7 +393,8 @@ function webProbeRunFailureKind(degradedReason: string | null, promptValidation:
if (degradedReason === "agent-terminal-timeout") return "agent-runtime-timeout";
if (/trace-fetch|api|fetch|http|network/iu.test(degradedReason)) return "network-or-api-fetch-bug";
if (/auth|login|credential/iu.test(degradedReason)) return "target-auth-bug";
- if (/browser|timeout|playwright|chromium/iu.test(degradedReason)) return "browser-environment-bug";
+ if (/browser-timeout|selector-timeout|readiness|session|composer|navigation|load-jitter|timeout/iu.test(degradedReason)) return "target-page-readiness-timeout";
+ if (/browser|playwright|chromium/iu.test(degradedReason)) return "browser-environment-bug";
const failures = Array.isArray(promptValidation.failures) ? promptValidation.failures.join(" ") : "";
if (/final-response|agent-message|markdown|completed/iu.test(failures)) return "unmet-expectation";
return "user-facing-web-bug";
@@ -424,6 +426,7 @@ function webProbeRunNextAction(
if (failureKind === "network-or-api-fetch-bug") return "Inspect trace/session API fetch fields in reportPath and retry after checking target API availability.";
if (failureKind === "agent-runtime-timeout") return "Inspect summary.traceId/sessionId and the corresponding Code Agent/AgentRun runtime; the browser probe submitted the prompt and observed trace events, but no terminal agent response arrived before the wait boundary.";
if (failureKind === "target-auth-bug") return "Inspect credential sourceRef/fingerprint and /auth/login status; do not print secrets.";
+ if (failureKind === "target-page-readiness-timeout") return "Inspect finalUrl, readiness/session-create state, Workbench loading indicators, network request failures, and session API evidence in reportPath before changing browser launcher settings.";
if (failureKind === "browser-environment-bug") return "Inspect browser launcher and Playwright availability on the target workspace.";
if (promptSubmitted && !traceRequested) return "Rerun with trace sampling if trace evidence is required.";
return degradedReason === null ? null : "Inspect reportPath for full redacted details, then rerun the same node/lane entry.";
diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts
index cb27095d..c5ffa702 100644
--- a/scripts/src/hwlab-node-web-sentinel-cicd.ts
+++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -1353,10 +1354,28 @@ function sentinelPayloadFromLogs(logsTail: string): Record {
return {};
}
-function sentinelElapsedWarnings(value: unknown): string[] {
+function sentinelElapsedWarnings(value: unknown, subject = "sentinel confirmed operation"): string[] {
const elapsedMs = typeof value === "number" && Number.isFinite(value) ? value : null;
if (elapsedMs === null || elapsedMs <= 120_000) return [];
- return [`sentinel confirmed operation exceeded 120s (${Math.round(elapsedMs / 1000)}s); investigate env-reuse/git mirror/source build path before treating this as normal.`];
+ return [`${subject} exceeded 120s (${Math.round(elapsedMs / 1000)}s); treat this as a severe timeout and investigate env-reuse/git mirror/source build path plus the current wait stage before retrying.`];
+}
+
+function mergeWarnings(...items: readonly (readonly unknown[] | unknown)[]): string[] {
+ const warnings: string[] = [];
+ for (const item of items) {
+ const values = Array.isArray(item) ? item : [item];
+ for (const value of values) {
+ if (value === undefined || value === null || value === "") continue;
+ const warning = text(value).trim();
+ if (warning.length > 0 && warning !== "-" && !warnings.includes(warning)) warnings.push(warning);
+ }
+ }
+ return warnings;
+}
+
+function withWarnings(payload: Record, warnings: readonly unknown[]): Record {
+ const merged = mergeWarnings(payload.warnings, warnings);
+ return merged.length === 0 ? payload : { ...payload, warnings: merged, valuesRedacted: true };
}
function sentinelProgressEvent(event: string, payload: Record): void {
@@ -1469,6 +1488,7 @@ function runSentinelMaintenance(state: SentinelCicdState, options: Extract): RenderedCliResult {
const command = "web-probe sentinel validate";
+ const startedAt = Date.now();
const initialHealth = callSentinelService(state, "GET", "/api/health", null, options.timeoutSeconds);
let quickVerify: Record | null = null;
if (options.quickVerify) {
@@ -1508,6 +1528,9 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract {
+ const startedAt = Date.now();
+ const elapsedMs = () => Date.now() - startedAt;
+ const elapsedWarnings = () => sentinelElapsedWarnings(elapsedMs(), "quick verify confirm-wait");
const scenarioId = stringAt(state.cicd, "targetValidation.scenarioId");
const maxSeconds = numberAt(state.cicd, "targetValidation.maxSeconds");
const scenario = findScenario(state, scenarioId);
@@ -1595,6 +1621,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
steps.push({ phase: "observe-start", ok: started.ok, result: started.result });
const observerId = observerIdFromText(String(record(started.result).stdoutPreview ?? ""));
if (!started.ok || observerId === null) {
+ const findings = quickVerifyControlFindings("observe-start-failed", 0, null, null);
return recordQuickVerify(state, {
ok: false,
runId,
@@ -1602,8 +1629,12 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
reason,
status: "blocked",
observerId,
+ elapsedMs: elapsedMs(),
steps,
failure: "observe-start-failed",
+ findingCount: findings.length,
+ findings,
+ warnings: elapsedWarnings(),
valuesRedacted: true,
});
}
@@ -1621,7 +1652,8 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
promptIndex,
steps,
failure: "quick-verify-timeout-over-120s",
- warnings: ["quick verify exceeded the configured 120s targetValidation budget; investigate env-reuse/git mirror/source build path before retrying."],
+ elapsedMs: elapsedMs(),
+ warnings: mergeWarnings("quick verify exceeded the configured 120s targetValidation budget; investigate env-reuse/git mirror/source build path before retrying.", elapsedWarnings()),
promptSource: prompts.summary,
}));
}
@@ -1642,6 +1674,8 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
promptIndex,
steps,
failure: `observe-command-${type}-failed`,
+ elapsedMs: elapsedMs(),
+ warnings: elapsedWarnings(),
promptSource: prompts.summary,
}));
}
@@ -1658,7 +1692,8 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
steps,
failure: text(waitResult.failure ?? "observe-turn-terminal-wait-failed"),
promptSource: prompts.summary,
- warnings: Array.isArray(waitResult.warnings) ? waitResult.warnings : [],
+ elapsedMs: elapsedMs(),
+ warnings: mergeWarnings(Array.isArray(waitResult.warnings) ? waitResult.warnings : [], elapsedWarnings()),
}));
}
}
@@ -1670,7 +1705,11 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
const artifactSummary = indexEntry === null ? { ok: false, reason: "observe-index-entry-missing", observerId, valuesRedacted: true } : readAnalysisSummaryFromWorkspace(state, indexEntry.stateDir, remainingSeconds(deadline, 30));
const turnSummary = collectObserveView(state, observerId, "turn-summary", null, remainingSeconds(deadline, 30));
const traceFrame = collectObserveView(state, observerId, "trace-frame", promptIndex > 0 ? promptIndex : null, remainingSeconds(deadline, 30));
- const ok = analysis.ok && record(artifactSummary).ok === true;
+ const controlFindings = quickVerifyControlFindings(null, promptIndex, turnSummary, traceFrame);
+ const artifactSummaryRecord = record(artifactSummary);
+ const artifactFindings = Array.isArray(artifactSummaryRecord.findings) ? artifactSummaryRecord.findings.map(record) : [];
+ const findings = mergeFindingRecords(artifactFindings, controlFindings);
+ const ok = analysis.ok && record(artifactSummary).ok === true && controlFindings.length === 0;
return recordQuickVerify(state, {
ok,
runId,
@@ -1678,10 +1717,12 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
reason,
status: ok ? "analyzed" : "blocked",
observerId,
+ elapsedMs: elapsedMs(),
stateDir: indexEntry?.stateDir ?? null,
reportJsonSha256: stringAtNullable(artifactSummary, "reportJsonSha256"),
- findingCount: numberAtNullable(artifactSummary, "findingCount") ?? 0,
+ findingCount: findings.length,
artifactCount: numberAtNullable(artifactSummary, "artifactCount") ?? 0,
+ failure: controlFindings.length > 0 ? "quick-verify-no-business-turn" : null,
promptSource: prompts.summary,
steps,
analysis: artifactSummary,
@@ -1690,9 +1731,10 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
"turn-summary": { renderedText: typeof turnSummary.renderedText === "string" ? turnSummary.renderedText : null, ok: turnSummary.ok },
"trace-frame": { renderedText: typeof traceFrame.renderedText === "string" ? traceFrame.renderedText : null, ok: traceFrame.ok },
},
- findings: Array.isArray(record(artifactSummary).findings) ? record(artifactSummary).findings : [],
+ findings,
screenshot: record(artifactSummary).screenshot,
publicOrigin: stringAt(state.publicExposure, "publicBaseUrl"),
+ warnings: elapsedWarnings(),
valuesRedacted: true,
});
}
@@ -1706,6 +1748,7 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: {
readonly steps: readonly Record[];
readonly failure: string;
readonly promptSource?: Record;
+ readonly elapsedMs?: number;
readonly warnings?: readonly unknown[];
}): Record {
const cleanupSteps: Record[] = [];
@@ -1741,6 +1784,10 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: {
: readAnalysisSummaryFromWorkspace(state, indexEntry.stateDir, 30);
const turnSummary = collectObserveView(state, input.observerId, "turn-summary", null, 30);
const traceFrame = collectObserveView(state, input.observerId, "trace-frame", input.promptIndex > 0 ? input.promptIndex : null, 30);
+ const controlFindings = quickVerifyControlFindings(input.failure, input.promptIndex, turnSummary, traceFrame);
+ const artifactSummaryRecord = record(artifactSummary);
+ const artifactFindings = Array.isArray(artifactSummaryRecord.findings) ? artifactSummaryRecord.findings.map(record) : [];
+ const findings = mergeFindingRecords(artifactFindings, controlFindings);
return {
ok: false,
runId: input.runId,
@@ -1748,9 +1795,10 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: {
reason: input.reason,
status: "blocked",
observerId: input.observerId,
+ elapsedMs: input.elapsedMs ?? null,
stateDir: indexEntry?.stateDir ?? null,
reportJsonSha256: stringAtNullable(artifactSummary, "reportJsonSha256"),
- findingCount: numberAtNullable(artifactSummary, "findingCount") ?? 0,
+ findingCount: findings.length,
artifactCount: numberAtNullable(artifactSummary, "artifactCount") ?? 0,
failure: input.failure,
promptSource: input.promptSource,
@@ -1761,10 +1809,10 @@ function finalizeQuickVerifyFailure(state: SentinelCicdState, input: {
"turn-summary": { renderedText: typeof turnSummary.renderedText === "string" ? turnSummary.renderedText : null, ok: turnSummary.ok },
"trace-frame": { renderedText: typeof traceFrame.renderedText === "string" ? traceFrame.renderedText : null, ok: traceFrame.ok },
},
- findings: Array.isArray(record(artifactSummary).findings) ? record(artifactSummary).findings : [],
+ findings,
screenshot: record(artifactSummary).screenshot,
publicOrigin: stringAt(state.publicExposure, "publicBaseUrl"),
- warnings: Array.isArray(input.warnings) ? input.warnings.map(text) : [],
+ warnings: mergeWarnings(Array.isArray(input.warnings) ? input.warnings : [], sentinelElapsedWarnings(input.elapsedMs ?? null, "quick verify confirm-wait")),
valuesRedacted: true,
};
}
@@ -1782,6 +1830,9 @@ function recordQuickVerify(state: SentinelCicdState, payload: Record[], extra: readonly Record[]): Record[] {
+ const merged: Record[] = [];
+ const seen = new Set();
+ for (const item of [...primary, ...extra]) {
+ const id = stringAtNullable(item, "id") ?? stringAtNullable(item, "kind") ?? stringAtNullable(item, "code") ?? stringAtNullable(item, "finding_id") ?? "finding";
+ const severity = stringAtNullable(item, "severity") ?? stringAtNullable(item, "level") ?? "unknown";
+ const key = `${id}\0${severity}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(item);
+ }
+ return merged;
+}
+
+function quickVerifyControlFindings(failure: string | null, promptIndex: number, turnSummary: Record | null, traceFrame: Record | null): Record[] {
+ const rendered = [
+ typeof turnSummary?.renderedText === "string" ? turnSummary.renderedText : "",
+ typeof traceFrame?.renderedText === "string" ? traceFrame.renderedText : "",
+ ].join("\n");
+ const noPrompt = promptIndex <= 0 || /无\s*sendPrompt|no\s+sendPrompt/iu.test(rendered);
+ const noTrace = /无\s*trace\s*rows|no\s+trace\s+rows|traceId=-|routeSession=-|activeSession=-/iu.test(rendered);
+ const emptyFinal = /Final Response[\s\S]*\(空内容\)/iu.test(rendered);
+ if (!noPrompt && !noTrace && !emptyFinal && failure !== "observe-start-failed") return [];
+ return [{
+ id: "quick-verify-no-business-turn",
+ severity: "red",
+ count: 1,
+ summary: "quick verify did not reach a durable business turn/session/trace rows/final response; public dashboard health cannot be treated as HWLAB recovery.",
+ failure: failure ?? null,
+ promptIndex,
+ valuesRedacted: true,
+ }];
+}
+
function compactCommandWithTail(result: CommandResult): CompactCommandResult & { stdoutTail: string; stderrTail: string } {
return {
...compactCommand(result),
diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
index 4942bbf0..4b8c2b92 100644
--- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
+++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
@@ -1,4 +1,4 @@
-// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
@@ -11,7 +11,7 @@ interface DashboardShellConfig {
}
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
-const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p7-web-probe-sentinel-dashboard";
+const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p8-web-probe-sentinel-recovery";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
@@ -20,7 +20,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
- HWLAB Web Probe Sentinel
+ HWLAB Web哨兵
@@ -33,58 +33,58 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
>
-
+
-
HWLAB Web Probe Sentinel
+
HWLAB Web哨兵
${escapeHtml(config.node)} / ${escapeHtml(config.lane)}
-