From 8b2521d8170400be68c4572af1fbca147412db0d Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 23:58:19 +0000 Subject: [PATCH] test: block stale workbench live smoke --- package.json | 2 +- scripts/dev-cloud-workbench-smoke.test.mjs | 102 ++++++++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 245 +++++++++++++++++- scripts/validate-dev-gate-report.mjs | 151 ++++++++++- 4 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 scripts/dev-cloud-workbench-smoke.test.mjs diff --git a/package.json b/package.json index e1f3aa4e..52e21b60 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs new file mode 100644 index 00000000..4e6fac52 --- /dev/null +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyLiveDeploymentIdentity, + classifyLiveWebAssetIdentity +} from "./src/dev-cloud-workbench-smoke-lib.mjs"; + +const sourceIdentity = Object.freeze({ + status: "observed", + commitId: "ff4b3f928f76a0b46cd7998a2a865f3e1caf02c8", + shortCommitId: "ff4b3f928f76", + reportCommitId: "ff4b3f928f76", + worktreeState: "clean", + dirty: false +}); + +const expectedRuntimeIdentity = Object.freeze({ + status: "observed", + serviceId: "hwlab-cloud-api", + source: "deploy/artifact-catalog.dev.json+deploy/deploy.json", + commitId: "7de6edd", + imageTag: "7de6edd", + image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:7de6edd" +}); + +test("live workbench identity passes only when runtime commit or image tag matches current source", () => { + assert.equal( + classifyLiveDeploymentIdentity(sourceIdentity, { + status: "observed", + commitId: "7de6edd2c41f", + imageTag: "c7de474" + }, expectedRuntimeIdentity).status, + "pass" + ); + + assert.equal( + classifyLiveDeploymentIdentity(sourceIdentity, { + status: "observed", + commitId: "unknown", + imageTag: "7de6edd" + }, expectedRuntimeIdentity).status, + "pass" + ); + + const stale = classifyLiveDeploymentIdentity(sourceIdentity, { + status: "observed", + commitId: "c7de474", + imageTag: "c7de474" + }, expectedRuntimeIdentity); + assert.equal(stale.status, "blocked"); + assert.match(stale.summary, /Deployment drift/u); + assert.match(stale.reason, /does not match current source desired runtime identity/u); +}); + +test("live workbench identity blocks when source identity is dirty or runtime identity is absent", () => { + const dirty = classifyLiveDeploymentIdentity( + { ...sourceIdentity, worktreeState: "dirty", dirty: true, reportCommitId: "unknown" }, + { status: "observed", commitId: "7de6edd", imageTag: "7de6edd" }, + expectedRuntimeIdentity + ); + assert.equal(dirty.status, "blocked"); + assert.match(dirty.reason, /not cleanly attributable/u); + + const missingRuntime = classifyLiveDeploymentIdentity(sourceIdentity, { + status: "not_observed", + commitId: "not_observed", + imageTag: "not_observed" + }, expectedRuntimeIdentity); + assert.equal(missingRuntime.status, "blocked"); + assert.match(missingRuntime.reason, /not observed/u); +}); + +test("live workbench identity uses deploy desired runtime identity instead of git HEAD", () => { + const deployed = classifyLiveDeploymentIdentity(sourceIdentity, { + status: "observed", + commitId: "7de6edd", + imageTag: "7de6edd" + }, expectedRuntimeIdentity); + assert.equal(deployed.status, "pass"); + assert.equal(deployed.expectedCommit, "7de6edd"); + assert.equal(deployed.expectedReportCommitId, "ff4b3f928f76"); + assert.equal(deployed.expectedSource, "deploy/artifact-catalog.dev.json+deploy/deploy.json"); +}); + +test("live web asset identity blocks stale deployed primary assets", () => { + const pass = classifyLiveWebAssetIdentity([ + { path: "index.html", status: "match" }, + { path: "styles.css", status: "match" }, + { path: "app.mjs", status: "match" } + ]); + assert.equal(pass.status, "pass"); + + const stale = classifyLiveWebAssetIdentity([ + { path: "index.html", status: "match" }, + { path: "styles.css", status: "mismatch" }, + { path: "app.mjs", status: "mismatch" } + ]); + assert.equal(stale.status, "blocked"); + assert.deepEqual(stale.mismatches, ["styles.css", "app.mjs"]); + assert.match(stale.summary, /Deployment drift/u); +}); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index eeb2951d..4dba9618 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -31,6 +32,8 @@ const requiredWebAssets = Object.freeze([ "third_party/marked/LICENSE" ]); +const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs"]); + const chineseWorkbenchLabels = Object.freeze([ "HWLAB 云工作台", "硬件资源", @@ -369,13 +372,64 @@ function runStaticSmoke() { async function runLiveSmoke(args) { const checks = []; const blockers = []; + const sourceIdentity = observeSourceIdentity(); const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api); + const expectedRuntimeIdentity = observeExpectedRuntimeIdentity("hwlab-cloud-api"); + const deploymentIdentity = classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity); + addCheck(checks, blockers, "live-runtime-current-main", deploymentIdentity.status, deploymentIdentity.summary, { + blocker: "observability_blocker", + evidence: deploymentIdentity.evidence, + observations: deploymentIdentity + }); + const http = await fetchText(args.url); addCheck(checks, blockers, "live-http-html", http.ok && http.status === 200 && /text\/html/u.test(http.contentType) && liveHtmlLooksLikeWorkbench(http.body), "Live URL serves the Cloud Workbench HTML.", { blocker: "runtime_blocker", evidence: [args.url, `HTTP ${http.status ?? "none"}`, http.contentType ?? "no content-type"] }); + const webAssetIdentity = http.ok + ? await inspectLiveWebAssetIdentity(args.url, http.body) + : { + status: "blocked", + summary: "Live web asset identity could not be checked because the 16666 HTML fetch failed.", + assets: [] + }; + addCheck(checks, blockers, "live-web-assets-current-main", webAssetIdentity.status, webAssetIdentity.summary, { + blocker: "observability_blocker", + evidence: webAssetIdentity.assets.map((asset) => `${asset.path}: ${asset.status}`), + observations: webAssetIdentity + }); + + const identityBlocked = deploymentIdentity.status !== "pass" || webAssetIdentity.status !== "pass"; + if (identityBlocked) { + return baseReport({ + mode: "live", + url: args.url, + status: "blocked", + checks, + blockers, + evidenceLevel: "BLOCKED", + devLive: false, + runtimeIdentity, + sourceIdentity, + deploymentIdentity, + expectedRuntimeIdentity, + webAssetIdentity, + safety: { + prodTouched: false, + servicesRestarted: false, + heavyE2E: false, + hardwareWriteApis: false, + sourceIsDevLive: false, + liveMode: "deployment-identity-preflight", + codeAgentBrowserJourneySkipped: true, + retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status"], + statement: "Live smoke blocked before the browser chat journey because deployed runtime/assets do not match the current source identity; it did not post Code Agent chat, call hardware write APIs, restart services, or read secret values." + } + }); + } + const dom = http.ok ? await inspectLiveDom(args.url) : { status: "skip", summary: "Browser DOM check skipped because HTTP fetch failed.", evidence: [] }; addCheck(checks, blockers, "live-browser-dom", dom.status, dom.summary, { blocker: dom.status === "pass" ? null : "observability_blocker", @@ -418,6 +472,10 @@ async function runLiveSmoke(args) { evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", devLive: status === "pass", runtimeIdentity, + sourceIdentity, + deploymentIdentity, + expectedRuntimeIdentity, + webAssetIdentity, safety: { prodTouched: false, servicesRestarted: false, @@ -431,8 +489,22 @@ async function runLiveSmoke(args) { }); } -function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null, runtimeIdentity = null }) { - const sourceIdentity = observeSourceIdentity(); +function baseReport({ + mode, + status, + checks, + blockers, + evidenceLevel, + devLive, + help = undefined, + safety, + url = null, + runtimeIdentity = null, + sourceIdentity = observeSourceIdentity(), + deploymentIdentity = null, + expectedRuntimeIdentity = null, + webAssetIdentity = null +}) { const liveJourneyPassed = mode === "live" && status === "pass"; return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", @@ -473,6 +545,9 @@ function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, he devLive, sourceIdentity, runtimeIdentity: runtimeIdentity ?? notObservedRuntimeIdentity("live runtime identity was not observed in this smoke mode"), + ...(expectedRuntimeIdentity ? { expectedRuntimeIdentity } : {}), + ...(deploymentIdentity ? { deploymentIdentity } : {}), + ...(webAssetIdentity ? { webAssetIdentity } : {}), sourceContract: { status: "pass", documents: [ @@ -630,6 +705,172 @@ function runGit(args) { } } +export function classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity = null) { + const expectedCommit = sanitizeRevision(expectedRuntimeIdentity?.commitId ?? sourceIdentity?.commitId); + const expectedShort = expectedCommit === "unknown" ? "unknown" : expectedCommit.slice(0, 7); + const expectedReport = sourceIdentity?.reportCommitId ?? "unknown"; + const expectedImageTag = sanitizeRuntimeString(expectedRuntimeIdentity?.imageTag ?? expectedShort).toLowerCase(); + const observedRuntimeCommit = sanitizeRevision(runtimeIdentity?.commitId); + const observedImageTag = sanitizeRuntimeString(runtimeIdentity?.imageTag).toLowerCase(); + const runtimeObserved = runtimeIdentity?.status === "observed"; + const expectedObserved = expectedRuntimeIdentity === null || expectedRuntimeIdentity?.status === "observed"; + const sourceClean = + sourceIdentity?.status === "observed" && + sourceIdentity?.worktreeState === "clean" && + expectedReport !== "unknown"; + const accepted = new Set([...commitIdentityVariants(expectedCommit), ...commitIdentityVariants(expectedImageTag)]); + const runtimeMatches = + runtimeObserved && + sourceClean && + expectedObserved && + expectedCommit !== "unknown" && + (identityVariantMatches(accepted, observedRuntimeCommit) || identityVariantMatches(accepted, observedImageTag)); + + if (runtimeMatches) { + return { + status: "pass", + expectedCommit, + expectedReportCommitId: expectedReport, + expectedImageTag, + expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head", + observedRuntimeCommit, + observedImageTag, + evidence: [ + `expected=${expectedShort}`, + `expectedImageTag=${expectedImageTag}`, + `runtimeCommit=${observedRuntimeCommit}`, + `imageTag=${observedImageTag}` + ], + summary: "Live API runtime identity matches the current source desired runtime identity." + }; + } + + const reason = !sourceClean + ? "current source identity is not cleanly attributable" + : !expectedObserved || expectedCommit === "unknown" + ? "source desired runtime identity was not observed" + : !runtimeObserved + ? "live API runtime identity was not observed" + : "live API runtime commit/image tag does not match current source desired runtime identity"; + return { + status: "blocked", + expectedCommit, + expectedReportCommitId: expectedReport, + expectedImageTag, + expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head", + observedRuntimeCommit, + observedImageTag, + reason, + evidence: [ + `expected=${expectedShort}`, + `expectedImageTag=${expectedImageTag}`, + `runtimeCommit=${observedRuntimeCommit}`, + `imageTag=${observedImageTag}` + ], + summary: `Deployment drift: ${reason}.` + }; +} + +function observeExpectedRuntimeIdentity(serviceId) { + const deploy = readJsonOrNull("deploy/deploy.json"); + const catalog = readJsonOrNull("deploy/artifact-catalog.dev.json"); + const deployService = Array.isArray(deploy?.services) + ? deploy.services.find((service) => service?.serviceId === serviceId) + : null; + const catalogService = Array.isArray(catalog?.services) + ? catalog.services.find((service) => service?.serviceId === serviceId) + : null; + const image = sanitizeRuntimeString(catalogService?.image ?? deployService?.image); + const imageTag = sanitizeRuntimeString(catalogService?.imageTag ?? deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromImage(image)); + const commitId = sanitizeRevision(catalogService?.commitId ?? deployService?.env?.HWLAB_COMMIT_ID ?? deploy?.commitId ?? catalog?.commitId ?? imageTag); + const sources = []; + if (catalogService) sources.push("deploy/artifact-catalog.dev.json"); + if (deployService || deploy?.commitId) sources.push("deploy/deploy.json"); + const observed = commitId !== "unknown" || imageTag !== "unknown"; + return { + status: observed ? "observed" : "not_observed", + serviceId, + source: sources.length > 0 ? sources.join("+") : "not_observed", + commitId, + imageTag, + image, + summary: observed + ? "Expected DEV runtime identity was derived from current source deploy desired state, not from live health." + : "Expected DEV runtime identity was not found in current source deploy desired state." + }; +} + +function readJsonOrNull(relativePath) { + try { + return JSON.parse(readText(relativePath)); + } catch { + return null; + } +} + +function imageTagFromImage(image) { + if (typeof image !== "string") return "unknown"; + const match = image.match(/:([^:/]+)$/u); + return match?.[1] ?? "unknown"; +} + +function commitIdentityVariants(commitId) { + if (typeof commitId !== "string" || !/^[a-f0-9]{7,40}$/u.test(commitId)) return new Set(); + return new Set([commitId, commitId.slice(0, 12), commitId.slice(0, 7)]); +} + +function identityVariantMatches(accepted, observed) { + if (typeof observed !== "string" || observed === "unknown" || observed === "not_observed") return false; + if (accepted.has(observed)) return true; + return [...accepted].some((expected) => expected.length >= 7 && observed.startsWith(expected)); +} + +async function inspectLiveWebAssetIdentity(baseUrl, fetchedIndexHtml) { + const assets = []; + for (const assetPath of liveIdentityWebAssets) { + const sourceText = readText(`web/hwlab-cloud-web/${assetPath}`); + const live = assetPath === "index.html" + ? { ok: true, status: 200, contentType: "text/html; charset=utf-8", body: fetchedIndexHtml } + : await fetchText(new URL(assetPath, baseUrl).toString()); + assets.push(compareLiveAsset(assetPath, live, sourceText)); + } + return classifyLiveWebAssetIdentity(assets); +} + +export function classifyLiveWebAssetIdentity(assets) { + const mismatches = assets.filter((asset) => asset.status !== "match"); + return { + status: mismatches.length === 0 ? "pass" : "blocked", + assets, + mismatches: mismatches.map((asset) => asset.path), + summary: mismatches.length === 0 + ? "Live 16666 primary web assets match the current clean source files." + : `Deployment drift: live 16666 primary web assets differ from current source (${mismatches.map((asset) => asset.path).join(", ")}).` + }; +} + +function compareLiveAsset(assetPath, live, sourceText) { + const liveText = typeof live?.body === "string" ? live.body : ""; + const sourceHash = hashText(sourceText); + const liveHash = hashText(liveText); + const fetched = live?.ok === true && live?.status === 200; + const matches = fetched && liveText === sourceText; + return { + path: assetPath, + status: matches ? "match" : fetched ? "mismatch" : "fetch_failed", + httpStatus: live?.status ?? null, + contentType: live?.contentType ?? "", + sourceHash, + liveHash, + sourceLength: sourceText.length, + liveLength: liveText.length + }; +} + +function hashText(value) { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + function assetsExist() { return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file))); } diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 94526acb..eb69734a 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -1381,7 +1381,7 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { }); assertArray(report.checks, `${label}.checks`); - assert.ok(report.checks.length >= 3, `${label}.checks must include HTTP, DOM, and Code Agent journey checks`); + assert.ok(report.checks.length >= 3, `${label}.checks must include HTTP plus either deployment identity or browser journey checks`); assertUnique(report.checks.map((check) => check.id), `${label}.checks ids`); const checks = new Map(report.checks.map((check, index) => { const checkLabel = `${label}.checks[${index}]`; @@ -1400,6 +1400,16 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { } return [check.id, check]; })); + const deploymentIdentityPreflight = + report.status === "blocked" && + report.safety?.liveMode === "deployment-identity-preflight" && + checks.has("live-runtime-current-main") && + checks.has("live-web-assets-current-main"); + if (deploymentIdentityPreflight) { + assertDevCloudWorkbenchDeploymentPreflight(report, label, checks); + return; + } + for (const requiredCheck of [ "live-http-html", "live-browser-dom", @@ -1431,6 +1441,10 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { assert.equal(journey.observations.request.method, "POST", `${label}.live-code-agent-browser-journey.request.method`); assert.equal(journey.observations.request.urlPath, "/v1/agent/chat", `${label}.live-code-agent-browser-journey.request.urlPath`); assertObject(journey.observations.response, `${label}.live-code-agent-browser-journey.response`); + if (report.status === "blocked" && journey.status === "blocked") { + assertDevCloudWorkbenchCodeAgentBlocked(report, label, journey); + return; + } assert.equal(journey.observations.response.status, "completed", `${label}.live-code-agent-browser-journey.response.status`); for (const field of ["provider", "model", "backend", "traceId"]) { assertString(journey.observations.response[field], `${label}.live-code-agent-browser-journey.response.${field}`); @@ -1465,6 +1479,141 @@ async function validateDevCloudWorkbenchLiveReport(report, label, raw) { assertFreshDevCloudWorkbenchLiveEvidence(report, label, raw, journey); } +function assertDevCloudWorkbenchCodeAgentBlocked(report, label, journey) { + const response = journey.observations.response; + assert.equal(response.status, "failed", `${label}.live-code-agent-browser-journey.response.status`); + assert.equal(response.hasReply, false, `${label}.live-code-agent-browser-journey.response.hasReply`); + assertObject(response.error, `${label}.live-code-agent-browser-journey.response.error`); + assert.ok( + ["provider_unavailable", "untrusted_completion"].includes(response.error.code), + `${label}.live-code-agent-browser-journey.response.error.code` + ); + for (const field of ["provider", "model", "backend", "traceId"]) { + assertString(response[field], `${label}.live-code-agent-browser-journey.response.${field}`); + } + for (const forbiddenField of ["conversationId", "sessionId", "messageId", "reply"]) { + assert.equal( + Object.hasOwn(response, forbiddenField), + false, + `${label}.live-code-agent-browser-journey.response must not retain ${forbiddenField}` + ); + } + + assertObject(journey.observations.ui, `${label}.live-code-agent-browser-journey.ui`); + assert.equal(journey.observations.ui.agentChatStatus, "发送失败", `${label}.live-code-agent-browser-journey.ui.agentChatStatus`); + assert.equal(journey.observations.ui.completedMessageVisible, false, `${label}.live-code-agent-browser-journey.ui.completedMessageVisible`); + assert.equal(journey.observations.ui.failedMessageVisible, true, `${label}.live-code-agent-browser-journey.ui.failedMessageVisible`); + assertBlockers(report.blockers, `${label}.blockers`); + assert.ok( + report.blockers.some((blocker) => blocker.scope === "live-code-agent-browser-journey"), + `${label}.blockers must include live-code-agent-browser-journey` + ); + + assertObject(report.safety, `${label}.safety`); + for (const field of ["prodTouched", "servicesRestarted", "heavyE2E", "hardwareWriteApis", "sourceIsDevLive"]) { + assert.equal(report.safety[field], false, `${label}.safety.${field}`); + } + assert.equal(report.safety.liveMode, "browser-user-journey-with-code-agent-post", `${label}.safety.liveMode`); + assert.deepEqual( + report.safety.retainedApiFields, + ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"], + `${label}.safety.retainedApiFields` + ); + assertString(report.safety.statement, `${label}.safety.statement`); +} + +function assertDevCloudWorkbenchDeploymentPreflight(report, label, checks) { + for (const requiredCheck of [ + "live-runtime-current-main", + "live-http-html", + "live-web-assets-current-main" + ]) { + assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`); + } + + const runtimeCheck = checks.get("live-runtime-current-main"); + const webAssetCheck = checks.get("live-web-assets-current-main"); + assert.ok(["pass", "blocked"].includes(runtimeCheck.status), `${label}.live-runtime-current-main.status`); + assert.ok(["pass", "blocked"].includes(webAssetCheck.status), `${label}.live-web-assets-current-main.status`); + assert.ok( + runtimeCheck.status === "blocked" || webAssetCheck.status === "blocked", + `${label}.deployment preflight requires runtime or web asset drift` + ); + assertObject(runtimeCheck.observations, `${label}.live-runtime-current-main.observations`); + assertObject(webAssetCheck.observations, `${label}.live-web-assets-current-main.observations`); + assertObject(report.expectedRuntimeIdentity, `${label}.expectedRuntimeIdentity`); + for (const field of ["status", "serviceId", "source", "commitId", "imageTag", "image", "summary"]) { + assertString(report.expectedRuntimeIdentity[field], `${label}.expectedRuntimeIdentity.${field}`); + } + assert.ok(["observed", "not_observed"].includes(report.expectedRuntimeIdentity.status), `${label}.expectedRuntimeIdentity.status`); + assertObject(report.deploymentIdentity, `${label}.deploymentIdentity`); + assert.equal(report.deploymentIdentity.status, runtimeCheck.status, `${label}.deploymentIdentity.status`); + assertString(report.deploymentIdentity.expectedCommit, `${label}.deploymentIdentity.expectedCommit`); + assertString(report.deploymentIdentity.expectedImageTag, `${label}.deploymentIdentity.expectedImageTag`); + assertString(report.deploymentIdentity.expectedSource, `${label}.deploymentIdentity.expectedSource`); + assertString(report.deploymentIdentity.observedRuntimeCommit, `${label}.deploymentIdentity.observedRuntimeCommit`); + assertString(report.deploymentIdentity.observedImageTag, `${label}.deploymentIdentity.observedImageTag`); + if (report.deploymentIdentity.status === "blocked") { + assertString(report.deploymentIdentity.reason, `${label}.deploymentIdentity.reason`); + } + assertString(report.deploymentIdentity.summary, `${label}.deploymentIdentity.summary`); + assertStringArray(report.deploymentIdentity.evidence, `${label}.deploymentIdentity.evidence`, { minLength: 4 }); + assert.deepEqual( + runtimeCheck.observations, + report.deploymentIdentity, + `${label}.live-runtime-current-main.observations` + ); + + assertObject(report.webAssetIdentity, `${label}.webAssetIdentity`); + assert.equal(report.webAssetIdentity.status, webAssetCheck.status, `${label}.webAssetIdentity.status`); + assertArray(report.webAssetIdentity.assets, `${label}.webAssetIdentity.assets`); + assertStringArray(report.webAssetIdentity.mismatches, `${label}.webAssetIdentity.mismatches`, { + minLength: report.webAssetIdentity.status === "blocked" ? 1 : 0 + }); + assertString(report.webAssetIdentity.summary, `${label}.webAssetIdentity.summary`); + for (const [index, asset] of report.webAssetIdentity.assets.entries()) { + const assetLabel = `${label}.webAssetIdentity.assets[${index}]`; + assertObject(asset, assetLabel); + for (const field of ["path", "status", "contentType", "sourceHash", "liveHash"]) { + assertString(asset[field], `${assetLabel}.${field}`); + } + assert.ok(["match", "mismatch", "fetch_failed"].includes(asset.status), `${assetLabel}.status`); + assert.ok(Number.isInteger(asset.sourceLength), `${assetLabel}.sourceLength`); + assert.ok(Number.isInteger(asset.liveLength), `${assetLabel}.liveLength`); + } + assert.deepEqual( + webAssetCheck.observations, + report.webAssetIdentity, + `${label}.live-web-assets-current-main.observations` + ); + + assertBlockers(report.blockers, `${label}.blockers`); + if (runtimeCheck.status === "blocked") { + assert.ok( + report.blockers.some((blocker) => blocker.scope === "live-runtime-current-main"), + `${label}.blockers must include runtime identity drift` + ); + } + if (webAssetCheck.status === "blocked") { + assert.ok( + report.blockers.some((blocker) => blocker.scope === "live-web-assets-current-main"), + `${label}.blockers must include web asset drift` + ); + } + + assertObject(report.safety, `${label}.safety`); + for (const field of ["prodTouched", "servicesRestarted", "heavyE2E", "hardwareWriteApis", "sourceIsDevLive", "codeAgentBrowserJourneySkipped"]) { + assert.equal(report.safety[field], field === "codeAgentBrowserJourneySkipped", `${label}.safety.${field}`); + } + assert.equal(report.safety.liveMode, "deployment-identity-preflight", `${label}.safety.liveMode`); + assert.deepEqual( + report.safety.retainedApiFields, + ["runtime.commitId", "runtime.imageTag", "web asset hash/status"], + `${label}.safety.retainedApiFields` + ); + assertString(report.safety.statement, `${label}.safety.statement`); +} + function assertDevCloudWorkbenchIdentity(report, label) { assertObject(report.sourceIdentity, `${label}.sourceIdentity`); for (const field of ["status", "source", "commitId", "shortCommitId", "ref", "worktreeState", "reportCommitId", "summary"]) {