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, pathToFileURL } from "node:url"; import { gateSummary } from "../../web/hwlab-cloud-web/gate-summary.mjs"; import { runtime } from "../../web/hwlab-cloud-web/runtime.mjs"; import { classifyCodeAgentBrowserJourney, classifyCodeAgentBrowserFailure, summarizeCodeAgentPayload } from "./code-agent-response-contract.mjs"; export { classifyCodeAgentBrowserJourney }; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const webRoot = path.join(repoRoot, "web/hwlab-cloud-web"); const defaultLiveUrl = "http://74.48.78.17:16666/"; const helpOwner = "codex_1779444232735_1"; const legacyFailureWindowMs = 4500; const codeAgentLongTimeoutMs = 150000; const localAgentFixtureDelayMs = 5200; const codeAgentE2ePrompts = Object.freeze([ { id: "simple-chinese", label: "简单中文提示", text: "请用一句话说明当前 HWLAB 工作台可以做什么。" }, { id: "list-skills", label: "列出技能提示", text: "列出你能使用的所有skill" } ]); const readOnlyRpcMethods = Object.freeze([ "system.health", "cloud.adapter.describe", "audit.event.query", "evidence.record.query" ]); const requiredWebAssets = Object.freeze([ "index.html", "styles.css", "app.mjs", "runtime.mjs", "gate-summary.mjs", "workbench-hardware-panel.mjs", "help.md", "third_party/marked/marked.esm.js", "third_party/marked/LICENSE" ]); const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs"]); const chineseWorkbenchLabels = Object.freeze([ "HWLAB 云工作台", "硬件资源", "Agent 对话", "工作清单", "可信记录", "内部复核", "使用说明" ]); const defaultHomepageForbiddenTerms = Object.freeze([ "Gate", "诊断", "验收", "M0-M5", "M3 基本", "BLOCKED", "SOURCE", "DRY-RUN", "DEV-LIVE", "db-live", "m3-hardware-loop-runtime", "trace/evidence", "执行轨迹", "runtime_durable_adapter_query_blocked", "OPENAI_API_KEY", "Secret", "secretRef", "hwlab-code-agent-provider", "m3-route-required", "waiting-for-dev-live" ]); 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"]); const helpRouteAliases = Object.freeze(["/help"]); const incompletePrimaryNavLabels = Object.freeze(["台", "证", "诊", "帮"]); const requiredWiringLongTableHeaders = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); const forbiddenLegacyWiringColumns = Object.freeze([ "源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据" ]); const requiredWiringSummaryTerms = Object.freeze(["hwlab-patch-panel", "状态", "证据来源", "轨迹/证据"]); const requiredTrustedRecordTerms = Object.freeze([ "Code Agent 对话记录", "接线/operation", "audit 记录", "evidence 证据", "trace/messageId", "audit.event.query + SOURCE 回退", "evidence.record.query + SOURCE 回退", "conversationId、traceId 和 messageId", "provider/model/trace/conversation", "失败原因=", "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定", "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定", "当前显示 SOURCE 回退,不能冒充 DEV-LIVE" ]); const requiredHardwareStatusTerms = Object.freeze([ "hardware-source-status", "hardware-status-tabs", "hardwareKvGroup", "/v1/m3/status", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel", "Gateway 在线", "BOX 在线", "patch-panel", "runtime durable", "DISABLED 后续能力", "SOURCE", "DEV-LIVE", "unverified_cloud_api_m3_status" ]); const workbenchMarkers = Object.freeze([ "data-app-shell", "workbench-shell", "activity-rail", "explorer", "resource-tree", "center-workspace", "conversation-list", "agent-chat-status", "task-list", "right-sidebar", "hardware-list", "command-form" ]); const requiredCodeAgentEvidenceTerms = Object.freeze([ "provider", "model", "backend", "runner", "workspace", "sandbox", "capability", "sessionMode", "sessionReuse", "implementation", "limitations", "codexStdio", "toolCalls", "skills", "runnerTrace", "conversation", "trace", "message", "providerTrace", "openai-responses", "codex-cli", "controlled-readonly-session-registry", "not-codex-stdio", "not-write-capable", "not-durable-session", "echo/mock/stub", "untrusted_completion", "SOURCE fixture", "Code Agent SOURCE 回复", "message-evidence-chip" ]); const blockedCodeAgentUiLabels = Object.freeze(["服务受阻", "BLOCKED 凭证缺口"]); const forbiddenWritePatterns = Object.freeze([ /callRpc\(\s*["']hardware\./u, /hardware\.operation\.request/u, /hardware\.invoke\.shell/u, /audit\.event\.write/u, /evidence\.record\.write/u, /\/v1\/rpc\//u, /\/wiring\/apply/u, /\/wiring\/reload/u, /--live --confirm-dev --confirmed-non-production/u ]); const markdownRendererPackages = Object.freeze([ "marked", "markdown-it", "micromark", "remark", "remark-parse", "unified", "commonmark" ]); const playwrightDependencyCommand = "npm install"; const playwrightDependencySummary = "Install repository dependencies before running browser smokes: npm install provides the declared playwright package; the Code Queue image may also provide the same package globally."; export function runDevCloudWorkbenchStaticSmoke() { return runStaticSmoke(); } export async function runDevCloudWorkbenchTimeoutFixtureSmoke(options = {}) { return runLocalAgentFixtureSmoke({ mode: "local-agent-timeout-fixture-browser", responseDelayMs: options.responseDelayMs ?? 120, timeoutConfigMs: options.timeoutConfigMs ?? 50, expectTimeout: true }); } export async function runDevCloudWorkbenchSmoke(argv = []) { const args = parseSmokeArgs(argv); if (args.mode === "live") return runLiveSmoke(args); if (args.mode === "local-agent-timeout-fixture") return runDevCloudWorkbenchTimeoutFixtureSmoke(); if (args.mode === "dom-only") return runLiveDomOnlySmoke(args); if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke(); if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args); if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke(); if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke(); return runStaticSmoke(); } async function importPlaywright() { try { return normalizePlaywrightModule(await import("playwright")); } catch (localError) { const globalRoot = npmGlobalRoot(); if (globalRoot) { try { return normalizePlaywrightModule(await import(pathToFileURL(path.join(globalRoot, "playwright/index.js")).href)); } catch { // Fall through to the original local resolution error so the remediation points at package.json. } } throw localError; } } function normalizePlaywrightModule(module) { if (module?.chromium) return module; if (module?.default?.chromium) return module.default; return module; } function npmGlobalRoot() { try { return execFileSync("npm", ["root", "-g"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { return ""; } } export function parseSmokeArgs(argv) { const args = { mode: "source", url: defaultLiveUrl, confirmDevLive: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--source" || arg === "--static") { args.mode = "source"; } else if (arg === "--live") { args.mode = "live"; } else if (arg === "--confirm-dev-live") { args.confirmDevLive = true; } else if (arg === "--dom-only") { args.mode = "dom-only"; } else if (arg === "--m3-status-fixture") { args.mode = "m3-status-fixture"; } else if (arg === "--layout") { args.mode = "layout"; } else if (arg === "--build") { args.mode = "layout"; args.build = true; } else if (arg === "--mobile") { args.mode = "mobile"; args.mobile = true; } else if (arg === "--local-agent-fixture") { args.mode = "local-agent-fixture"; } else if (arg === "--local-agent-timeout-fixture") { args.mode = "local-agent-timeout-fixture"; } else if (arg === "--url") { index += 1; if (!argv[index]) throw new Error("--url requires a value"); args.url = argv[index]; args.urlExplicit = true; } else if (arg === "--report") { index += 1; if (!argv[index]) throw new Error("--report requires a value"); args.reportPath = argv[index]; } else if (arg === "--help") { args.help = true; } else { throw new Error(`unknown argument ${arg}`); } } if (args.mode === "live" && !args.confirmDevLive) { throw new Error("DEV-LIVE mode requires explicit --live --confirm-dev-live; default/source mode never calls the live provider."); } return args; } function runStaticSmoke() { const checks = []; 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"); const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.mjs"); addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", { evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`) }); addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Default route is the VS Code-style Cloud Workbench.", { blocker: "runtime_blocker", evidence: ["workspace view is visible by default", "Gate view is hidden by default", ...workbenchMarkers] }); addCheck(checks, blockers, "secondary-gate-status-help", secondaryViewsAreNotDefault(files), "Gate/status/diagnostics/help surfaces are not accepted as the default homepage.", { blocker: "runtime_blocker", evidence: ["routeFromLocation final fallback is workspace", "non-workspace data-view sections must be hidden"] }); addCheck(checks, blockers, "chinese-workbench-labels", labelsPresent(source, chineseWorkbenchLabels), "Current Chinese workbench labels are present.", { blocker: "contract_blocker", evidence: chineseWorkbenchLabels }); addCheck(checks, blockers, "feedback-117-default-no-backstage", defaultHomepageHidesBackstage(files), "Default workbench hides Gate, diagnostics, acceptance, BLOCKED, and M0-M5 backstage information.", { blocker: "runtime_blocker", evidence: defaultHomepageForbiddenTerms }); addCheck(checks, blockers, "feedback-118-complete-nav", hasCompletePrimaryNavigation(files.html), "Primary navigation uses complete Chinese labels and removes the left-rail trusted-records shortcut.", { blocker: "contract_blocker", evidence: ["工作台", "内部复核", "使用说明", "资源树"] }); 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: [...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.", { blocker: "runtime_blocker", evidence: gateRouteAliases }); addCheck(checks, blockers, "direct-help-route-alias", helpRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/help is a direct user-facing help alias and does not become the default route.", { blocker: "runtime_blocker", evidence: [...helpRouteAliases, "routeFromLocation final fallback is workspace"] }); addCheck(checks, blockers, "feedback-276-wiring-long-table", wiringTableContract(files.html, files.styles, files.app), "Patch-panel wiring panel is a two-column long table: device headers first, IO mapping rows after, and metadata moved to Chinese summary/detail text.", { blocker: "contract_blocker", evidence: [ ...requiredWiringLongTableHeaders, "DO1 对 DI1", "data-wiring-layout=\"two-column-long-table\"", "legacy 8-column table forbidden", ...requiredWiringSummaryTerms ] }); addCheck(checks, blockers, "trusted-record-groups", trustedRecordGroups(files), "Trusted Records panel groups Code Agent, wiring/operation, audit, and evidence summaries with source labels and live-failure fallback.", { blocker: "observability_blocker", evidence: requiredTrustedRecordTerms }); addCheck(checks, blockers, "hardware-status-rich-structure", hardwareStatusStructureContract(files), "Right hardware status renders Gateway/Box, DI/DO/AI/AO/FREQ, patch-panel, and DO1 -> DI1 source-labeled link state.", { blocker: "contract_blocker", evidence: requiredHardwareStatusTerms }); addCheck(checks, blockers, "m3-default-wiring-source", defaultTopologyIsM3Only(), "Default 16666 workbench topology exposes only the M3 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 wiring.", { blocker: "runtime_blocker", evidence: ["res_boxsimu_1:DO1", "hwlab-patch-panel", "res_boxsimu_2:DI1"] }); addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { blocker: "contract_blocker", evidence: ["html/body overflow hidden", "workbench-shell 100vh/100dvh overflow hidden", ".view overflow auto"] }); addCheck(checks, blockers, "mobile-workbench-layout-contract", hasMobileWorkbenchLayoutContract(files), "390x844 mobile layout keeps the default workbench reachable and moves the resource tree into a controlled drawer.", { blocker: "runtime_blocker", evidence: [ "syncMobileExplorer collapses resource tree at <=860px", "mobile rail spans full height", "center workspace and right sidebar share the content column", "resource explorer opens as a full-height drawer" ] }); addCheck(checks, blockers, "feedback-278-sidebar-collapse-contract", hasSidebarCollapseContract(files), "Left resource tree exposes explicit Chinese collapse/expand semantics and releases width for the Agent workspace plus M3 panel.", { blocker: "runtime_blocker", evidence: [ "收起左侧资源树 / 展开左侧资源树 aria-label", "收起资源树 / 展开资源树 visible button text", "explorer-collapsed sets explorer column to 0", "collapsed desktop center column minmax(420px, 1fr)", "right side keeps a 560-740px M3 status workspace boundary through --right-width" ] }); 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/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.", { blocker: "observability_blocker", evidence: ["healthLive.data.status === degraded", "ready false", "runtime_durable_adapter_query_blocked", "runtime_durable_adapter_auth_blocked", "API 错误 / 只读模式", "可信记录受阻 / 只读模式", "no Gate/diagnostics copy in default status classifier"] }); addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { blocker: "runtime_blocker", evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); addCheck(checks, blockers, "code-agent-long-timeout-contract", hasCodeAgentLongTimeoutContract(files.app), "Code Agent chat uses a dedicated long timeout and does not reuse the old 4500ms API timeout.", { blocker: "runtime_blocker", evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] }); addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider blockers without exposing credential internals and only real completed replies can become DEV-LIVE.", { blocker: "observability_blocker", evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"] }); addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", { blocker: "observability_blocker", evidence: defaultHomepageForbiddenTerms }); addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", { blocker: "runtime_blocker", evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile message evidence column"] }); addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed Code Agent replies expose provider/model/trace/conversation evidence and reject echo/mock/stub completions.", { blocker: "observability_blocker", evidence: requiredCodeAgentEvidenceTerms }); addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded evidence.", { blocker: "observability_blocker", evidence: [ "DEV-LIVE 回复", "SOURCE 回复", "发送失败", "服务受阻 or legacy BLOCKED 凭证缺口", "message-evidence/details/chips" ] }); addCheck(checks, blockers, "no-hardware-write-api", noForbiddenWriteSurface(source), "Workbench source does not expose hardware write APIs or live mutation commands.", { blocker: "safety_blocker", evidence: forbiddenWritePatterns.map(String) }); addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", { blocker: "observability_blocker", evidence: ["M3 remains blocked without live loop evidence", "checked-in evidence records remain dry-run"] }); addCheck(checks, blockers, "m3-fixture-counts-not-dev-live", m3FixtureCountsNotDevLive(files.app), "Fixture/source patch-panel wiring plus generic runtime counts cannot render M3 DEV-LIVE.", { blocker: "observability_blocker", evidence: [ "hasTrustedM3LiveEvidence requires patchPanelLiveReport", "operationId/traceId/auditId/evidenceId required", "runtime counts are SOURCE-only" ] }); addCheck(checks, blockers, "m3-rendered-workbench-not-m5-fixture", m3RenderedWorkbenchNotM5Fixture(files.app), "Right workbench hardware status and wiring table render the M3 chain, not M5 fixture rows or undefined DOM-node rows.", { blocker: "runtime_blocker", evidence: [ "renderHardwareStatus renders tabbed Key/Value groups", "renderWiringList uses M3_TRUSTED_ROUTE", "M5 activeConnections are not mapped into the workbench wiring table" ] }); const help = inspectHelpContract(files); addCheck(checks, blockers, "help-md-contract", help.status, help.summary, { blocker: help.blocker, owner: helpOwner, observations: help.observations }); return baseReport({ mode: "source", status: blockers.length === 0 ? "pass" : "blocked", checks, blockers, evidenceLevel: "SOURCE", devLive: false, help: { status: help.status, ...help.observations }, safety: staticSafety() }); } async function runLiveSmoke(args) { const checks = []; const blockers = []; const sourceIdentity = observeSourceIdentity(); const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api); const apiRuntimeReadiness = classifyLiveApiRuntimeReadiness(runtimeIdentity); addCheck(checks, blockers, "live-api-runtime-readiness", apiRuntimeReadiness.status, apiRuntimeReadiness.summary, { blocker: "runtime_blocker", evidence: apiRuntimeReadiness.evidence, observations: apiRuntimeReadiness }); 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) { addCheck( checks, blockers, "live-code-agent-browser-journey", "blocked", "Browser Code Agent journey was not posted because deployed runtime/assets do not match current source identity.", { blocker: "observability_blocker", evidence: [ "POST /v1/agent/chat not sent", `runtimeIdentity=${deploymentIdentity.status}`, `webAssetIdentity=${webAssetIdentity.status}` ], observations: { request: { method: "POST", status: "not_sent", urlPath: "/v1/agent/chat" }, response: blockedAgentChatResponse("deployment_identity_preflight"), classification: { status: "blocked", reason: "deployment_identity_preflight", codeAgentBrowserJourneySkipped: true } } } ); 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", liveConfirmed: args.confirmDevLive === true, codeAgentBrowserJourneySkipped: true, prompts: codeAgentE2ePrompts.map((prompt) => ({ id: prompt.id, label: prompt.label })), legacyFailureWindowMs, codeAgentTimeoutMs: codeAgentLongTimeoutMs, 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", evidence: dom.evidence, observations: dom.observations }); if (dom.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-browser-dom", status: "open", summary: dom.summary }); } const help = http.ok ? await inspectLiveHelpRoute(args.url) : { status: "skip", summary: "Browser Markdown help check skipped because HTTP fetch failed.", evidence: [] }; addCheck(checks, blockers, "live-help-markdown-route", help.status, help.summary, { blocker: help.status === "pass" ? null : "runtime_blocker", evidence: help.evidence, observations: help.observations }); if (help.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-help-markdown-route", status: "open", summary: help.summary }); } const journey = http.ok ? await inspectLiveCodeAgentE2e(args.url) : { status: "skip", summary: "浏览器 Code Agent E2E 因 HTTP fetch 失败而跳过。", evidence: [] }; addCheck(checks, blockers, "live-code-agent-browser-journey", journey.status, journey.summary, { blocker: ["pass", "degraded"].includes(journey.status) ? null : "runtime_blocker", evidence: journey.evidence, observations: journey.observations }); if (journey.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-code-agent-browser-journey", status: "open", summary: journey.summary }); } const hasDegradedCheck = checks.some((check) => check.status === "degraded"); const status = blockers.length === 0 ? apiRuntimeReadiness.status === "degraded" || hasDegradedCheck ? "degraded" : "pass" : "blocked"; return baseReport({ mode: "live", url: args.url, status, checks, blockers, evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : status === "degraded" ? "DEV-LIVE-BROWSER-DEGRADED" : "BLOCKED", devLive: status === "pass", runtimeIdentity, sourceIdentity, deploymentIdentity, expectedRuntimeIdentity, webAssetIdentity, safety: { prodTouched: false, servicesRestarted: false, heavyE2E: false, hardwareWriteApis: false, sourceIsDevLive: false, liveMode: "browser-user-journey-with-code-agent-post", liveConfirmed: args.confirmDevLive === true, prompts: codeAgentE2ePrompts.map((prompt) => ({ id: prompt.id, label: prompt.label })), legacyFailureWindowMs, codeAgentTimeoutMs: codeAgentLongTimeoutMs, retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv", "error.providerStatus"], statement: status === "degraded" ? "Live smoke records deployed UI usable in degraded/read-only mode only; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance and must not be used to infer M3/M4/M5 acceptance." : "Live smoke opens the deployed workbench and sends two controlled Code Agent messages; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." } }); } async function runLiveDomOnlySmoke(args) { const checks = []; const blockers = []; const sourceIdentity = observeSourceIdentity(); const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api); const apiRuntimeReadiness = classifyLiveApiRuntimeReadiness(runtimeIdentity); addCheck(checks, blockers, "live-api-runtime-readiness", apiRuntimeReadiness.status, apiRuntimeReadiness.summary, { blocker: "runtime_blocker", evidence: apiRuntimeReadiness.evidence, observations: apiRuntimeReadiness }); 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: [], mismatches: ["index.html"] }; addCheck(checks, blockers, "live-web-assets-current-main", webAssetIdentity.status, webAssetIdentity.summary, { blocker: "observability_blocker", evidence: webAssetIdentity.assets.length > 0 ? webAssetIdentity.assets.map((asset) => `${asset.path}: ${asset.status}`) : ["web asset identity not observed"], observations: webAssetIdentity }); const dom = http.ok ? await inspectLiveDom(args.url, { noCodeAgentPost: true }) : { 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", evidence: dom.evidence, observations: dom.observations }); if (dom.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-browser-dom", status: "open", summary: dom.summary }); } const gate = http.ok ? await inspectLiveGateRoute(args.url, { noCodeAgentPost: true }) : { status: "skip", summary: "Browser Gate route check skipped because HTTP fetch failed.", evidence: [] }; addCheck(checks, blockers, "live-gate-route", gate.status, gate.summary, { blocker: gate.status === "pass" ? null : "runtime_blocker", evidence: gate.evidence, observations: gate.observations }); if (gate.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-gate-route", status: "open", summary: gate.summary }); } const help = http.ok ? await inspectLiveHelpRoute(args.url, { noCodeAgentPost: true }) : { status: "skip", summary: "Browser Markdown help check skipped because HTTP fetch failed.", evidence: [] }; addCheck(checks, blockers, "live-help-markdown-route", help.status, help.summary, { blocker: help.status === "pass" ? null : "runtime_blocker", evidence: help.evidence, observations: help.observations }); if (help.status === "skip") { blockers.push({ type: "observability_blocker", scope: "live-help-markdown-route", status: "open", summary: help.summary }); } addDomOnlyCodeAgentCheck(checks); const status = blockers.length === 0 ? "pass" : "blocked"; return baseReport({ mode: "dom-only", url: args.url, status, checks, blockers, evidenceLevel: status === "pass" ? "DEV-LIVE-DOM-READONLY" : "BLOCKED", devLive: false, runtimeIdentity, sourceIdentity, deploymentIdentity, expectedRuntimeIdentity, webAssetIdentity, safety: { prodTouched: false, servicesRestarted: false, heavyE2E: false, hardwareWriteApis: false, sourceIsDevLive: false, 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", "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." } }); } export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { const useLiveUrl = args.live === true || args.urlExplicit === true; const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static"; const artifactRoot = path.resolve( repoRoot, args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) ); const layoutViewports = workbenchLayoutViewports(); let chromium; try { ({ chromium } = await importPlaywright()); } catch (error) { const summary = `Workbench layout smoke skipped because Playwright is unavailable: ${error.message}`; return sanitizeLayoutReport({ $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: reportModeId("layout"), reportVersion: "v1", status: "skip", issue: "pikasTech/HWLAB#273", taskId: "dev-cloud-workbench-layout", commitId: observeSourceIdentity().reportCommitId, acceptanceLevel: "dev_cloud_workbench_layout", devOnly: true, prodDisabled: true, reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", sourceMode: layoutSourceMode, url: useLiveUrl ? args.url : null, generatedAt: new Date().toISOString(), sourceContract: layoutSourceContract(), validationCommands: layoutValidationCommands(), localSmoke: layoutLocalSmoke("skip"), dryRun: layoutDryRun(), devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", devLive: false, summary, viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks: [ { id: "layout-browser-dependency", status: "skip", summary: playwrightDependencySummary, evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"] } ], blockers: [ { type: "environment_blocker", scope: "layout-browser-dependency", status: "open", summary } ], safety: staticSafety(), dependency: { package: "playwright", declaredIn: "package.json", installCommand: playwrightDependencyCommand }, artifacts: { screenshotDir: artifactRoot, reportPath: args.reportPath ?? null }, safety: layoutSafety() }); } if (!useLiveUrl && args.build === true) { execFileSync("node", ["web/hwlab-cloud-web/scripts/build.mjs"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }); } await fs.promises.mkdir(artifactRoot, { recursive: true }); const server = useLiveUrl ? null : await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot }); const url = useLiveUrl ? args.url : server.url; let browser; try { browser = await chromium.launch({ headless: true }); const viewportResults = {}; for (const viewport of layoutViewports) { viewportResults[viewport.id] = await inspectWorkbenchLayoutViewport(browser, url, viewport, { artifactRoot }); } const desktop = viewportResults.desktop; const narrowDesktop = viewportResults["narrow-desktop"]; const mobile = viewportResults.mobile; const checks = [ { id: "layout-desktop-expanded", status: desktop.expanded.pass ? "pass" : "blocked", viewport: desktop.expanded.viewport, summary: "Desktop 1366x768 expanded layout keeps M3 controls, Code Agent input, hardware panels, hit targets, overflow, and outer scroll in contract.", observations: desktop.expanded }, { id: "layout-desktop-collapsed", status: desktop.collapsed.pass ? "pass" : "blocked", viewport: desktop.collapsed.viewport, summary: "Desktop 1366x768 collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.", observations: desktop.collapsed }, { id: "layout-desktop-restored", status: desktop.restored.pass ? "pass" : "blocked", viewport: desktop.restored.viewport, summary: "Desktop expand action restores the left navigation/resource tree without layout overlap.", observations: desktop.restored }, { id: "layout-narrow-desktop-expanded", status: narrowDesktop.expanded.pass ? "pass" : "blocked", viewport: narrowDesktop.expanded.viewport, summary: "Narrow desktop 1024x768 layout keeps the right hardware area below the workbench without covered controls or outer scroll.", observations: narrowDesktop.expanded }, { id: "layout-narrow-desktop-collapsed", status: narrowDesktop.collapsed.pass ? "pass" : "blocked", viewport: narrowDesktop.collapsed.viewport, summary: "Narrow desktop 1024x768 collapsed layout releases left-sidebar width without hiding M3 or Code Agent controls.", observations: narrowDesktop.collapsed }, { id: "layout-narrow-desktop-restored", status: narrowDesktop.restored.pass ? "pass" : "blocked", viewport: narrowDesktop.restored.viewport, summary: "Narrow desktop 1024x768 restored layout returns the resource tree without incoherent overlap.", observations: narrowDesktop.restored }, { id: "layout-mobile-collapsed", status: mobile.collapsed.pass ? "pass" : "blocked", viewport: mobile.collapsed.viewport, summary: "Mobile default layout keeps the resource tree collapsed and preserves Agent input plus M3 tabs/controls.", observations: mobile.collapsed }, { id: "layout-mobile-drawer", status: mobile.drawer.pass ? "pass" : "blocked", viewport: mobile.drawer.viewport, summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.", observations: mobile.drawer }, { id: "layout-gate-desktop", status: desktop.gate.currentRoute.pass ? "pass" : "blocked", viewport: desktop.gate.currentRoute.viewport, summary: "Desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", observations: desktop.gate.currentRoute }, { id: "layout-gate-narrow-desktop", status: narrowDesktop.gate.currentRoute.pass ? "pass" : "blocked", viewport: narrowDesktop.gate.currentRoute.viewport, summary: "Narrow desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", observations: narrowDesktop.gate.currentRoute }, { id: "layout-gate-mobile", status: mobile.gate.currentRoute.pass ? "pass" : "blocked", viewport: mobile.gate.currentRoute.viewport, summary: "Mobile /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", observations: mobile.gate.currentRoute }, { id: "layout-issue-287-future-hardware-status-tabs", status: allViewportCoverage(viewportResults, "issue287HardwareTabsReady") ? "pass" : "skip", summary: "#287 hardware status tab set is not required for this #273 smoke until the wider live hardware workspace is implemented; current visible right-side containers are still checked for overflow.", observations: summarizeCoverageGap(viewportResults, "issue287") }, { id: "layout-issue-288-future-single-table-gate", status: allViewportCoverage(viewportResults, "issue288SingleTableReady") ? "pass" : "skip", summary: "#288 single-table internal review page is not required for this #273 smoke until that UI lands; current /gate route is checked only for outer-scroll and visible-layout regressions.", observations: summarizeCoverageGap(viewportResults, "issue288") } ]; const blockers = checks .filter((check) => check.status !== "pass") .filter((check) => check.status !== "skip") .map((check) => ({ type: "runtime_blocker", scope: check.id, status: "open", summary: check.summary, viewport: check.viewport ?? null, selector: firstFailure(check)?.selector ?? null, failureType: firstFailure(check)?.failureType ?? "blocked" })); const failures = checks.flatMap((check) => (check.observations?.failures ?? []).map((failure) => ({ checkId: check.id, viewport: check.viewport ?? failure.viewport ?? null, selector: failure.selector ?? null, failureType: failure.failureType ?? "blocked", summary: failure.summary ?? check.summary, ...failure })) ); const skipped = checks .filter((check) => check.status === "skip") .map((check) => ({ checkId: check.id, status: "skip", failureType: "skip", summary: check.summary, observations: check.observations })); return sanitizeLayoutReport({ $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: reportModeId("layout"), reportVersion: "v1", status: blockers.length === 0 ? "pass" : "blocked", issue: "pikasTech/HWLAB#273", taskId: "dev-cloud-workbench-layout", commitId: observeSourceIdentity().reportCommitId, acceptanceLevel: "dev_cloud_workbench_layout", devOnly: true, prodDisabled: true, reportLifecycle: layoutReportLifecycle(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), sourceContract: layoutSourceContract(), validationCommands: layoutValidationCommands(), localSmoke: layoutLocalSmoke(blockers.length === 0 ? "pass" : "blocked"), dryRun: layoutDryRun(), devPreconditions: layoutDevPreconditions(useLiveUrl, blockers.length === 0 ? "pass" : "blocked"), evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", devLive: false, summary: useLiveUrl ? "Live browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; it is not M3 hardware acceptance." : "Static local browser layout smoke verifies workbench layout, hit targets, overflow, /gate usability, and the two-column wiring panel only; deployment still requires DEV live verification.", refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#276", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"], viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks, blockers, failures, skipped, artifacts: { screenshotDir: artifactRoot, screenshots: Object.values(viewportResults).flatMap((result) => [ ...(result.artifacts?.screenshots ?? []), ...(result.gate?.currentRoute?.artifacts?.screenshots ?? []) ]), reportPath: args.reportPath ?? null }, safety: layoutSafety() }); } catch (error) { return sanitizeLayoutReport({ $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: reportModeId("layout"), reportVersion: "v1", status: "blocked", issue: "pikasTech/HWLAB#273", taskId: "dev-cloud-workbench-layout", commitId: observeSourceIdentity().reportCommitId, acceptanceLevel: "dev_cloud_workbench_layout", devOnly: true, prodDisabled: true, reportLifecycle: layoutReportLifecycle(useLiveUrl, "blocked"), task: "DC-DCSN-P0-2026-003", mode: "layout-browser", sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), sourceContract: layoutSourceContract(), validationCommands: layoutValidationCommands(), localSmoke: layoutLocalSmoke("blocked"), dryRun: layoutDryRun(), devPreconditions: layoutDevPreconditions(useLiveUrl, "blocked"), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", devLive: false, summary: `Workbench layout smoke failed: ${error.message}`, viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks: [], blockers: [ { type: "runtime_blocker", scope: "layout-browser-smoke", status: "open", summary: error.message, selector: null, failureType: "blocked", viewport: null } ], failures: [ { selector: null, failureType: "blocked", viewport: null, summary: error.message } ], artifacts: { screenshotDir: artifactRoot, screenshots: [], reportPath: args.reportPath ?? null }, safety: layoutSafety() }); } finally { if (browser) await browser.close(); if (server) await server.close(); } } function workbenchLayoutViewports() { return [ { id: "desktop", width: 1366, height: 768, isMobile: false }, { id: "narrow-desktop", width: 1024, height: 768, isMobile: false }, { id: "mobile", width: 390, height: 844, isMobile: true } ]; } function layoutSafety() { return { ...staticSafety(), sourceIsDevLive: false, layoutOnly: true, codeAgentPostSent: false, hardwareWriteApis: false, hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks", statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance; it must not claim M3 DEV-LIVE hardware-loop acceptance." }; } function sanitizeLayoutReport(report) { return stripLayoutObservationNoise(report); } function stripLayoutObservationNoise(value) { if (Array.isArray(value)) return value.map(stripLayoutObservationNoise); if (!value || typeof value !== "object") return value; const cleaned = {}; const looksLikeElementBox = Number.isFinite(value.left) && Number.isFinite(value.top) && Number.isFinite(value.right) && Number.isFinite(value.bottom) && Number.isFinite(value.width) && Number.isFinite(value.height); for (const [key, item] of Object.entries(value)) { if (looksLikeElementBox && key === "text") continue; cleaned[key] = stripLayoutObservationNoise(item); } return cleaned; } function addDomOnlyCodeAgentCheck(checks) { checks.push({ id: "live-code-agent-browser-journey", status: "not_applicable", summary: "DOM-only mode never posts the browser Code Agent journey; /v1/agent/chat is not sent.", evidence: [ "POST /v1/agent/chat not_applicable", "POST /v1/agent/chat not sent", "DOM-only read-only mode" ], observations: { request: { method: "POST", status: "not_applicable", urlPath: "/v1/agent/chat" }, response: { status: "not_applicable", provider: "not_observed", model: "not_observed", backend: "not_observed", traceId: "not_observed", hasReply: false, error: null }, classification: { status: "not_applicable", reason: "dom_only_no_code_agent_post", codeAgentBrowserJourneySkipped: true }, networkEvents: [] } }); } function layoutReportLifecycle(useLiveUrl, status) { return { version: "v1", state: "active", activeEndpoint: runtime.endpoints.api, activeBrowserEndpoint: runtime.endpoints.frontend, deprecatedEndpoint: null, summary: useLiveUrl ? `DEV live layout smoke ${status}; this is UI layout/clickability evidence only and not M3 hardware-loop acceptance.` : `Repository source/static layout smoke ${status}; this runs without public DEV dependency and cannot be used as DEV-LIVE evidence.` }; } function layoutSourceContract() { return { status: "pass", documents: [ "docs/cloud-web-workbench.md", "docs/reference/cloud-workbench.md", "docs/reference/code-agent-chat-readiness.md" ], summary: "Cloud Workbench layout smoke protects the #99 workbench route, #227 visible M3 control area, Code Agent input, right hardware/trusted-record containers, /gate current route, and outer-scroll lock without claiming hardware acceptance." }; } function layoutValidationCommands() { return [ "node --check scripts/dev-cloud-workbench-layout-smoke.mjs", "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", "npm run web:check", "npm run web:layout", "npm run web:layout:build", "npm run web:layout:live" ]; } function layoutLocalSmoke(status) { return { status, commands: [ "npm run web:check", "npm run web:layout", "npm run web:layout:build" ], evidence: [ "web:check runs the SOURCE/static layout smoke as a repo-owned frontend gate.", "web:layout runs the source/static Playwright geometry, hit-target, overflow, and outer-scroll checks without public DEV dependency.", "web:layout:build rebuilds local dist and checks the built Cloud Web assets." ], summary: "SOURCE/static and local-build layout checks classify UI overlap, covered hit targets, overflow, and outer-scroll regressions as layout blockers." }; } function layoutDryRun() { return { status: "not_applicable", commands: ["npm run web:layout"], evidence: ["Layout smoke is browser evidence, not a dry-run substitute."], summary: "No dry-run mode is accepted for layout/clickability evidence." }; } function layoutDevPreconditions(useLiveUrl, status) { return { status: useLiveUrl ? status : "not_applicable", requirements: [ "SOURCE/static PR checks must pass without public DEV dependency.", "local-build mode must rebuild and inspect Cloud Web dist before publish/apply.", "DEV live mode must target http://74.48.78.17:16666/ after a controlled DEV deployment or confirmed live revision.", "The report must include viewport, selector, failureType, and artifact/report paths for failures.", "Layout pass is not M3 DEV-LIVE trusted closure and does not replace #227 DO/DI functional evidence." ], commands: ["npm run web:layout:live"], summary: useLiveUrl ? "DEV live layout smoke was executed as UI layout/clickability evidence only." : "DEV live layout smoke is a post-deploy check and is intentionally not required for SOURCE/static gates." }; } 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"; const liveJourneyDegraded = mode === "live" && status === "degraded"; const sourceOnlyMode = mode === "source"; return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: reportModeId(mode), reportVersion: "v1", status, issue: "pikasTech/HWLAB#7", taskId: "dev-cloud-workbench-live", commitId: sourceIdentity.reportCommitId, acceptanceLevel: sourceOnlyMode ? "dev_cloud_workbench_source" : "dev_cloud_workbench_live", devOnly: true, prodDisabled: true, reportLifecycle: { version: "v1", state: "active", activeEndpoint: runtime.endpoints.api, activeBrowserEndpoint: runtime.endpoints.frontend, deprecatedEndpoint: null, summary: sourceOnlyMode ? "SOURCE-only Workbench contract report; it does not call the live provider and cannot be used as DEV-LIVE evidence." : liveJourneyDegraded ? "Current deployed browser user journey report for the 16666 Cloud Workbench; deployed UI usable in degraded/read-only mode and does not substitute for M3/M4/M5 DEV-LIVE acceptance." : "Current deployed browser user journey report for the 16666 Cloud Workbench; it does not substitute for M3/M4/M5 hardware acceptance." }, task: "DC-DCSN-P0-2026-003", refs: [ "pikasTech/HWLAB#7", "pikasTech/HWLAB#108", "pikasTech/HWLAB#99", "pikasTech/HWLAB#78", "pikasTech/HWLAB#117", "pikasTech/HWLAB#118", "pikasTech/HWLAB#119", "pikasTech/HWLAB#120", "pikasTech/HWLAB#131", "pikasTech/HWLAB#143", "pikasTech/HWLAB#164", "pikasTech/HWLAB#224", "pikasTech/HWLAB#276", "pikasTech/HWLAB#288" ], mode, url, generatedAt: new Date().toISOString(), evidenceLevel, 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: [ "docs/cloud-web-workbench.md", "docs/reference/cloud-workbench.md", "docs/reference/code-agent-chat-readiness.md" ], summary: sourceOnlyMode ? "SOURCE mode verifies repository Workbench and Code Agent readiness contracts without calling the live provider." : "The deployed browser journey is anchored to the Cloud Workbench and Code Agent readiness contracts." }, validationCommands: [ "node --check scripts/dev-cloud-workbench-smoke.mjs", "node --check scripts/src/dev-cloud-workbench-smoke-lib.mjs", "node scripts/dev-cloud-workbench-smoke.mjs --source", "node scripts/dev-cloud-workbench-smoke.mjs --layout", "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/", "node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", mode === "dom-only" ? "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-dom-only.json" : "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json", "node scripts/validate-dev-gate-report.mjs" ], localSmoke: { status: mode === "source" ? status : "not_run", commands: [ "node scripts/dev-cloud-workbench-smoke.mjs --source", "node scripts/dev-cloud-workbench-smoke.mjs --layout", "node scripts/dev-cloud-workbench-smoke.mjs --mobile", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture", "node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture" ], evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."], summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey." }, dryRun: { status: "not_applicable", commands: ["node scripts/dev-cloud-workbench-smoke.mjs --source"], evidence: ["No dry-run substitute is accepted for the deployed browser journey."], summary: "The journey evidence is collected from the real DEV browser endpoint, not a dry-run fixture." }, devPreconditions: { status: liveJourneyPassed ? "pass" : liveJourneyDegraded ? "degraded" : "blocked", requirements: [ "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", "Browser DOM exposes the default workbench and core controls.", "DOM-only mode may inspect read-only deployed DOM wiring after identity preflight blocks, but must not send Code Agent chat.", "A controlled same-origin POST /v1/agent/chat returns completed UI-visible evidence or reports a blocker.", "No hardware write API, service restart, PROD access, or secret read is performed." ], commands: [ "node scripts/dev-cloud-workbench-smoke.mjs --dom-only --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-dom-only.json", "node scripts/dev-cloud-workbench-smoke.mjs --live --confirm-dev-live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" ], summary: liveJourneyPassed ? "Deployed 16666 browser journey and same-origin Code Agent chat completed." : liveJourneyDegraded ? "Deployed UI usable in degraded/read-only mode; API health/runtime durability is degraded, so this is not full DEV-LIVE acceptance." : mode === "dom-only" ? "Deployed browser DOM was inspected read-only; Code Agent chat was not posted." : mode === "live" ? "Deployed browser journey is blocked; see checks and blockers." : "Static/local fixture evidence does not establish the deployed 16666 browser journey." }, endpoints: { frontend: runtime.endpoints.frontend, api: runtime.endpoints.api, edge: runtime.endpoints.edge }, checks, blockers, help, safety }; } function reportModeId(mode) { return mode === "layout" ? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-layout.json" : mode === "dom-only" ? "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-dom-only.json" : "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json"; } function addCheck(checks, blockers, id, result, summary, options = {}) { const status = result === true ? "pass" : result === false ? "blocked" : result; const check = { id, status, summary, ...(options.owner ? { owner: options.owner } : {}), ...(options.evidence ? { evidence: options.evidence } : {}), ...(options.observations ? { observations: options.observations } : {}) }; checks.push(check); if (status === "blocked" && options.blocker) { blockers.push({ type: options.blocker, scope: id, status: "open", summary }); } return check; } function readStaticFiles() { return { html: readText("web/hwlab-cloud-web/index.html"), 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"), help: readText("web/hwlab-cloud-web/help.md") }; } function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); } function observeSourceIdentity() { const head = runGit(["rev-parse", "--verify", "HEAD"]); const shortHead = head ? runGit(["rev-parse", "--short=12", "HEAD"]) : null; const ref = runGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? "unknown"; const statusOutput = runGit(["status", "--porcelain"]); const envIdentity = head ? null : environmentCommitIdentity(); const commitId = head ?? envIdentity?.commitId ?? "unknown"; const shortCommitId = shortHead ?? envIdentity?.commitId.slice(0, 12) ?? "unknown"; const dirty = statusOutput === null ? null : statusOutput.length > 0; const worktreeState = dirty === null ? "unknown" : dirty ? "dirty" : "clean"; const source = head ? "git-head" : envIdentity ? "environment" : "unknown"; const reportCommitId = commitId !== "unknown" && worktreeState === "clean" ? shortCommitId : "unknown"; return { status: commitId === "unknown" ? "unknown" : "observed", source, ...(envIdentity ? { environmentKey: envIdentity.key } : {}), commitId, shortCommitId, ref, worktreeState, dirty, reportCommitId, summary: worktreeState !== "clean" ? "Source HEAD was observed, but the worktree was not cleanly attributable when the report was generated; top-level commitId is unknown rather than pretending to identify uncommitted source." : "Source identity was derived from the current worktree or source environment and is separate from the live runtime identity." }; } function environmentCommitIdentity() { for (const key of [ "GITHUB_SHA", "CI_COMMIT_SHA", "BUILDKITE_COMMIT", "CODEBUILD_RESOLVED_SOURCE_VERSION", "SOURCE_COMMIT", "HWLAB_SOURCE_COMMIT_ID" ]) { const value = process.env[key]; if (typeof value === "string" && /^[a-f0-9]{7,40}$/iu.test(value.trim())) { return { key, commitId: value.trim().toLowerCase() }; } } return null; } function runGit(args) { try { return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { return null; } } 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))); } function hasDefaultWorkbench({ html, app }) { const workspaceTag = firstTagForDataView(html, "workspace"); const gateTag = firstTagForDataView(html, "gate"); return ( labelsPresent(html, workbenchMarkers) && /