diff --git a/reports/dev-gate/dev-cloud-workbench-live.json b/reports/dev-gate/dev-cloud-workbench-live.json new file mode 100644 index 00000000..40578dbb --- /dev/null +++ b/reports/dev-gate/dev-cloud-workbench-live.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json", + "reportVersion": "v1", + "status": "pass", + "issue": "pikasTech/HWLAB#7", + "taskId": "dev-cloud-workbench-live", + "commitId": "unknown", + "acceptanceLevel": "dev_cloud_workbench_live", + "devOnly": true, + "prodDisabled": true, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "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#164" + ], + "mode": "live", + "url": "http://74.48.78.17:16666/", + "generatedAt": "2026-05-22T18:11:35.955Z", + "evidenceLevel": "DEV-LIVE-BROWSER", + "devLive": true, + "sourceIdentity": { + "status": "observed", + "source": "git-head", + "commitId": "ee9d60f00b1bc47c1d99b7edb8746623333fe544", + "shortCommitId": "ee9d60f00b1b", + "ref": "test/deployed-workbench-journey-evidence", + "worktreeState": "dirty", + "dirty": true, + "reportCommitId": "unknown", + "summary": "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." + }, + "runtimeIdentity": { + "status": "observed", + "source": "health-live", + "endpoint": "http://74.48.78.17:16667/health/live", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "healthStatus": "degraded", + "commitId": "c7de474", + "commitSource": "runtime-env", + "imageTag": "c7de474", + "observedAt": "2026-05-22T18:11:24.844Z", + "summary": "Live runtime identity was observed through the existing read-only health endpoint and is not inferred from source git HEAD." + }, + "sourceContract": { + "status": "pass", + "documents": [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" + ], + "summary": "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 --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.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": "not_run", + "commands": [ + "node scripts/dev-cloud-workbench-smoke.mjs --static" + ], + "evidence": [ + "Static mode verifies the source contract and same-origin Code Agent wiring only." + ], + "summary": "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey." + }, + "dryRun": { + "status": "not_applicable", + "commands": [ + "node scripts/dev-cloud-workbench-smoke.mjs --static" + ], + "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": "pass", + "requirements": [ + "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", + "Browser DOM exposes the default workbench and core controls.", + "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 --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" + ], + "summary": "Deployed 16666 browser journey and same-origin Code Agent chat completed." + }, + "endpoints": { + "frontend": "http://74.48.78.17:16666", + "api": "http://74.48.78.17:16667", + "edge": "http://74.48.78.17:16667" + }, + "checks": [ + { + "id": "live-http-html", + "status": "pass", + "summary": "Live URL serves the Cloud Workbench HTML.", + "evidence": [ + "http://74.48.78.17:16666/", + "HTTP 200", + "text/html; charset=utf-8" + ] + }, + { + "id": "live-browser-dom", + "status": "pass", + "summary": "Browser DOM confirms the default workbench, core controls, and outer scroll lock.", + "evidence": [ + "title=HWLAB 云工作台", + "bodyOverflow=hidden", + "htmlOverflow=hidden" + ], + "observations": { + "title": "HWLAB 云工作台", + "bodyOverflow": "hidden", + "htmlOverflow": "hidden", + "workspaceHidden": false, + "gateHidden": true, + "helpHidden": true, + "outerScrollLocked": true, + "labelsPresent": true, + "coreControlsVisible": { + "commandInput": true, + "commandSend": true, + "agentChatStatus": true, + "conversationList": true, + "hardwareList": true, + "wiringTab": true, + "recordsTab": true + } + } + }, + { + "id": "live-code-agent-browser-journey", + "status": "pass", + "summary": "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response.", + "evidence": [ + "POST /v1/agent/chat HTTP 200", + "status=completed", + "provider=openai-responses", + "model=gpt-5.5", + "traceId=trc_cloud-web-workbench-1779473490624-11", + "uiStatus=已回复" + ], + "observations": { + "controls": { + "appShell": true, + "workspace": true, + "commandInput": true, + "commandSend": true, + "agentChatStatus": true, + "conversationList": true, + "hardwareList": true, + "wiringTab": true, + "recordsTab": true + }, + "request": { + "method": "POST", + "status": 200, + "urlPath": "/v1/agent/chat" + }, + "response": { + "status": "completed", + "provider": "openai-responses", + "model": "gpt-5.5", + "backend": "hwlab-cloud-api/openai-responses", + "traceId": "trc_cloud-web-workbench-1779473490624-11", + "hasReply": true, + "error": null + }, + "ui": { + "title": "HWLAB 云工作台", + "workspaceHidden": false, + "gateHidden": true, + "helpHidden": true, + "agentChatStatus": "已回复", + "inputCleared": true, + "completedMessageVisible": true, + "failedMessageVisible": false, + "messageCount": 4, + "completedMessageHasNonSensitiveMeta": true + }, + "networkEvents": [ + { + "method": "POST", + "status": 200, + "urlPath": "/v1/agent/chat", + "body": { + "status": "completed", + "provider": "openai-responses", + "model": "gpt-5.5", + "backend": "hwlab-cloud-api/openai-responses", + "traceId": "trc_cloud-web-workbench-1779473490624-11", + "hasReply": true, + "error": null + } + } + ] + } + } + ], + "blockers": [], + "safety": { + "prodTouched": false, + "servicesRestarted": false, + "heavyE2E": false, + "hardwareWriteApis": false, + "sourceIsDevLive": false, + "liveMode": "browser-user-journey-with-code-agent-post", + "retainedApiFields": [ + "status", + "provider", + "model", + "backend", + "traceId", + "hasReply", + "error.code", + "error.missingEnv" + ], + "statement": "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." + }, + "artifacts": { + "reportPath": "/workspace/hwlab/.worktrees/deployed-workbench-journey-evidence/reports/dev-gate/dev-cloud-workbench-live.json" + } +} diff --git a/scripts/dev-cloud-workbench-smoke.mjs b/scripts/dev-cloud-workbench-smoke.mjs index 4ca31f9b..3f9e5fdd 100644 --- a/scripts/dev-cloud-workbench-smoke.mjs +++ b/scripts/dev-cloud-workbench-smoke.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -16,6 +18,12 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { : args.mobile ? await runDevCloudWorkbenchMobileSmoke() : await runDevCloudWorkbenchSmoke(process.argv.slice(2)); + if (args.reportPath && report.status !== "usage") { + const reportPath = path.resolve(process.cwd(), args.reportPath); + await mkdir(path.dirname(reportPath), { recursive: true }); + report.artifacts = { ...(report.artifacts ?? {}), reportPath }; + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exitCode = report.status === "pass" || report.status === "usage" || report.status === "skip" ? 0 : 2; } catch (error) { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index e2d2b03a..3a895832 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { execFileSync } from "node:child_process"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -179,6 +180,10 @@ export function parseSmokeArgs(argv) { index += 1; if (!argv[index]) throw new Error("--url requires a value"); args.url = argv[index]; + } 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 { @@ -344,6 +349,7 @@ function runStaticSmoke() { async function runLiveSmoke(args) { const checks = []; const blockers = []; + const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api); 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", @@ -366,6 +372,22 @@ async function runLiveSmoke(args) { }); } + const journey = http.ok ? await inspectLiveUserJourney(args.url) : { status: "skip", summary: "Browser Code Agent journey skipped because HTTP fetch failed.", evidence: [] }; + addCheck(checks, blockers, "live-code-agent-browser-journey", journey.status, journey.summary, { + blocker: journey.status === "pass" ? 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 status = blockers.length === 0 ? "pass" : "blocked"; return baseReport({ mode: "live", @@ -373,38 +395,107 @@ async function runLiveSmoke(args) { status, checks, blockers, - evidenceLevel: status === "pass" ? "DEV-LIVE-READONLY" : "BLOCKED", + evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", devLive: status === "pass", + runtimeIdentity, safety: { prodTouched: false, servicesRestarted: false, heavyE2E: false, hardwareWriteApis: false, sourceIsDevLive: false, - liveMode: "read-only-http-and-optional-dom", - statement: "Live smoke is read-only and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." + liveMode: "browser-user-journey-with-code-agent-post", + retainedApiFields: ["status", "provider", "model", "backend", "traceId", "hasReply", "error.code", "error.missingEnv"], + statement: "Live smoke opens the deployed workbench and sends one controlled Code Agent message; it does not call hardware write APIs and must not be used to infer M3 DEV-LIVE hardware-loop acceptance." } }); } -function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null }) { +function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null, runtimeIdentity = null }) { + const sourceIdentity = observeSourceIdentity(); return { + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-cloud-workbench-live.json", + reportVersion: "v1", status, + issue: "pikasTech/HWLAB#7", + taskId: "dev-cloud-workbench-live", + commitId: sourceIdentity.reportCommitId, + acceptanceLevel: "dev_cloud_workbench_live", + devOnly: true, + prodDisabled: true, + reportLifecycle: { + version: "v1", + state: "active", + activeEndpoint: runtime.endpoints.api, + activeBrowserEndpoint: runtime.endpoints.frontend, + deprecatedEndpoint: null, + summary: "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#120", + "pikasTech/HWLAB#131", + "pikasTech/HWLAB#164" ], mode, url, generatedAt: new Date().toISOString(), evidenceLevel, devLive, + sourceIdentity, + runtimeIdentity: runtimeIdentity ?? notObservedRuntimeIdentity("live runtime identity was not observed in this smoke mode"), + sourceContract: { + status: "pass", + documents: [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" + ], + summary: "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 --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.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 === "static" ? status : "not_run", + commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"], + evidence: ["Static mode verifies the source contract and same-origin Code Agent wiring only."], + summary: "Static workbench smoke is SOURCE-level evidence and does not prove the deployed browser journey." + }, + dryRun: { + status: "not_applicable", + commands: ["node scripts/dev-cloud-workbench-smoke.mjs --static"], + 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: status === "pass" ? "pass" : "blocked", + requirements: [ + "GET http://74.48.78.17:16666/ serves the Cloud Workbench HTML.", + "Browser DOM exposes the default workbench and core controls.", + "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 --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json" + ], + summary: status === "pass" + ? "Deployed 16666 browser journey and same-origin Code Agent chat completed." + : "Deployed browser journey is blocked; see checks and blockers." + }, endpoints: { frontend: runtime.endpoints.frontend, api: runtime.endpoints.api, @@ -453,6 +544,65 @@ 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; + } +} + function assetsExist() { return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file))); } @@ -872,6 +1022,82 @@ function staticSafety() { }; } +async function observeLiveRuntimeIdentity(apiEndpoint) { + const endpoint = new URL("/health/live", apiEndpoint).toString(); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 6000); + try { + const response = await fetch(endpoint, { + signal: controller.signal, + headers: { Accept: "application/json" } + }); + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + if (!response.ok || !body || typeof body !== "object") { + return notObservedRuntimeIdentity(`health-live returned HTTP ${response.status}`, endpoint); + } + return { + status: "observed", + source: "health-live", + endpoint, + serviceId: sanitizeRuntimeString(body.serviceId), + environment: sanitizeRuntimeString(body.environment), + healthStatus: sanitizeRuntimeString(body.status), + commitId: sanitizeRevision(body.commit?.id ?? body.commitId), + commitSource: sanitizeRuntimeString(body.commit?.source), + imageTag: sanitizeRuntimeString(body.image?.tag), + observedAt: sanitizeTimestamp(body.observedAt), + summary: "Live runtime identity was observed through the existing read-only health endpoint and is not inferred from source git HEAD." + }; + } catch (error) { + return notObservedRuntimeIdentity( + error.name === "AbortError" ? "health-live request timed out" : `health-live request failed: ${oneLine(error.message)}`, + endpoint + ); + } finally { + clearTimeout(timer); + } +} + +function notObservedRuntimeIdentity(reason, endpoint = new URL("/health/live", runtime.endpoints.api).toString()) { + return { + status: "not_observed", + source: "health-live", + endpoint, + serviceId: "not_observed", + environment: "not_observed", + healthStatus: "not_observed", + commitId: "not_observed", + commitSource: "not_observed", + imageTag: "not_observed", + observedAt: new Date().toISOString(), + reason: oneLine(reason), + summary: "Live runtime identity is recorded as not_observed; source identity must not be treated as the deployed runtime revision." + }; +} + +function sanitizeRevision(value) { + if (typeof value !== "string") return "unknown"; + const trimmed = value.trim().toLowerCase(); + return /^[a-f0-9]{7,40}$/u.test(trimmed) ? trimmed : "unknown"; +} + +function sanitizeTimestamp(value) { + return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : new Date().toISOString(); +} + +function sanitizeRuntimeString(value) { + return typeof value === "string" && value.trim().length > 0 ? oneLine(value) : "unknown"; +} + +function oneLine(value) { + return String(value ?? "unknown").replace(/\s+/gu, " ").trim().slice(0, 240) || "unknown"; +} + async function fetchText(url) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 6000); @@ -932,19 +1158,37 @@ async function inspectLiveDom(url) { const dom = await page.evaluate(() => { const workspace = document.querySelector('[data-view="workspace"]'); const gate = document.querySelector('[data-view="gate"]'); + const help = document.querySelector('[data-view="help"]'); const diagnostics = document.querySelector('[data-side-panel="diagnostics"]'); const bodyStyle = getComputedStyle(document.body); const htmlStyle = getComputedStyle(document.documentElement); const text = document.body.textContent ?? ""; + const visible = (selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return style.visibility !== "hidden" && style.display !== "none" && box.width > 0 && box.height > 0; + }; return { title: document.title, bodyOverflow: bodyStyle.overflow, htmlOverflow: htmlStyle.overflow, workspaceHidden: workspace ? workspace.hidden : null, gateHidden: gate ? gate.hidden : null, + helpHidden: help ? help.hidden : null, diagnosticsHidden: diagnostics ? diagnostics.hidden : null, outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2, - labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)) + labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)), + coreControlsVisible: { + commandInput: visible("#command-input"), + commandSend: visible("#command-send"), + agentChatStatus: visible("#agent-chat-status"), + conversationList: visible("#conversation-list"), + hardwareList: visible("#hardware-list"), + wiringTab: visible("#tab-wiring"), + recordsTab: visible("#tab-records") + } }; }); const pass = @@ -953,12 +1197,14 @@ async function inspectLiveDom(url) { dom.htmlOverflow === "hidden" && dom.workspaceHidden === false && dom.gateHidden === true && + (dom.helpHidden === true || dom.helpHidden === null) && (dom.diagnosticsHidden === true || dom.diagnosticsHidden === null) && dom.outerScrollLocked && - dom.labelsPresent; + dom.labelsPresent && + Object.values(dom.coreControlsVisible).every(Boolean); return { status: pass ? "pass" : "blocked", - summary: pass ? "Browser DOM confirms the default workbench and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.", + summary: pass ? "Browser DOM confirms the default workbench, core controls, and outer scroll lock." : "Browser DOM did not satisfy the workbench contract.", evidence: [`title=${dom.title}`, `bodyOverflow=${dom.bodyOverflow}`, `htmlOverflow=${dom.htmlOverflow}`], observations: dom }; @@ -973,6 +1219,171 @@ async function inspectLiveDom(url) { } } +async function inspectLiveUserJourney(url) { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + status: "skip", + summary: `Browser Code Agent journey skipped because Playwright is unavailable: ${error.message}`, + evidence: ["playwright import failed"] + }; + } + + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1366, height: 768 } }); + const agentResponses = []; + page.on("response", async (response) => { + if (!response.url().includes("/v1/agent/chat")) return; + let body = null; + try { + body = await response.json(); + } catch { + body = null; + } + agentResponses.push({ + method: response.request().method(), + status: response.status(), + urlPath: new URL(response.url()).pathname, + body: sanitizeAgentChatBody(body) + }); + }); + + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 12000 }); + await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 }); + const controls = await inspectJourneyControls(page); + if (!Object.values(controls).every(Boolean)) { + return { + status: "blocked", + summary: "Browser could not reliably locate deployed workbench controls for the Code Agent journey.", + evidence: ["selectors=#command-input,#command-send,#agent-chat-status,#conversation-list,#hardware-list"], + observations: { controls } + }; + } + + await page.locator("#command-input").fill("请用一句话确认 HWLAB 工作台 Code Agent 浏览器链路可用。"); + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/v1/agent/chat") && response.request().method() === "POST", + { timeout: 35000 } + ); + await page.locator("#command-send").click(); + const response = await responsePromise; + let responseBody = null; + try { + responseBody = await response.json(); + } catch { + responseBody = null; + } + await page.waitForTimeout(500); + const ui = await inspectJourneyUi(page); + const responseSummary = sanitizeAgentChatBody(responseBody); + const backendCompleted = + response.ok() && + responseSummary?.status === "completed" && + Boolean(responseSummary.provider) && + Boolean(responseSummary.model) && + Boolean(responseSummary.traceId); + const uiCompleted = ui.agentChatStatus === "已回复" || ui.completedMessageVisible; + const pass = backendCompleted && uiCompleted && !ui.failedMessageVisible; + + return { + status: pass ? "pass" : "blocked", + summary: pass + ? "Deployed browser journey opened the workbench, sent a Code Agent message, and observed a completed UI response." + : "Deployed browser journey did not observe a completed non-error Code Agent response.", + evidence: [ + `POST /v1/agent/chat HTTP ${response.status()}`, + `status=${responseSummary?.status ?? "unknown"}`, + `provider=${responseSummary?.provider ?? "unknown"}`, + `model=${responseSummary?.model ?? "unknown"}`, + `traceId=${responseSummary?.traceId ?? "unknown"}`, + `uiStatus=${ui.agentChatStatus ?? "unknown"}` + ], + observations: { + controls, + request: { + method: response.request().method(), + status: response.status(), + urlPath: new URL(response.url()).pathname + }, + response: responseSummary, + ui, + networkEvents: agentResponses + } + }; + } catch (error) { + return { + status: "blocked", + summary: `Browser Code Agent journey failed: ${error.message}`, + evidence: ["browser navigation, selector, or POST failed"] + }; + } finally { + if (browser) await browser.close(); + } +} + +async function inspectJourneyControls(page) { + return page.evaluate(() => { + const visible = (selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0; + }; + return { + appShell: visible("[data-app-shell]"), + workspace: document.querySelector('[data-view="workspace"]')?.hidden === false, + commandInput: visible("#command-input"), + commandSend: visible("#command-send"), + agentChatStatus: visible("#agent-chat-status"), + conversationList: visible("#conversation-list"), + hardwareList: visible("#hardware-list"), + wiringTab: visible("#tab-wiring"), + recordsTab: visible("#tab-records") + }; + }); +} + +async function inspectJourneyUi(page) { + return page.evaluate(() => ({ + title: document.title, + workspaceHidden: document.querySelector('[data-view="workspace"]')?.hidden ?? null, + gateHidden: document.querySelector('[data-view="gate"]')?.hidden ?? null, + helpHidden: document.querySelector('[data-view="help"]')?.hidden ?? null, + agentChatStatus: document.querySelector("#agent-chat-status")?.textContent?.trim() ?? null, + inputCleared: document.querySelector("#command-input")?.value === "", + completedMessageVisible: Boolean(document.querySelector(".message-card.status-completed")), + failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), + messageCount: document.querySelectorAll(".message-card").length, + completedMessageHasNonSensitiveMeta: Boolean( + [...document.querySelectorAll(".message-card.status-completed .message-meta")] + .some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? "")) + ) + })); +} + +function sanitizeAgentChatBody(body) { + if (!body || typeof body !== "object") return null; + return { + status: typeof body.status === "string" ? body.status : null, + provider: typeof body.provider === "string" ? body.provider : null, + model: typeof body.model === "string" ? body.model : null, + backend: typeof body.backend === "string" ? body.backend : null, + traceId: typeof body.traceId === "string" ? body.traceId : null, + hasReply: Boolean(body.reply?.content), + error: body.error && typeof body.error === "object" + ? { + code: typeof body.error.code === "string" ? body.error.code : null, + missingEnv: Array.isArray(body.error.missingEnv) ? body.error.missingEnv.filter((item) => typeof item === "string") : [] + } + : null + }; +} + export async function runDevCloudWorkbenchMobileSmoke() { let chromium; try { @@ -1414,11 +1825,11 @@ function escapeRegExp(value) { export function printSmokeHelp() { return { status: "usage", - command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --live --url http://74.48.78.17:16666/", + command: "node scripts/dev-cloud-workbench-smoke.mjs --static | --mobile | --live --url http://74.48.78.17:16666/ [--report reports/dev-gate/dev-cloud-workbench-live.json]", notes: [ "Static mode reads repository files and emits SOURCE-level evidence only.", "--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.", - "Live mode is read-only and reports blocked/skip when optional browser DOM evidence is unavailable." + "Live mode opens the deployed workbench in a browser, verifies core controls, sends one Code Agent message, and retains only non-sensitive response fields." ] }; } diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 1b04b3c3..1aed10e7 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -29,7 +29,8 @@ const issueFamily = Object.freeze({ DEV_M5_GATE_REPORT: "pikasTech/HWLAB#39", DEV_BASE_IMAGE_PREFLIGHT: "pikasTech/HWLAB#40", DEV_EVIDENCE_BLOCKER_AGGREGATOR: "pikasTech/HWLAB#41", - DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58" + DEV_M5_GATE_AGGREGATOR_V2: "pikasTech/HWLAB#58", + DEV_CLOUD_WORKBENCH_LIVE: "pikasTech/HWLAB#7" }); const allowedIssues = new Set([ contractIssue, @@ -118,6 +119,18 @@ const requiredDevM2Docs = [ "docs/dev-acceptance-matrix.md", "fixtures/dev-deploy-smoke/dev-deploy-smoke.json" ]; +const requiredDevCloudWorkbenchValidationCommands = [ + "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 --static", + "node scripts/dev-cloud-workbench-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-live.json", + "node scripts/validate-dev-gate-report.mjs reports/dev-gate/dev-cloud-workbench-live.json" +]; +const requiredDevCloudWorkbenchDocs = [ + "docs/cloud-web-workbench.md", + "docs/reference/cloud-workbench.md", + "docs/reference/code-agent-chat-readiness.md" +]; const reportFamilyTemplates = new Map([ [ "dev-gate-report-contract", @@ -459,6 +472,13 @@ async function validateReport(relativePath) { await validateDevM2Report(report, label); return; } + if ( + report.issue === issueFamily.DEV_CLOUD_WORKBENCH_LIVE || + report.taskId === "dev-cloud-workbench-live" + ) { + await validateDevCloudWorkbenchLiveReport(report, label); + return; + } const template = reportFamilyTemplates.get(report.taskId); assert.ok( @@ -1277,6 +1297,225 @@ async function validateDevM2Report(report, label) { ); } +async function validateDevCloudWorkbenchLiveReport(report, label) { + for (const field of [ + "$schema", + "$id", + "reportVersion", + "issue", + "taskId", + "commitId", + "acceptanceLevel", + "devOnly", + "prodDisabled", + "status", + "generatedAt", + "mode", + "url", + "evidenceLevel", + "devLive", + "sourceIdentity", + "runtimeIdentity", + "sourceContract", + "validationCommands", + "localSmoke", + "dryRun", + "devPreconditions", + "checks", + "blockers", + "safety" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assertString(report.$schema, `${label}.$schema`); + assertString(report.$id, `${label}.$id`); + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.issue, issueFamily.DEV_CLOUD_WORKBENCH_LIVE, `${label}.issue`); + assert.equal(report.taskId, "dev-cloud-workbench-live", `${label}.taskId`); + assert.match(report.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.commitId`); + assert.equal(report.acceptanceLevel, "dev_cloud_workbench_live", `${label}.acceptanceLevel`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assertStatus(report.status, `${label}.status`); + assert.equal(report.status, report.blockers.length === 0 ? "pass" : "blocked", `${label}.status must match blockers`); + assertTimestamp(report.generatedAt, `${label}.generatedAt`); + assert.equal(report.mode, "live", `${label}.mode`); + assert.equal(report.url, "http://74.48.78.17:16666/", `${label}.url`); + assert.equal(report.evidenceLevel, report.status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED", `${label}.evidenceLevel`); + assert.equal(report.devLive, report.status === "pass", `${label}.devLive`); + assertDevCloudWorkbenchIdentity(report, label); + + assertObject(report.sourceContract, `${label}.sourceContract`); + assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); + await assertDocumentSet( + report.sourceContract.documents, + `${label}.sourceContract.documents`, + requiredDevCloudWorkbenchDocs + ); + assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); + + assertStringArray(report.validationCommands, `${label}.validationCommands`, { + minLength: requiredDevCloudWorkbenchValidationCommands.length + }); + assertUnique(report.validationCommands, `${label}.validationCommands`); + for (const requiredCommand of requiredDevCloudWorkbenchValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + for (const section of ["localSmoke", "dryRun", "devPreconditions"]) { + assertObject(report[section], `${label}.${section}`); + assertStatus(report[section].status, `${label}.${section}.status`); + assertStringArray(report[section].commands, `${label}.${section}.commands`, { minLength: 1 }); + assertStringArray(report[section].evidence ?? [], `${label}.${section}.evidence`, { minLength: section === "devPreconditions" ? 0 : 1 }); + assertString(report[section].summary, `${label}.${section}.summary`); + } + assertStringArray(report.devPreconditions.requirements, `${label}.devPreconditions.requirements`, { + minLength: 4 + }); + + assertArray(report.checks, `${label}.checks`); + assert.ok(report.checks.length >= 3, `${label}.checks must include HTTP, DOM, and Code Agent 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}]`; + assertObject(check, checkLabel); + for (const field of ["id", "status", "summary"]) { + assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`); + } + assertString(check.id, `${checkLabel}.id`); + assertStatus(check.status, `${checkLabel}.status`); + assertString(check.summary, `${checkLabel}.summary`); + if (Object.hasOwn(check, "evidence")) { + assertStringArray(check.evidence, `${checkLabel}.evidence`, { minLength: 1 }); + } + if (Object.hasOwn(check, "observations")) { + assertObject(check.observations, `${checkLabel}.observations`); + } + return [check.id, check]; + })); + for (const requiredCheck of [ + "live-http-html", + "live-browser-dom", + "live-code-agent-browser-journey" + ]) { + assert.ok(checks.has(requiredCheck), `${label}.checks missing ${requiredCheck}`); + } + + const httpCheck = checks.get("live-http-html"); + assert.equal(httpCheck.status, "pass", `${label}.live-http-html.status`); + assert.ok(httpCheck.evidence.includes("HTTP 200"), `${label}.live-http-html.evidence missing HTTP 200`); + assert.ok(httpCheck.evidence.includes("text/html; charset=utf-8"), `${label}.live-http-html.evidence missing content-type`); + + const dom = checks.get("live-browser-dom").observations; + assertObject(dom, `${label}.live-browser-dom.observations`); + assert.equal(dom.title, "HWLAB 云工作台", `${label}.live-browser-dom.title`); + assert.equal(dom.workspaceHidden, false, `${label}.live-browser-dom.workspaceHidden`); + assert.equal(dom.gateHidden, true, `${label}.live-browser-dom.gateHidden`); + assert.equal(dom.helpHidden, true, `${label}.live-browser-dom.helpHidden`); + assertObject(dom.coreControlsVisible, `${label}.live-browser-dom.coreControlsVisible`); + for (const control of ["commandInput", "commandSend", "agentChatStatus", "conversationList", "hardwareList"]) { + assert.equal(dom.coreControlsVisible[control], true, `${label}.live-browser-dom.coreControlsVisible.${control}`); + } + + const journey = checks.get("live-code-agent-browser-journey"); + assert.equal(journey.status, report.status, `${label}.live-code-agent-browser-journey.status`); + assertObject(journey.observations, `${label}.live-code-agent-browser-journey.observations`); + assertObject(journey.observations.request, `${label}.live-code-agent-browser-journey.request`); + 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`); + 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}`); + } + assert.equal(journey.observations.response.hasReply, true, `${label}.live-code-agent-browser-journey.response.hasReply`); + assert.equal(journey.observations.response.error, null, `${label}.live-code-agent-browser-journey.response.error`); + for (const forbiddenField of ["conversationId", "sessionId", "messageId", "reply"]) { + assert.equal( + Object.hasOwn(journey.observations.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, true, `${label}.live-code-agent-browser-journey.ui.completedMessageVisible`); + assert.equal(journey.observations.ui.failedMessageVisible, false, `${label}.live-code-agent-browser-journey.ui.failedMessageVisible`); + + assertBlockers(report.blockers, `${label}.blockers`); + + 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 assertDevCloudWorkbenchIdentity(report, label) { + assertObject(report.sourceIdentity, `${label}.sourceIdentity`); + for (const field of ["status", "source", "commitId", "shortCommitId", "ref", "worktreeState", "reportCommitId", "summary"]) { + assert.ok(Object.hasOwn(report.sourceIdentity, field), `${label}.sourceIdentity missing ${field}`); + assertString(report.sourceIdentity[field], `${label}.sourceIdentity.${field}`); + } + assert.ok(["observed", "unknown"].includes(report.sourceIdentity.status), `${label}.sourceIdentity.status`); + assert.ok(["git-head", "environment", "unknown"].includes(report.sourceIdentity.source), `${label}.sourceIdentity.source`); + assert.match(report.sourceIdentity.commitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.sourceIdentity.commitId`); + assert.match(report.sourceIdentity.shortCommitId, /^([a-f0-9]{7,40}|unknown)$/, `${label}.sourceIdentity.shortCommitId`); + assert.ok(["clean", "dirty", "unknown"].includes(report.sourceIdentity.worktreeState), `${label}.sourceIdentity.worktreeState`); + if (report.sourceIdentity.source === "environment") { + assertString(report.sourceIdentity.environmentKey, `${label}.sourceIdentity.environmentKey`); + } + if (report.sourceIdentity.worktreeState === "clean" && report.sourceIdentity.commitId !== "unknown") { + assert.match(report.sourceIdentity.reportCommitId, /^[a-f0-9]{7,40}$/, `${label}.sourceIdentity.reportCommitId`); + assert.equal(report.commitId, report.sourceIdentity.reportCommitId, `${label}.commitId must match clean source reportCommitId`); + } else { + assert.equal(report.sourceIdentity.reportCommitId, "unknown", `${label}.sourceIdentity.reportCommitId`); + assert.equal(report.commitId, "unknown", `${label}.commitId must be unknown when source worktree identity is dirty or unknown`); + } + if (report.sourceIdentity.dirty !== null) { + assertBoolean(report.sourceIdentity.dirty, `${label}.sourceIdentity.dirty`); + assert.equal(report.sourceIdentity.dirty, report.sourceIdentity.worktreeState === "dirty", `${label}.sourceIdentity.dirty`); + } + + assertObject(report.runtimeIdentity, `${label}.runtimeIdentity`); + for (const field of ["status", "source", "endpoint", "serviceId", "environment", "healthStatus", "commitId", "commitSource", "imageTag", "observedAt", "summary"]) { + assert.ok(Object.hasOwn(report.runtimeIdentity, field), `${label}.runtimeIdentity missing ${field}`); + assertString(report.runtimeIdentity[field], `${label}.runtimeIdentity.${field}`); + } + assert.ok(["observed", "not_observed"].includes(report.runtimeIdentity.status), `${label}.runtimeIdentity.status`); + assert.equal(report.runtimeIdentity.source, "health-live", `${label}.runtimeIdentity.source`); + assert.equal(report.runtimeIdentity.endpoint, "http://74.48.78.17:16667/health/live", `${label}.runtimeIdentity.endpoint`); + assertTimestamp(report.runtimeIdentity.observedAt, `${label}.runtimeIdentity.observedAt`); + assert.match(report.runtimeIdentity.commitId, /^([a-f0-9]{7,40}|unknown|not_observed)$/, `${label}.runtimeIdentity.commitId`); + if (report.runtimeIdentity.status === "not_observed") { + assert.equal(report.runtimeIdentity.commitId, "not_observed", `${label}.runtimeIdentity.commitId`); + assertString(report.runtimeIdentity.reason, `${label}.runtimeIdentity.reason`); + } else { + assert.ok( + ["hwlab-cloud-api", "unknown"].includes(report.runtimeIdentity.serviceId), + `${label}.runtimeIdentity.serviceId` + ); + assert.ok(["dev", "unknown"].includes(report.runtimeIdentity.environment), `${label}.runtimeIdentity.environment`); + } + assert.ok( + report.sourceIdentity.commitId === "unknown" || + report.runtimeIdentity.commitId === "unknown" || + report.runtimeIdentity.commitId === "not_observed" || + report.runtimeIdentity.commitSource !== "source-git-head", + `${label}.runtimeIdentity must not present source git HEAD as the deployed live revision` + ); +} + async function validateDevM3Report(report, label) { for (const field of [ "$schema",