fix: improve hwlab web-probe script diagnostics (#462)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-17 22:17:52 +08:00
committed by GitHub
parent ae23112c57
commit 08610de0dc
2 changed files with 450 additions and 13 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 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 <N>``--trace-sample-interval-ms <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 <node> --lane <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 <path>`,一次性探针才用 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 当成输入基础镜像。
+447 -12
View File
@@ -282,12 +282,18 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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<string, unknown> | 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function nullableRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : 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;