fix: collapse internal gate review to live table

This commit is contained in:
Code Queue Review
2026-05-23 09:07:39 +00:00
parent 0c12cd71a5
commit 93373ac104
13 changed files with 1007 additions and 526 deletions
+320
View File
@@ -0,0 +1,320 @@
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import { describeCodeAgentAvailability } from "./code-agent-chat.mjs";
import {
applyRuntimeDbReadinessLayers,
buildDbRuntimeReadiness
} from "./db-contract.mjs";
import { buildCloudApiReadiness } from "./health-contract.mjs";
import {
M3_IO_CONTROL_ROUTE,
describeM3IoControlLive
} from "./m3-io-control.mjs";
const STATUS = Object.freeze({
pass: "通过",
blocked: "阻塞",
failed: "失败",
pending: "待验证",
info: "信息"
});
export async function buildGateDiagnosticsRows(options = {}) {
const observedAt = new Date().toISOString();
const rows = [];
const health = await observeHealth(options, observedAt);
const m3 = await observeM3(options, observedAt);
const audit = await observeAudit(options, observedAt);
const evidence = await observeEvidence(options, observedAt);
rows.push(...health.rows, m3.row, audit.row, evidence.row);
const status = rows.some((row) => row.statusKey === "failed")
? "failed"
: rows.some((row) => row.statusKey === "blocked")
? "blocked"
: rows.some((row) => row.statusKey === "pending")
? "pending"
: "pass";
return {
serviceId: CLOUD_API_SERVICE_ID,
route: "/v1/diagnostics/gate",
contractVersion: "gate-diagnostics-table-v1",
status,
sourceKind: "LIVE-BACKEND",
liveBackend: true,
observedAt,
rowCount: rows.length,
columns: ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"],
rows,
safety: {
frontendHardcodedRows: false,
sourceFixturePromoted: false,
localStorageUsed: false,
blockedIsGreen: false,
hardwareControlSurface: false,
codeAgentConversationSurface: false
}
};
}
async function observeHealth(options, observedAt) {
try {
const env = options.env ?? process.env;
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
const runtime = await runtimeReadiness(options.runtimeStore);
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
const codeAgent = describeCodeAgentAvailability(env, options);
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
const commitId = env.HWLAB_COMMIT_ID || env.HWLAB_GIT_SHA || "unknown";
const imageTag = env.HWLAB_IMAGE_TAG || shortHash(commitId);
return {
rows: [
row({
category: "部署",
check: "Cloud API 实况身份",
statusKey: commitId === "unknown" ? "info" : "pass",
owner: CLOUD_API_SERVICE_ID,
detail: `service=${CLOUD_API_SERVICE_ID}; env=dev; status=${readiness.status}; imageTag=${imageTag}`,
evidence: [`commit=${commitId}`, "GET /health/live"],
updatedAt: observedAt,
next: readiness.ready ? "继续保持与 DEV desired state 对齐。" : "先处理下方阻塞行,再重新执行 DEV live 复核。"
}),
row({
category: "健康检查",
check: "/health/live readiness",
statusKey: readiness.ready ? "pass" : "blocked",
owner: CLOUD_API_SERVICE_ID,
detail: `ready=${readiness.ready}; status=${readiness.status}; blockers=${readiness.blockerCodes.join(",") || "无"}`,
evidence: readiness.blockerCodes.length > 0 ? readiness.blockerCodes.map((code) => `blocker=${code}`) : ["GET /health/live"],
updatedAt: observedAt,
next: readiness.ready ? "无额外动作。" : "按 blocker code 分别处理 DB、runtime、Code Agent 或 M3 依赖。"
}),
row({
category: "DB",
check: "DB live readiness",
statusKey: db?.ready && db?.connected && db?.liveDbEvidence ? "pass" : "blocked",
owner: "hwlab-cloud-api / DEV Postgres",
detail: `ready=${Boolean(db?.ready)}; connected=${Boolean(db?.connected)}; liveDbEvidence=${Boolean(db?.liveDbEvidence)}; endpointSource=${db?.endpointSource ?? "unknown"}`,
evidence: db?.blocker ? [`blocker=${db.blocker}`] : ["db.liveDbEvidence=true"],
updatedAt: observedAt,
next: db?.ready && db?.connected && db?.liveDbEvidence
? "继续保持 Secret URL host 与 live DB 证据分层。"
: "修复 DEV DB env/连接/auth,且不打印 Secret 值。"
}),
row({
category: "运行态",
check: "runtime durable adapter",
statusKey: runtime?.durable === true && runtime?.ready === true && runtime?.liveRuntimeEvidence === true ? "pass" : "blocked",
owner: "hwlab-cloud-api runtime-store",
detail: `adapter=${runtime?.adapter ?? "unknown"}; durable=${Boolean(runtime?.durable)}; ready=${Boolean(runtime?.ready)}; liveRuntimeEvidence=${Boolean(runtime?.liveRuntimeEvidence)}`,
evidence: [
runtime?.blocker ? `blocker=${runtime.blocker}` : null,
runtime?.durabilityContract?.blockedLayer ? `blockedLayer=${runtime.durabilityContract.blockedLayer}` : null,
runtime?.connection?.queryResult ? `queryResult=${runtime.connection.queryResult}` : null
].filter(Boolean),
updatedAt: observedAt,
next: runtime?.durable === true && runtime?.ready === true && runtime?.liveRuntimeEvidence === true
? "可作为可信记录持久化前置。"
: "修复 runtime durable auth/schema/migration/readiness 后再升级可信闭环。"
}),
row({
category: "Code Agent",
check: "provider readiness",
statusKey: codeAgent?.ready && codeAgent?.status !== "blocked" ? "pass" : "blocked",
owner: "hwlab-cloud-api / Code Agent provider",
detail: `status=${codeAgent?.status ?? "unknown"}; provider=${codeAgent?.provider ?? "unknown"}; model=${codeAgent?.model ?? "unknown"}; backend=${codeAgent?.backend ?? "unknown"}`,
evidence: [
codeAgent?.reason ? `reason=${codeAgent.reason}` : null,
...(Array.isArray(codeAgent?.missingEnv) ? codeAgent.missingEnv.map((name) => `missingEnv=${name}`) : []),
"Secret 值未读取/未展示"
].filter(Boolean),
updatedAt: observedAt,
next: codeAgent?.ready && codeAgent?.status !== "blocked"
? "继续用受控 /v1/agent/chat 验证真实 runner 能力。"
: "补齐 provider env/Secret/egress 后复测,不用 mock 回复冒充。"
})
]
};
} catch (error) {
return {
rows: [
row({
category: "健康检查",
check: "后端聚合读取",
statusKey: "failed",
owner: CLOUD_API_SERVICE_ID,
detail: `live 聚合失败:${oneLine(error.message)}`,
evidence: "GET /v1/diagnostics/gate",
updatedAt: observedAt,
next: "先修复 cloud-api 聚合接口,再重新刷新内部复核表。"
})
]
};
}
}
async function observeM3(options, observedAt) {
try {
const payload = await describeM3IoControlLive(options);
const ready = payload?.readiness?.status === "ready" && payload?.readiness?.controlReady === true;
return {
row: row({
category: "M3",
check: "DO1 经 hwlab-patch-panel 同步到 DI1",
statusKey: ready ? "pass" : payload?.readiness?.status === "pending" ? "pending" : "blocked",
owner: "hwlab-cloud-api / hwlab-patch-panel",
detail: payload?.readiness?.summary ?? payload?.blockedReason ?? "M3 IO readiness 未返回摘要。",
evidence: [
`route=${M3_IO_CONTROL_ROUTE}`,
payload?.readiness?.sourceKind ? `sourceKind=${payload.readiness.sourceKind}` : null,
payload?.readiness?.blocker?.code ? `blocker=${payload.readiness.blocker.code}` : null,
payload?.readiness?.blocker?.layer ? `layer=${payload.readiness.blocker.layer}` : null
].filter(Boolean),
updatedAt: payload?.readiness?.observedAt ?? observedAt,
next: ready
? "只表示控制路径可执行;M3 验收仍需 operation/trace/audit/evidence 闭环。"
: payload?.readiness?.blocker?.zh ?? "修复 gateway-simu、box-simu 或 patch-panel 只读 readiness 后再复核。"
})
};
} catch (error) {
return {
row: row({
category: "M3",
check: "DO1 经 hwlab-patch-panel 同步到 DI1",
statusKey: "failed",
owner: "hwlab-cloud-api / hwlab-patch-panel",
detail: `M3 live readiness 读取失败:${oneLine(error.message)}`,
evidence: `GET ${M3_IO_CONTROL_ROUTE}`,
updatedAt: observedAt,
next: "先恢复 M3 IO readiness 接口,再验证 patch-panel 目标链路。"
})
};
}
}
async function observeAudit(options, observedAt) {
try {
const result = await options.runtimeStore?.queryAuditEvents?.({ limit: 6 });
const events = Array.isArray(result?.events) ? result.events : [];
return {
row: row({
category: "证据",
check: "audit.event.query",
statusKey: events.length > 0 ? "info" : "pending",
owner: "runtime-store / audit",
detail: `只读查询返回 ${events.length} 条 audit;该行不单独证明 M3 通过。`,
evidence: events.slice(0, 3).map((event) => [
event.auditId ? `audit=${event.auditId}` : null,
event.traceId ? `trace=${event.traceId}` : null
].filter(Boolean).join(" / ")).filter(Boolean),
updatedAt: observedAt,
next: events.length > 0 ? "将 audit 与 operation/evidence 绑定后再判断可信闭环。" : "等待 live audit 记录,或先修复 runtime durable blocker。"
})
};
} catch (error) {
return {
row: row({
category: "证据",
check: "audit.event.query",
statusKey: "blocked",
owner: "runtime-store / audit",
detail: `只读 audit 查询受阻:${oneLine(error.message)}`,
evidence: "POST /json-rpc audit.event.query",
updatedAt: observedAt,
next: "修复 runtime durable/readiness 后再查询 audit,不用 SOURCE 回退冒充。"
})
};
}
}
async function observeEvidence(options, observedAt) {
try {
const result = await options.runtimeStore?.queryEvidenceRecords?.({ limit: 6 });
const records = Array.isArray(result?.records) ? result.records : [];
return {
row: row({
category: "证据",
check: "evidence.record.query",
statusKey: records.length > 0 ? "info" : "pending",
owner: "runtime-store / evidence",
detail: `只读查询返回 ${records.length} 条 evidence;该行不单独证明 M3 通过。`,
evidence: records.slice(0, 3).map((record) => [
record.evidenceId ? `evidence=${record.evidenceId}` : null,
record.traceId ?? record.metadata?.traceId ? `trace=${record.traceId ?? record.metadata?.traceId}` : null
].filter(Boolean).join(" / ")).filter(Boolean),
updatedAt: observedAt,
next: records.length > 0 ? "将 evidence 与 operation/audit/patch-panel live report 绑定后再判断。" : "等待 live evidence 记录,或先修复 runtime durable blocker。"
})
};
} catch (error) {
return {
row: row({
category: "证据",
check: "evidence.record.query",
statusKey: "blocked",
owner: "runtime-store / evidence",
detail: `只读 evidence 查询受阻:${oneLine(error.message)}`,
evidence: "POST /json-rpc evidence.record.query",
updatedAt: observedAt,
next: "修复 runtime durable/readiness 后再查询 evidence,不用 SOURCE 回退冒充。"
})
};
}
}
async function runtimeReadiness(runtimeStore) {
if (typeof runtimeStore?.readiness === "function") {
return runtimeStore.readiness();
}
if (typeof runtimeStore?.summary === "function") {
return runtimeStore.summary();
}
return {
adapter: "unknown",
durable: false,
ready: false,
liveRuntimeEvidence: false,
blocker: "runtime_store_unavailable"
};
}
function row({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) {
const normalizedStatusKey = normalizeStatusKey(statusKey);
return {
category,
check,
status: STATUS[normalizedStatusKey],
statusKey: normalizedStatusKey,
owner,
detail: oneLine(detail),
evidence: normalizeEvidence(evidence),
updatedAt,
next: oneLine(next),
sourceKind: "LIVE-BACKEND"
};
}
function normalizeStatusKey(statusKey) {
const normalized = String(statusKey ?? "").trim().toLowerCase();
if (normalized === "pass" || normalized === "passed" || normalized === "ok" || normalized === "ready") return "pass";
if (normalized === "failed" || normalized === "failure" || normalized === "error") return "failed";
if (normalized === "pending" || normalized === "unknown" || normalized === "loading") return "pending";
if (normalized === "info" || normalized === "information") return "info";
return "blocked";
}
function normalizeEvidence(value) {
const items = Array.isArray(value) ? value : [value];
const normalized = items.map(oneLine).filter(Boolean);
return normalized.length > 0 ? normalized : ["live 后端未返回可用 evidence 字段"];
}
function oneLine(value) {
return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 360);
}
function shortHash(value) {
const text = String(value ?? "unknown");
return text === "unknown" ? "unknown" : text.slice(0, 7);
}
+6
View File
@@ -13,6 +13,7 @@ import {
handleJsonRpcRequest
} from "./json-rpc.mjs";
import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs";
import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs";
import {
applyRuntimeDbReadinessLayers,
buildDbRuntimeReadiness
@@ -202,6 +203,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") {
sendJson(response, 200, await buildGateDiagnosticsRows(options));
return;
}
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
await handleM3IoControlHttp(request, response, options);
return;
+60
View File
@@ -1646,6 +1646,66 @@ test("cloud api /v1 exposes M3 IO control contract without generic frontend hard
}
});
test("cloud api /v1/diagnostics/gate exposes Chinese single-table rows from live backend aggregation", async () => {
const runtimeStore = createCloudRuntimeStore({
now: () => "2026-05-23T00:00:00.000Z"
});
const server = createCloudApiServer({
runtimeStore,
env: {
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel",
HWLAB_COMMIT_ID: "abc123456789",
HWLAB_IMAGE_TAG: "abc1234"
},
m3IoRequestJson: async () => ({
ok: false,
status: 503,
body: {
error: {
message: "gateway offline"
}
}
})
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/v1/diagnostics/gate`);
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.route, "/v1/diagnostics/gate");
assert.equal(payload.contractVersion, "gate-diagnostics-table-v1");
assert.equal(payload.sourceKind, "LIVE-BACKEND");
assert.equal(payload.liveBackend, true);
assert.deepEqual(payload.columns, ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]);
assert.equal(payload.safety.frontendHardcodedRows, false);
assert.equal(payload.safety.sourceFixturePromoted, false);
assert.equal(payload.safety.blockedIsGreen, false);
assert.equal(payload.safety.hardwareControlSurface, false);
assert.equal(payload.safety.codeAgentConversationSurface, false);
assert.ok(payload.rows.length >= 6);
assert.ok(payload.rows.every((row) => row.sourceKind === "LIVE-BACKEND"));
assert.ok(payload.rows.every((row) => ["通过", "阻塞", "失败", "待验证", "信息"].includes(row.status)));
assert.ok(payload.rows.some((row) => row.category === "M3" && row.status === "阻塞"));
assert.ok(payload.rows.some((row) => row.check === "/health/live readiness"));
assert.ok(payload.rows.some((row) => row.check === "audit.event.query"));
assert.ok(payload.rows.some((row) => row.check === "evidence.record.query"));
assert.ok(payload.rows.some((row) => row.check === "Cloud API 实况身份" && row.status === "通过"));
assert.equal(
payload.rows.some((row) => row.status === "阻塞" && row.statusKey === "pass"),
false,
"blocked rows must not be mapped to pass"
);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
test("cloud api /v1/m3/io returns structured blocked payload when gateway is unavailable", async () => {
const runtimeStore = createCloudRuntimeStore({
now: () => "2026-05-23T00:00:00.000Z"
@@ -216,6 +216,13 @@ test("source/default smoke covers #276 two-column wiring long-table contract", (
assert.equal(check.evidence.includes("legacy 8-column table forbidden"), true);
});
test("source/default smoke covers #288 gate single-table contract", () => {
const report = runDevCloudWorkbenchStaticSmoke();
const check = report.checks.find((item) => item.id === "feedback-288-gate-single-table");
assert.equal(check?.status, "pass");
assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false);
});
test("live workbench identity passes only when runtime commit or image tag matches current source", () => {
assert.equal(
classifyLiveDeploymentIdentity(sourceIdentity, {
+222 -31
View File
@@ -89,12 +89,35 @@ const defaultHomepageForbiddenTerms = Object.freeze([
"waiting-for-dev-live"
]);
const diagnosticsRequiredTerms = Object.freeze([
"Gate / 诊断 / 验收",
"M0-M5",
"BLOCKED",
"DB live readiness",
"patch-panel DO1 -> DI1"
const gateReviewTableColumns = Object.freeze([
"类别",
"检查项",
"状态",
"归因对象",
"关键细节",
"证据/trace",
"更新时间",
"下一步"
]);
const gateReviewStatusLabels = Object.freeze([
"通过",
"阻塞",
"失败",
"待验证",
"信息"
]);
const legacyGateDashboardMarkers = Object.freeze([
"workspace-panel",
"gate-grid",
"gate-split",
"milestone-grid",
"trace-list",
"health-body",
"diagnostics-list",
"method-list",
"unblock-list"
]);
const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]);
@@ -370,9 +393,9 @@ function runStaticSmoke() {
evidence: ["工作台", "内部复核", "使用说明", "资源树"]
});
addCheck(checks, blockers, "feedback-119-gate-retains-diagnostics", gateRetainsDiagnostics(files.html, files.app), "Internal Gate page retains BLOCKED/M0-M5 diagnostics and the DB live plus patch-panel DO1 -> DI1 unblock path.", {
addCheck(checks, blockers, "feedback-288-gate-single-table", gateSingleTableContract(files), "Internal review /gate is a single read-only table backed by the live diagnostics aggregation route.", {
blocker: "contract_blocker",
evidence: diagnosticsRequiredTerms
evidence: [...gateReviewTableColumns, "/v1/diagnostics/gate", "LIVE-BACKEND", ...gateReviewStatusLabels]
});
addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", {
@@ -439,7 +462,7 @@ function runStaticSmoke() {
addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", {
blocker: "safety_blocker",
evidence: ["/health/live", "/v1", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
evidence: ["/health/live", "/v1", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
});
addCheck(checks, blockers, "api-degraded-default-status", hasApiDegradedDefaultStatus(files), "Default workbench status distinguishes reachable blocked API from fully OK with concrete blocker copy.", {
@@ -839,8 +862,8 @@ async function runLiveDomOnlySmoke(args) {
liveMode: "dom-only-readonly-no-code-agent-post",
codeAgentBrowserJourneySkipped: true,
codeAgentPostSent: false,
retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status", "DOM route/control/wiring observations"],
statement: "DOM-only live smoke preserves runtime and web-asset identity preflight semantics, continues with read-only browser DOM/help inspection even when identity is blocked, and never posts /v1/agent/chat, reads Secret values, mutates live DEV, or claims M3/M4/M5 acceptance."
retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status", "DOM route/control/wiring observations", "gate single-table observations"],
statement: "DOM-only live smoke preserves runtime and web-asset identity preflight semantics, continues with read-only browser DOM/help/gate table inspection even when identity is blocked, and never posts /v1/agent/chat, reads Secret values, mutates live DEV, or claims M3/M4/M5 acceptance."
}
});
}
@@ -1376,7 +1399,8 @@ function baseReport({
"pikasTech/HWLAB#143",
"pikasTech/HWLAB#164",
"pikasTech/HWLAB#224",
"pikasTech/HWLAB#276"
"pikasTech/HWLAB#276",
"pikasTech/HWLAB#288"
],
mode,
url,
@@ -1504,7 +1528,8 @@ function readStaticFiles() {
styles: readText("web/hwlab-cloud-web/styles.css"),
app: readText("web/hwlab-cloud-web/app.mjs"),
runtime: readText("web/hwlab-cloud-web/runtime.mjs"),
panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs")
panel: readText("web/hwlab-cloud-web/workbench-hardware-panel.mjs"),
help: readText("web/hwlab-cloud-web/help.md")
};
}
@@ -1771,8 +1796,18 @@ function firstTagForDataView(html, view) {
function sectionSource(source, viewId) {
const start = source.search(new RegExp(`<section\\b[^>]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u"));
if (start === -1) return "";
const next = source.slice(start + 1).search(/<section\b[^>]*\bdata-view=["'][^"']+["'][^>]*>/u);
return next === -1 ? source.slice(start) : source.slice(start, start + 1 + next);
const tagPattern = /<\/?section\b[^>]*>/giu;
tagPattern.lastIndex = start;
let depth = 0;
for (let match = tagPattern.exec(source); match; match = tagPattern.exec(source)) {
if (match[0].startsWith("</")) {
depth -= 1;
if (depth === 0) return source.slice(start, tagPattern.lastIndex);
} else {
depth += 1;
}
}
return source.slice(start);
}
function beforeSection(source, viewId) {
@@ -1846,10 +1881,29 @@ function hasCompletePrimaryNavigation(html) {
return navLabelsPresent && legacyLabelsAbsent && noLeftSideRecordsShortcut;
}
function gateRetainsDiagnostics(html, app) {
function gateSingleTableContract({ html, app, styles }) {
const gateHtml = sectionSource(html, "gate");
const gateSource = `${gateHtml}\n${app}`;
return diagnosticsRequiredTerms.every((term) => gateSource.includes(term));
const gateSource = `${gateHtml}\n${app}\n${styles}`;
const tableMatches = gateHtml.match(/<table\b[^>]*\bclass=["'][^"']*\bgate-table\b[^"']*["'][^>]*>/gu) ?? [];
const controlIds = ["gate-source-status", "gate-status-filter", "gate-search", "gate-refresh", "gate-review-body"];
const oldRenderFunctions = /function\s+(?:renderTrace|renderUnblockList|renderDiagnostics|renderGateDiagnostics|renderMethodList)\s*\(/u;
return (
/<h2\b[^>]*\bid=["']gate-title["'][^>]*>内部复核<\/h2>/u.test(gateHtml) &&
tableMatches.length === 1 &&
gateReviewTableColumns.every((column) => gateHtml.includes(`<th>${column}</th>`)) &&
controlIds.every((id) => gateHtml.includes(`id="${id}"`) || gateHtml.includes(`id='${id}'`)) &&
gateReviewStatusLabels.every((label) => gateSource.includes(label)) &&
/fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) &&
/function\s+renderGateTable\s*\(/u.test(app) &&
/function\s+normalizeGateRow\s*\(/u.test(app) &&
/function\s+gateLiveBlockerRow\s*\(/u.test(app) &&
/sourceKind:\s*"LIVE-BACKEND"/u.test(app) &&
/live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。/u.test(app) &&
/\.gate-table\b/u.test(styles) &&
/\.gate-controls\b/u.test(styles) &&
legacyGateDashboardMarkers.every((marker) => !gateHtml.includes(marker)) &&
!oldRenderFunctions.test(app)
);
}
function gateRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) {
@@ -1998,7 +2052,7 @@ function hasScrollLockContract(styles) {
/body\s*>\s*\[data-app-shell\]\s*\{[^\}]*min-height:\s*0;/su.test(styles) &&
/\.workbench-shell\s*\{[^\}]*height:\s*100(?:d)?vh;[^\}]*overflow:\s*hidden;/su.test(styles) &&
/\.view\s*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles) &&
/\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles)
/\.(?:resource-tree|compact-list|conversation-list|task-list|hardware-list)[^\{]*\{[^\}]*min-height:\s*0;[^\}]*overflow:\s*auto;/su.test(styles)
);
}
@@ -2063,6 +2117,7 @@ function hasSameOriginReadOnlyBoundary(app) {
/fetchJson\("\/health\/live"\)/u.test(app) &&
/fetchJson\("\/v1"\)/u.test(app) &&
/fetchJson\("\/v1\/m3\/status"\)/u.test(app) &&
/fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) &&
/fetchJson\("\/v1\/agent\/chat"/u.test(app) &&
/fetchJson\("\/json-rpc"/u.test(app) &&
JSON.stringify(rpcCalls) === JSON.stringify([...readOnlyRpcMethods].sort()) &&
@@ -2254,7 +2309,7 @@ function sourceEvidenceNotDevLive(source) {
gateSummary.auditEvents.every((event) => event.dryRun === true) &&
gateSummary.evidenceRecords.every((record) => record.dryRun === true) &&
gateSummary.safety.allowNetwork === false &&
["来源 SOURCE", "演练 DRY-RUN", "开发实况 DEV-LIVE", "阻塞 BLOCKED"].every((label) => source.includes(label))
["`SOURCE`", "`DRY-RUN`", "`DEV-LIVE`", "`BLOCKED`"].every((label) => source.includes(label))
);
}
@@ -2875,13 +2930,34 @@ async function inspectLiveGateRoute(url, options = {}) {
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
const gateBackendRequests = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/v1/diagnostics/gate") {
gateBackendRequests.push({ method: request.method(), urlPath: "/v1/diagnostics/gate" });
}
});
const postGuard = options.noCodeAgentPost ? await installNoCodeAgentPostGuard(page) : null;
await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
const gate = await page.evaluate(() => {
await page.waitForFunction(() => {
const rows = document.querySelectorAll("#gate-review-body tr");
const source = document.querySelector("#gate-source-status")?.textContent ?? "";
return rows.length > 0 && !/等待 live 后端|读取 live 后端中/u.test(source);
}, null, { timeout: 10000 });
const gate = await page.evaluate(({ columns, statuses, legacyMarkers }) => {
const workspace = document.querySelector('[data-view="workspace"]');
const gateView = document.querySelector('[data-view="gate"]');
const help = document.querySelector('[data-view="help"]');
const text = document.body.textContent ?? "";
const gateText = gateView?.textContent ?? "";
const tableHeaders = [...(gateView?.querySelectorAll(".gate-table thead th") ?? [])]
.map((cell) => cell.textContent?.trim() ?? "");
const tableRows = [...(gateView?.querySelectorAll("#gate-review-body tr") ?? [])].map((row) =>
[...row.querySelectorAll("td")].map((cell) => cell.textContent?.replace(/\s+/gu, " ").trim() ?? "")
);
const tableStatuses = [...(gateView?.querySelectorAll("#gate-review-body tr[data-status]") ?? [])]
.map((row) => row.getAttribute("data-status"))
.filter(Boolean);
const blockedRows = [...(gateView?.querySelectorAll('#gate-review-body tr[data-status="阻塞"], #gate-review-body tr[data-status-key="blocked"]') ?? [])];
const blockedRowsGreen = blockedRows.some((row) => row.querySelector(".tone-pass, .tone-ok, .tone-healthy"));
return {
pathname: window.location.pathname,
hash: window.location.hash,
@@ -2889,26 +2965,67 @@ async function inspectLiveGateRoute(url, options = {}) {
workspaceHidden: workspace ? workspace.hidden : null,
gateHidden: gateView ? gateView.hidden : null,
helpHidden: help ? help.hidden : null,
diagnosticsTermsPresent: ["Gate / 诊断 / 验收", "M0-M5", "BLOCKED", "DB live readiness", "patch-panel DO1 -> DI1"].every((term) => text.includes(term))
heading: document.querySelector("#gate-title")?.textContent?.trim() ?? "",
sourceStatus: document.querySelector("#gate-source-status")?.textContent?.trim() ?? "",
tableCount: gateView?.querySelectorAll("table").length ?? 0,
mainTableCount: gateView?.querySelectorAll(".gate-table").length ?? 0,
tableHeaders,
tableRows,
tableStatuses,
controls: {
statusFilter: Boolean(gateView?.querySelector("#gate-status-filter")),
search: Boolean(gateView?.querySelector("#gate-search")),
refresh: Boolean(gateView?.querySelector("#gate-refresh")),
controlCount: gateView?.querySelectorAll("select, input, button").length ?? 0
},
requiredHeadersPresent: columns.every((column) => tableHeaders.includes(column)),
statusLabelsChinese: tableStatuses.length > 0 && tableStatuses.every((status) => statuses.includes(status)),
rowsPresent: tableRows.length > 0,
liveBackendOrBlockerVisible: /live 后端|\/v1\/diagnostics\/gate|LIVE-BACKEND/u.test(gateText),
legacyLayoutAbsent: legacyMarkers.every((marker) => !gateView?.querySelector(`.${marker}, #${marker}`)),
userOperationsAbsent:
!gateView?.querySelector("#command-form, #m3-control-form, [data-side-tab], [data-side-tab-jump], #command-input, #command-send"),
blockedRowsGreen,
oldDashboardTermsAbsent: !/(M0-M5|Gate \/ 诊断 \/ 验收|执行轨迹)/u.test(gateText)
};
}, {
columns: gateReviewTableColumns,
statuses: gateReviewStatusLabels,
legacyMarkers: legacyGateDashboardMarkers
});
gate.backendRouteRequested = gateBackendRequests.some((request) => request.method === "GET");
const pass =
gate.pathname === "/gate" &&
gate.title === "HWLAB 云工作台" &&
gate.workspaceHidden === true &&
gate.gateHidden === false &&
(gate.helpHidden === true || gate.helpHidden === null) &&
gate.diagnosticsTermsPresent &&
gate.heading === "内部复核" &&
gate.tableCount === 1 &&
gate.mainTableCount === 1 &&
gate.requiredHeadersPresent &&
gate.rowsPresent &&
gate.statusLabelsChinese &&
gate.liveBackendOrBlockerVisible &&
gate.legacyLayoutAbsent &&
gate.userOperationsAbsent &&
!gate.blockedRowsGreen &&
gate.oldDashboardTermsAbsent &&
gate.backendRouteRequested &&
(postGuard ? postGuard.attempts.length === 0 : true);
return {
status: pass ? "pass" : "blocked",
summary: pass
? "Direct /gate route opens the internal diagnostics view without becoming the default workbench."
: "Direct /gate route did not satisfy the internal diagnostics route contract.",
? "Direct /gate route opens the internal single-table review view backed by the live diagnostics route."
: "Direct /gate route did not satisfy the internal single-table review contract.",
evidence: [
`pathname=${gate.pathname}`,
`gateHidden=${gate.gateHidden}`,
`diagnosticsTermsPresent=${gate.diagnosticsTermsPresent}`,
`heading=${gate.heading}`,
`tableCount=${gate.tableCount}`,
`rows=${gate.tableRows.length}`,
`statuses=${gate.tableStatuses.join(",") || "none"}`,
`backendRouteRequested=${gate.backendRouteRequested}`,
...(postGuard ? [`codeAgentPostAttempts=${postGuard.attempts.length}`] : [])
],
observations: {
@@ -3317,21 +3434,18 @@ async function inspectDefaultApiStatus(page) {
return page.evaluate(() => {
const liveStatus = document.querySelector("#live-status")?.textContent?.trim() ?? "";
const liveDetail = document.querySelector("#live-detail")?.textContent?.trim() ?? "";
const methodText = [...document.querySelectorAll("#method-list .badge")].map((element) => element.textContent?.trim() ?? "");
const firstScreenText = document.querySelector(".topbar")?.textContent?.replace(/\s+/gu, " ").trim() ?? "";
const forbidden = /Gate|诊断|验收|BLOCKED|M0-M5|执行轨迹|runtime_durable_adapter_query_blocked|DB live readiness/u;
return {
liveStatus,
liveDetail,
methodText,
firstScreenText,
degradedStatusVisible:
(liveStatus === "可信记录受阻 / 只读模式" || liveStatus === "API 错误 / 只读模式") &&
(
liveDetail.startsWith("/health/live 可响应,但可信记录持久化尚未就绪:") ||
liveDetail === "同源 API 返回 blocked/degraded/failed 或 ready=false;当前保持只读查看和本地任务整理。"
) &&
methodText.includes("system.health"),
),
defaultCopyIsWorkbenchSafe: !forbidden.test(`${liveStatus} ${liveDetail} ${firstScreenText}`)
};
});
@@ -4213,6 +4327,61 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
});
return true;
}
if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") {
const timestamp = "2026-05-22T00:00:00.000Z";
jsonResponse(response, 200, {
serviceId: "hwlab-cloud-api",
route: "/v1/diagnostics/gate",
contractVersion: "gate-diagnostics-table-v1",
status: "blocked",
sourceKind: "LIVE-BACKEND",
liveBackend: true,
observedAt: timestamp,
rowCount: 3,
columns: [...gateReviewTableColumns],
rows: [
localGateReviewRow({
category: "健康检查",
check: "/health/live readiness",
statusKey: "blocked",
owner: "hwlab-cloud-api",
detail: "Local fixture mirrors degraded live backend readiness for browser contract verification.",
evidence: ["GET /health/live", "blocker=runtime_durable_adapter_query_blocked"],
updatedAt: timestamp,
next: "DEV live 验证必须读取真实 /v1/diagnostics/gate。"
}),
localGateReviewRow({
category: "证据",
check: "audit.event.query",
statusKey: "pending",
owner: "runtime-store / audit",
detail: "Local fixture has no audit records; this is not DEV-LIVE evidence.",
evidence: ["POST /json-rpc audit.event.query"],
updatedAt: timestamp,
next: "等待真实 live audit。"
}),
localGateReviewRow({
category: "路由",
check: "single-table contract",
statusKey: "info",
owner: "cloud-web",
detail: "Browser smoke only validates the read-only table surface.",
evidence: ["GET /v1/diagnostics/gate", "LIVE-BACKEND fixture shape"],
updatedAt: timestamp,
next: "上线后以 DEV live route 结果为准。"
})
],
safety: {
frontendHardcodedRows: false,
sourceFixturePromoted: false,
localStorageUsed: false,
blockedIsGreen: false,
hardwareControlSurface: false,
codeAgentConversationSurface: false
}
});
return true;
}
if (request.method === "POST" && url.pathname === "/json-rpc") {
const body = await readJsonBody(request);
jsonResponse(response, 200, {
@@ -4419,6 +4588,28 @@ async function hardwarePanelText(page) {
return page.evaluate(() => document.querySelector("#hardware-list")?.textContent?.replace(/\s+/gu, " ").trim() ?? "");
}
function localGateReviewRow({ category, check, statusKey, owner, detail, evidence, updatedAt, next }) {
const label = {
pass: "通过",
blocked: "阻塞",
failed: "失败",
pending: "待验证",
info: "信息"
}[statusKey] ?? "阻塞";
return {
category,
check,
status: label,
statusKey,
owner,
detail,
evidence,
updatedAt,
next,
sourceKind: "LIVE-BACKEND"
};
}
function delay(ms) {
const normalized = Number.isFinite(Number(ms)) && Number(ms) > 0 ? Number(ms) : 0;
return new Promise((resolve) => setTimeout(resolve, normalized));
+1
View File
@@ -331,6 +331,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
"/help.md",
"/health/live",
"/v1",
"/v1/diagnostics/gate",
"/v1/agent/chat",
"/v1/m3/io",
"/v1/m3/status",
+26 -1
View File
@@ -2451,7 +2451,32 @@ function assertDevCloudWorkbenchGateRoute(gate, label) {
assert.equal(gate.workspaceHidden, true, `${label}.workspaceHidden`);
assert.equal(gate.gateHidden, false, `${label}.gateHidden`);
assert.equal(gate.helpHidden, true, `${label}.helpHidden`);
assert.equal(gate.diagnosticsTermsPresent, true, `${label}.diagnosticsTermsPresent`);
assert.equal(gate.heading, "内部复核", `${label}.heading`);
assert.equal(gate.tableCount, 1, `${label}.tableCount`);
assert.equal(gate.mainTableCount, 1, `${label}.mainTableCount`);
assert.equal(gate.requiredHeadersPresent, true, `${label}.requiredHeadersPresent`);
assert.equal(gate.rowsPresent, true, `${label}.rowsPresent`);
assert.equal(gate.statusLabelsChinese, true, `${label}.statusLabelsChinese`);
assert.equal(gate.liveBackendOrBlockerVisible, true, `${label}.liveBackendOrBlockerVisible`);
assert.equal(gate.legacyLayoutAbsent, true, `${label}.legacyLayoutAbsent`);
assert.equal(gate.userOperationsAbsent, true, `${label}.userOperationsAbsent`);
assert.equal(gate.blockedRowsGreen, false, `${label}.blockedRowsGreen`);
assert.equal(gate.oldDashboardTermsAbsent, true, `${label}.oldDashboardTermsAbsent`);
assert.equal(gate.backendRouteRequested, true, `${label}.backendRouteRequested`);
assertStringArray(gate.tableHeaders, `${label}.tableHeaders`, { minLength: 8 });
for (const column of ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]) {
assert.ok(gate.tableHeaders.includes(column), `${label}.tableHeaders missing ${column}`);
}
assertStringArray(gate.tableStatuses, `${label}.tableStatuses`, { minLength: 1 });
for (const status of gate.tableStatuses) {
assert.ok(["通过", "阻塞", "失败", "待验证", "信息"].includes(status), `${label}.tableStatuses unexpected ${status}`);
}
assertArray(gate.tableRows, `${label}.tableRows`);
assertObject(gate.controls, `${label}.controls`);
assert.equal(gate.controls.statusFilter, true, `${label}.controls.statusFilter`);
assert.equal(gate.controls.search, true, `${label}.controls.search`);
assert.equal(gate.controls.refresh, true, `${label}.controls.refresh`);
assert.ok(gate.controls.controlCount <= 4, `${label}.controls.controlCount`);
if (Object.hasOwn(gate, "codeAgentPostGuard")) {
assertObject(gate.codeAgentPostGuard, `${label}.codeAgentPostGuard`);
assert.equal(gate.codeAgentPostGuard.installed, true, `${label}.codeAgentPostGuard.installed`);
+199 -284
View File
@@ -1,5 +1,4 @@
import { gateSummary } from "./gate-summary.mjs";
import { runtime } from "./runtime.mjs";
import { marked } from "./third_party/marked/marked.esm.js";
const DEFAULT_API_TIMEOUT_MS = 4500;
@@ -74,13 +73,11 @@ const el = {
agentChatStatus: byId("agent-chat-status"),
conversationList: byId("conversation-list"),
taskList: byId("task-list"),
traceList: byId("trace-list"),
unblockList: byId("unblock-list"),
gateMetadata: byId("gate-metadata"),
milestoneGrid: byId("milestone-grid"),
healthBody: byId("health-body"),
gateBlockers: byId("gate-blockers"),
validationList: byId("validation-list"),
gateSourceStatus: byId("gate-source-status"),
gateStatusFilter: byId("gate-status-filter"),
gateSearch: byId("gate-search"),
gateRefresh: byId("gate-refresh"),
gateReviewBody: byId("gate-review-body"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
commandSend: byId("command-send"),
@@ -90,8 +87,6 @@ const el = {
controlList: byId("control-list"),
wiringBody: byId("wiring-body"),
recordsList: byId("records-list"),
diagnosticsList: byId("diagnostics-list"),
methodList: byId("method-list"),
m3ControlForm: byId("m3-control-form"),
m3ControlStatus: byId("m3-control-status"),
m3GatewaySelect: byId("m3-gateway-select"),
@@ -111,6 +106,13 @@ const state = {
chatMessages: [],
chatPending: false,
liveSurface: null,
gateDiagnostics: {
loading: false,
payload: null,
rows: [],
error: null,
loadedAt: null
},
m3Control: {
contract: null,
status: null,
@@ -128,6 +130,7 @@ initHardwareTabs();
initQuickActions();
initCommandBar();
initM3Control();
initGateControls();
renderStaticWorkbench();
renderProbePending();
loadHelpSurface();
@@ -184,6 +187,9 @@ function showView(route) {
button.classList.toggle("active", active);
button.setAttribute("aria-current", active ? "page" : "false");
}
if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) {
loadGateDiagnostics();
}
}
function initExplorerToggle() {
@@ -433,6 +439,14 @@ function initM3Control() {
});
}
function initGateControls() {
el.gateStatusFilter.addEventListener("change", renderGateTable);
el.gateSearch.addEventListener("input", renderGateTable);
el.gateRefresh.addEventListener("click", () => {
loadGateDiagnostics();
});
}
function renderStaticWorkbench() {
el.explorerStatus.textContent = "只读工作区";
el.explorerStatus.className = "status-dot tone-source";
@@ -442,22 +456,18 @@ function renderStaticWorkbench() {
renderCapabilityList();
renderConversation();
renderTaskList();
renderTrace();
renderUnblockList();
renderHardwareStatus(null);
renderDrafts();
renderM3ControlStatus();
renderWiringList(null);
renderRecords(null);
renderDiagnostics(null);
renderGateDiagnostics();
renderGateTable();
}
function renderProbePending() {
el.liveStatus.textContent = "只读模式";
el.liveStatus.className = "tone-source";
el.liveDetail.textContent = "可整理任务、查看资源、核对接线和可信记录;不会触发硬件变更。";
renderMethodList(rpcReadMethods, "pending");
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
@@ -523,6 +533,29 @@ async function loadLiveSurface() {
};
}
async function loadGateDiagnostics() {
if (state.gateDiagnostics.loading) return;
state.gateDiagnostics.loading = true;
state.gateDiagnostics.error = null;
renderGateTable();
const response = await fetchJson("/v1/diagnostics/gate", {
timeoutMs: API_TIMEOUT_MS,
timeoutName: "内部复核 live 聚合"
});
state.gateDiagnostics.loading = false;
if (response.ok && Array.isArray(response.data?.rows)) {
state.gateDiagnostics.payload = response.data;
state.gateDiagnostics.rows = response.data.rows.map(normalizeGateRow);
state.gateDiagnostics.loadedAt = new Date().toISOString();
} else {
state.gateDiagnostics.payload = null;
state.gateDiagnostics.rows = [gateLiveBlockerRow(response)];
state.gateDiagnostics.error = response.error ?? "内部复核 live 后端聚合不可用";
state.gateDiagnostics.loadedAt = new Date().toISOString();
}
renderGateTable();
}
async function fetchJson(path, options = {}) {
const {
timeoutMs = API_TIMEOUT_MS,
@@ -725,7 +758,6 @@ function renderLiveSurface(live) {
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes);
const runtimeSummary = runtimeSummaryFrom(live);
const methods = methodsFrom(live);
el.liveStatus.textContent = surfaceStatus.label;
el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`;
@@ -736,8 +768,6 @@ function renderLiveSurface(live) {
renderHardwareStatus(state.m3Control.status);
renderWiringList(state.m3Control.status ?? runtimeSummary);
renderRecords(live);
renderDiagnostics(live);
renderMethodList(methods.length > 0 ? methods.filter((method) => rpcReadMethods.includes(method)) : rpcReadMethods, surfaceStatus.methodTone);
renderM3ControlStatus();
renderConversation();
renderDrafts();
@@ -964,7 +994,6 @@ function renderCapabilityList() {
];
el.capabilityCount.textContent = String(capabilities.length);
replaceChildren(el.explorerCapabilities, ...capabilities.map(infoCard));
replaceChildren(el.gateBlockers, ...gateSummary.blockers.map(blockerCard));
}
function renderConversation() {
@@ -1001,43 +1030,6 @@ function renderTaskList() {
replaceChildren(el.taskList, ...rows.map(infoCard));
}
function renderTrace() {
replaceChildren(
el.traceList,
...gateSummary.steps.map((step) => {
const article = document.createElement("article");
article.className = "trace-item";
article.append(textSpan(String(step.order).padStart(2, "0"), "trace-order"));
const body = document.createElement("div");
body.append(textSpan(stepTitle(step.title), "trace-title"));
body.append(textSpan(`${stepKindLabel(step.kind)} / ${step.outputs.length} 个输出`, "trace-meta"));
article.append(body, badge(step.requires.length === 0 ? "入口" : "依赖前序", step.requires.length === 0 ? "source" : "dry-run"));
return article;
})
);
}
function renderUnblockList() {
const rows = [
{
title: "DB live readiness",
detail: "先让 cloud-api /health/live 返回 db.ready=true、db.connected=true,再进入 Agent runtime 调度。",
tone: "blocked"
},
{
title: "patch-panel DO1 -> DI1",
detail: "再由 hwlab-patch-panel 建立 res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 的活动链路,并附带可信记录。",
tone: "blocked"
},
{
title: "证据升级规则",
detail: "源码、本地与演练记录只能作为解释材料,不能升级为实况验收证据。",
tone: "dry-run"
}
];
replaceChildren(el.unblockList, ...rows.map(infoCard));
}
function renderHardwareStatus(m3Status) {
syncHardwareTabs();
const status = m3Status ?? sourceFallbackM3Status();
@@ -1923,124 +1915,120 @@ function sourceKindLabel(tone) {
return statusLabel(tone);
}
function renderDiagnostics(live) {
const diagnostics = [
{
title: "前端",
detail: runtime.endpoints.frontend,
tone: "source"
},
{
title: "API 边界",
detail: runtime.endpoints.api,
tone: "source"
},
{
title: "运行路由",
detail: runtime.serviceRoute.join(" -> "),
tone: "source"
}
];
function renderGateTable() {
const rows = filteredGateRows();
const loading = state.gateDiagnostics.loading;
const payload = state.gateDiagnostics.payload;
const statusTone = gatePayloadTone();
const loadedAt = state.gateDiagnostics.loadedAt;
el.gateSourceStatus.textContent = loading
? "读取 live 后端中"
: payload
? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length}`
: state.gateDiagnostics.error
? "live 后端阻塞"
: "等待 live 后端";
el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`;
el.gateSourceStatus.title = payload
? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}`
: state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate";
if (live) {
diagnostics.push(
{
title: "/health/live",
detail: live.healthLive.ok ? `HTTP ${live.healthLive.status} ${live.healthLive.data?.status ?? "ok"}` : live.healthLive.error,
tone: live.healthLive.ok ? live.healthLive.data?.status ?? "dev-live" : "blocked"
},
{
title: "/v1",
detail: live.restIndex.ok ? `HTTP ${live.restIndex.status} ${live.restIndex.data?.status ?? "ok"}` : live.restIndex.error,
tone: live.restIndex.ok ? live.restIndex.data?.status ?? "dev-live" : "blocked"
},
{
title: "system.health",
detail: live.health.ok ? `RPC ${live.health.data?.status ?? "ok"}` : live.health.error,
tone: live.health.ok ? live.health.data?.status ?? "dev-live" : "blocked"
},
{
title: "audit.event.query",
detail: live.audit.ok ? `${live.audit.data?.count ?? live.audit.data?.events?.length ?? 0} 条记录` : live.audit.error,
tone: live.audit.ok ? "dev-live" : "blocked"
}
);
} else {
diagnostics.push(
{
title: "/health/live",
detail: "正在探测同源 Web/API 健康状态",
tone: "dev-live"
},
{
title: "/v1 + /json-rpc",
detail: "只读诊断等待中",
tone: "source"
}
);
}
replaceChildren(el.diagnosticsList, ...diagnostics.map(infoCard));
}
function renderGateDiagnostics() {
appendMetricGrid(el.gateMetadata, [
["环境", gateSummary.environment, "source"],
["前端", runtime.endpoints.frontend, "source"],
["API", runtime.endpoints.api, "source"],
["Gate", statusLabel(gateSummary.gateStatus), gateSummary.gateStatus],
["演练 DRY-RUN", statusLabel(gateSummary.dryRun.status), "dry-run"],
["报告", shortHash(gateSummary.reportCommitId), "source"]
]);
replaceChildren(
el.milestoneGrid,
...gateSummary.milestones.map((milestone) => {
const item = document.createElement("article");
item.className = "milestone-card";
const header = document.createElement("div");
header.className = "panel-title-row";
header.append(textSpan(milestone.id, "milestone-id"), badge(statusLabel(milestone.status), milestone.status));
item.append(header);
item.append(textSpan(userFacingSummary(milestone.summary), "milestone-summary"));
item.append(textSpan(`${milestone.commandCount} 条命令 / ${milestone.evidenceCount} 条证据`, "milestone-meta"));
return item;
})
);
el.healthBody.replaceChildren();
for (const row of gateSummary.health) {
if (loading && state.gateDiagnostics.rows.length === 0) {
const tr = document.createElement("tr");
tr.append(cell(componentLabel(row.component)));
tr.append(cell(row.serviceId, "mono"));
const status = document.createElement("td");
status.append(badge(statusLabel(row.status), row.status));
tr.append(status);
tr.append(cell(observedByLabel(row.observedBy)));
tr.append(cell(row.endpoint ?? gateSummary.endpoint, "mono wrap"));
el.healthBody.append(tr);
tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8));
replaceChildren(el.gateReviewBody, tr);
return;
}
replaceChildren(
el.validationList,
...[
"node --check web/hwlab-cloud-web/app.mjs",
"node --check web/hwlab-cloud-web/scripts/check.mjs",
"node web/hwlab-cloud-web/scripts/check.mjs",
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
"node scripts/l6-cli-web-smoke.mjs",
"git diff --check"
].map((command) => {
const li = document.createElement("li");
li.textContent = command;
return li;
})
);
if (rows.length === 0) {
const tr = document.createElement("tr");
tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8));
replaceChildren(el.gateReviewBody, tr);
return;
}
replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow));
}
function renderMethodList(methods, tone) {
replaceChildren(el.methodList, ...methods.map((method) => badge(method, tone === "live" ? "dev-live" : tone)));
function filteredGateRows() {
const filter = el.gateStatusFilter.value;
const query = el.gateSearch.value.trim().toLowerCase();
return state.gateDiagnostics.rows.filter((row) => {
if (filter !== "all" && row.status !== filter) return false;
if (!query) return true;
return [
row.category,
row.check,
row.status,
row.owner,
row.detail,
...(row.evidence ?? []),
row.updatedAt,
row.next
].join(" ").toLowerCase().includes(query);
});
}
function gateTableRow(row) {
const tr = document.createElement("tr");
tr.dataset.status = row.status;
tr.dataset.statusKey = row.statusKey;
tr.append(tableCell(row.category, "wrap"));
tr.append(tableCell(row.check, "wrap"));
const status = document.createElement("td");
status.append(badge(row.status, row.statusKey));
tr.append(status);
tr.append(tableCell(row.owner, "mono wrap"));
tr.append(tableCell(row.detail, "wrap"));
tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap"));
tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap"));
tr.append(tableCell(row.next, "wrap"));
return tr;
}
function normalizeGateRow(row) {
const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status);
return {
category: oneLine(row.category || "内部复核"),
check: oneLine(row.check || "未命名检查项"),
status: gateStatusLabel(row.status, statusKey),
statusKey,
owner: oneLine(row.owner || "未标明"),
detail: oneLine(row.detail || "live 后端未返回关键细节"),
evidence: normalizeEvidenceList(row.evidence),
updatedAt: row.updatedAt || new Date().toISOString(),
next: oneLine(row.next || "等待维护者补充下一步。"),
sourceKind: "LIVE-BACKEND"
};
}
function gateLiveBlockerRow(response) {
const now = new Date().toISOString();
return normalizeGateRow({
category: "健康检查",
check: "内部复核 live 后端聚合",
status: "阻塞",
statusKey: response.timeout ? "failed" : "blocked",
owner: "hwlab-cloud-api /v1/diagnostics/gate",
detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。",
evidence: [
"GET /v1/diagnostics/gate",
response.status ? `HTTP ${response.status}` : null,
response.timeout ? `timeoutMs=${response.timeoutMs}` : null
].filter(Boolean),
updatedAt: now,
next: "先恢复后端聚合接口,再刷新本表。"
});
}
function gatePayloadTone() {
if (state.gateDiagnostics.loading) return "pending";
if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source";
const rows = state.gateDiagnostics.rows;
if (rows.some((row) => row.statusKey === "failed")) return "failed";
if (rows.some((row) => row.statusKey === "blocked")) return "blocked";
if (rows.some((row) => row.statusKey === "pending")) return "pending";
return "pass";
}
function renderAgentChatStatus(status, result = null) {
@@ -2112,14 +2100,6 @@ function failureMessage(result) {
return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}${suffix}请稍后重试或联系维护者补齐后端 provider。`;
}
function blockerCard(blocker) {
return infoCard({
title: blockerTitle(blocker),
detail: userFacingSummary(blocker.summary),
tone: blocker.status === "open" ? "blocked" : blocker.status
});
}
function messageCard(message) {
const article = document.createElement("article");
article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`;
@@ -2264,18 +2244,6 @@ function badge(text, tone = text) {
return span;
}
function appendMetricGrid(parent, entries) {
replaceChildren(
parent,
...entries.map(([label, value, tone]) => {
const item = document.createElement("div");
item.className = "metric";
item.append(textSpan(label, "metric-label"), textSpan(String(value ?? "无"), `metric-value tone-${toneClass(tone ?? "source")}`));
return item;
})
);
}
function textSpan(text, className) {
const span = document.createElement("span");
span.textContent = text ?? "无";
@@ -2290,16 +2258,20 @@ function cell(text, className) {
return td;
}
function runtimeSummaryFrom(live) {
return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
function formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
return shortTime(date.toISOString());
}
function methodsFrom(live) {
const restMethods = live.restIndex.data?.methods;
const adapterMethods = live.adapter.data?.methods;
if (Array.isArray(restMethods)) return restMethods;
if (Array.isArray(adapterMethods)) return adapterMethods;
return [];
function tableCell(text, className, colspan = 1) {
const td = cell(text, className);
if (colspan > 1) td.colSpan = colspan;
return td;
}
function runtimeSummaryFrom(live) {
return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
}
function messageFromPayload(payload) {
@@ -2575,6 +2547,37 @@ function statusLabel(value) {
return STATUS_LABELS[normalized] ?? key;
}
function gateStatusLabel(value, statusKey) {
const explicit = String(value ?? "").trim();
const labels = {
pass: "通过",
blocked: "阻塞",
failed: "失败",
pending: "待验证",
info: "信息"
};
return labels[statusKey] ?? (["通过", "阻塞", "失败", "待验证", "信息"].includes(explicit) ? explicit : "阻塞");
}
function normalizeGateStatusKey(value) {
const text = String(value ?? "").trim().toLowerCase();
if (["通过", "pass", "passed", "ok", "ready"].includes(text)) return "pass";
if (["失败", "failed", "failure", "error"].includes(text)) return "failed";
if (["待验证", "pending", "loading", "unknown"].includes(text)) return "pending";
if (["信息", "info", "information"].includes(text)) return "info";
return "blocked";
}
function normalizeEvidenceList(value) {
const items = Array.isArray(value) ? value : [value];
const normalized = items.map(oneLine).filter(Boolean);
return normalized.length > 0 ? normalized : ["live 后端未返回 evidence 字段"];
}
function oneLine(value) {
return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 240);
}
function roleLabel(role) {
return (
{
@@ -2595,37 +2598,6 @@ function resourceTypeLabel(type) {
);
}
function stepKindLabel(kind) {
return (
{
agent: "Agent 会话",
audit: "审计",
cleanup: "清理",
evidence: "证据",
hardware_operation: "硬件操作",
health: "健康检查",
status: "状态检查",
wiring: "接线"
}[String(kind ?? "").toLowerCase()] ?? statusLabel(kind)
);
}
function stepTitle(title) {
return (
{
audit: "审计",
cleanup: "清理",
"agent hardware call": "Agent 硬件调用",
"agent session": "Agent 会话",
"DEV health": "DEV 健康",
"direct hardware call": "直接硬件调用",
evidence: "证据",
"gateway/box status": "Gateway/Box 状态",
wiring: "接线"
}[String(title ?? "")] ?? statusLabel(title)
);
}
function recordKindLabel(kind) {
return (
{
@@ -2639,40 +2611,6 @@ function recordKindLabel(kind) {
);
}
function componentLabel(component) {
return (
{
"Agent manager": "Agent 管理器",
"Agent skills": "Agent 技能",
"Agent worker": "Agent Worker",
"Box simulator": "Box 模拟器",
CLI: "CLI",
"Cloud API": "Cloud API",
"Cloud Web": "Cloud Web",
"D601 router": "D601 路由器",
"DEV ingress": "DEV 入口",
Gateway: "Gateway",
"Gateway simulator": "Gateway 模拟器",
"Patch panel": "Patch Panel",
frp: "frp",
"master edge proxy": "master 边界代理"
}[String(component ?? "")] ?? statusLabel(component)
);
}
function observedByLabel(value) {
return (
{
fixture: "fixture 来源 SOURCE"
}[String(value ?? "").toLowerCase()] ?? statusLabel(value)
);
}
function blockerTitle(blocker) {
const scope = blocker?.scope ?? "unknown";
return `阻塞:${scope}`;
}
function internalGatePathnames() {
return new Set(["/gate", "/diagnostics/gate"]);
}
@@ -2681,25 +2619,6 @@ function helpPathnames() {
return new Set(["/help"]);
}
function userFacingSummary(text) {
return String(text ?? "")
.replaceAll("Public DEV route/frp", "公共 DEV 路由/frp")
.replaceAll("live MVP remains blocked", "实况 MVP 仍被阻塞")
.replaceAll("DB live readiness", "DB 实况就绪")
.replaceAll("M3 hardware loop topology", "M3 硬件闭环拓扑")
.replaceAll("no historical 6667 failure is counted as active evidence", "历史 6667 失败不计为当前有效证据")
.replaceAll("cloud-api /health/live reports DB degraded", "cloud-api /health/live 报告 DB 降级")
.replaceAll("live DB evidence is not established", "尚未建立实况 DB 证据")
.replaceAll("Live M3 smoke reached DEV simulators", "实况 M3 smoke 已触达 DEV 模拟器")
.replaceAll("patch-panel active DO1 -> DI1 connection is missing", "缺少 patch-panel 上的活动 DO1 -> DI1 连接")
.replaceAll("Frozen contract, artifact, boundary, drift, and evidence fixtures are parseable and aligned to the DEV boundary.", "冻结的契约、产物、边界、漂移与证据 fixture 可解析,并已对齐 DEV 边界。")
.replaceAll("The local M1 contract smoke passes.", "本地 M1 契约 smoke 已通过。")
.replaceAll("The deploy smoke remains fixture-only and validates the frozen route phases and artifact metadata.", "部署 smoke 仍仅使用 fixture,用于验证冻结路由阶段与产物元数据。")
.replaceAll("Local M3 remains green, but active DEV-LIVE M3 is blocked by hardware loop topology.", "本地 M3 仍为通过,但活动 DEV-LIVE M3 受硬件闭环拓扑阻塞。")
.replaceAll("Local M4 remains green, but active DEV-LIVE M4 is blocked at DB live readiness before agent runtime scheduling.", "本地 M4 仍为通过,但活动 DEV-LIVE M4 在 Agent 运行时调度前受 DB 实况就绪阻塞。")
.replaceAll("The M5 orchestration is green in dry-run mode, but active DEV-LIVE acceptance is blocked by DB live and hardware loop evidence.", "M5 编排在 DRY-RUN 模式下通过,但活动 DEV-LIVE 验收受 DB 实况与硬件闭环证据阻塞。");
}
function valueOrUnavailable(liveValue, sourceValue) {
if (liveValue !== undefined && liveValue !== null) return liveValue;
if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`;
@@ -2710,10 +2629,6 @@ function shortTime(value) {
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
}
function shortHash(value) {
return String(value ?? "unknown").slice(0, 7);
}
function toneClass(tone) {
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
}
+6 -5
View File
@@ -4,7 +4,7 @@
## 左侧资源与功能导航
- 活动栏使用完整中文标签:`工作台` 回到默认工作台,`内部复核` 打开 Gate / 诊断 / 验收二级页面`使用说明` 打开本说明,`资源树` 展开或收起资源树。
- 活动栏使用完整中文标签:`工作台` 回到默认工作台,`内部复核` 打开只读复核表`使用说明` 打开本说明,`资源树` 展开或收起资源树。
- 左侧不再放独立的可信记录跳转;可信记录保留在右侧子面板。
- 资源树只展示当前项目、Gateway-SIMU、BOX-SIMU、`hwlab-patch-panel`、Agent manager / worker 等用户可理解资源,不把 M0-M5 Gate 状态放在默认首屏。
- 常用能力区提供任务草稿、资源查看与接线核对入口。`LOCAL` 只表示浏览器本地草稿,不等同于 DEV-LIVE。
@@ -27,13 +27,14 @@
- 控制:显示本地草稿和只读能力边界。这里的控制项保持禁用或本地记录状态。
- 接线:解释 `hwlab-patch-panel` 当前 source wiring,帮助确认 DO1 到 DI1 的可信闭环路径是否仍由 patch-panel 拥有。
- 可信记录:合并 source fixture 与只读 `evidence.record.query` 结果。source、fixture、LOCAL、DRY-RUN 记录不能升级成 DEV-LIVE。
- 技术复核:Gate、诊断验收、BLOCKED 和 M0-M5 执行轨迹只在活动栏 `内部复核` 打开的二级页面展示,不替代默认工作台。
- 技术复核:Gate、诊断验收状态收敛到活动栏 `内部复核` 打开的只读表,不替代默认工作台,也不展示 M0-M5 流程墙
## 内部复核页
- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,作为验收复核二级页面保留
- 解阻路径:先完成 DB live readiness,让 same-origin `/health/live` 能证明 db.ready=true、db.connected=true;再通过 `hwlab-patch-panel` 建立 `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`,并附带可信轨迹/证据
- 诊断探测检查 same-origin `/health/live`、same-origin `/v1``/v1/m3/io` 合同与只读 `/json-rpc` 方法。允许的 JSON-RPC 方法包括 `system.health``cloud.adapter.describe``audit.event.query``evidence.record.query`
- Gate / 诊断 / 验收:从活动栏 `内部复核` 进入,主体只有一个只读主表和少量过滤、搜索、刷新控件
- 表格数据来自 same-origin `/v1/diagnostics/gate` live 后端聚合;如果 live 聚合不可用,页面必须显示明确 blocker,不能用 SOURCE 静态拓扑冒充
- 表格状态使用中文:通过、阻塞、失败、待验证、信息。阻塞或降级行不得显示为 green
- 该页不提供硬件控制、Agent 对话或资源选择入口;这些操作仍在默认工作台内按受控后端边界执行。
## 来源标签含义
+43 -84
View File
@@ -100,92 +100,51 @@
</section>
<section class="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title" hidden>
<div class="workspace-panel">
<div class="panel-title-row">
<div>
<p class="eyebrow">内部二级入口</p>
<h2 id="gate-title">Gate / 诊断 / 验收</h2>
</div>
<span class="state-tag tone-source">来源 SOURCE 报告</span>
<header class="gate-header">
<div>
<p class="eyebrow">内部二级入口</p>
<h2 id="gate-title">内部复核</h2>
</div>
<p class="panel-copy">
Gate、诊断、验收、BLOCKED 与 M0-M5 执行轨迹仅在此内部页面展示;默认入口保持用户工作台。
</p>
<span class="state-tag tone-source" id="gate-source-status">等待 live 后端</span>
</header>
<div class="gate-controls" aria-label="内部复核过滤控件">
<label>
<span>状态</span>
<select id="gate-status-filter">
<option value="all">全部</option>
<option value="通过">通过</option>
<option value="阻塞">阻塞</option>
<option value="失败">失败</option>
<option value="待验证">待验证</option>
<option value="信息">信息</option>
</select>
</label>
<label>
<span>搜索</span>
<input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autocomplete="off" />
</label>
<button class="command-button secondary" id="gate-refresh" type="button">刷新</button>
</div>
<div class="workspace-panel danger">
<div class="panel-title-row">
<div>
<p class="eyebrow">解阻路径</p>
<h2>DB live 与 patch-panel 闭环</h2>
</div>
<span class="state-tag tone-blocked">阻塞 BLOCKED</span>
</div>
<div id="unblock-list" class="compact-list"></div>
</div>
<div class="gate-grid" id="gate-metadata"></div>
<div class="workspace-panel">
<div class="panel-title-row">
<h2>M0-M5 Gate 状态</h2>
<span class="state-tag tone-blocked">阻塞 BLOCKED 实况前置失败时</span>
</div>
<div class="milestone-grid" id="milestone-grid"></div>
</div>
<div class="workspace-panel">
<div class="panel-title-row">
<h2>M0-M5 执行轨迹</h2>
<span class="state-tag tone-dry-run">演练 DRY-RUN 计划</span>
</div>
<div id="trace-list" class="trace-list"></div>
</div>
<div class="workspace-panel">
<div class="panel-title-row">
<h2>DEV 健康契约</h2>
<span class="state-tag tone-source">来源 SOURCE fixture</span>
</div>
<div class="table-wrap wide-table-wrap">
<table class="wide-table">
<thead>
<tr>
<th>组件</th>
<th>服务</th>
<th>状态</th>
<th>观测来源</th>
<th>端点</th>
</tr>
</thead>
<tbody id="health-body"></tbody>
</table>
</div>
</div>
<div class="gate-split">
<div class="workspace-panel danger">
<div class="panel-title-row">
<h2>未关闭阻塞</h2>
<span class="state-tag tone-blocked">阻塞 BLOCKED</span>
</div>
<div id="gate-blockers" class="compact-list"></div>
</div>
<div class="workspace-panel">
<div class="panel-title-row">
<h2>轻量验证</h2>
<span class="state-tag tone-source">来源 SOURCE 检查</span>
</div>
<ol id="validation-list" class="command-list"></ol>
</div>
</div>
<div class="workspace-panel">
<div class="panel-title-row">
<div>
<p class="eyebrow">同源只读</p>
<h2>诊断探测</h2>
</div>
<span class="state-tag tone-dev-live">开发实况 DEV-LIVE 只读探测</span>
</div>
<div id="diagnostics-list" class="compact-list"></div>
<div class="readonly-rpc">
<h3>允许的只读 RPC</h3>
<div id="method-list" class="rpc-chip-list"></div>
</div>
<div class="gate-table-wrap">
<table class="gate-table" aria-describedby="gate-source-status">
<thead>
<tr>
<th>类别</th>
<th>检查项</th>
<th>状态</th>
<th>归因对象</th>
<th>关键细节</th>
<th>证据/trace</th>
<th>更新时间</th>
<th>下一步</th>
</tr>
</thead>
<tbody id="gate-review-body">
<tr>
<td colspan="8">正在读取 live 后端聚合。</td>
</tr>
</tbody>
</table>
</div>
</section>
+55 -13
View File
@@ -10,6 +10,7 @@ import {
runDevCloudWorkbenchStaticSmoke
} from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
import { gateSummary } from "../gate-summary.mjs";
import { runtime } from "../runtime.mjs";
import { runM3ControlPanelGuard } from "./m3-control-panel-guard.mjs";
import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs";
import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs";
@@ -172,8 +173,7 @@ for (const chineseWorkbenchText of [
"工作清单",
"可信记录",
"内部复核",
"使用说明",
"允许的只读 RPC"
"使用说明"
]) {
assert.match(`${html}\n${app}`, new RegExp(chineseWorkbenchText));
}
@@ -264,7 +264,7 @@ assert.match(styles, /\.record-group-title\s*{/);
assert.match(styles, /\.hardware-section\s*{/);
assert.match(styles, /\.hardware-status-tabs\s*{/);
assert.match(styles, /\.kv-row\s*{/);
assert.match(html, /Gate \/ 诊断 \/ 验收/);
assert.match(styles, /\.hardware-row\s*{/);
assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/);
assert.match(html, /href="\/styles\.css"/);
assert.match(html, /src="\/app\.mjs"/);
@@ -283,7 +283,7 @@ for (const workbenchElement of [
"control-list",
"wiring-body",
"records-list",
"diagnostics-list"
"gate-review-body"
]) {
assert.match(html, new RegExp(workbenchElement));
}
@@ -342,6 +342,7 @@ for (const helpTerm of [
"BLOCKED",
"res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
"same-origin `/v1`",
"`/v1/diagnostics/gate`",
"只读 `/json-rpc`",
"`/health/live`",
"`:16666`",
@@ -370,10 +371,35 @@ for (const wiringTerm of ["res_boxsimu_1", "res_boxsimu_2", "DO1", "DI1", "hwlab
for (const legacyWideHeader of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"]) {
assert.doesNotMatch(wiringPanel, new RegExp(`<th>${escapeRegExp(legacyWideHeader)}<\\/th>`), `legacy wide wiring column must not return: ${legacyWideHeader}`);
}
assert.match(html, /id="unblock-list"/);
assert.match(app, /DB live readiness/);
assert.match(app, /res_boxsimu_1:DO1 -> res_boxsimu_2:DI1/);
assert.match(app, /patch-panel DO1 -> DI1/);
const gateHtml = sectionSource(html, "gate");
assert.match(gateHtml, /<h2 id="gate-title">内部复核<\/h2>/);
assert.match(gateHtml, /id="gate-source-status"/);
assert.match(gateHtml, /id="gate-status-filter"/);
assert.match(gateHtml, /id="gate-search"/);
assert.match(gateHtml, /id="gate-refresh"/);
assert.match(gateHtml, /class="gate-table"/);
assert.equal((gateHtml.match(/class="gate-table"/gu) ?? []).length, 1, "/gate must contain one main table");
for (const gateHeader of ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]) {
assert.match(gateHtml, new RegExp(`<th>${escapeRegExp(gateHeader)}<\\/th>`), `missing gate table column ${gateHeader}`);
}
for (const gateStatus of ["通过", "阻塞", "失败", "待验证", "信息"]) {
assert.match(`${gateHtml}\n${app}`, new RegExp(escapeRegExp(gateStatus)), `missing gate status ${gateStatus}`);
}
for (const forbiddenGateMarker of ["workspace-panel", "gate-grid", "gate-split", "milestone-grid", "trace-list", "health-body", "diagnostics-list", "method-list", "unblock-list"]) {
assert.doesNotMatch(gateHtml, new RegExp(escapeRegExp(forbiddenGateMarker)), `/gate must not contain legacy dashboard marker ${forbiddenGateMarker}`);
}
assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/);
assert.match(app, /function renderGateTable/);
assert.match(app, /function normalizeGateRow/);
assert.match(app, /function gateLiveBlockerRow/);
assert.match(app, /sourceKind:\s*"LIVE-BACKEND"/);
assert.match(app, /live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。/);
assert.doesNotMatch(app, /function\s+(?:renderTrace|renderUnblockList|renderDiagnostics|renderGateDiagnostics|renderMethodList)\s*\(/);
assert.match(styles, /\.gate-table\s*{/);
assert.match(styles, /\.gate-controls\s*{/);
assert.doesNotMatch(styles, /\.gate-grid\s*{/);
assert.doesNotMatch(styles, /\.milestone-grid\s*{/);
assert.doesNotMatch(styles, /\.trace-list\s*{/);
for (const trustedRecordTerm of requiredTrustedRecordTerms) {
assert.match(app, new RegExp(escapeRegExp(trustedRecordTerm)), `missing trusted-record term: ${trustedRecordTerm}`);
}
@@ -411,7 +437,6 @@ assert.match(app, /同源 API 返回 blocked\/degraded\/failed 或 ready=false
assert.match(app, /\/health\/live 可响应,但可信记录持久化尚未就绪:/);
assert.doesNotMatch(app, /API 降级 \/ 只读模式/);
assert.doesNotMatch(functionBody(app, "workbenchApiSurfaceStatus"), /runtime_durable_adapter_query_blocked|DB live readiness|Gate|诊断|验收|M0-M5|执行轨迹/u);
assert.match(html, /method-list/);
assert.match(html, /quick-actions/);
assert.match(html, /编写任务/);
assert.match(html, /查看接线/);
@@ -552,7 +577,7 @@ assert.match(styles, /html,\s*\nbody\s*{[^}]*height:\s*100%;[^}]*overflow:\s*hid
assert.match(styles, /body\s*>\s*\[data-app-shell\]\s*{[^}]*min-height:\s*0;/s);
assert.match(styles, /\.workbench-shell\s*{[^}]*height:\s*100(?:d)?vh;[^}]*overflow:\s*hidden;/s);
assert.match(styles, /\.view\s*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.(?:resource-tree|compact-list|conversation-list|trace-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(styles, /\.(?:resource-tree|compact-list|conversation-list|task-list|hardware-list)[^{]*{[^}]*min-height:\s*0;[^}]*overflow:\s*auto;/s);
assert.match(app, /function syncMobileExplorer/);
assert.match(app, /window\.matchMedia\("\(max-width: 860px\)"\)/);
assert.match(app, /setExplorerCollapsed\(true\)/);
@@ -565,6 +590,7 @@ assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.center-workspace\s*{[
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\S]*?grid-row:\s*2;/);
assert.match(styles, /@media \(max-width: 520px\)[\s\S]*?\.command-bar\s*{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;/);
assert.match(app, /fetchJson\("\/v1"\)/);
assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/);
assert.match(app, /fetchJson\("\/health\/live"\)/);
assert.match(app, /callRpc\("system\.health"\)/);
assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
@@ -593,7 +619,13 @@ assert.match(artifactPublisher, /routePath === "\/gate"/);
assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/);
assert.match(artifactPublisher, /routePath === "\/help"/);
assert.match(app, /degraded/);
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/);
assert.deepEqual(runtime.serviceRoute, [
"browser/CLI/gateway",
"master hwlab-edge-proxy",
"frp",
"D601 hwlab-dev/hwlab-router",
"cloud-web/cloud-api"
]);
assert.doesNotMatch(app, /callRpc\("hardware\./);
assert.doesNotMatch(app, /hardware\.operation\.request/);
assert.doesNotMatch(app, /hardware\.invoke\.shell/);
@@ -657,8 +689,18 @@ function escapeRegExp(value) {
function sectionSource(source, viewId) {
const start = source.search(new RegExp(`<section\\b[^>]*\\bdata-view=["']${escapeRegExp(viewId)}["'][^>]*>`, "u"));
if (start === -1) return "";
const next = source.slice(start + 1).search(/<section\b[^>]*\bdata-view=["'][^"']+["'][^>]*>/u);
return next === -1 ? source.slice(start) : source.slice(start, start + 1 + next);
const tagPattern = /<\/?section\b[^>]*>/giu;
tagPattern.lastIndex = start;
let depth = 0;
for (let match = tagPattern.exec(source); match; match = tagPattern.exec(source)) {
if (match[0].startsWith("</")) {
depth -= 1;
if (depth === 0) return source.slice(start, tagPattern.lastIndex);
} else {
depth += 1;
}
}
return source.slice(start);
}
function beforeSection(source, viewId) {
@@ -58,10 +58,17 @@ export function runCloudWebM3ReadonlyContract() {
"Cloud Web must call only read-only JSON-RPC diagnostics methods"
);
assert.match(app, /fetchJson\("\/v1"\)/u, "Cloud Web must read the public REST index");
assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/u, "Internal review page must read the same-origin live diagnostics table route");
assert.match(app, /fetchJson\("\/v1\/m3\/io"/u, "Cloud Web M3 control must use only the same-origin cloud-api M3 IO route");
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "Cloud Web M3 status must use only the same-origin cloud-api aggregate status route");
assert.match(app, /fetchJson\("\/json-rpc"/u, "Cloud Web must use same-origin JSON-RPC for diagnostics");
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/u, "Cloud Web must render the observed route contract");
assert.deepEqual(runtime.serviceRoute, [
"browser/CLI/gateway",
"master hwlab-edge-proxy",
"frp",
"D601 hwlab-dev/hwlab-router",
"cloud-web/cloud-api"
], "Cloud Web runtime must retain the observed route contract");
assert.ok(app.includes("frontendBypass=false"), "M3 control evidence must explicitly record no frontend bypass");
for (const [pattern, label] of [
@@ -110,7 +117,7 @@ export function runCloudWebM3ReadonlyContract() {
/res_m5-control-relay|res_m5-target-board|out1|reset/u,
"M5/reset dry-run wiring must not appear in the default M3 workbench topology"
);
assert.ok(gateSummary.health.length >= 1, "health diagnostics must remain visible");
assert.ok(gateSummary.health.length >= 1, "checked-in gate summary must retain source health context for fallback explanation");
assert.ok(gateSummary.evidenceRecords.every((record) => record.dryRun === true), "checked-in evidence must stay dry-run labeled");
assert.ok(
+53 -106
View File
@@ -292,7 +292,6 @@ h3 {
.resource-tree,
.compact-list,
.conversation-list,
.trace-list,
.task-list,
.hardware-list {
min-height: 0;
@@ -456,7 +455,6 @@ h3 {
}
.conversation-panel,
.trace-panel,
.task-panel {
grid-template-rows: auto minmax(0, 1fr);
}
@@ -469,7 +467,6 @@ h3 {
.conversation-list,
.compact-list,
.trace-list,
.task-list {
display: grid;
align-content: start;
@@ -512,10 +509,7 @@ h3 {
.message-card,
.info-card,
.trace-item,
.metric,
.milestone-card,
.status-card {
.metric {
min-width: 0;
background: var(--surface-2);
border: 1px solid var(--line);
@@ -684,39 +678,14 @@ h3 {
color: var(--bad);
}
.trace-item {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
}
.trace-order {
color: var(--accent);
font-family: var(--mono);
font-weight: 760;
}
.trace-title,
.info-title,
.milestone-id {
.info-title {
display: block;
color: var(--text);
font-weight: 760;
overflow-wrap: anywhere;
}
.trace-meta,
.info-detail,
.milestone-meta,
.milestone-summary,
.status-meta {
display: block;
color: var(--muted);
overflow-wrap: anywhere;
}
.command-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -948,19 +917,6 @@ h3 {
font-size: 11px;
}
.status-card {
display: grid;
gap: 5px;
padding: 9px;
}
.status-value {
font-size: 18px;
font-weight: 800;
line-height: 1;
overflow-wrap: anywhere;
}
.side-workspace {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -1091,28 +1047,53 @@ h3 {
background: var(--surface);
}
.rpc-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gate-view {
display: grid;
gap: 10px;
padding: 10px;
}
.gate-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
padding: 10px;
overflow: hidden;
}
.gate-split {
.gate-header,
.gate-controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 10px;
padding: 12px;
background: rgba(24, 27, 24, 0.97);
border: 1px solid var(--line);
}
.gate-header {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.gate-controls {
grid-template-columns: minmax(92px, 140px) minmax(0, 1fr) auto;
}
.gate-controls label {
min-width: 0;
display: grid;
gap: 4px;
}
.gate-controls label span {
color: var(--muted);
font-size: 10px;
font-weight: 760;
}
.gate-controls select,
.gate-controls input {
width: 100%;
min-height: 34px;
padding: 0 9px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
}
.metric {
@@ -1128,25 +1109,10 @@ h3 {
text-transform: uppercase;
}
.metric-value {
color: var(--text);
font-family: var(--mono);
overflow-wrap: anywhere;
}
.milestone-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.milestone-card {
display: grid;
gap: 8px;
padding: 10px;
}
.table-wrap {
.table-wrap,
.gate-table-wrap {
min-height: 0;
overflow: auto;
border: 1px solid var(--line);
overflow-x: auto;
overflow-y: auto;
@@ -1227,6 +1193,10 @@ table {
table-layout: fixed;
}
.gate-table {
min-width: 1120px;
}
th,
td {
text-align: left;
@@ -1251,23 +1221,6 @@ tbody tr:last-child td {
border-bottom: 0;
}
.command-list {
display: grid;
gap: 8px;
padding: 0;
list-style: none;
}
.command-list li {
min-width: 0;
padding: 9px;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--muted);
font-family: var(--mono);
overflow-wrap: anywhere;
}
.mono {
font-family: var(--mono);
font-size: 11px;
@@ -1307,6 +1260,7 @@ tbody tr:last-child td {
.tone-dry-run,
.tone-not_run,
.tone-pending,
.tone-warn,
.tone-warning,
.tone-probing,
@@ -1408,10 +1362,6 @@ tbody tr:last-child td {
border-top: 1px solid var(--line);
border-left: 0;
}
.gate-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 860px) {
@@ -1506,13 +1456,11 @@ tbody tr:last-child td {
}
.topbar,
.gate-split {
.gate-controls {
grid-template-columns: 1fr;
}
.hardware-list,
.milestone-grid,
.gate-grid,
.m3-control-grid,
.m3-control-actions,
.quick-actions {
@@ -1524,8 +1472,7 @@ tbody tr:last-child td {
gap: 3px;
}
.message-card,
.trace-item {
.message-card {
grid-template-columns: 1fr;
}