From 08610de0dcc29d121b2dbca39ba754ca943403c9 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:17:52 +0800 Subject: [PATCH] fix: improve hwlab web-probe script diagnostics (#462) Co-authored-by: Codex --- docs/reference/cli.md | 4 +- scripts/src/hwlab-node.ts | 459 +++++++++++++++++++++++++++++++++++++- 2 files changed, 450 insertions(+), 13 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b61b1a5b..b38b0830 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,7 +24,9 @@ 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。Code Agent Trace 实时性验收使用 `--trace-sample-count ` 和 `--trace-sample-interval-ms ` 透传到目标 helper,输出每次采样的 agent status、trace presence/status、row count、empty label 和最新 row preview,用于证明运行中渐进拉取;这类采样不能由终态截图替代。需要自定义 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、origin、loginPath、status、attempts、retryCount、fallbackUsed、fallback、retryable、transientObserved、fingerprint、commanderAction 和 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。 +`web-probe script` 的默认 `goto('/workbench')` 是稳定导航边界:它会先复用当前 page,失败后有限次切 fresh page 重试,并等待 workbench 基础 DOM(默认 `#workspace` 和 `#command-input`)可见;需要显式控制时使用注入的 `gotoStable(target, { selectors, activeSelector, attempts, readinessTimeoutMs })`、`waitWorkbenchReady({ selectors })`、`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。 + +`web-probe script` 的调试 helper 必须覆盖常见 Workbench 探针动作:`fetchJson(path)` 在已登录页面上下文里按同源 cookie 请求 JSON,失败返回 `{ ok:false, path, status, error }` 而不是吞掉证据;`collectText(selector)` 返回 selector 命中数量和文本摘要;`safeEvaluate(fn, arg)` 固定使用 Playwright 单参数规则;`screenshotOnError(name, fn)` 在用户断言抛错时落 `failure.png`;`summarizeWorkspace()` 与 `summarizeConversation()` 只输出会话/消息摘要,不打印 cookie、密码或 token。可复用脚本优先用 `--script-file `,一次性探针才用 stdin heredoc。Playwright `page.evaluate` 只能传一个可序列化参数;需要多个值时必须写成 `page.evaluate(({ a, b }) => ..., { a, b })` 或使用 `safeEvaluate(fn, { a, b })`。脚本抛错或返回 `{ ok:false }` 时,`probe` 顶层必须保留 `failureKind=script-api-misuse|assertion-failed|navigation-failed|auth-failed|browser-failed`、`errorMessage`、`scriptSha256`、`runDir`、`lastUrl` 和 `lastScreenshot`;默认失败截图文件名是 `failure.png`,调用方脚本源码保存在同一 `runDir` 以便复查。 `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 当成输入基础镜像。 diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 6fa19d05..63ca6630 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -282,12 +282,18 @@ 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 ({ 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", + "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs", + "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ waitWorkbenchReady, fetchJson, collectText, safeEvaluate, screenshot }) => {\n const ready = await waitWorkbenchReady();\n const workspace = await fetchJson('/v1/workbench/workspace?projectId=prj_hwpod_workbench');\n const workspaceText = await collectText('#workspace');\n const evaluated = await safeEvaluate(({ a, b }) => ({ sum: a + b }), { a: 1, b: 2 });\n await screenshot('workbench.png');\n return { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, workspaceText, evaluated };\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/gotoStable/waitForReady 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 plus fetchJson/collectText/safeEvaluate/waitWorkbenchReady/screenshotOnError/summarizeWorkspace/summarizeConversation helpers and must not handle secrets itself.", }, + notes: [ + "Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.", + "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).", + "Failures include failureKind, errorMessage, scriptSha256, runDir, lastUrl, and lastScreenshot when a screenshot can be captured.", + ], }; } @@ -4756,10 +4762,12 @@ let context; let page; let auth = null; let cookieSecrets = []; +let userScriptSha256 = null; try { if (!password) throw new Error("missing HWLAB_WEB_PASS"); await mkdir(runDir, { recursive: true, mode: 0o700 }); + userScriptSha256 = await sha256File(userScript).catch(() => null); const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href); const { chromium } = await launcher.importPlaywright(); browser = await launcher.launchChromium(chromium); @@ -4774,6 +4782,9 @@ try { const scriptResult = await fn(scriptHelpers()); const safeResult = sanitize(scriptResult); const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false); + const failure = scriptOk ? null : classifiedProbeError(assertionResultError(safeResult)); + const lastScreenshot = scriptOk ? null : await captureFailureScreenshot("failure.png"); + const lastUrl = currentPageUrl(); await emit({ ok: scriptOk, status: scriptOk ? "pass" : "blocked", @@ -4781,16 +4792,26 @@ try { generatedAt: new Date().toISOString(), startedAt, baseUrl, - finalUrl: page.url(), + finalUrl: lastUrl, + lastUrl, + scriptSha256: userScriptSha256, + runDir, auth: publicAuth(auth), script: { ok: scriptOk, result: safeResult }, - readiness: publicReadiness(), + failureKind: failure ? failure.failureKind : null, + error: failure ? failure.code : null, + errorMessage: failure ? sanitize(failure.message) : null, + guidance: failure ? failure.guidance : null, + lastScreenshot, + readiness: publicReadiness(failure ? { error: failure.code, failureKind: failure.failureKind } : {}), artifacts: { runDir, items: artifactRecords }, safety: { valuesRedacted: true, secretValuesPrinted: false } }); process.exitCode = scriptOk ? 0 : 2; } catch (error) { const failure = classifiedProbeError(error); + const lastScreenshot = await captureFailureScreenshot("failure.png"); + const lastUrl = currentPageUrl(); await emit({ ok: false, status: "blocked", @@ -4798,11 +4819,17 @@ try { generatedAt: new Date().toISOString(), startedAt, baseUrl, - finalUrl: page ? page.url() : null, + finalUrl: lastUrl, + lastUrl, + scriptSha256: userScriptSha256, + runDir, auth: auth === null ? null : publicAuth(auth), + failureKind: failure.failureKind, error: failure.code, - errorMessage: sanitize(error instanceof Error ? error.message : String(error)), - readiness: publicReadiness({ error: failure.code }), + errorMessage: sanitize(failure.message), + guidance: failure.guidance, + lastScreenshot, + readiness: publicReadiness({ error: failure.code, failureKind: failure.failureKind }), artifacts: { runDir, items: artifactRecords }, safety: { valuesRedacted: true, secretValuesPrinted: false } }); @@ -5025,13 +5052,20 @@ function scriptHelpers() { auth: publicAuth(auth), artifactPath, screenshot, + screenshotOnError, jsonArtifact, sha256File, wait: sleep, goto, gotoRaw, gotoStable, + waitWorkbenchReady, waitForReady, + fetchJson, + collectText, + safeEvaluate, + summarizeWorkspace, + summarizeConversation, getPage: () => page, request: context.request, }; @@ -5042,6 +5076,214 @@ function scriptHelpers() { return helpers; } +async function safeEvaluate(fn, arg = undefined, options = {}) { + options = normalizeHelperOptions(options); + if (typeof fn !== "function") { + return { + ok: false, + failureKind: "script-api-misuse", + error: "safeEvaluate requires a function", + guidance: evaluateSingleArgGuidance(), + }; + } + try { + return { ok: true, value: sanitize(await page.evaluate(fn, arg)) }; + } catch (error) { + const failure = classifiedProbeError(markEvaluateError(error)); + if (options.throwOnError === true) throw markEvaluateError(error); + return { + ok: false, + failureKind: failure.failureKind, + error: sanitize(failure.message), + guidance: failure.guidance, + }; + } +} + +async function fetchJson(target, options = {}) { + options = normalizeHelperOptions(options); + let url; + try { + url = new URL(String(target || "/"), baseUrl).toString(); + } catch (error) { + return { + ok: false, + path: String(target ?? ""), + status: 0, + statusText: "invalid-url", + error: error instanceof Error ? error.message : String(error), + }; + } + + const headers = normalizeFetchHeaders(options.headers); + const method = typeof options.method === "string" && options.method.length > 0 ? options.method : "GET"; + const body = normalizeFetchBody(options.body, headers); + const evaluated = await safeEvaluate(async ({ url: requestUrl, method: requestMethod, headers: requestHeaders, body: requestBody }) => { + try { + const response = await fetch(requestUrl, { + method: requestMethod, + headers: requestHeaders, + body: requestBody === null ? undefined : requestBody, + credentials: "include", + }); + const text = await response.text(); + let json = null; + let parseError = null; + if (text.length > 0) { + try { + json = JSON.parse(text); + } catch (error) { + parseError = error instanceof Error ? error.message : String(error); + } + } + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + body: json, + textPreview: json === null ? text.slice(0, 1000) : null, + parseError, + }; + } catch (error) { + return { + ok: false, + status: 0, + statusText: "fetch-error", + error: error instanceof Error ? error.message : String(error), + }; + } + }, { url, method, headers, body }); + + if (evaluated.ok !== true) { + const result = { + ok: false, + path: urlPath(url), + status: 0, + statusText: "page-evaluate-failed", + failureKind: evaluated.failureKind ?? "script-api-misuse", + error: evaluated.error ?? "page.evaluate failed", + guidance: evaluated.guidance ?? null, + }; + if (options.throwOnError === true) throw stableProbeError("api-response-failed", result.error, publicReadiness({ error: "api-response-failed" })); + return result; + } + + const response = evaluated.value && typeof evaluated.value === "object" ? evaluated.value : {}; + const result = { + ok: response.ok === true, + path: urlPath(response.url || url), + status: Number.isInteger(response.status) ? response.status : 0, + statusText: typeof response.statusText === "string" ? response.statusText : null, + body: response.body ?? null, + textPreview: typeof response.textPreview === "string" ? response.textPreview : null, + parseError: typeof response.parseError === "string" ? response.parseError : null, + error: typeof response.error === "string" ? response.error : null, + }; + if (options.throwOnError === true && result.ok !== true) { + throw stableProbeError("api-response-failed", result.error || result.statusText || "fetchJson failed", publicReadiness({ error: "api-response-failed" })); + } + return result; +} + +async function collectText(selector, options = {}) { + options = normalizeHelperOptions(options); + const normalizedSelector = String(selector || "").trim(); + if (normalizedSelector.length === 0) return { ok: false, selector: normalizedSelector, count: 0, texts: [], error: "selector is required" }; + const limit = boundedInteger(options.limit, 20, 1, 200); + const textLimit = boundedInteger(options.textLimit, 1000, 80, 6000); + const waitTimeoutMs = boundedInteger(options.timeoutMs ?? options.waitTimeoutMs, Math.min(timeoutMs, 5000), 1, Math.max(timeoutMs, 60000)); + const state = typeof options.state === "string" ? options.state : "attached"; + try { + const locator = page.locator(normalizedSelector); + if (options.wait !== false) await locator.first().waitFor({ state, timeout: waitTimeoutMs }); + const count = await locator.count(); + const texts = (await locator.allTextContents()) + .slice(0, limit) + .map((text) => normalizeTextPreview(text, textLimit)); + return { ok: true, selector: normalizedSelector, count, returned: texts.length, texts }; + } catch (error) { + return { + ok: false, + selector: normalizedSelector, + count: 0, + texts: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function waitWorkbenchReady(options = {}) { + options = normalizeHelperOptions(options); + const target = typeof options.target === "string" && options.target.length > 0 ? options.target : "/workbench"; + const next = { ...options }; + delete next.target; + if (next.selectors === undefined) next.selectors = ["#workspace", "#command-input"]; + return gotoStable(target, next); +} + +async function screenshotOnError(name = "failure.png", fn = null, options = {}) { + if (typeof name === "function") { + options = normalizeHelperOptions(fn); + fn = name; + name = "failure.png"; + } + if (typeof fn !== "function") return captureFailureScreenshot(String(name || "failure.png")); + try { + return await fn(); + } catch (error) { + const shot = await captureFailureScreenshot(String(name || "failure.png")); + if (error && typeof error === "object") error.screenshot = shot; + throw error; + } +} + +async function summarizeWorkspace(options = {}) { + options = normalizeHelperOptions(options); + const projectId = typeof options.projectId === "string" && options.projectId.length > 0 ? options.projectId : inferProjectId(); + const response = await fetchJson("/v1/workbench/workspace?projectId=" + encodeURIComponent(projectId)); + if (response.ok !== true) return { ...response, projectId }; + const body = response.body && typeof response.body === "object" ? response.body : {}; + const conversations = firstArrayAtPaths(body, ["conversations", "workspace.conversations", "data.conversations"]); + return { + ok: true, + path: response.path, + status: response.status, + projectId, + selectedConversationId: firstStringAtPaths(body, ["selectedConversationId", "workspace.selectedConversationId", "data.selectedConversationId"]), + sessionId: firstStringAtPaths(body, ["sessionId", "workspace.sessionId", "data.sessionId"]), + conversationCount: conversations ? conversations.length : null, + conversationPreview: conversations ? conversations.slice(0, 8).map(summarizeConversationItem) : [], + }; +} + +async function summarizeConversation(input = {}) { + const options = typeof input === "string" ? { conversationId: input } : normalizeHelperOptions(input); + let workspace = null; + let conversationId = typeof options.conversationId === "string" && options.conversationId.length > 0 ? options.conversationId : inferConversationIdFromUrl(); + if (!conversationId) { + workspace = await summarizeWorkspace(options); + conversationId = typeof workspace.selectedConversationId === "string" ? workspace.selectedConversationId : null; + } + if (!conversationId) { + return { ok: false, error: "conversationId is required and could not be inferred from the current workbench", workspace }; + } + const response = await fetchJson("/v1/agent/conversations/" + encodeURIComponent(conversationId)); + if (response.ok !== true) return { ...response, conversationId, workspace }; + const body = response.body && typeof response.body === "object" ? response.body : {}; + const messages = firstArrayAtPaths(body, ["messages", "conversation.messages", "data.messages"]) || []; + return { + ok: true, + path: response.path, + status: response.status, + conversationId, + title: firstStringAtPaths(body, ["title", "conversation.title", "data.title"]), + lastTraceId: firstStringAtPaths(body, ["lastTraceId", "conversation.lastTraceId", "data.lastTraceId"]), + messageCount: messages.length, + lastMessage: messages.length > 0 ? summarizeMessage(messages[messages.length - 1]) : null, + }; +} + async function goto(target = "/workbench", options = {}) { options = normalizeHelperOptions(options); if (options && typeof options === "object" && options.stable === false) return gotoRaw(target, options); @@ -5391,6 +5633,19 @@ async function captureReadinessScreenshot(targetPage, attempt, options) { } } +async function captureFailureScreenshot(name = "failure.png") { + if (!page || page.isClosed()) return null; + try { + return await screenshotPage(page, name); + } catch (error) { + return { + kind: "screenshot", + path: artifactPath(name), + 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 }); @@ -5448,12 +5703,15 @@ function classifyNavigationError(error) { } 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"; + const message = error instanceof Error ? error.message : String(error); + const code = probeErrorCode(error, message); + const failureKind = probeFailureKind(code, message); return { code, - message: error instanceof Error ? error.message : String(error), + failureKind, + message, detail: error && typeof error === "object" ? error.readiness ?? null : null, + guidance: probeFailureGuidance(failureKind), }; } @@ -5464,6 +5722,85 @@ function stableProbeError(code, message, readiness) { return error; } +function assertionResultError(value) { + const message = extractFailureMessage(value) || "script returned ok:false"; + const error = new Error(message); + error.code = "assertion-failed"; + return error; +} + +function extractFailureMessage(value) { + if (!value || typeof value !== "object") return null; + for (const key of ["errorMessage", "message", "error", "reason", "summary"]) { + const nested = value[key]; + if (typeof nested === "string" && nested.length > 0) return nested; + } + return null; +} + +function probeErrorCode(error, message) { + const known = new Set([ + "assertion-failed", + "auth-login-failed", + "browser-failed", + "browser-load-jitter", + "selector-timeout", + "api-not-sent", + "api-response-failed", + "script-api-misuse", + "script-failed", + ]); + const explicit = error && typeof error === "object" && typeof error.code === "string" ? error.code : null; + if (explicit && known.has(explicit)) return explicit; + if (known.has(message)) return message; + if (isEvaluateApiMisuse(message)) return "script-api-misuse"; + if (/AssertionError|ERR_ASSERTION|\bassert(?:ion)?\b|expect\(.*\)|ok:false/iu.test(message)) return "assertion-failed"; + if (/auth-login-failed|login form not visible|missing HWLAB_WEB_PASS/iu.test(message)) return "auth-login-failed"; + if (/executable doesn't exist|browser executable|failed to launch|browserType\.launch|browser has been closed/iu.test(message)) return "browser-failed"; + if (/timeout|navigation|net::|target page|page has been closed|page did not become ready|selector/iu.test(message)) return "browser-load-jitter"; + return "script-failed"; +} + +function probeFailureKind(code, message) { + if (code === "script-api-misuse") return "script-api-misuse"; + if (code === "auth-login-failed") return "auth-failed"; + if (code === "browser-failed") return "browser-failed"; + if (code === "browser-load-jitter" || code === "selector-timeout" || code === "api-not-sent" || code === "api-response-failed") return "navigation-failed"; + if (/browser has been closed|browser executable|failed to launch/iu.test(message)) return "browser-failed"; + return "assertion-failed"; +} + +function probeFailureGuidance(failureKind) { + if (failureKind === "script-api-misuse") return evaluateSingleArgGuidance(); + if (failureKind === "navigation-failed") return "Inspect probe.readiness for selector/API readiness details and lastScreenshot for the browser state."; + if (failureKind === "auth-failed") return "Inspect probe.auth sourceRef/fingerprint/status; do not print or copy the web login secret."; + if (failureKind === "browser-failed") return "Inspect Playwright/browser-launcher availability on the target workspace."; + return null; +} + +function isEvaluateApiMisuse(message) { + return /Too many arguments.*wrap them in an object|page\.evaluate.*one serializable argument|safeEvaluate requires a function/iu.test(String(message)); +} + +function evaluateSingleArgGuidance() { + return "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b })."; +} + +function markEvaluateError(error) { + if (error && typeof error === "object" && isEvaluateApiMisuse(error instanceof Error ? error.message : String(error))) { + error.code = "script-api-misuse"; + } + return error; +} + +function currentPageUrl() { + try { + return page && !page.isClosed() ? page.url() : null; + } catch { + return null; + } +} + function urlPath(rawUrl) { try { const parsed = new URL(rawUrl, baseUrl); @@ -5479,6 +5816,94 @@ function boundedInteger(raw, fallback, min, max) { return Math.max(min, Math.min(max, value)); } +function inferProjectId() { + try { + const current = page && !page.isClosed() ? new URL(page.url()) : null; + const fromUrl = current ? current.searchParams.get("projectId") : null; + if (fromUrl) return fromUrl; + } catch { + // Fall through to the Workbench default. + } + return "prj_hwpod_workbench"; +} + +function inferConversationIdFromUrl() { + try { + if (!page || page.isClosed()) return null; + const pathname = new URL(page.url()).pathname; + const match = pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/]+)/u); + return match ? decodeURIComponent(match[1]) : null; + } catch { + return null; + } +} + +function firstStringAtPaths(value, paths) { + for (const pathSpec of paths) { + const nested = valueAtPath(value, pathSpec); + if (typeof nested === "string" && nested.length > 0) return nested; + } + return null; +} + +function firstArrayAtPaths(value, paths) { + for (const pathSpec of paths) { + const nested = valueAtPath(value, pathSpec); + if (Array.isArray(nested)) return nested; + } + return null; +} + +function valueAtPath(value, pathSpec) { + let current = value; + for (const key of String(pathSpec).split(".")) { + if (!current || typeof current !== "object") return null; + current = current[key]; + } + return current ?? null; +} + +function summarizeConversationItem(value) { + if (!value || typeof value !== "object") return value; + return { + conversationId: firstStringAtPaths(value, ["conversationId", "id"]), + title: firstStringAtPaths(value, ["title", "name"]), + lastTraceId: firstStringAtPaths(value, ["lastTraceId", "traceId"]), + status: firstStringAtPaths(value, ["status", "state"]), + }; +} + +function summarizeMessage(value) { + if (!value || typeof value !== "object") return value ?? null; + return { + role: firstStringAtPaths(value, ["role", "type", "sender"]), + status: firstStringAtPaths(value, ["status", "state"]), + traceId: firstStringAtPaths(value, ["traceId", "runnerTrace.traceId"]), + textPreview: normalizeTextPreview(firstStringAtPaths(value, ["content", "text", "message", "finalResponse"]) || "", 500), + }; +} + +function normalizeTextPreview(text, limit) { + return String(text ?? "").replace(/\s+/gu, " ").trim().slice(0, limit); +} + +function normalizeFetchHeaders(value) { + const headers = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return headers; + for (const [key, nested] of Object.entries(value)) { + if (typeof nested === "string") headers[key] = nested; + } + return headers; +} + +function normalizeFetchBody(value, headers) { + if (value === undefined || value === null) return null; + if (typeof value === "string") return value; + const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === "content-type"); + if (!hasContentType) headers["content-type"] = "application/json"; + return JSON.stringify(value); +} + function isRetryableAuthStatus(status) { return status === 0 || status === 429 || status >= 500; } @@ -5638,8 +6063,8 @@ async function recordArtifact(file, kind) { byteCount: fileStat.size, sha256: await sha256File(file), }; - artifactRecords.push(item); - return item; + artifactRecords.push({ ...item }); + return { ...item }; } async function sha256File(file) { @@ -5692,8 +6117,14 @@ function compactWebProbeScriptResult(report: Record | null): Re status: typeof report.status === "string" ? report.status : null, baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null, finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null, + lastUrl: typeof report.lastUrl === "string" ? report.lastUrl : null, + scriptSha256: typeof report.scriptSha256 === "string" ? report.scriptSha256 : null, + runDir: typeof report.runDir === "string" ? report.runDir : null, auth: record(report.auth), script: record(report.script), + failureKind: typeof report.failureKind === "string" ? report.failureKind : null, + guidance: typeof report.guidance === "string" ? report.guidance : null, + lastScreenshot: nullableRecord(report.lastScreenshot), readiness: record(report.readiness), artifacts: record(report.artifacts), error: typeof report.error === "string" ? report.error : null, @@ -8441,6 +8872,10 @@ function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } +function nullableRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} + function stringValue(value: unknown, path: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`); return value;