fix: harden web probe observe diagnostics (#684)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
+5
-5
@@ -1278,11 +1278,11 @@ function resolveReadViewNumberReference(kind: "issue" | "pr", sub: "read" | "vie
|
||||
}
|
||||
|
||||
function issueReadJsonFields(options: GitHubOptions): IssueViewJsonField[] | undefined {
|
||||
return options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : options.jsonFields;
|
||||
return options.jsonFields ?? (options.raw || options.full ? ISSUE_VIEW_JSON_FIELDS.slice() : undefined);
|
||||
}
|
||||
|
||||
function prReadJsonFields(options: GitHubOptions): PrReadJsonField[] | undefined {
|
||||
return options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : options.prJsonFields;
|
||||
return options.prJsonFields ?? (options.raw || options.full ? PR_READ_JSON_FIELDS.slice() : undefined);
|
||||
}
|
||||
|
||||
function readDisclosureOptions(options: GitHubOptions, shorthand: GitHubShorthandReference | undefined): Record<string, unknown> | null {
|
||||
@@ -8194,9 +8194,9 @@ export function ghHelp(): unknown {
|
||||
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
|
||||
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
|
||||
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
|
||||
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; full body is included only when requested with --json body, --full, or --raw, and unsupported fields fail structurally.",
|
||||
"issue view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. View/read accept positional numbers, GitHub issue URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single issue/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create/scan-escape/cleanup-plan/board-audit/board-row list do not accept it. Comment delete treats --number as commentId, not an issue number. View supports lifecycle fields closed/closedAt plus legacy --json field selection; explicit --json fields limit output even with --raw/--full; full body is included only when requested with --json body or when --json is omitted and --full/--raw requests all fields. Unsupported fields fail structurally.",
|
||||
"issue attachment list/download scan issue body and comments for GitHub user attachment URLs (`https://github.com/user-attachments/assets/...`). list is read-only and returns bounded attachment metadata. download writes the selected attachment to --output or /tmp/unidesk-gh-attachments, returns bytes/SHA-256/content-type/path, redacts redirected signed URL query parameters, and never prints binary bytes.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit/patch and gh pr list/read/view. For read/view commands, an explicit --json field list narrows the disclosure and prevents accidental full body/comment expansion. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||||
"CLI output larger than config/unidesk-cli.yaml output.maxStdoutBytes is automatically written to /tmp/unidesk-cli-output; stdout stays bounded with outputTruncated=true, warning text, dump file metadata, and drill-down read commands.",
|
||||
"issue create accepts --body-stdin or --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
|
||||
"--body-stdin is the first-class heredoc/stdin source for Markdown bodies. Use quoted heredoc syntax such as bun scripts/cli.ts gh issue comment create 1 --body-stdin <<'EOF' so real newlines, backticks, and tables are read as stdin bytes instead of shell arguments.",
|
||||
@@ -8221,7 +8221,7 @@ export function ghHelp(): unknown {
|
||||
"comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.",
|
||||
"PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff <number> --stat is a compatibility alias for the same JSON summary.",
|
||||
"PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.",
|
||||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR view is the canonical GitHub CLI-compatible read path; read remains a UniDesk compatibility alias. PR view/read accept positional numbers, GitHub PR URLs, and owner/repo#number shorthand, deriving --repo unless an explicit conflicting --repo is supplied. --number is accepted on single PR/comment numeric target commands for low-friction compatibility and returns a standard syntax hint; list/create do not accept it. PR comment update/edit/delete treat --number as commentId, not a PR number. PR view/read supports REST closeout fields stateDetail, closed, closedAt, merged, mergedAt, mergeCommit, headRefName, and baseRefName; explicit --json fields limit output even with --raw/--full. Mergeable, mergeStateStatus, and statusCheckRollup are fetched through GitHub GraphQL only when requested or when --json is omitted and --raw/--full requests full disclosure, and closeoutMetadata makes GraphQL errors plus UNKNOWN/null metadata explicit.",
|
||||
"PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.",
|
||||
"PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view <number> --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.",
|
||||
"PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.",
|
||||
|
||||
+186
-12
@@ -77,6 +77,7 @@ interface NodeWebProbeObserveOptions {
|
||||
waitMs: number;
|
||||
tailLines: number;
|
||||
maxFiles: number;
|
||||
collectFile: string | null;
|
||||
full: boolean;
|
||||
stateDir: string | null;
|
||||
jobId: string | null;
|
||||
@@ -6903,6 +6904,7 @@ function parseNodeWebProbeObserveOptions(
|
||||
"--wait-ms",
|
||||
"--tail-lines",
|
||||
"--max-files",
|
||||
"--file",
|
||||
"--state-dir",
|
||||
"--job-id",
|
||||
"--type",
|
||||
@@ -6918,6 +6920,8 @@ function parseNodeWebProbeObserveOptions(
|
||||
const jobId = optionValue(args, "--job-id") ?? observeId ?? indexed?.id ?? null;
|
||||
if (stateDir !== null && !isSafeWebObserveStateDir(stateDir)) throw new Error(`unsafe web-probe observe --state-dir: ${stateDir}`);
|
||||
if (jobId !== null && !isSafeWebObserveJobId(jobId)) throw new Error(`unsafe web-probe observe --job-id: ${jobId}`);
|
||||
const collectFile = optionValue(args, "--file") ?? null;
|
||||
if (collectFile !== null && !isSafeWebObserveCollectFile(collectFile)) throw new Error(`unsafe web-probe observe --file: ${collectFile}`);
|
||||
if (observeActionRaw !== "start" && stateDir === null && jobId === null) {
|
||||
throw new Error("web-probe observe status|command|stop|collect|analyze requires --state-dir or --job-id");
|
||||
}
|
||||
@@ -6937,6 +6941,7 @@ function parseNodeWebProbeObserveOptions(
|
||||
waitMs: positiveIntegerOption(args, "--wait-ms", observeActionRaw === "command" || observeActionRaw === "stop" ? 30000 : 0, 600000),
|
||||
tailLines: positiveIntegerOption(args, "--tail-lines", 5, 200),
|
||||
maxFiles: positiveIntegerOption(args, "--max-files", 80, 5000),
|
||||
collectFile,
|
||||
full: args.includes("--full"),
|
||||
stateDir,
|
||||
jobId,
|
||||
@@ -7739,15 +7744,15 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||
function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
|
||||
const script = [
|
||||
"set -eu",
|
||||
nodeWebObserveResolveStateDirShell(options),
|
||||
nodeWebObserveCollectNodeScript(options.maxFiles),
|
||||
nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile),
|
||||
].join("\n");
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const collect = parseJsonObject(result.stdout);
|
||||
return {
|
||||
return withWebObserveCollectRendered({
|
||||
ok: result.exitCode === 0 && collect?.ok !== false,
|
||||
status: result.exitCode === 0 ? "collected" : "blocked",
|
||||
command: webObserveCommandLabel("collect", options),
|
||||
@@ -7756,11 +7761,147 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
|
||||
lane: options.lane,
|
||||
workspace: spec.workspace,
|
||||
collect,
|
||||
result: compactCommandResultWithStdoutTail(result),
|
||||
result: collect === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
|
||||
function withWebObserveCollectRendered(value: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
ok: value.ok !== false,
|
||||
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe collect",
|
||||
contentType: "text/plain",
|
||||
renderedText: renderWebObserveCollectTable(value),
|
||||
};
|
||||
}
|
||||
|
||||
function renderWebObserveCollectTable(value: Record<string, unknown>): string {
|
||||
const collect = record(value.collect);
|
||||
const result = record(value.result);
|
||||
const file = record(collect.file);
|
||||
const files = Array.isArray(collect.files) ? collect.files.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 20) : [];
|
||||
const jsonlTail = Array.isArray(file.jsonlTail) ? file.jsonlTail.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
|
||||
const jsonSummary = record(file.jsonSummary);
|
||||
const jsonRunnerErrors = Array.isArray(jsonSummary.runnerErrors) ? jsonSummary.runnerErrors.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
|
||||
const jsonFindings = Array.isArray(jsonSummary.findings) ? jsonSummary.findings.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
|
||||
"",
|
||||
webObserveTable(["ID", "NODE", "LANE", "MODE", "STATE_DIR"], [[
|
||||
webObserveText(value.id),
|
||||
webObserveText(value.node),
|
||||
webObserveText(value.lane),
|
||||
webObserveText(collect.mode ?? "files"),
|
||||
webObserveShort(webObserveText(collect.stateDir), 88),
|
||||
]]),
|
||||
"",
|
||||
];
|
||||
if (collect.mode === "file") {
|
||||
lines.push(
|
||||
"File:",
|
||||
webObserveTable(["RELATIVE", "BYTES", "TRUNC", "SHA256"], [[
|
||||
webObserveShort(webObserveText(file.relative), 64),
|
||||
webObserveText(file.byteCount),
|
||||
webObserveText(file.truncated),
|
||||
webObserveShort(webObserveText(file.sha256), 28),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
if (Object.keys(jsonSummary).length > 0) {
|
||||
lines.push(
|
||||
"JSON summary:",
|
||||
webObserveTable(["KEYS", "COUNTS", "PARSE_ERROR"], [[
|
||||
webObserveShort(webObserveText(jsonSummary.topLevelKeys), 80),
|
||||
webObserveShort(webObserveText(jsonSummary.counts), 80),
|
||||
webObserveShort(webObserveText(jsonSummary.parseError), 80),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonRunnerErrors.length > 0) {
|
||||
lines.push(
|
||||
"Runner errors:",
|
||||
webObserveTable(["TS", "TYPE", "ATTEMPTS", "READY", "MESSAGE"], jsonRunnerErrors.map((item) => [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveShort(webObserveText(item.type), 20),
|
||||
webObserveText(item.attemptCount),
|
||||
webObserveShort(webObserveText(item.lastReadinessReason), 24),
|
||||
webObserveShort(webObserveText(item.message ?? item.lastError), 96),
|
||||
])),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonFindings.length > 0) {
|
||||
lines.push(
|
||||
"Findings:",
|
||||
webObserveTable(["KIND", "SEVERITY", "COUNT", "SUMMARY"], jsonFindings.map((item) => [
|
||||
webObserveShort(webObserveText(item.kind ?? item.id ?? item.code), 48),
|
||||
webObserveText(item.severity ?? item.level),
|
||||
webObserveText(item.count ?? item.sampleCount),
|
||||
webObserveShort(webObserveText(item.summary ?? item.message), 96),
|
||||
])),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (jsonlTail.length > 0) {
|
||||
lines.push(
|
||||
"JSONL tail:",
|
||||
webObserveTable(["TS", "TYPE", "COMMAND", "ATTEMPTS", "READY", "DOM", "PATH", "MESSAGE"], jsonlTail.map((item) => {
|
||||
const error = record(item.error);
|
||||
const attempts = Array.isArray(error.attempts) ? error.attempts : [];
|
||||
const lastAttempt = attempts.length > 0 ? record(attempts[attempts.length - 1]) : {};
|
||||
const rawReadiness = record(lastAttempt.readiness ?? error.navigationReadiness);
|
||||
const readiness = record(rawReadiness.snapshot ?? rawReadiness);
|
||||
const domBits = [
|
||||
`shell=${readiness.workbenchShellVisible === true ? "Y" : "n"}`,
|
||||
`create=${readiness.sessionCreateVisible === true ? "Y" : "n"}`,
|
||||
`input=${readiness.commandInputPresent === true ? "Y" : "n"}`,
|
||||
`tab=${readiness.activeTabPresent === true ? "Y" : "n"}`,
|
||||
`login=${readiness.loginVisible === true ? "Y" : "n"}`,
|
||||
].join(" ");
|
||||
return [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveShort(webObserveText(item.type), 20),
|
||||
webObserveShort(webObserveText(item.commandId), 28),
|
||||
webObserveText(attempts.length),
|
||||
webObserveShort(webObserveText(readiness.reason), 24),
|
||||
webObserveShort(domBits, 48),
|
||||
webObserveShort(webObserveText(item.path ?? item.urlPath ?? item.url ?? item.currentUrl ?? item.status), 60),
|
||||
webObserveShort(webObserveText(error.message ?? item.message ?? item.text ?? item.preview), 96),
|
||||
];
|
||||
})),
|
||||
"",
|
||||
);
|
||||
} else if (typeof file.content === "string" && file.content.length > 0) {
|
||||
lines.push("Content preview:", webObserveShort(file.content, 4000), "");
|
||||
}
|
||||
} else {
|
||||
lines.push(
|
||||
"Files:",
|
||||
webObserveTable(["RELATIVE", "BYTES", "SHA256"], files.length > 0 ? files.map((item) => [
|
||||
webObserveShort(webObserveText(item.relative), 72),
|
||||
webObserveText(item.byteCount),
|
||||
webObserveShort(webObserveText(item.sha256), 28),
|
||||
]) : [["-", "-", "-"]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (value.ok === false) {
|
||||
lines.push(
|
||||
"Blocked detail:",
|
||||
webObserveTable(["REASON", "EXIT", "TIMEOUT", "STDERR"], [[
|
||||
webObserveShort(webObserveText(collect.reason ?? value.degradedReason), 48),
|
||||
webObserveText(result.exitCode),
|
||||
webObserveText(result.timedOut),
|
||||
webObserveShort(webObserveText(result.stderr), 120),
|
||||
]]),
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push("Disclosure:", " collect is bounded; use --file <relative> for id-specific drill-down instead of raw remote cat.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
|
||||
const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64");
|
||||
const script = [
|
||||
@@ -7796,6 +7937,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
"const takeHead = (value, limit) => Array.isArray(value) ? value.slice(0, limit) : [];",
|
||||
"const takeTail = (value, limit) => Array.isArray(value) ? value.slice(-limit) : [];",
|
||||
"const firstArray = (...values) => { for (const value of values) if (Array.isArray(value)) return value; return []; };",
|
||||
"const firstNonEmptyArray = (...values) => { for (const value of values) if (Array.isArray(value) && value.length > 0) return value; return firstArray(...values); };",
|
||||
"const readJsonlTail = (path, limit) => readText(path).split(/\\r?\\n/).filter(Boolean).slice(-limit).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);",
|
||||
"const mergeArrays = (...values) => { const out = []; const seen = new Set(); for (const value of values) { if (!Array.isArray(value)) continue; for (const item of value) { const key = JSON.stringify([item?.id ?? item?.kind ?? item?.code ?? item?.columnLabel ?? item?.traceId ?? null, item?.path ?? item?.urlPath ?? null, item?.method ?? null, item?.status ?? null, item?.summary ?? item?.message ?? item?.fromSeq ?? item?.firstAt ?? null, item?.toSeq ?? item?.lastAt ?? null]); if (seen.has(key)) continue; seen.add(key); out.push(item); } } return out; };",
|
||||
"const clip = (value, limit = 160) => value === null || value === undefined ? null : String(value).slice(0, limit);",
|
||||
"const findingRank = (item) => { const id = String(item?.id ?? item?.kind ?? item?.code ?? ''); if (id === 'page-performance-slow-same-origin-api') return 0; if (id.startsWith('turn-timing-total-elapsed')) return 1; if (id.startsWith('turn-timing-recent-update')) return 2; if (id.includes('runtime-execution') || id.includes('prompt-chat-submit-failed')) return 3; return 10; };",
|
||||
@@ -7816,7 +7959,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
"const slimDomSample = (item) => { const v = objectOrNull(item) || {}; return { seq: v.seq ?? null, ts: v.ts ?? null, source: clip(v.source, 32), diagnosticCode: clip(v.diagnosticCode, 48), traceId: clip(v.traceId, 64), httpStatus: v.httpStatus ?? null, idleSeconds: v.idleSeconds ?? null, waitingFor: clip(v.waitingFor, 48), lastEventLabel: clip(v.lastEventLabel, 80), text: clip(v.text ?? v.preview, 180) }; };",
|
||||
"const slimConsoleGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, type: clip(v.type, 24), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), lastAt: v.lastAt ?? v.firstAt ?? null, firstAt: v.firstAt ?? null, traceIds: Array.isArray(v.traceIds) ? v.traceIds.slice(0, 3).map((x) => clip(x, 64)) : [] }; };",
|
||||
"const slimConsoleSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, type: clip(v.type, 24), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), traceId: clip(v.traceId, 64), text: clip(v.text ?? v.preview, 180) }; };",
|
||||
"const slimRunnerError = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, type: clip(v.type, 32), commandId: clip(v.commandId, 80), sampleSeq: v.sampleSeq ?? null, message: clip(v.message, 240), retry: clip(v.retry, 24), retryExhausted: v.retryExhausted === true, lastError: clip(v.lastError, 180) }; };",
|
||||
"const slimRunnerError = (item) => { const v = objectOrNull(item) || {}; const readiness = objectOrNull(v.lastReadiness); return { ts: v.ts ?? null, type: clip(v.type, 32), commandId: clip(v.commandId, 80), sampleSeq: v.sampleSeq ?? null, message: clip(v.message, 240), retry: clip(v.retry, 24), retryExhausted: v.retryExhausted === true, lastError: clip(v.lastError, 180), attemptCount: v.attemptCount ?? null, lastFailureKind: clip(v.lastFailureKind, 48), lastReadinessReason: clip(v.lastReadinessReason, 48), lastReadiness: readiness ? { reason: clip(readiness.reason, 48), path: clip(readiness.path, 96), readyState: clip(readiness.readyState, 24), workbenchShellVisible: readiness.workbenchShellVisible === true, sessionCreateVisible: readiness.sessionCreateVisible === true, commandInputPresent: readiness.commandInputPresent === true, activeTabPresent: readiness.activeTabPresent === true, warningPresent: readiness.warningPresent === true, loginVisible: readiness.loginVisible === true, bodyTextHash: clip(readiness.bodyTextHash, 80) } : null }; };",
|
||||
"const slimRunnerErrorFromJsonl = (item) => { const v = objectOrNull(item) || {}; const error = objectOrNull(v.error) || {}; const attempts = Array.isArray(error.attempts) ? error.attempts : []; const lastAttempt = attempts.length > 0 ? objectOrNull(attempts[attempts.length - 1]) || {} : {}; const rawReadiness = objectOrNull(lastAttempt.readiness) || objectOrNull(error.navigationReadiness); const readiness = objectOrNull(rawReadiness?.snapshot) || rawReadiness; return { ts: v.ts ?? null, type: v.type ?? null, commandId: v.commandId ?? null, sampleSeq: v.sampleSeq ?? null, message: error.message ?? v.message ?? null, attemptCount: attempts.length, lastFailureKind: lastAttempt.failureKind ?? null, lastReadinessReason: rawReadiness?.reason ?? readiness?.reason ?? null, lastReadiness: readiness ?? null }; };",
|
||||
"const slimJump = (item) => { const v = objectOrNull(item) || {}; return { columnLabel: v.columnLabel ?? null, promptIndex: v.promptIndex ?? null, fromSeq: v.fromSeq ?? null, toSeq: v.toSeq ?? null, fromValue: v.fromValue ?? null, toValue: v.toValue ?? null, delta: v.delta ?? null, sampleDeltaSeconds: v.sampleDeltaSeconds ?? null, allowedIncreaseSeconds: v.allowedIncreaseSeconds ?? null, traceId: v.traceId ?? null }; };",
|
||||
"const slimLoadingOwner = (item) => { const v = objectOrNull(item) || {}; return { ownerKey: clip(v.ownerKey, 120), ownerKind: clip(v.ownerKind, 32), ownerLabel: clip(v.ownerLabel, 120), sampleCount: v.sampleCount ?? null, occurrenceCount: v.occurrenceCount ?? null, maxSimultaneousCount: v.maxSimultaneousCount ?? null, longestContinuousSeconds: v.longestContinuousSeconds ?? null, firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, promptIndexes: Array.isArray(v.promptIndexes) ? v.promptIndexes.slice(0, 8) : [] }; };",
|
||||
"const slimLoadingSegment = (item) => { const v = objectOrNull(item) || {}; return { firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, endedAt: v.endedAt ?? null, firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, durationSeconds: v.durationSeconds ?? null, upperBoundSeconds: v.upperBoundSeconds ?? null, endedGapSeconds: v.endedGapSeconds ?? null, sampleCount: v.sampleCount ?? null, maxCount: v.maxCount ?? null, ownerCount: v.ownerCount ?? null, ongoing: v.ongoing === true, owners: Array.isArray(v.owners) ? v.owners.slice(0, 6).map((owner) => ({ ownerKind: clip(owner?.ownerKind, 32), ownerLabel: clip(owner?.ownerLabel, 120), count: owner?.count ?? null })) : [] }; };",
|
||||
@@ -7858,6 +8002,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
"const combinedFindings = sortFindings(firstArray(source?.findings, fullRecentWindow?.findings, fullSource?.findings));",
|
||||
"const srcPromptNetwork = objectOrNull(source?.promptNetwork);",
|
||||
"const promptNetwork = srcPromptNetwork ? { promptSegments: srcPromptNetwork.promptSegments ?? null } : null;",
|
||||
"const runnerErrorsFromJsonl = readJsonlTail(reportJsonPath.replace(/\\/analysis\\/report\\.json$/u, '/errors.jsonl'), 8).filter((item) => item?.type === 'runner-error').map(slimRunnerErrorFromJsonl);",
|
||||
"const compact = source ? {",
|
||||
" ok: analyzerExit === 0,",
|
||||
" counts: source.counts ?? null,",
|
||||
@@ -7875,7 +8020,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
" domDiagnosticSamples: takeHead(firstArray(source.domDiagnosticSamples, fullRecentWindow?.runtimeAlerts?.domDiagnostics, fullSource?.domDiagnosticSamples, fullSource?.runtimeAlerts?.domDiagnostics), 5).map(slimDomSample),",
|
||||
" consoleAlertGroups: takeHead(firstArray(source.consoleAlertGroups, fullRecentWindow?.runtimeAlerts?.consoleAlertsByPath, fullSource?.consoleAlertGroups, fullSource?.runtimeAlerts?.consoleAlertsByPath), 5).map(slimConsoleGroup),",
|
||||
" consoleAlertSamples: takeHead(firstArray(source.consoleAlertSamples, fullRecentWindow?.runtimeAlerts?.consoleAlerts, fullSource?.consoleAlertSamples, fullSource?.runtimeAlerts?.consoleAlerts), 5).map(slimConsoleSample),",
|
||||
" runnerErrors: takeTail(firstArray(source.runnerErrors, fullSource?.runnerErrors), 8).map(slimRunnerError),",
|
||||
" runnerErrors: takeTail(firstNonEmptyArray(source.runnerErrors, fullSource?.runnerErrors, runnerErrorsFromJsonl), 8).map(slimRunnerError),",
|
||||
" turnTimingRecentUpdateJumps: takeHead(firstArray(source.turnTimingRecentUpdateJumps, srcMetrics?.turnTimingRecentUpdateJumps, srcMetrics?.turnTimingRecentUpdateSawtoothJumps, fullRecentWindow?.sampleMetrics?.turnTimingRecentUpdateSawtoothJumps, fullRecentWindow?.turnTimingRecentUpdateJumps, fullSource?.sampleMetrics?.turnTimingRecentUpdateSawtoothJumps, fullSource?.turnTimingRecentUpdateJumps), 5).map(slimJump),",
|
||||
" turnTimingElapsedZeroResets: takeHead(firstArray(source.turnTimingElapsedZeroResets, srcMetrics?.turnTimingElapsedZeroResets, fullRecentWindow?.sampleMetrics?.turnTimingElapsedZeroResets, fullRecentWindow?.turnTimingElapsedZeroResets, fullSource?.sampleMetrics?.turnTimingElapsedZeroResets, fullSource?.turnTimingElapsedZeroResets), 5).map(slimJump),",
|
||||
" turnTimingTotalElapsedForwardJumps: takeHead(firstArray(source.turnTimingTotalElapsedForwardJumps, srcMetrics?.turnTimingTotalElapsedForwardJumps, fullRecentWindow?.sampleMetrics?.turnTimingTotalElapsedForwardJumps, fullRecentWindow?.turnTimingTotalElapsedForwardJumps, fullSource?.sampleMetrics?.turnTimingTotalElapsedForwardJumps, fullSource?.turnTimingTotalElapsedForwardJumps), 5).map(slimJump),",
|
||||
@@ -8301,13 +8446,16 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
|
||||
]]),
|
||||
"",
|
||||
"Runner errors:",
|
||||
webObserveTable(["TS", "TYPE", "RETRY", "EXHAUSTED", "MESSAGE"], runnerErrors.length > 0 ? runnerErrors.map((item) => [
|
||||
webObserveTable(["TS", "TYPE", "RETRY", "EXH", "ATTEMPTS", "LAST_KIND", "READY", "MESSAGE"], runnerErrors.length > 0 ? runnerErrors.map((item) => [
|
||||
webObserveShort(webObserveText(item.ts), 24),
|
||||
webObserveShort(webObserveText(item.type), 18),
|
||||
webObserveText(item.retry),
|
||||
webObserveText(item.retryExhausted),
|
||||
webObserveText(item.attemptCount),
|
||||
webObserveShort(webObserveText(item.lastFailureKind), 24),
|
||||
webObserveShort(webObserveText(item.lastReadinessReason), 24),
|
||||
webObserveShort(webObserveText(item.message || item.lastError), 96),
|
||||
]) : [["-", "-", "-", "-", "-"]]),
|
||||
]) : [["-", "-", "-", "-", "-", "-", "-", "-"]]),
|
||||
"",
|
||||
"HTTP error groups:",
|
||||
webObserveTable(["COUNT", "METHOD", "STATUS", "PATH", "LAST", "FAILURE"], httpErrorRows.length > 0 ? httpErrorRows : [["-", "-", "-", "-", "-", "-"]]),
|
||||
@@ -9060,16 +9208,31 @@ console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir:
|
||||
`)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)}`;
|
||||
}
|
||||
|
||||
function nodeWebObserveCollectNodeScript(maxFiles: number): string {
|
||||
function nodeWebObserveCollectNodeScript(maxFiles: number, collectFile: string | null): string {
|
||||
return `node -e ${shellQuote(`
|
||||
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
||||
const dir=process.argv[1]; const maxFiles=${maxFiles};
|
||||
const dir=process.argv[1]; const selected=process.argv[2]||''; const maxFiles=${maxFiles}; const maxReadBytes=64*1024;
|
||||
const shaFile=(file)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const safeRel=(value)=>Boolean(value)&&!path.isAbsolute(value)&&!value.includes('..')&&!value.includes('\\\\')&&value.split('/').every((part)=>part&&part!=='.'&&part!=='..');
|
||||
if(selected){
|
||||
if(!safeRel(selected)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'unsafe-file',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
|
||||
const file=path.join(dir,selected); const relative=path.relative(dir,file);
|
||||
if(relative.startsWith('..')||path.isAbsolute(relative)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-outside-state-dir',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
|
||||
if(!fs.existsSync(file)||!fs.statSync(file).isFile()){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-not-found',file:selected,valuesRedacted:true},null,2)); process.exit(1);}
|
||||
const st=fs.statSync(file); const raw=fs.readFileSync(file); const text=raw.slice(0,Math.min(raw.length,maxReadBytes)).toString('utf8'); const truncated=raw.length>maxReadBytes;
|
||||
const lines=text.split(/\\r?\\n/).filter(Boolean); const isJsonl=selected.endsWith('.jsonl');
|
||||
let jsonSummary=null;
|
||||
if(selected.endsWith('.json')&&!truncated){try{const parsed=JSON.parse(text); jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,40):[],counts:parsed&&parsed.counts||null,runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-8):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,8):[]}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}}
|
||||
const jsonlTail=isJsonl?lines.slice(-20).map((line,index)=>{try{return JSON.parse(line)}catch(error){return {parseError:true,index,lineTail:line.slice(-500),error:String(error&&error.message||error).slice(0,160)}}}):null;
|
||||
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||jsonSummary?undefined:text,jsonlTail,jsonSummary,lineCount:lines.length},valuesRedacted:true},null,2));
|
||||
process.exit(0);
|
||||
}
|
||||
const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}};
|
||||
walk(dir);
|
||||
const files=out.slice(0,maxFiles).map(file=>{const st=fs.statSync(file); const hash=crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); return {path:file,relative:path.relative(dir,file),byteCount:st.size,sha256:'sha256:'+hash}});
|
||||
const files=out.slice(0,maxFiles).map(file=>{const st=fs.statSync(file); return {path:file,relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}});
|
||||
const totalBytes=files.reduce((sum,item)=>sum+item.byteCount,0);
|
||||
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:files.length,totalBytes,files,valuesRedacted:true},null,2));
|
||||
`)} "$state_dir"`;
|
||||
`)} "$state_dir" ${shellQuote(collectFile ?? "")}`;
|
||||
}
|
||||
|
||||
function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number): string {
|
||||
@@ -9113,6 +9276,17 @@ function isSafeWebObserveStateDir(value: string): boolean {
|
||||
&& /^\.state\/web-observe\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\d{4}\/\d{2}\/\d{2}\/[A-Za-z0-9_.TZ-]+_[A-Za-z0-9_.-]+_webobs-[A-Za-z0-9_.-]+$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveCollectFile(value: string): boolean {
|
||||
return value.length > 0
|
||||
&& value.length <= 240
|
||||
&& !value.includes("\0")
|
||||
&& !value.includes("..")
|
||||
&& !value.startsWith("/")
|
||||
&& !value.includes("\\")
|
||||
&& value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")
|
||||
&& /^[A-Za-z0-9_.\/-]+$/u.test(value);
|
||||
}
|
||||
|
||||
function isSafeWebObserveJobId(value: string): boolean {
|
||||
return /^webobs-[A-Za-z0-9_.-]+$/u.test(value);
|
||||
}
|
||||
|
||||
@@ -326,7 +326,19 @@ async function runControlCommand(command, fn) {
|
||||
await appendJsonl(files.control, controlRecord(command, "completed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, result: sanitize(result) }));
|
||||
return result;
|
||||
} catch (error) {
|
||||
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error) }));
|
||||
let failurePageProvenance = null;
|
||||
let failureScreenshot = null;
|
||||
try {
|
||||
failurePageProvenance = compactPageProvenance(await refreshPageProvenance("command-failed", null));
|
||||
} catch (captureError) {
|
||||
await appendJsonl(files.errors, eventRecord("failure-provenance-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||||
}
|
||||
try {
|
||||
failureScreenshot = await captureScreenshot("command-failed", "jpeg");
|
||||
} catch (captureError) {
|
||||
await appendJsonl(files.errors, eventRecord("failure-screenshot-error", { commandId: command.id, pageRole: "control", pageId, error: errorSummary(captureError) }));
|
||||
}
|
||||
await appendJsonl(files.control, controlRecord(command, "failed", { beforeUrl, afterUrl: currentPageUrl(), durationMs: Date.now() - started, error: errorSummary(error), failurePageProvenance, failureScreenshot }));
|
||||
throw error;
|
||||
} finally {
|
||||
activeCommandId = null;
|
||||
@@ -552,12 +564,12 @@ async function gotoTarget(rawTarget) {
|
||||
const target = new URL(String(rawTarget || targetPath), baseUrl).toString();
|
||||
const beforeUrl = currentPageUrl();
|
||||
const attempts = [];
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
try {
|
||||
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||
await page.waitForTimeout(1000).catch(() => {});
|
||||
const httpStatus = response ? response.status() : null;
|
||||
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 15000 });
|
||||
const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 45000 });
|
||||
if (!readiness.ok) {
|
||||
const error = new Error("workbench-app-not-ready: " + (readiness.reason || "target page did not expose the Workbench shell"));
|
||||
error.navigationReadiness = readiness;
|
||||
@@ -569,15 +581,51 @@ async function gotoTarget(rawTarget) {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null });
|
||||
if (attempt >= 3 || !isRetryableNavigationError(message)) {
|
||||
if (/workbench-app-not-ready/iu.test(message)) {
|
||||
const lateReadiness = await waitForTargetPageReady(page, target, { timeoutMs: 5000 }).catch(() => null);
|
||||
if (lateReadiness?.ok) {
|
||||
const pageProvenance = await refreshPageProvenance("goto-late-ready", null);
|
||||
attempts.push({ attempt, ok: true, lateReady: true, httpStatus: null, readiness: lateReadiness });
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness: lateReadiness, attempts };
|
||||
}
|
||||
}
|
||||
if (attempt >= 2 || !isRetryableNavigationError(message)) {
|
||||
throw Object.assign(new Error(message), { attempts, target });
|
||||
}
|
||||
await page.waitForTimeout(500 * attempt).catch(() => {});
|
||||
if (!observerPage) {
|
||||
await recreateAuthenticatedContextForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-context-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||||
} else {
|
||||
await recreateControlPageForNavigation("retryable-navigation-" + navigationFailureKind(message), attempt).catch((resetError) => appendJsonl(files.errors, eventRecord("navigation-page-reset-error", { commandId: activeCommandId, attempt, error: errorSummary(resetError) })));
|
||||
}
|
||||
await page.waitForTimeout(1500 * attempt).catch(() => {});
|
||||
}
|
||||
}
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), httpStatus: null, pageId, attempts };
|
||||
}
|
||||
|
||||
async function recreateControlPageForNavigation(reason, attempt) {
|
||||
const before = currentPageUrl();
|
||||
if (page && !page.isClosed()) await page.close().catch(() => {});
|
||||
page = await context.newPage();
|
||||
attachPassiveListeners(page, "control", pageId);
|
||||
currentPageProvenance = null;
|
||||
await appendJsonl(files.control, eventRecord("page-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, valuesRedacted: true }));
|
||||
}
|
||||
|
||||
async function recreateAuthenticatedContextForNavigation(reason, attempt) {
|
||||
const before = currentPageUrl();
|
||||
if (page && !page.isClosed()) await page.close().catch(() => {});
|
||||
if (observerPage && !observerPage.isClosed()) await observerPage.close().catch(() => {});
|
||||
observerPage = null;
|
||||
if (context) await context.close().catch(() => {});
|
||||
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
||||
auth = await authenticate(context);
|
||||
page = await context.newPage();
|
||||
attachPassiveListeners(page, "control", pageId);
|
||||
currentPageProvenance = null;
|
||||
await appendJsonl(files.control, eventRecord("context-recreated", { reason, attempt, beforeUrl: before, afterUrl: currentPageUrl(), pageRole: "control", pageId, auth: publicAuth(auth), valuesRedacted: true }));
|
||||
}
|
||||
|
||||
async function refreshPageProvenance(reason, httpStatus = null) {
|
||||
if (!page || page.isClosed()) return currentPageProvenance;
|
||||
const observed = await page.evaluate(() => {
|
||||
@@ -699,14 +747,11 @@ async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
const workspace = document.querySelector("#workspace, .workbench-route");
|
||||
const sessionCreate = document.querySelector("#session-create");
|
||||
const commandInput = document.querySelector("#command-input");
|
||||
const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']");
|
||||
const warning = document.querySelector(".composer-warning");
|
||||
return Boolean(visible(workspace) && (visible(sessionCreate) || visible(commandInput) || visible(activeTab) || visible(warning)));
|
||||
const login = document.querySelector("form.login-card, .login-card, [data-testid='login']");
|
||||
return Boolean(visible(workspace) || visible(login));
|
||||
}, null, { timeout: timeoutMs }).catch(() => null);
|
||||
const snapshot = await workbenchReadinessSnapshot(targetPage);
|
||||
const ok = snapshot.workbenchShellVisible === true && (snapshot.sessionCreateVisible === true || snapshot.commandInputPresent === true || snapshot.activeTabPresent === true || snapshot.warningPresent === true);
|
||||
const ok = snapshot.workbenchShellVisible === true;
|
||||
return {
|
||||
ok,
|
||||
reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready",
|
||||
@@ -717,7 +762,7 @@ async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
|
||||
}
|
||||
|
||||
async function workbenchReadinessSnapshot(targetPage) {
|
||||
return targetPage.evaluate(() => {
|
||||
const snapshot = await targetPage.evaluate(() => {
|
||||
const visible = (element) => {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -734,10 +779,15 @@ async function workbenchReadinessSnapshot(targetPage) {
|
||||
activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")),
|
||||
warningPresent: visible(document.querySelector(".composer-warning")),
|
||||
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
|
||||
bodyTextHash: sha256Text(String(document.body?.innerText || "").slice(0, 2000)),
|
||||
bodyTextPreview: String(document.body?.innerText || "").slice(0, 2000),
|
||||
valuesRedacted: true
|
||||
};
|
||||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||||
if (snapshot && typeof snapshot.bodyTextPreview === "string") {
|
||||
snapshot.bodyTextHash = sha256Text(snapshot.bodyTextPreview);
|
||||
delete snapshot.bodyTextPreview;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function isWorkbenchPathname(value) {
|
||||
@@ -1591,6 +1641,10 @@ function sanitize(value) {
|
||||
function errorSummary(error) {
|
||||
const summary = { name: error && error.name ? error.name : "Error", message: error && error.message ? truncate(error.message, 1000) : truncate(String(error), 1000), stackTail: error && error.stack ? truncate(error.stack, 2000) : null };
|
||||
if (error && error.webProbeAuth) summary.auth = sanitize(error.webProbeAuth);
|
||||
if (error && Array.isArray(error.attempts)) summary.attempts = sanitize(error.attempts.slice(-5));
|
||||
if (error && error.navigationReadiness) summary.navigationReadiness = sanitize(error.navigationReadiness);
|
||||
if (error && error.details) summary.details = sanitize(error.details);
|
||||
if (error && error.target) summary.target = sanitize(error.target);
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -1629,16 +1683,46 @@ const pageProvenance = buildPageProvenanceReport(samples, control, manifest);
|
||||
const pagePerformance = buildPagePerformanceReport(samples, manifest);
|
||||
const promptNetwork = buildPromptNetworkReport(control, network);
|
||||
const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors);
|
||||
const runnerErrors = errors.slice(-8).map((item) => ({
|
||||
ts: item.ts ?? null,
|
||||
type: item.type ?? null,
|
||||
commandId: item.commandId ?? null,
|
||||
sampleSeq: item.sampleSeq ?? null,
|
||||
message: limitText(item.error?.message ?? item.message ?? "", 240),
|
||||
retry: item.error?.auth?.lastRetryLabel ?? null,
|
||||
retryExhausted: item.error?.auth?.retryExhausted === true,
|
||||
lastError: limitText(item.error?.auth?.lastError ?? "", 160),
|
||||
}));
|
||||
const runnerErrors = errors.slice(-8).map((item) => {
|
||||
const attempts = Array.isArray(item.error?.attempts) ? item.error.attempts : [];
|
||||
const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
|
||||
const readiness = lastAttempt?.readiness || item.error?.navigationReadiness || null;
|
||||
const readinessSnapshot = readiness?.snapshot || readiness;
|
||||
return {
|
||||
ts: item.ts ?? null,
|
||||
type: item.type ?? null,
|
||||
commandId: item.commandId ?? null,
|
||||
sampleSeq: item.sampleSeq ?? null,
|
||||
message: limitText(item.error?.message ?? item.message ?? "", 240),
|
||||
retry: item.error?.auth?.lastRetryLabel ?? null,
|
||||
retryExhausted: item.error?.auth?.retryExhausted === true,
|
||||
lastError: limitText(item.error?.auth?.lastError ?? "", 160),
|
||||
attemptCount: attempts.length,
|
||||
lastFailureKind: lastAttempt?.failureKind ?? null,
|
||||
lastReadinessReason: readiness?.reason ?? null,
|
||||
lastReadiness: readinessSnapshot ? {
|
||||
reason: readiness?.reason ?? readinessSnapshot.reason ?? null,
|
||||
path: readinessSnapshot.path ?? null,
|
||||
readyState: readinessSnapshot.readyState ?? null,
|
||||
workbenchShellVisible: readinessSnapshot.workbenchShellVisible === true,
|
||||
sessionCreateVisible: readinessSnapshot.sessionCreateVisible === true,
|
||||
commandInputPresent: readinessSnapshot.commandInputPresent === true,
|
||||
activeTabPresent: readinessSnapshot.activeTabPresent === true,
|
||||
warningPresent: readinessSnapshot.warningPresent === true,
|
||||
loginVisible: readinessSnapshot.loginVisible === true,
|
||||
bodyTextHash: readinessSnapshot.bodyTextHash ?? null,
|
||||
valuesRedacted: true
|
||||
} : null,
|
||||
navigationAttempts: attempts.slice(-5).map((attempt) => ({
|
||||
attempt: attempt?.attempt ?? null,
|
||||
ok: attempt?.ok === true,
|
||||
failureKind: attempt?.failureKind ?? null,
|
||||
message: limitText(attempt?.message ?? "", 160),
|
||||
readinessReason: attempt?.readiness?.reason ?? null,
|
||||
valuesRedacted: true
|
||||
})),
|
||||
};
|
||||
});
|
||||
const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance);
|
||||
if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) });
|
||||
const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest });
|
||||
|
||||
Reference in New Issue
Block a user