fix: add cloud web gate route contract

This commit is contained in:
HWLAB Code Queue
2026-05-22 12:23:45 +00:00
parent fa8ec2b21f
commit 131e6ae190
8 changed files with 61 additions and 9 deletions
+5 -2
View File
@@ -32,6 +32,7 @@ diagnostics under secondary routes:
| --- | --- | --- | --- |
| `/` | Cloud Workbench default. Shows agent conversation, trace, selected resource context, hardware/evidence side panel, and command input. | Yes | Same-origin `/v1`, read-only `/json-rpc`, source/gate report, blocked report. |
| `/workbench` | Explicit alias for the default workbench. | Yes, alias | Same as `/`. |
| `/gate` | Internal static alias for the read-only gate diagnostics view. | No | Same as `/diagnostics/gate`; source/gate report and same-origin read-only diagnostics only. |
| `/diagnostics` | Secondary diagnostics hub that links to status and gate views. | No | Same-origin `/v1` and report fixtures only. |
| `/diagnostics/status` | Read-only DEV status: edge, router, API, web, DB readiness, agent service health, and artifact identity. | No | Same-origin `/v1/status` or equivalent read-only status endpoint. |
| `/diagnostics/gate` | Read-only gate and M3 diagnostics inherited from `#59`: topology, route, health, blockers, and evidence summary. | No | Gate report and blocked report; read-only `/json-rpc` only when already defined. |
@@ -42,6 +43,8 @@ diagnostics under secondary routes:
The route relationship is:
- `/` and `/workbench` are the primary product surface.
- `/gate` is a short internal alias for the same hidden gate view as
`/diagnostics/gate`; it must not become the default route.
- `diagnostics/status` and `diagnostics/gate` are auxiliary views for
troubleshooting and acceptance review.
- Agent, hardware resources, and trusted records are first-class workbench
@@ -151,9 +154,9 @@ Acceptance for the frontend design and later implementation is:
| Area | Acceptance |
| --- | --- |
| Default page | `http://74.48.78.17:16666/` opens the Cloud Workbench, not the Gate/M3 Diagnostics dashboard. |
| Default page | `http://74.48.78.17:16666/` opens the Cloud Workbench, not the Gate/M3 Diagnostics dashboard; its first screen does not show Gate, `BLOCKED`, `M0-M5`, or acceptance-review copy. |
| API status | `http://74.48.78.17:16667` remains the DEV API/edge/status boundary; browser code uses same-origin `/v1` for workbench data and only read-only `/json-rpc` methods for diagnostics/hardware status. |
| Routes | `/`, `/workbench`, `/diagnostics`, `/diagnostics/status`, `/diagnostics/gate`, Agent, hardware resources, and trusted records are represented in navigation or route aliases. |
| Routes | `/`, `/workbench`, `/gate`, `/diagnostics`, `/diagnostics/status`, `/diagnostics/gate`, Agent, hardware resources, and trusted records are represented in navigation or route aliases. |
| Layout | Left activity rail/resource tree, center agent conversation/trace, right hardware/evidence side panel, and bottom command/input area are present on the default workbench. |
| Labels | `SOURCE`, `LOCAL`, `DRY-RUN`, `DEV-LIVE`, and `BLOCKED` labels are visible where evidence state is shown and follow the rules above. |
| Read-only boundary | Hardware resources and diagnostics are read-only. Live controls are disabled with blocker reasons. |
+10
View File
@@ -8,7 +8,12 @@ Cloud Workbench is the default user-facing frontend at
## Default Route
- `/` must show the Cloud Workbench, not Gate, status, help, or diagnostics.
- The default first screen must not show Gate, `BLOCKED`, `M0-M5`, or
acceptance-review copy. Those terms may remain in hidden/internal views.
- Gate and diagnostics are secondary routes or right-panel support views.
- `/gate` and `/diagnostics/gate` are internal diagnostic aliases served by the
same Cloud Web app router. They may carry Gate, blocker, and M0-M5 evidence
because they are not the default homepage.
- `http://74.48.78.17:16667/` remains the API/edge boundary.
- Browser data sources are same-origin `/v1`, read-only `/json-rpc`, source
reports, gate reports, and blocked reports.
@@ -63,6 +68,11 @@ node web/hwlab-cloud-web/scripts/build.mjs
node scripts/dev-cloud-workbench-smoke.mjs --static
```
The static smoke checks both sides of the route contract: `/` remains the
user-facing workbench, while `/gate` and `/diagnostics/gate` remain available
for internal diagnostics. This PR-class check is intentionally source-only and
does not deploy, restart services, or run browser e2e.
Live checks against `http://74.48.78.17:16666/` are read-only and must report
blocked/skip instead of false green when the browser or network path is not
available.
+2 -1
View File
@@ -466,7 +466,8 @@ async function serveCloudWeb() {
return;
}
const relativePath = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
const routePath = url.pathname.replace(/\/+$/, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" ? "index.html" : url.pathname.slice(1);
for (const root of roots) {
const candidate = path.resolve(root, relativePath);
if (!candidate.startsWith(root)) continue;
@@ -58,6 +58,8 @@ const diagnosticsRequiredTerms = Object.freeze([
"patch-panel DO1 -> DI1"
]);
const gateRouteAliases = Object.freeze(["/gate", "/diagnostics/gate"]);
const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]);
const requiredWiringColumns = Object.freeze([
@@ -142,6 +144,8 @@ function runStaticSmoke() {
const blockers = [];
const files = readStaticFiles();
const source = Object.values(files).join("\n");
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
const buildScript = readText("web/hwlab-cloud-web/scripts/build.mjs");
addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", {
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
@@ -177,6 +181,11 @@ function runStaticSmoke() {
evidence: diagnosticsRequiredTerms
});
addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", {
blocker: "runtime_blocker",
evidence: gateRouteAliases
});
addCheck(checks, blockers, "feedback-120-wiring-table", wiringTableContract(files.html), "Patch-panel wiring panel is a table with source, target, status, evidence source, and trace/evidence columns.", {
blocker: "contract_blocker",
evidence: requiredWiringColumns
@@ -398,6 +407,15 @@ function gateRetainsDiagnostics(html, app) {
return diagnosticsRequiredTerms.every((term) => gateSource.includes(term));
}
function gateRouteAliasesAreServed(app, artifactPublisher, buildScript) {
return (
/internalGatePathnames\(\)\.has/u.test(app) &&
gateRouteAliases.every((route) => app.includes(`"${route}"`)) &&
gateRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) &&
/\["gate", "diagnostics\/gate"\]/u.test(buildScript)
);
}
function wiringTableContract(html) {
const wiringPanel = html.match(/<section\b[^>]*\bdata-side-panel=["']wiring["'][\s\S]*?<\/section>/u)?.[0] ?? "";
return requiredWiringColumns.every((column) => wiringPanel.includes(`<th>${column}</th>`));
+6 -2
View File
@@ -114,7 +114,7 @@ function initRoutes() {
function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#/, "");
if (viewIds.has(hashRoute)) return hashRoute;
if (window.location.pathname.replace(/\/+$/, "").endsWith("/gate")) return "gate";
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
return "workspace";
}
@@ -144,7 +144,7 @@ async function loadHelpSurface() {
el.helpContent.dataset.helpState = "loading";
try {
const response = await fetch("./help.md", { cache: "no-store" });
const response = await fetch("/help.md", { cache: "no-store" });
if (!response.ok) {
throw new Error(`help.md HTTP ${response.status}`);
}
@@ -960,6 +960,10 @@ function blockerTitle(blocker) {
return `阻塞:${scope}`;
}
function internalGatePathnames() {
return new Set(["/gate", "/diagnostics/gate"]);
}
function userFacingSummary(text) {
return String(text ?? "")
.replaceAll("Public DEV route/frp", "公共 DEV 路由/frp")
+3 -3
View File
@@ -4,11 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HWLAB 云工作台</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./app.mjs"></script>
<link rel="stylesheet" href="/styles.css" />
<script type="module" src="/app.mjs"></script>
</head>
<body>
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="./help.md">
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md">
<aside class="activity-rail" aria-label="工作台活动栏">
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
+6
View File
@@ -23,4 +23,10 @@ for (const file of runtimeFiles) {
fs.copyFileSync(path.resolve(rootDir, file), targetPath);
}
for (const alias of ["gate", "diagnostics/gate"]) {
const targetPath = path.resolve(distDir, alias, "index.html");
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(path.resolve(rootDir, "index.html"), targetPath);
}
console.log(`hwlab-cloud-web build ok: ${distDir}`);
+11 -1
View File
@@ -39,11 +39,13 @@ const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-ar
const frontendSource = `${html}\n${app}\n${artifactPublisher}`;
const workbenchSmoke = runDevCloudWorkbenchStaticSmoke();
const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract");
const gateAliasCheck = workbenchSmoke.checks.find((check) => check.id === "internal-gate-route-aliases");
assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2));
assert.equal(workbenchSmoke.evidenceLevel, "SOURCE");
assert.equal(workbenchSmoke.devLive, false);
assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready");
assert.equal(gateAliasCheck?.status, "pass", "internal Gate aliases must be directly served");
assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending");
assert.match(html, /HWLAB 云工作台/);
@@ -76,6 +78,9 @@ for (const legacyEnglishTitle of [
}
assert.match(html, /硬件状态:BOX-SIMU \/ Gateway-SIMU \/ hwlab-patch-panel/);
assert.match(html, /Gate \/ 诊断 \/ 验收/);
assert.match(app, /new Set\(\["\/gate", "\/diagnostics\/gate"\]\)/);
assert.match(html, /href="\/styles\.css"/);
assert.match(html, /src="\/app\.mjs"/);
for (const workbenchElement of [
"activity-rail",
"explorer",
@@ -103,7 +108,7 @@ assert.match(html, /data-help-route-policy="non-default-internal-help"/);
assert.match(app, /from "\.\/third_party\/marked\/marked\.esm\.js"/);
assert.match(app, /marked\.parse\(/);
assert.doesNotMatch(app, /function\s+parseMarkdown|markedRegex|markdownRegex/);
assert.match(app, /fetch\("\.\/help\.md"/);
assert.match(app, /fetch\("\/help\.md"/);
assert.match(app, /帮助内容加载失败/);
assert.match(app, /return "workspace";/);
assert.match(styles, /\.help-view\s*{[^}]*overflow:\s*hidden;/s);
@@ -159,6 +164,8 @@ assert.match(app, /patch-panel DO1 -> DI1/);
assert.match(buildScript, /"help\.md"/);
assert.match(buildScript, /"third_party\/marked\/marked\.esm\.js"/);
assert.match(buildScript, /"third_party\/marked\/LICENSE"/);
assert.match(buildScript, /\["gate", "diagnostics\/gate"\]/);
assert.match(buildScript, /alias,\s*"index\.html"/);
assert.match(markedLicense, /MarkedJS/);
assert.match(markedLicense, /Permission is hereby granted, free of charge/);
assert.match(html, /live-status/);
@@ -190,6 +197,9 @@ assert.match(artifactPublisher, /"audit\.event\.query"/);
assert.match(artifactPublisher, /"evidence\.record\.query"/);
assert.match(artifactPublisher, /readonly_rpc_required/);
assert.match(artifactPublisher, /cloud_api_proxy_failed/);
assert.match(artifactPublisher, /url\.pathname\.replace\(\/\\\/\+\$\/,\s*""\) \|\| "\/"/);
assert.match(artifactPublisher, /routePath === "\/gate"/);
assert.match(artifactPublisher, /routePath === "\/diagnostics\/gate"/);
assert.match(app, /degraded/);
assert.match(app, /runtime\.serviceRoute\.join\(" -> "\)/);
assert.doesNotMatch(app, /callRpc\("hardware\./);