fix: collapse internal gate review to live table
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 对话或资源选择入口;这些操作仍在默认工作台内按受控后端边界执行。
|
||||
|
||||
## 来源标签含义
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user