diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1401fb12..40fe3fed 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,6 +24,8 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期 `hwlab nodes web-probe run --node --lane [--url ]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node --lane <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。`web-probe script` 托管登录先对同源 `/auth/login` 做短重试;仍未拿到 `hwlab_session` 时自动回到当前 Cloud Web 登录表单,以浏览器方式提交同一凭据。`probe.auth` 只输出 method、status、attempts、fallback 和 redacted errorSummary,不打印密码、cookie 或可复制 session 值。 +`web-probe script` 的默认 `goto('/workbench')` 是稳定导航边界:它会先复用当前 page,失败后有限次切 fresh page 重试,并等待 workbench 基础 DOM(默认 `#workspace` 和 `#command-input`)可见;需要显式控制时使用注入的 `gotoStable(target, { selectors, activeSelector, attempts, readinessTimeoutMs })`、`waitForReady({ selectors })`、`gotoRaw()` 和 `getPage()`。稳定化失败必须在 `probe.readiness` 中低噪声披露 attempt、阶段、selector、是否观察到 `/v1` API request、API failure 摘要和失败截图 artifact;分类值固定为 `browser-load-jitter`、`selector-timeout`、`api-not-sent`、`api-response-failed`,避免把“页面没准备好/请求未发出”和“后端响应失败”混成同一种 selector timeout。runner 不在用户脚本执行前抢先导航同一 page,保证脚本仍可先安装 `page.route` 或 context route;如重试切换 fresh page,后续脚本应通过 `gotoStable()` 返回值或 `getPage()` 取得当前 page。 + `hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。 `hwlab nodes control-plane infra tools-image status|build|logs --node D601 --lane v03` 是 D601 tools image 的受控入口。Dockerfile 必须由 `config/hwlab-node-control-plane.yaml` 的 `tekton.toolsImage.dockerfileInline` 声明,输入镜像必须列在 `publicBaseImages`,构建参数和网络模式也来自 YAML;confirmed build 只在 D601 后台异步构建并推送到 node-local registry,返回 status/logs 轮询命令。`hwlab nodes control-plane infra argo status|apply|logs --node D601 --lane v03` 是 D601 Argo CD 的声明式安装入口。Argo 版本、官方 manifest URL、镜像 rewrite/preload、field manager、imagePullPolicy、CRD 列表、期望 Deployment/StatefulSet 以及生成的 AppProject/Application 都必须来自同一个 YAML;`argo apply --confirm` 只执行可重复 server-side apply 和后台轮询,不把原生 `kubectl apply`、手工 Argo CLI 或临时 manifest 作为正式安装路径。 diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 6b9906d4..d6d63358 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -256,11 +256,11 @@ export function hwlabNodeWebProbeHelp(): Record { examples: [ "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000", "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'", - "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ page, goto, screenshot }) => {\n await page.route('**/v1/agent/conversations**', route => setTimeout(() => route.continue(), 3000));\n await goto('/workbench');\n await screenshot('workbench.png');\n return { finalUrl: page.url() };\n};\nJS", + "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ gotoStable, screenshot }) => {\n const ready = await gotoStable('/workbench', { selectors: ['#workspace', '#command-input'] });\n await screenshot('workbench.png');\n return { finalUrl: ready.finalUrl, readiness: ready.readiness };\n};\nJS", ], actions: { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", - script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page helpers and must not handle secrets itself.", + script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page/gotoStable/waitForReady helpers and must not handle secrets itself.", }, }; } @@ -3740,6 +3740,7 @@ const userScript = path.resolve(process.env.UNIDESK_WEB_PROBE_USER_SCRIPT || pat const timeoutMs = positiveInteger(process.env.UNIDESK_WEB_PROBE_TIMEOUT_MS, 30000); const viewport = parseViewport(process.env.UNIDESK_WEB_PROBE_VIEWPORT || "1440x900"); const artifactRecords = []; +const readinessRecords = []; let browser; let context; let page; @@ -3760,25 +3761,7 @@ try { const mod = await import(pathToFileURL(userScript).href + "?t=" + Date.now()); const fn = typeof mod.default === "function" ? mod.default : typeof mod.run === "function" ? mod.run : typeof mod.probe === "function" ? mod.probe : null; if (fn === null) throw new Error("custom script must export default, run, or probe function"); - const scriptResult = await fn({ - browser, - context, - page, - baseUrl, - runDir, - auth: publicAuth(auth), - artifactPath, - screenshot, - jsonArtifact, - sha256File, - wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - goto: async (target = "/workbench", options = {}) => { - const url = new URL(target, baseUrl).toString(); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs, ...options }); - return page.url(); - }, - request: context.request, - }); + const scriptResult = await fn(scriptHelpers()); const safeResult = sanitize(scriptResult); const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false); await emit({ @@ -3791,11 +3774,13 @@ try { finalUrl: page.url(), auth: publicAuth(auth), script: { ok: scriptOk, result: safeResult }, + readiness: publicReadiness(), artifacts: { runDir, items: artifactRecords }, safety: { valuesRedacted: true, secretValuesPrinted: false } }); process.exitCode = scriptOk ? 0 : 2; } catch (error) { + const failure = classifiedProbeError(error); await emit({ ok: false, status: "blocked", @@ -3805,7 +3790,9 @@ try { baseUrl, finalUrl: page ? page.url() : null, auth: auth === null ? null : publicAuth(auth), - error: sanitize(error instanceof Error ? error.message : String(error)), + error: failure.code, + errorMessage: sanitize(error instanceof Error ? error.message : String(error)), + readiness: publicReadiness({ error: failure.code }), artifacts: { runDir, items: artifactRecords }, safety: { valuesRedacted: true, secretValuesPrinted: false } }); @@ -4000,6 +3987,470 @@ async function formLoginOutcome(targetPage) { return handle.jsonValue().catch(() => null); } +function scriptHelpers() { + const helpers = { + browser, + context, + page, + baseUrl, + runDir, + auth: publicAuth(auth), + artifactPath, + screenshot, + jsonArtifact, + sha256File, + wait: sleep, + goto, + gotoRaw, + gotoStable, + waitForReady, + getPage: () => page, + request: context.request, + }; + Object.defineProperty(helpers, "currentPage", { + enumerable: false, + get: () => page, + }); + return helpers; +} + +async function goto(target = "/workbench", options = {}) { + options = normalizeHelperOptions(options); + if (options && typeof options === "object" && options.stable === false) return gotoRaw(target, options); + const stable = await gotoStable(target, options); + return stable.finalUrl; +} + +async function gotoRaw(target = "/workbench", options = {}) { + options = normalizeHelperOptions(options); + const url = new URL(target, baseUrl).toString(); + await page.goto(url, navigationOptions(options)); + return page.url(); +} + +async function gotoStable(target = "/workbench", options = {}) { + options = normalizeHelperOptions(options); + const url = new URL(target, baseUrl).toString(); + const attempts = boundedInteger(options.attempts, 3, 1, 5); + const retryDelayMs = boundedInteger(options.retryDelayMs, 350, 0, 5000); + const readinessSelectors = normalizeSelectorList(options.selectors, defaultReadinessSelectors(url)); + let lastRecord = null; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const useFreshPage = attempt > 1 || options.freshPage === true || options.reusePage === false || !page || page.isClosed(); + if (useFreshPage) { + const previousPage = page; + page = await context.newPage(); + if (previousPage && previousPage !== page && !previousPage.isClosed()) await previousPage.close().catch(() => {}); + } + + const attemptPage = page; + const network = createNetworkTracker(attemptPage, options.apiPattern); + const record = { + ok: false, + attempt, + attempts, + policy: useFreshPage ? "fresh-page" : "reuse-current-page", + targetPath: urlPath(url), + finalPath: null, + stage: "navigate", + error: null, + message: null, + selector: null, + selectors: [], + apiRequestsSent: false, + apiRequestCount: 0, + apiResponseFailed: false, + apiFailureCount: 0, + screenshot: null, + durationMs: 0, + }; + const started = Date.now(); + + try { + await attemptPage.goto(url, navigationOptions(options)); + record.finalPath = urlPath(attemptPage.url()); + const readiness = await waitForReadyInternal(attemptPage, { + ...options, + selectors: readinessSelectors, + url, + }, network); + applyReadinessToRecord(record, readiness); + if (!readiness.ok) { + record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options); + lastRecord = record; + } else if (options.expectApiRequest === true && !readiness.apiRequestsSent) { + record.ok = false; + record.stage = "api"; + record.error = "api-not-sent"; + record.message = "expected API request was not observed before readiness finished"; + record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options); + lastRecord = record; + } else if (options.failOnApiResponseFailed === true && readiness.apiResponseFailed) { + record.ok = false; + record.stage = "api"; + record.error = "api-response-failed"; + record.message = "API request failed during readiness"; + record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options); + lastRecord = record; + } else { + record.ok = true; + record.stage = "ready"; + lastRecord = record; + } + } catch (error) { + record.finalPath = attemptPage && !attemptPage.isClosed() ? urlPath(attemptPage.url()) : null; + record.error = classifyNavigationError(error); + record.message = error instanceof Error ? error.message : String(error); + record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options); + lastRecord = record; + } finally { + Object.assign(record, network.summary()); + record.durationMs = Date.now() - started; + network.dispose(); + readinessRecords.push(record); + } + + if (record.ok) { + const result = { + ok: true, + attempt, + attempts, + finalUrl: page.url(), + readiness: record, + summary: publicReadiness(), + }; + Object.defineProperty(result, "page", { + enumerable: false, + value: page, + }); + return result; + } + if (attempt < attempts && retryDelayMs > 0) await sleep(retryDelayMs); + } + + const code = lastRecord?.error || "browser-load-jitter"; + const message = lastRecord?.message || "page did not become ready"; + throw stableProbeError(code, message, publicReadiness({ error: code })); +} + +async function waitForReady(options = {}) { + options = normalizeHelperOptions(options); + const readiness = await waitForReadyInternal(options.page || page, options, null); + readinessRecords.push({ + ...readiness, + attempt: readinessRecords.length + 1, + attempts: 1, + policy: "wait-current-page", + durationMs: readiness.durationMs, + }); + if (options.throwOnFailure === true && !readiness.ok) { + throw stableProbeError(readiness.error || "selector-timeout", readiness.message || "page did not become ready", publicReadiness({ error: readiness.error || "selector-timeout" })); + } + return readiness; +} + +async function waitForReadyInternal(targetPage, options = {}, network = null) { + const started = Date.now(); + const url = typeof options.url === "string" ? options.url : targetPage.url(); + const readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? options.timeoutMs, timeoutMs, 1, Math.max(timeoutMs, 60000)); + const selectorState = typeof options.selectorState === "string" ? options.selectorState : "visible"; + const selectors = normalizeSelectorList(options.selectors, defaultReadinessSelectors(url)); + const activeSelector = typeof options.activeSelector === "string" ? options.activeSelector : typeof options.activeTabSelector === "string" ? options.activeTabSelector : null; + const result = { + ok: false, + stage: "load-state", + error: null, + message: null, + targetPath: urlPath(url), + finalPath: null, + selector: null, + selectors: [], + apiRequestsSent: false, + apiRequestCount: 0, + apiResponseFailed: false, + apiFailureCount: 0, + durationMs: 0, + }; + + try { + await targetPage.waitForLoadState(typeof options.loadState === "string" ? options.loadState : "domcontentloaded", { timeout: readinessTimeoutMs }); + result.finalPath = urlPath(targetPage.url()); + for (const selector of selectors) { + result.stage = "selector"; + result.selector = selector; + await targetPage.locator(selector).first().waitFor({ state: selectorState, timeout: readinessTimeoutMs }); + result.selectors.push({ selector, state: selectorState, matched: true }); + } + if (activeSelector !== null) { + result.stage = "active-selector"; + result.selector = activeSelector; + await targetPage.locator(activeSelector).first().waitFor({ state: "visible", timeout: readinessTimeoutMs }); + result.selectors.push({ selector: activeSelector, state: "visible", matched: true, role: "active" }); + } + result.ok = true; + result.stage = "ready"; + } catch (error) { + result.ok = false; + result.error = result.stage === "selector" || result.stage === "active-selector" ? "selector-timeout" : "browser-load-jitter"; + result.message = error instanceof Error ? error.message : String(error); + } finally { + Object.assign(result, network ? network.summary() : emptyNetworkSummary()); + result.durationMs = Date.now() - started; + } + return result; +} + +function applyReadinessToRecord(record, readiness) { + record.ok = readiness.ok; + record.stage = readiness.stage; + record.error = readiness.error; + record.message = readiness.message; + record.selector = readiness.selector; + record.selectors = readiness.selectors; + record.finalPath = readiness.finalPath; + record.apiRequestsSent = readiness.apiRequestsSent; + record.apiRequestCount = readiness.apiRequestCount; + record.apiResponseFailed = readiness.apiResponseFailed; + record.apiFailureCount = readiness.apiFailureCount; +} + +function createNetworkTracker(targetPage, apiPattern) { + const started = Date.now(); + const requests = []; + const failures = []; + const matches = createApiMatcher(apiPattern); + const onRequest = (request) => { + if (!matches(request.url())) return; + requests.push({ + method: request.method(), + path: urlPath(request.url()), + atMs: Date.now() - started, + }); + }; + const onResponse = (response) => { + if (!matches(response.url())) return; + if (response.status() >= 400) { + failures.push({ + type: "response", + status: response.status(), + statusText: response.statusText(), + path: urlPath(response.url()), + atMs: Date.now() - started, + }); + } + }; + const onRequestFailed = (request) => { + if (!matches(request.url())) return; + failures.push({ + type: "requestfailed", + method: request.method(), + path: urlPath(request.url()), + failureText: request.failure()?.errorText ?? null, + atMs: Date.now() - started, + }); + }; + targetPage.on("request", onRequest); + targetPage.on("response", onResponse); + targetPage.on("requestfailed", onRequestFailed); + return { + summary: () => ({ + apiRequestsSent: requests.length > 0, + apiRequestCount: requests.length, + apiResponseFailed: failures.length > 0, + apiFailureCount: failures.length, + apiRequests: requests.slice(0, 5), + apiFailures: failures.slice(0, 5), + }), + dispose: () => { + targetPage.off("request", onRequest); + targetPage.off("response", onResponse); + targetPage.off("requestfailed", onRequestFailed); + }, + }; +} + +function createApiMatcher(pattern) { + if (pattern === false) return () => false; + if (pattern instanceof RegExp) return (rawUrl) => pattern.test(rawUrl); + if (typeof pattern === "function") return (rawUrl) => { + try { + return pattern(rawUrl) === true; + } catch { + return false; + } + }; + const normalized = typeof pattern === "string" && pattern.length > 0 ? pattern : "/v1"; + return (rawUrl) => { + try { + const parsed = new URL(rawUrl); + if (normalized.startsWith("/")) return parsed.pathname === normalized || parsed.pathname.startsWith(normalized.replace(/\/$/u, "") + "/"); + return rawUrl.includes(normalized); + } catch { + return false; + } + }; +} + +function emptyNetworkSummary() { + return { + apiRequestsSent: false, + apiRequestCount: 0, + apiResponseFailed: false, + apiFailureCount: 0, + apiRequests: [], + apiFailures: [], + }; +} + +function defaultReadinessSelectors(rawUrl) { + try { + const pathname = new URL(rawUrl, baseUrl).pathname; + if (pathname === "/" || pathname === "/workbench" || pathname.startsWith("/workbench/")) return ["#workspace", "#command-input"]; + } catch { + return ["body"]; + } + return ["body"]; +} + +function normalizeSelectorList(value, fallback) { + if (value === false || value === null) return []; + if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).slice(0, 10); + if (typeof value === "string" && value.trim().length > 0) return [value.trim()]; + return fallback; +} + +function normalizeHelperOptions(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function navigationOptions(options = {}) { + options = normalizeHelperOptions(options); + const next = { ...options }; + for (const key of [ + "activeSelector", + "activeTabSelector", + "apiPattern", + "attempts", + "expectApiRequest", + "failOnApiResponseFailed", + "freshPage", + "readinessTimeoutMs", + "retryDelayMs", + "reusePage", + "screenshotOnFailure", + "selectorState", + "selectors", + "stable", + ]) { + delete next[key]; + } + return { + waitUntil: "domcontentloaded", + timeout: timeoutMs, + ...next, + }; +} + +async function captureReadinessScreenshot(targetPage, attempt, options) { + if (options.screenshotOnFailure === false || !targetPage || targetPage.isClosed()) return null; + try { + return await screenshotPage(targetPage, "readiness-attempt-" + attempt + ".png"); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function screenshotPage(targetPage, name = "screenshot.png", options = {}) { + const file = artifactPath(name); + await targetPage.screenshot({ path: file, fullPage: true, ...options }); + return recordArtifact(file, "screenshot"); +} + +function publicReadiness(extra = {}) { + const attempts = readinessRecords.slice(-10).map((record) => ({ + ok: record.ok === true, + attempt: record.attempt ?? null, + attempts: record.attempts ?? null, + policy: record.policy ?? null, + targetPath: record.targetPath ?? null, + finalPath: record.finalPath ?? null, + stage: record.stage ?? null, + error: record.error ?? null, + message: typeof record.message === "string" ? record.message.slice(0, 500) : null, + selector: record.selector ?? null, + selectors: clonePlainList(record.selectors, 10), + apiRequestsSent: record.apiRequestsSent === true, + apiRequestCount: Number.isInteger(record.apiRequestCount) ? record.apiRequestCount : 0, + apiResponseFailed: record.apiResponseFailed === true, + apiFailureCount: Number.isInteger(record.apiFailureCount) ? record.apiFailureCount : 0, + apiRequests: clonePlainList(record.apiRequests, 5), + apiFailures: clonePlainList(record.apiFailures, 5), + screenshot: clonePlainObject(record.screenshot), + durationMs: Number.isInteger(record.durationMs) ? record.durationMs : null, + })); + return { + attemptCount: readinessRecords.length, + last: attempts.length > 0 ? deepClonePlain(attempts[attempts.length - 1]) : null, + attempts, + ...extra, + }; +} + +function deepClonePlain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function clonePlainList(value, limit) { + if (!Array.isArray(value)) return []; + return value.slice(0, limit).map((item) => clonePlainObject(item)); +} + +function clonePlainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null; + return { ...value }; +} + +function classifyNavigationError(error) { + const message = error instanceof Error ? error.message : String(error); + if (/timeout|navigation|net::|target page|browser has been closed|page has been closed/iu.test(message)) return "browser-load-jitter"; + return "browser-load-jitter"; +} + +function classifiedProbeError(error) { + const known = new Set(["browser-load-jitter", "selector-timeout", "api-not-sent", "api-response-failed", "auth-login-failed"]); + const code = error && typeof error === "object" && known.has(error.code) ? error.code : error instanceof Error && known.has(error.message) ? error.message : "script-failed"; + return { + code, + message: error instanceof Error ? error.message : String(error), + detail: error && typeof error === "object" ? error.readiness ?? null : null, + }; +} + +function stableProbeError(code, message, readiness) { + const error = new Error(message); + error.code = code; + error.readiness = readiness; + return error; +} + +function urlPath(rawUrl) { + try { + const parsed = new URL(rawUrl, baseUrl); + return parsed.pathname + parsed.search; + } catch { + return String(rawUrl); + } +} + +function boundedInteger(raw, fallback, min, max) { + const value = Number(raw ?? fallback); + if (!Number.isInteger(value)) return fallback; + return Math.max(min, Math.min(max, value)); +} + async function responseSummary(response) { const status = response.status(); let bodyPreview = null; @@ -4172,8 +4623,10 @@ function compactWebProbeScriptResult(report: Record | null): Re finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null, auth: record(report.auth), script: record(report.script), + readiness: record(report.readiness), artifacts: record(report.artifacts), error: typeof report.error === "string" ? report.error : null, + errorMessage: typeof report.errorMessage === "string" ? report.errorMessage : null, safety: record(report.safety), }; }