fix: extend hwlab web probe sampling timeout

This commit is contained in:
Codex
2026-06-17 15:48:34 +00:00
parent 38f14cdfd2
commit 542cf37935
2 changed files with 132 additions and 11 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 `config/hwlab-node-lanes.yaml``bootstrapAdmin` 声明与受控 `hwlab nodes secret status|ensure --node <node> --lane v03 --name hwlab-v03-bootstrap-admin` CLI。明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` sourceRef 文件;CLI 在本地把明文转换为 HWLAB 兼容 password hash,只向运行面同步 `password-hash`,并在输出中只披露 sourceRef、sourceKey、target Secret/key、presence、byte count、fingerprint、mutation 与后续命令。`secret ensure --force` 只用于明确需要按 YAML sourceRef 重灌 bootstrap admin hash 并重启 Cloud API 的受控恢复场景,默认 ensure 不做强制重灌;不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。
`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,用于证明运行中渐进拉取;这类采样不能由终态截图替代。CLI 默认按 trace 采样窗口、terminal wait 和页面 timeout 自动扩展短连接 `commandTimeout`,显式 `--command-timeout-seconds` 只允许延长该预算,不应把 90-180s 采样压回 60s。需要自定义 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 })``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。
+131 -10
View File
@@ -32,6 +32,8 @@ interface NodeWebProbeRunOptions {
freshSession: boolean;
cancelRunning: boolean;
commandTimeoutSeconds: number;
commandTimeoutAutoSeconds: number;
commandTimeoutUserProvided: boolean;
}
interface NodeWebProbeScriptOptions {
@@ -4784,14 +4786,16 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
}
const scriptText = scriptFile === undefined ? readFileSync(0, "utf8") : readFileSync(scriptFile, "utf8");
if (scriptText.trim().length === 0) throw new Error("web-probe script received an empty script");
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.max(60, Math.ceil(timeoutMs / 1000) + 30), 3600);
return {
action: "script",
node,
lane,
url: optionValue(args, "--url") ?? spec.publicWebUrl,
timeoutMs: positiveIntegerOption(args, "--timeout-ms", 30000, 60000),
timeoutMs,
viewport: optionValue(args, "--viewport") ?? "1440x900",
commandTimeoutSeconds: positiveIntegerOption(args, "--command-timeout-seconds", 60, 60),
commandTimeoutSeconds,
scriptText,
scriptSource: {
kind: scriptFile === undefined ? "stdin" : "file",
@@ -4818,25 +4822,69 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
"--fresh-session",
"--no-cancel-running",
]));
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 60000);
const waitAfterSubmitMs = positiveIntegerOption(args, "--wait-after-submit-ms", 1500, 60000);
const waitMessagesMs = positiveIntegerOption(args, "--wait-messages-ms", 2500, 60000);
const waitAgentTerminalMs = positiveIntegerOption(args, "--wait-agent-terminal-ms", 0, 600000);
const traceSampleCount = positiveIntegerOption(args, "--trace-sample-count", 0, 200);
const traceSampleIntervalMs = positiveIntegerOption(args, "--trace-sample-interval-ms", 0, 60000);
const commandTimeoutAutoSeconds = nodeWebProbeAutoCommandTimeoutSeconds({
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
freshSession: args.includes("--fresh-session"),
hasMessage: optionValue(args, "--message") !== undefined,
});
const commandTimeoutRaw = optionValue(args, "--command-timeout-seconds");
const commandTimeoutUserProvided = commandTimeoutRaw !== undefined;
const commandTimeoutSeconds = commandTimeoutUserProvided
? Math.max(positiveIntegerOption(args, "--command-timeout-seconds", commandTimeoutAutoSeconds, 3600), commandTimeoutAutoSeconds)
: commandTimeoutAutoSeconds;
return {
action: "run",
node,
lane,
url: optionValue(args, "--url") ?? spec.publicWebUrl,
timeoutMs: positiveIntegerOption(args, "--timeout-ms", 30000, 60000),
waitAfterSubmitMs: positiveIntegerOption(args, "--wait-after-submit-ms", 1500, 60000),
waitMessagesMs: positiveIntegerOption(args, "--wait-messages-ms", 2500, 60000),
waitAgentTerminalMs: positiveIntegerOption(args, "--wait-agent-terminal-ms", 0, 600000),
traceSampleCount: positiveIntegerOption(args, "--trace-sample-count", 0, 200),
traceSampleIntervalMs: positiveIntegerOption(args, "--trace-sample-interval-ms", 0, 60000),
timeoutMs,
waitAfterSubmitMs,
waitMessagesMs,
waitAgentTerminalMs,
traceSampleCount,
traceSampleIntervalMs,
message: optionValue(args, "--message") ?? null,
conversationId: optionValue(args, "--conversation-id") ?? null,
freshSession: args.includes("--fresh-session"),
cancelRunning: !args.includes("--no-cancel-running"),
commandTimeoutSeconds: positiveIntegerOption(args, "--command-timeout-seconds", 60, 60),
commandTimeoutSeconds,
commandTimeoutAutoSeconds,
commandTimeoutUserProvided,
};
}
function nodeWebProbeAutoCommandTimeoutSeconds(input: {
timeoutMs: number;
waitAfterSubmitMs: number;
waitMessagesMs: number;
waitAgentTerminalMs: number;
traceSampleCount: number;
traceSampleIntervalMs: number;
freshSession: boolean;
hasMessage: boolean;
}): number {
const traceWindowMs = input.traceSampleCount > 0
? Math.max(0, input.traceSampleCount - 1) * input.traceSampleIntervalMs
: 0;
const startupBudgetMs = input.timeoutMs + 30_000;
const freshnessBudgetMs = input.freshSession ? Math.min(input.timeoutMs, 30_000) : 0;
const submitBudgetMs = input.hasMessage ? input.waitAfterSubmitMs + input.waitMessagesMs + 15_000 : 0;
const terminalBudgetMs = input.waitAgentTerminalMs > 0 ? input.waitAgentTerminalMs : 0;
const totalMs = startupBudgetMs + freshnessBudgetMs + submitBudgetMs + terminalBudgetMs + traceWindowMs + 15_000;
return Math.min(3600, Math.max(60, Math.ceil(totalMs / 1000)));
}
function assertKnownOptions(args: string[], valueOptions: Set<string>, flagOptions: Set<string>): void {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
@@ -4894,6 +4942,11 @@ function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown>
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const probe = compactWebProbeResult(parseJsonObject(result.stdout));
const passed = result.exitCode === 0 && probe?.status === "pass";
const degradedReason = result.timedOut
? "web-probe-command-timeout"
: typeof probe?.degradedReason === "string"
? probe.degradedReason
: null;
return {
ok: passed,
status: passed ? "pass" : "blocked",
@@ -4903,6 +4956,13 @@ function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown>
workspace: spec.workspace,
url: options.url,
credential,
commandTimeout: {
seconds: options.commandTimeoutSeconds,
autoSeconds: options.commandTimeoutAutoSeconds,
userProvided: options.commandTimeoutUserProvided,
timedOut: result.timedOut,
},
degradedReason,
probe,
result: compactCommandResult(result),
valuesRedacted: true,
@@ -9212,16 +9272,25 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
function compactWebProbeResult(report: Record<string, unknown> | null): Record<string, unknown> | null {
if (report === null) return null;
const dom = record(report.dom);
const performance = record(report.performance);
const trace = record(report.trace);
const session = record(report.session);
return {
ok: report.ok === true,
status: typeof report.status === "string" ? report.status : null,
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null,
error: typeof report.error === "string" ? report.error : null,
actions: Array.isArray(report.actions) ? report.actions : [],
degradedReason: typeof report.degradedReason === "string" ? report.degradedReason : null,
actions: compactWebProbeActions(report.actions),
session,
trace,
performance,
traceSamples: Array.isArray(report.traceSamples) ? report.traceSamples : [],
dom: {
authState: typeof dom.authState === "string" ? dom.authState : null,
requiredSelectors: record(dom.requiredSelectors),
messageCount: typeof dom.messageCount === "number" ? dom.messageCount : null,
sessionRail: record(dom.sessionRail),
},
failureDom: record(report.failureDom),
artifacts: record(report.artifacts),
@@ -9229,6 +9298,58 @@ function compactWebProbeResult(report: Record<string, unknown> | null): Record<s
};
}
function compactWebProbeActions(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value.map(record).map((action) => {
const name = typeof action.action === "string" ? action.action : "unknown";
if (name === "fresh-session") {
const alignment = record(action.alignment);
const check = record(alignment.check);
const repair = record(alignment.repair);
return {
action: name,
settled: action.settled === true,
aligned: action.aligned === true,
reason: alignment.reason ?? check.reason ?? null,
conversationId: check.conversationId ?? null,
routeConversationId: check.routeConversationId ?? null,
selectedConversationId: check.selectedConversationId ?? null,
repaired: Object.keys(repair).length > 0,
repairConversationId: repair.conversationId ?? null,
attempts: Array.isArray(alignment.attempts) ? alignment.attempts.length : null,
};
}
if (name === "trace-interval-samples") {
return {
action: name,
count: action.count ?? null,
intervalMs: action.intervalMs ?? null,
};
}
if (name === "submit-prompt") {
return {
action: name,
chars: action.chars ?? null,
submittedAt: action.submittedAt ?? null,
};
}
if (name === "wait-agent-terminal") {
return {
action: name,
terminal: action.terminal === true,
timeoutMs: action.timeoutMs ?? null,
};
}
return {
action: name,
skipped: action.skipped ?? null,
ready: action.ready ?? null,
found: action.found ?? null,
timeoutMs: action.timeoutMs ?? null,
};
});
}
function compactCommand(command: string[]): string[] {
const scriptIndex = command.indexOf("--");
if (scriptIndex >= 0 && scriptIndex + 1 < command.length) return [...command.slice(0, scriptIndex + 1), "<script omitted>"];