fix: improve hwlab web-probe script diagnostics (#462)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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 值。
|
`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 当成输入基础镜像。
|
`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
@@ -282,12 +282,18 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
|||||||
examples: [
|
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 --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 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: {
|
actions: {
|
||||||
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
|
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 page;
|
||||||
let auth = null;
|
let auth = null;
|
||||||
let cookieSecrets = [];
|
let cookieSecrets = [];
|
||||||
|
let userScriptSha256 = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
||||||
await mkdir(runDir, { recursive: true, mode: 0o700 });
|
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 launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
||||||
const { chromium } = await launcher.importPlaywright();
|
const { chromium } = await launcher.importPlaywright();
|
||||||
browser = await launcher.launchChromium(chromium);
|
browser = await launcher.launchChromium(chromium);
|
||||||
@@ -4774,6 +4782,9 @@ try {
|
|||||||
const scriptResult = await fn(scriptHelpers());
|
const scriptResult = await fn(scriptHelpers());
|
||||||
const safeResult = sanitize(scriptResult);
|
const safeResult = sanitize(scriptResult);
|
||||||
const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false);
|
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({
|
await emit({
|
||||||
ok: scriptOk,
|
ok: scriptOk,
|
||||||
status: scriptOk ? "pass" : "blocked",
|
status: scriptOk ? "pass" : "blocked",
|
||||||
@@ -4781,16 +4792,26 @@ try {
|
|||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
startedAt,
|
startedAt,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
finalUrl: page.url(),
|
finalUrl: lastUrl,
|
||||||
|
lastUrl,
|
||||||
|
scriptSha256: userScriptSha256,
|
||||||
|
runDir,
|
||||||
auth: publicAuth(auth),
|
auth: publicAuth(auth),
|
||||||
script: { ok: scriptOk, result: safeResult },
|
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 },
|
artifacts: { runDir, items: artifactRecords },
|
||||||
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
||||||
});
|
});
|
||||||
process.exitCode = scriptOk ? 0 : 2;
|
process.exitCode = scriptOk ? 0 : 2;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = classifiedProbeError(error);
|
const failure = classifiedProbeError(error);
|
||||||
|
const lastScreenshot = await captureFailureScreenshot("failure.png");
|
||||||
|
const lastUrl = currentPageUrl();
|
||||||
await emit({
|
await emit({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: "blocked",
|
status: "blocked",
|
||||||
@@ -4798,11 +4819,17 @@ try {
|
|||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
startedAt,
|
startedAt,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
finalUrl: page ? page.url() : null,
|
finalUrl: lastUrl,
|
||||||
|
lastUrl,
|
||||||
|
scriptSha256: userScriptSha256,
|
||||||
|
runDir,
|
||||||
auth: auth === null ? null : publicAuth(auth),
|
auth: auth === null ? null : publicAuth(auth),
|
||||||
|
failureKind: failure.failureKind,
|
||||||
error: failure.code,
|
error: failure.code,
|
||||||
errorMessage: sanitize(error instanceof Error ? error.message : String(error)),
|
errorMessage: sanitize(failure.message),
|
||||||
readiness: publicReadiness({ error: failure.code }),
|
guidance: failure.guidance,
|
||||||
|
lastScreenshot,
|
||||||
|
readiness: publicReadiness({ error: failure.code, failureKind: failure.failureKind }),
|
||||||
artifacts: { runDir, items: artifactRecords },
|
artifacts: { runDir, items: artifactRecords },
|
||||||
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
||||||
});
|
});
|
||||||
@@ -5025,13 +5052,20 @@ function scriptHelpers() {
|
|||||||
auth: publicAuth(auth),
|
auth: publicAuth(auth),
|
||||||
artifactPath,
|
artifactPath,
|
||||||
screenshot,
|
screenshot,
|
||||||
|
screenshotOnError,
|
||||||
jsonArtifact,
|
jsonArtifact,
|
||||||
sha256File,
|
sha256File,
|
||||||
wait: sleep,
|
wait: sleep,
|
||||||
goto,
|
goto,
|
||||||
gotoRaw,
|
gotoRaw,
|
||||||
gotoStable,
|
gotoStable,
|
||||||
|
waitWorkbenchReady,
|
||||||
waitForReady,
|
waitForReady,
|
||||||
|
fetchJson,
|
||||||
|
collectText,
|
||||||
|
safeEvaluate,
|
||||||
|
summarizeWorkspace,
|
||||||
|
summarizeConversation,
|
||||||
getPage: () => page,
|
getPage: () => page,
|
||||||
request: context.request,
|
request: context.request,
|
||||||
};
|
};
|
||||||
@@ -5042,6 +5076,214 @@ function scriptHelpers() {
|
|||||||
return helpers;
|
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 = {}) {
|
async function goto(target = "/workbench", options = {}) {
|
||||||
options = normalizeHelperOptions(options);
|
options = normalizeHelperOptions(options);
|
||||||
if (options && typeof options === "object" && options.stable === false) return gotoRaw(target, 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 = {}) {
|
async function screenshotPage(targetPage, name = "screenshot.png", options = {}) {
|
||||||
const file = artifactPath(name);
|
const file = artifactPath(name);
|
||||||
await targetPage.screenshot({ path: file, fullPage: true, ...options });
|
await targetPage.screenshot({ path: file, fullPage: true, ...options });
|
||||||
@@ -5448,12 +5703,15 @@ function classifyNavigationError(error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function classifiedProbeError(error) {
|
function classifiedProbeError(error) {
|
||||||
const known = new Set(["browser-load-jitter", "selector-timeout", "api-not-sent", "api-response-failed", "auth-login-failed"]);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const code = error && typeof error === "object" && known.has(error.code) ? error.code : error instanceof Error && known.has(error.message) ? error.message : "script-failed";
|
const code = probeErrorCode(error, message);
|
||||||
|
const failureKind = probeFailureKind(code, message);
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
message: error instanceof Error ? error.message : String(error),
|
failureKind,
|
||||||
|
message,
|
||||||
detail: error && typeof error === "object" ? error.readiness ?? null : null,
|
detail: error && typeof error === "object" ? error.readiness ?? null : null,
|
||||||
|
guidance: probeFailureGuidance(failureKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5464,6 +5722,85 @@ function stableProbeError(code, message, readiness) {
|
|||||||
return error;
|
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) {
|
function urlPath(rawUrl) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl, baseUrl);
|
const parsed = new URL(rawUrl, baseUrl);
|
||||||
@@ -5479,6 +5816,94 @@ function boundedInteger(raw, fallback, min, max) {
|
|||||||
return Math.max(min, Math.min(max, value));
|
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) {
|
function isRetryableAuthStatus(status) {
|
||||||
return status === 0 || status === 429 || status >= 500;
|
return status === 0 || status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
@@ -5638,8 +6063,8 @@ async function recordArtifact(file, kind) {
|
|||||||
byteCount: fileStat.size,
|
byteCount: fileStat.size,
|
||||||
sha256: await sha256File(file),
|
sha256: await sha256File(file),
|
||||||
};
|
};
|
||||||
artifactRecords.push(item);
|
artifactRecords.push({ ...item });
|
||||||
return item;
|
return { ...item };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256File(file) {
|
async function sha256File(file) {
|
||||||
@@ -5692,8 +6117,14 @@ function compactWebProbeScriptResult(report: Record<string, unknown> | null): Re
|
|||||||
status: typeof report.status === "string" ? report.status : null,
|
status: typeof report.status === "string" ? report.status : null,
|
||||||
baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null,
|
baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null,
|
||||||
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : 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),
|
auth: record(report.auth),
|
||||||
script: record(report.script),
|
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),
|
readiness: record(report.readiness),
|
||||||
artifacts: record(report.artifacts),
|
artifacts: record(report.artifacts),
|
||||||
error: typeof report.error === "string" ? report.error : null,
|
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> : {};
|
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 {
|
function stringValue(value: unknown, path: string): string {
|
||||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user