From 2d1f96650e19648c226bf5f9c309345180d33eef Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 10:44:28 +0000 Subject: [PATCH] fix: add opencode smoke observability probes --- scripts/src/hwlab-node-help.ts | 3 + scripts/src/hwlab-node/entry.ts | 6 +- scripts/src/hwlab-node/web-observe-scripts.ts | 6 +- scripts/src/hwlab-node/web-probe-observe.ts | 4 +- scripts/src/hwlab-node/web-probe.ts | 240 +++++++++++++++++- .../diagnose-code-agent-script.ts | 70 ++++- .../platform-infra-observability/options.ts | 3 + .../platform-infra-observability/render.ts | 65 ++++- 8 files changed, 389 insertions(+), 8 deletions(-) diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index dc73be8a..66dd6aea 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -45,6 +45,7 @@ export function hwlabNodeWebProbeHelp(): Record { examples: [ "bun scripts/cli.ts web-probe run --node D601 --lane v03 --wait-messages-ms 1000", "bun scripts/cli.ts web-probe run --node D601 --lane v03 --fresh-session --message 'ping'", + "bun scripts/cli.ts web-probe opencode-smoke --node D601 --lane v03 --message 'hi'", "bun scripts/cli.ts web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 1440x900", "bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 390x844 --name monitor-mobile.png", @@ -72,6 +73,7 @@ export function hwlabNodeWebProbeHelp(): Record { ], actions: { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", + "opencode-smoke": "Run the repo-owned OpenCode iframe/direct-host composer smoke and require DOM assistant text plus EventSource update/finish/idle evidence.", script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.", screenshot: "Capture a no-auth or public page through the selected node/lane remote browser and download PNG artifacts to the caller /tmp by default.", observe: "Start, inspect, control, stop, collect, and analyze a long-running observer that writes JSONL artifacts.", @@ -80,6 +82,7 @@ export function hwlabNodeWebProbeHelp(): Record { notes: [ "Default URL, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.", "`web-probe script` is an ad-hoc exploration escape hatch; repeated/high-frequency workflows must become `web-probe observe command` types or repo-owned web-probe commands.", + "`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.", "observe is passive by default; user actions must be explicit observe command entries in control.jsonl.", "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 9d62ec69..1074fe66 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -86,8 +86,12 @@ export interface NodeWebProbeScriptOptions { browserProxyMode: WebProbeBrowserProxyMode; commandTimeoutSeconds: number; scriptText: string; + commandLabel?: string; + suppressAdHocWarning?: boolean; + generatedHints?: string[]; + generatedPreferredCommands?: Record; scriptSource: { - kind: "stdin" | "file"; + kind: "stdin" | "file" | "generated"; path: string | null; byteCount: number; sha256: string; diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index ae1383a8..6977eb0e 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -469,6 +469,7 @@ export function runNodeWebProbeScript( material: BootstrapAdminPasswordMaterial, credential: Record, ): Record { + const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`; const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); @@ -545,7 +546,7 @@ export function runNodeWebProbeScript( return renderWebProbeScriptResult({ ok: passed, status: passed ? "pass" : "blocked", - command: `web-probe script --node ${options.node} --lane ${options.lane}`, + command: commandLabel, node: options.node, lane: options.lane, workspace: spec.workspace, @@ -579,6 +580,7 @@ export function runNodeWebProbeScript( } function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record[] { + if (options.suppressAdHocWarning === true) return []; return [{ code: "web_probe_script_ad_hoc_only", severity: "warning", @@ -590,6 +592,7 @@ function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): R } function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] { + if (options.generatedHints !== undefined) return options.generatedHints; return [ "Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows; use `observe collect/analyze` for repeated evidence reads.", "If the same script is needed more than once, add or extend a reusable command type in the web-probe observe command surface.", @@ -598,6 +601,7 @@ function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): stri } function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record { + if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands; return { startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`, mdtodoSummary: "bun scripts/cli.ts web-probe observe collect --view project-mdtodo-summary", diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 27d03573..b9e79b4d 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -564,7 +564,9 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record 200) throw new Error("web-probe opencode-smoke --path must be an absolute path up to 200 chars"); + const message = optionValue(args, "--message") ?? "hi"; + if (message.includes("\0") || message.length > 500) throw new Error("web-probe opencode-smoke --message must be 0-500 non-NUL chars"); + const expectText = optionValue(args, "--expect-text") ?? null; + if (expectText !== null && (expectText.includes("\0") || expectText.length > 500)) throw new Error("web-probe opencode-smoke --expect-text must be 0-500 non-NUL chars"); + const scriptText = nodeWebProbeOpencodeSmokeScript({ + path, + message, + expectText, + responseTimeoutMs, + }); + const commandLabel = `web-probe opencode-smoke --node ${node} --lane ${lane}`; + return { + action: "script", + node, + lane, + url: optionValue(args, "--url") ?? nodeWebProbeDefaultUrl(spec), + timeoutMs, + viewport: optionValue(args, "--viewport") ?? "1440x900", + browserProxyMode: parseWebProbeBrowserProxyMode(optionValue(args, "--browser-proxy-mode") ?? spec.webProbe?.browserProxyMode), + commandTimeoutSeconds, + scriptText, + commandLabel, + suppressAdHocWarning: true, + generatedHints: [ + "OpenCode smoke is a repo-owned typed web-probe command; it uses the same managed auth, remote browser, report recovery and artifact contract as web-probe script.", + "Passing means the browser DOM reached assistant text and the OpenCode EventSource produced update, finish and idle evidence.", + ], + generatedPreferredCommands: { + rerun: commandLabel, + withExpectedText: `${commandLabel} --expect-text `, + }, + scriptSource: { + kind: "generated", + path: "builtin:opencode-smoke", + byteCount: Buffer.byteLength(scriptText), + sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`, + }, + }; + } assertKnownOptions(args.slice(1), new Set([ "--node", "--lane", @@ -2034,6 +2093,185 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { }; } +function nodeWebProbeOpencodeSmokeScript(input: { path: string; message: string; expectText: string | null; responseTimeoutMs: number }): string { + const config = { + path: input.path, + message: input.message, + expectText: input.expectText, + responseTimeoutMs: input.responseTimeoutMs, + }; + return patchOpenCodeSmokeScript(`const config = ${JSON.stringify(config)};\n\nexport default async function opencodeSmoke({ page, baseUrl, recordStep, wait, screenshot, jsonArtifact }) {\n await page.addInitScript(() => {\n const key = \"__unideskOpenCodeEvents\";\n window[key] = Array.isArray(window[key]) ? window[key] : [];\n if (window.__unideskOpenCodeEventSourceWrapped) return;\n window.__unideskOpenCodeEventSourceWrapped = true;\n const OriginalEventSource = window.EventSource;\n if (!OriginalEventSource) return;\n const push = (source, type, event) => {\n let parsedType = null;\n let parsedStatus = null;\n let dataBytes = 0;\n const rawData = typeof event?.data === \"string\" ? event.data : \"\";\n dataBytes = rawData.length;\n if (rawData) {\n try {\n const parsed = JSON.parse(rawData);\n parsedType = typeof parsed?.type === \"string\" ? parsed.type : typeof parsed?.event === \"string\" ? parsed.event : typeof parsed?.name === \"string\" ? parsed.name : null;\n parsedStatus = typeof parsed?.status === \"string\" ? parsed.status : typeof parsed?.part?.status === \"string\" ? parsed.part.status : null;\n } catch {\n parsedType = null;\n }\n }\n const label = parsedType || (parsedStatus ? type + \":\" + parsedStatus : type);\n window[key].push({ at: Date.now(), source, type, parsedType, status: parsedStatus, label, dataBytes });\n if (window[key].length > 240) window[key].splice(0, window[key].length - 240);\n };\n const originalAddEventListener = OriginalEventSource.prototype.addEventListener;\n OriginalEventSource.prototype.addEventListener = function wrappedAddEventListener(type, listener, options) {\n if (typeof listener !== \"function\") return originalAddEventListener.call(this, type, listener, options);\n const wrapped = function wrappedOpenCodeEvent(event) {\n push(String(this?.url || \"eventsource\"), String(type || \"message\"), event);\n return listener.call(this, event);\n };\n return originalAddEventListener.call(this, type, wrapped, options);\n };\n function WrappedEventSource(url, options) {\n const instance = new OriginalEventSource(url, options);\n originalAddEventListener.call(instance, \"message\", (event) => push(String(url || \"eventsource\"), \"message\", event));\n return instance;\n }\n WrappedEventSource.prototype = OriginalEventSource.prototype;\n WrappedEventSource.CONNECTING = OriginalEventSource.CONNECTING;\n WrappedEventSource.OPEN = OriginalEventSource.OPEN;\n WrappedEventSource.CLOSED = OriginalEventSource.CLOSED;\n window.EventSource = WrappedEventSource;\n });\n\n const targetUrl = new URL(config.path, baseUrl).toString();\n await page.goto(targetUrl, { waitUntil: \"domcontentloaded\", timeout: Math.min(Math.max(config.responseTimeoutMs, 1000), 120000) });\n recordStep(\"opencode-navigate\", { ok: true, targetPath: config.path, finalUrl: page.url() });\n\n const frame = await findOpenCodeFrame(page, config.responseTimeoutMs);\n const before = await collectDomState(frame, [], config);\n const beforeLines = before.lines;\n recordStep(\"opencode-frame\", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit });\n\n const prompt = await fillOpenCodePrompt(frame, config.message);\n recordStep(\"opencode-prompt-filled\", { ok: true, selector: prompt.selector, textBytes: config.message.length });\n\n const submit = frame.locator(\"[data-action='prompt-submit']\").first();\n await submit.waitFor({ state: \"visible\", timeout: Math.min(config.responseTimeoutMs, 10000) });\n await frame.waitForFunction(() => {\n const button = document.querySelector(\"[data-action='prompt-submit']\");\n return Boolean(button && !button.disabled && button.getAttribute(\"aria-disabled\") !== \"true\");\n }, null, { timeout: 5000 }).catch(() => null);\n await submit.click({ timeout: 10000 });\n recordStep(\"opencode-submit\", { ok: true, selector: \"[data-action='prompt-submit']\" });\n\n const final = await waitForOpenCodeFinal(page, frame, beforeLines, config);\n await jsonArtifact(\"opencode-smoke-events.json\", final.events);\n const finalScreenshot = await screenshot(\"opencode-smoke-final.png\").catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));\n const ok = final.conditions.assistantText && final.conditions.notThinking && final.conditions.updateEvent && final.conditions.finishEvent && final.conditions.idleEvent && final.conditions.expectedText;\n recordStep(\"opencode-final\", { ok, conditions: final.conditions, assistantTextPreview: final.assistantTextPreview, eventTypes: final.events.eventTypes });\n return {\n ok,\n status: ok ? \"pass\" : \"blocked\",\n failedCondition: ok ? null : failedCondition(final.conditions),\n frameUrl: final.url,\n finalUrl: page.url(),\n assistantTextPreview: final.assistantTextPreview,\n bodyPreview: final.bodyPreview,\n eventTypes: final.events.eventTypes,\n eventCount: final.events.eventCount,\n conditions: final.conditions,\n screenshot: finalScreenshot,\n issueEvidence: {\n frameUrl: final.url,\n assistantTextPreview: final.assistantTextPreview,\n eventTypes: final.events.eventTypes,\n conditions: final.conditions,\n screenshotSha256: typeof finalScreenshot?.sha256 === \"string\" ? finalScreenshot.sha256 : null,\n valuesRedacted: true,\n },\n valuesRedacted: true,\n };\n}\n\nasync function findOpenCodeFrame(page, timeoutMs) {\n const deadline = Date.now() + timeoutMs;\n let best = null;\n while (Date.now() < deadline) {\n for (const frame of page.frames()) {\n const summary = await frameSummary(frame);\n const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0);\n if (best === null || score > best.score) best = { frame, summary, score };\n if (summary.hasSubmit && summary.textboxCount > 0) return frame;\n }\n await waitFor(500);\n }\n throw new Error(\"OpenCode frame with prompt submit button was not found; best=\" + JSON.stringify(best?.summary || null));\n}\n\nasync function frameSummary(frame) {\n return await frame.evaluate(() => ({\n url: location.href,\n title: document.title,\n hasSubmit: Boolean(document.querySelector(\"[data-action='prompt-submit']\")),\n textboxCount: document.querySelectorAll(\"[contenteditable='true'], textarea, [role='textbox'], input:not([type]), input[type='text']\").length,\n bodyPreview: (document.body?.innerText || \"\").replace(/\\s+/gu, \" \").trim().slice(0, 240),\n })).catch((error) => ({ url: frame.url(), title: \"\", hasSubmit: false, textboxCount: 0, bodyPreview: String(error).slice(0, 160) }));\n}\n\nasync function fillOpenCodePrompt(frame, message) {\n const selectors = [\n \"[contenteditable='true']\",\n \"textarea\",\n \"[role='textbox']\",\n \"input:not([type])\",\n \"input[type='text']\",\n ];\n let lastError = null;\n for (const selector of selectors) {\n const locator = frame.locator(selector).last();\n const count = await locator.count().catch(() => 0);\n if (count < 1) continue;\n try {\n await locator.click({ timeout: 5000 });\n await locator.fill(message, { timeout: 5000 });\n return { selector };\n } catch (error) {\n lastError = error;\n try {\n await locator.evaluate((element, text) => {\n if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {\n element.value = text;\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true, data: text, inputType: \"insertText\" }));\n element.dispatchEvent(new Event(\"change\", { bubbles: true }));\n return;\n }\n element.textContent = text;\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true, data: text, inputType: \"insertText\" }));\n }, message);\n return { selector, fallback: \"dom-input-event\" };\n } catch (fallbackError) {\n lastError = fallbackError;\n }\n }\n }\n throw new Error(\"OpenCode prompt input was not fillable: \" + (lastError instanceof Error ? lastError.message : String(lastError || \"not found\")));\n}\n\nasync function waitForOpenCodeFinal(page, frame, beforeLines, config) {\n const deadline = Date.now() + config.responseTimeoutMs;\n let latest = await collectState(page, frame, beforeLines, config);\n while (Date.now() < deadline) {\n latest = await collectState(page, frame, beforeLines, config);\n if (latest.conditions.assistantText && latest.conditions.notThinking && latest.conditions.updateEvent && latest.conditions.finishEvent && latest.conditions.idleEvent && latest.conditions.expectedText) return latest;\n await waitFor(750);\n }\n return latest;\n}\n\nasync function collectState(page, frame, beforeLines, config) {\n const dom = await collectDomState(frame, beforeLines, config);\n const events = await collectEventSummary(page);\n const labels = events.eventTypes.join(\"\\n\");\n const conditions = {\n assistantText: Boolean(dom.assistantTextPreview),\n notThinking: dom.thinkingVisible !== true,\n updateEvent: /message[._-]?part[._-]?updated|part[._-]?updated|delta|updated/iu.test(labels),\n finishEvent: /step[._-]?finish|finish|completed|done/iu.test(labels),\n idleEvent: /session[._-]?idle|session[._-]?status[:/._-]?idle|\\bidle\\b/iu.test(labels),\n expectedText: config.expectText === null || dom.expectedTextPresent === true,\n };\n return { ...dom, events, conditions };\n}\n\nasync function collectDomState(frame, beforeLines, config) {\n return await frame.evaluate(({ beforeLines, message, expectText }) => {\n const normalize = (value) => String(value || \"\").replace(/\\r/g, \"\").replace(/[ \\t]+/g, \" \").trim();\n const bodyText = normalize(document.body?.innerText || \"\");\n const lines = bodyText.split(\"\\n\").map((line) => normalize(line)).filter(Boolean).slice(-400);\n const beforeSet = new Set(Array.isArray(beforeLines) ? beforeLines : []);\n const prompt = normalize(message).toLowerCase();\n const blocked = /^(send|stop|cancel|thinking|loading|new session|settings)$/iu;\n const selectorTexts = Array.from(document.querySelectorAll(\"[data-role='assistant'], [data-message-role='assistant'], [data-testid*='assistant' i], [aria-label*='assistant' i], [class*='assistant' i], main article, main [role='article'], main [data-message-id]\")).map((element) => normalize(element.textContent || \"\")).filter((text) => text.length > 0 && text.toLowerCase() !== prompt && !blocked.test(text)).slice(-20);\n const newLines = lines.filter((line) => !beforeSet.has(line) && line.toLowerCase() !== prompt && !blocked.test(line) && !/^you\\b/iu.test(line)).slice(-40);\n const assistantText = selectorTexts.find((text) => text.length > 0) || newLines.find((line) => line.length > 0) || null;\n return {\n url: location.href,\n title: document.title,\n hasSubmit: Boolean(document.querySelector(\"[data-action='prompt-submit']\")),\n lines,\n lineCount: lines.length,\n newLineCount: newLines.length,\n assistantCandidateCount: selectorTexts.length,\n assistantTextPreview: assistantText === null ? null : assistantText.slice(0, 500),\n thinkingVisible: /(^|\\n|\\b)Thinking(\\b|\\n)/u.test(bodyText),\n expectedTextPresent: expectText === null ? true : bodyText.includes(expectText),\n bodyPreview: bodyText.slice(-1200),\n };\n }, { beforeLines, message: config.message, expectText: config.expectText });\n}\n\nasync function collectEventSummary(page) {\n const frameEvents = [];\n for (const frame of page.frames()) {\n const events = await frame.evaluate(() => Array.isArray(window.__unideskOpenCodeEvents) ? window.__unideskOpenCodeEvents.slice(-120) : []).catch(() => []);\n if (events.length > 0) frameEvents.push({ frameUrl: frame.url(), events });\n }\n const labels = [];\n for (const item of frameEvents) {\n for (const event of item.events) {\n const raw = [event.label, event.parsedType, event.status, event.type].filter(Boolean).join(\":\");\n if (raw && !labels.includes(raw)) labels.push(raw);\n }\n }\n return {\n eventCount: frameEvents.reduce((sum, item) => sum + item.events.length, 0),\n eventTypes: labels.slice(0, 40),\n frames: frameEvents.map((item) => ({ frameUrl: item.frameUrl, count: item.events.length, last: item.events.slice(-5) })),\n valuesRedacted: true,\n };\n}\n\nfunction failedCondition(conditions) {\n return Object.entries(conditions).filter(([, ok]) => ok !== true).map(([key]) => key).join(\",\") || \"unknown\";\n}\n\nfunction waitFor(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n`); +} + +function patchOpenCodeSmokeScript(source: string): string { + const oldFlow = ` const frame = await findOpenCodeFrame(page, config.responseTimeoutMs); + const before = await collectDomState(frame, [], config); + const beforeLines = before.lines; + recordStep("opencode-frame", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit }); + + const prompt = await fillOpenCodePrompt(frame, config.message);`; + const newFlow = ` const frame = await findOpenCodeFrame(page, config.responseTimeoutMs); + const project = await ensureOpenCodeProjectOpen(frame, config.responseTimeoutMs); + recordStep("opencode-project", project); + const composer = await waitForOpenCodeComposer(frame, config.responseTimeoutMs); + recordStep("opencode-composer", composer); + const eventProbe = await startOpenCodeEventProbe(frame); + recordStep("opencode-event-probe", eventProbe); + const before = await collectDomState(frame, [], config); + const beforeLines = before.lines; + recordStep("opencode-frame", { ok: true, frameUrl: before.url, title: before.title, beforeLineCount: beforeLines.length, hasSubmit: before.hasSubmit }); + + const prompt = await fillOpenCodePrompt(frame, config.message);`; + const oldFrameFinder = `async function findOpenCodeFrame(page, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let best = null; + while (Date.now() < deadline) { + for (const frame of page.frames()) { + const summary = await frameSummary(frame); + const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0); + if (best === null || score > best.score) best = { frame, summary, score }; + if (summary.hasSubmit && summary.textboxCount > 0) return frame; + } + await waitFor(500); + } + throw new Error("OpenCode frame with prompt submit button was not found; best=" + JSON.stringify(best?.summary || null)); +}`; + const newFrameFinder = `async function findOpenCodeFrame(page, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let best = null; + while (Date.now() < deadline) { + for (const frame of page.frames()) { + const summary = await frameSummary(frame); + const score = (summary.hasSubmit ? 100 : 0) + (summary.textboxCount > 0 ? 20 : 0) + (/opencode/iu.test(summary.url) ? 20 : 0) + (/opencode/iu.test(summary.title) ? 10 : 0) + (isOpenCodeFrame(frame, summary) ? 50 : 0); + if (best === null || score > best.score) best = { frame, summary, score }; + if (summary.hasSubmit && summary.textboxCount > 0) return frame; + if (isOpenCodeFrame(frame, summary)) return frame; + } + await waitFor(500); + } + throw new Error("OpenCode frame was not found; best=" + JSON.stringify(best?.summary || null)); +} + +function isOpenCodeFrame(frame, summary) { + const rawUrl = String(summary?.url || frame.url() || ""); + let hostname = ""; + try { + hostname = new URL(rawUrl).hostname; + } catch { + hostname = ""; + } + const hasParent = typeof frame.parentFrame === "function" && frame.parentFrame() !== null; + if (hasParent && (/opencode/iu.test(rawUrl) || String(summary?.title || "") === "OpenCode")) return true; + return /^opencode[-.]/iu.test(hostname); +} + +async function ensureOpenCodeProjectOpen(frame, timeoutMs) { + const deadline = Date.now() + Math.min(timeoutMs, 20000); + let clickedProject = null; + let dismissed = []; + let lastSummary = null; + while (Date.now() < deadline) { + lastSummary = await frameSummary(frame); + if (lastSummary.hasSubmit && lastSummary.textboxCount > 0) { + return { ok: true, alreadyOpen: true, frameUrl: lastSummary.url, dismissed, clickedProject }; + } + const dismissedButton = await clickButtonMatching(frame, (text) => /^(not yet|skip|maybe later)$/iu.test(text), "dismiss-provider"); + if (dismissedButton) { + dismissed.push(dismissedButton.text); + await waitFor(500); + continue; + } + if (/No projects open|Recent projects|Open a project/iu.test(String(lastSummary.bodyPreview || ""))) { + const clicked = await clickButtonMatching(frame, (text) => text === "/" || /^\\/\\s*\\d/iu.test(text) || /^\\/.*ago$/iu.test(text), "recent-project"); + if (clicked) { + clickedProject = clicked.text; + await waitFor(1500); + return { ok: true, alreadyOpen: false, frameUrl: lastSummary.url, dismissed, clickedProject }; + } + } + await waitFor(500); + } + return { ok: false, alreadyOpen: false, frameUrl: lastSummary?.url ?? frame.url(), dismissed, clickedProject, bodyPreview: lastSummary?.bodyPreview ?? null }; +} + +async function waitForOpenCodeComposer(frame, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastSummary = null; + while (Date.now() < deadline) { + lastSummary = await frameSummary(frame); + if (lastSummary.hasSubmit && lastSummary.textboxCount > 0) { + return { ok: true, frameUrl: lastSummary.url, textboxCount: lastSummary.textboxCount, hasSubmit: true }; + } + await waitFor(750); + } + throw new Error("OpenCode composer was not ready; last=" + JSON.stringify(lastSummary)); +} + +async function startOpenCodeEventProbe(frame) { + return await frame.evaluate(() => { + const key = "__unideskOpenCodeEvents"; + window[key] = Array.isArray(window[key]) ? window[key] : []; + if (window.__unideskManualOpenCodeEventSourceStarted) { + return { ok: true, reused: true, eventCount: window[key].length }; + } + window.__unideskManualOpenCodeEventSourceStarted = true; + const eventUrl = new URL("/global/event", location.origin).toString(); + const source = new EventSource(eventUrl); + window.__unideskManualOpenCodeEventSource = source; + const push = (type, event) => { + const rawData = typeof event?.data === "string" ? event.data : ""; + let parsedType = null; + let status = null; + if (rawData) { + try { + const parsed = JSON.parse(rawData); + parsedType = typeof parsed?.type === "string" ? parsed.type : typeof parsed?.event === "string" ? parsed.event : typeof parsed?.name === "string" ? parsed.name : null; + status = typeof parsed?.status === "string" ? parsed.status : typeof parsed?.part?.status === "string" ? parsed.part.status : null; + } catch { + parsedType = null; + } + } + const label = parsedType || (status ? String(type) + ":" + status : String(type)); + window[key].push({ at: Date.now(), source: eventUrl, type: String(type), parsedType, status, label, dataBytes: rawData.length, manualProbe: true }); + if (window[key].length > 240) window[key].splice(0, window[key].length - 240); + }; + const eventTypes = ["message", "message.part.updated", "message.part.added", "message.updated", "step-finish", "step.finish", "session.idle", "session.status", "session.updated", "session.error", "error"]; + for (const type of eventTypes) source.addEventListener(type, (event) => push(type, event)); + source.addEventListener("open", () => push("open", { data: "" })); + return { ok: true, reused: false, eventUrl, listenedTypes: eventTypes }; + }); +} + +async function clickButtonMatching(frame, predicate, label) { + const buttons = frame.locator("button,[role='button']"); + const count = Math.min(await buttons.count().catch(() => 0), 80); + for (let index = 0; index < count; index += 1) { + const button = buttons.nth(index); + const visible = await button.isVisible().catch(() => false); + if (!visible) continue; + const text = normalizeButtonText(await button.textContent().catch(() => "")); + if (!predicate(text)) continue; + await button.click({ timeout: 5000 }); + return { ok: true, label, index, text }; + } + return null; +} + +function normalizeButtonText(value) { + return String(value || "").replace(/\\s+/gu, " ").trim(); +}`; + const oldBlocked = "const blocked = /^(send|stop|cancel|thinking|loading|new session|settings)$/iu;"; + const newBlocked = "const blocked = /^(send|stop|cancel|thinking|loading|new session|settings|build|shell|review|last turn changes|create git repository|all files|main branch|deepseek-v4-flash)$/iu;"; + const withFlow = source.replace(oldFlow, newFlow); + const withFrameFinder = withFlow.replace(oldFrameFinder, newFrameFinder); + const patched = withFrameFinder.replace(oldBlocked, newBlocked); + if (withFlow === source) throw new Error("internal opencode-smoke generated script patch did not apply: flow"); + if (withFrameFinder === withFlow) throw new Error("internal opencode-smoke generated script patch did not apply: frame finder"); + if (patched === withFrameFinder) throw new Error("internal opencode-smoke generated script patch did not apply: assistant text filter"); + if (!patched.includes("ensureOpenCodeProjectOpen") || !patched.includes("startOpenCodeEventProbe")) throw new Error("internal opencode-smoke generated script patch is missing required helpers"); + return patched; +} + function resolveWebProbeScreenshotUrl(spec: HwlabRuntimeLaneSpec, targetPath: string): string { const origin = nodeWebProbeDefaultUrl(spec); try { diff --git a/scripts/src/platform-infra-observability/diagnose-code-agent-script.ts b/scripts/src/platform-infra-observability/diagnose-code-agent-script.ts index dfbb36df..1635d044 100644 --- a/scripts/src/platform-infra-observability/diagnose-code-agent-script.ts +++ b/scripts/src/platform-infra-observability/diagnose-code-agent-script.ts @@ -321,7 +321,7 @@ export function searchScript(observability: ObservabilityConfig, target: Observa const proxyPrefix = `/api/v1/namespaces/${target.namespace}/services/http:${observability.traceBackend.serviceName}:http/proxy`; const searchProxyPath = `${proxyPrefix}${searchPath}`; const grepLiteral = options.grep === null ? "None" : JSON.stringify(options.grep); - const effectiveQuery = inferSearchTempoQuery(options); + const effectiveQuery = options.query ?? inferSearchTempoQuery(options); const queryLiteral = effectiveQuery === null ? "None" : JSON.stringify(effectiveQuery); const pathLiteral = options.path === null ? "None" : JSON.stringify(options.path); const statusLiteral = options.status === null ? "None" : String(options.status); @@ -383,6 +383,16 @@ IMPORTANT_ATTRS = [ "returnedMessages", "totalMessages", "roleSequencePrefix", "consecutiveUserPrefix", "adjacentSameRoleCount", "userCount", "agentCount", + "opencode.proxy.phase", "opencode.proxy.streaming", + "opencode.proxy.ticket_accepted", "opencode.proxy.sse.directory_rewrite_enabled", + "opencode.proxy.sse.directory_rewrite_from", + "opencode.proxy.sse.directory_rewrite_to", + "opencode.provider.sse.content_chunks", + "opencode.provider.sse.content_chars", + "opencode.provider.sse.output_data_lines", + "opencode.provider.sse.done_lines", + "opencode.provider.sse.json_errors", + "opencode.provider.sse.reasoning_only_choices_dropped", "http.target", "http.url", "url.path", "db.system", "db.operation.name", "db.sql.table", "db.query.arg_count", "db.index.expected", "db.pool.max_open", "db.pool.open_connections", @@ -542,8 +552,57 @@ def compact_span(span, service, resource_attrs, scope_name): def grep_matches_text(text): return GREP is not None and GREP.lower() in text.lower() +def parse_grep_key_value(): + if GREP is None: + return None, None + match = re.match(r"^([A-Za-z0-9_.-]+)=(.+)$", GREP) + if not match: + return None, None + key = match.group(1) + if key.startswith("span."): + key = key[5:] + raw = match.group(2).strip() + if len(raw) >= 2 and ((raw[0] == raw[-1] == '"') or (raw[0] == raw[-1] == "'")): + raw = raw[1:-1] + lowered = raw.lower() + if lowered == "true": + return key, True + if lowered == "false": + return key, False + if re.match(r"^-?(?:0|[1-9][0-9]*)$", raw): + try: + return key, int(raw) + except Exception: + pass + if re.match(r"^-?(?:0|[1-9][0-9]*)[.][0-9]+$", raw): + try: + return key, float(raw) + except Exception: + pass + return key, raw + +def values_equal_for_grep(actual, expected): + if actual == expected: + return True + if isinstance(actual, bool) or isinstance(expected, bool): + return str(actual).lower() == str(expected).lower() + if isinstance(actual, (int, float)) and isinstance(expected, (int, float)): + return float(actual) == float(expected) + return str(actual) == str(expected) + def grep_matches_item(item): - return GREP is not None and grep_matches_text(json.dumps(item, ensure_ascii=False, sort_keys=True)) + if GREP is None: + return False + key, expected = parse_grep_key_value() + if key is not None: + if key == "name" and values_equal_for_grep(item.get("name"), expected): + return True + if key.startswith("resource.") and values_equal_for_grep(item.get(key.replace("resource.", "", 1)), expected): + return True + attrs = item.get("attributes", {}) if isinstance(item.get("attributes"), dict) else {} + if key in attrs and values_equal_for_grep(attrs.get(key), expected): + return True + return grep_matches_text(json.dumps(item, ensure_ascii=False, sort_keys=True)) def span_matches_filters(item): attrs = item.get("attributes", {}) if isinstance(item.get("attributes"), dict) else {} @@ -641,7 +700,10 @@ def trace_summary(trace_id, meta, body, rc, stderr): for span in raw_spans: if not isinstance(span, dict): continue + raw_attrs = attrs_to_dict(span.get("attributes")) item = compact_span(span, service, resource_attrs, scope_name) + match_item = dict(item) + match_item["attributes"] = raw_attrs attrs = item.get("attributes", {}) if isinstance(attrs, dict) and isinstance(attrs.get("traceId"), str): business_trace_ids.add(attrs.get("traceId")) @@ -650,7 +712,7 @@ def trace_summary(trace_id, meta, body, rc, stderr): spans.append(item) if is_error_span(span, attrs if isinstance(attrs, dict) else {}): error_spans.append(item) - if span_matches_filters(item) and (GREP is None or grep_matches_item(item)): + if span_matches_filters(match_item) and (GREP is None or grep_matches_item(match_item)): matched_spans.append(item) return { "traceId": trace_id, @@ -728,6 +790,8 @@ payload = { "tempoQuery": QUERY, "pathFilter": PATH_FILTER, "statusFilter": STATUS_FILTER, + "grepCoverage": None if GREP is None else "raw trace body, span name, status message, route and full span attributes inside scanned candidate traces", + "grepQueryInference": "tempo-query-present" if QUERY is not None and GREP is not None else None, "businessTraceSearch": BUSINESS_TRACE_GREP, "limit": LIMIT, "candidateLimit": CANDIDATE_LIMIT, diff --git a/scripts/src/platform-infra-observability/options.ts b/scripts/src/platform-infra-observability/options.ts index b461f3cf..f738a9a0 100644 --- a/scripts/src/platform-infra-observability/options.ts +++ b/scripts/src/platform-infra-observability/options.ts @@ -39,6 +39,9 @@ export function observabilityHelp(): Record { "bun scripts/cli.ts platform-infra observability validate --target D518 [--full|--raw]", "bun scripts/cli.ts platform-infra observability trace --target D518 --trace-id [--grep provider-stream-disconnected] [--limit 40] [--full|--raw]", "bun scripts/cli.ts platform-infra observability search --target D518 --grep 'no rollout found' [--lookback-minutes 360] [--candidate-limit 80] [--limit 20] [--full|--raw]", + "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.stream.start --lookback-minutes 30 --limit 20", + "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode.proxy.sse.directory_rewrite_enabled --lookback-minutes 30 --limit 20", + "bun scripts/cli.ts platform-infra observability search --target JD01 --grep opencode-provider-proxy --lookback-minutes 30 --limit 20", "bun scripts/cli.ts platform-infra observability search --target D518 --path /v1/workbench/sessions --status 502 [--lookback-minutes 120] [--full|--raw]", "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --business-trace-id [--full|--raw]", "bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D518 --run-id [--command-id ] [--session-id ] [--runner-job-id ] [--full|--raw]", diff --git a/scripts/src/platform-infra-observability/render.ts b/scripts/src/platform-infra-observability/render.ts index c8d16278..6a822771 100644 --- a/scripts/src/platform-infra-observability/render.ts +++ b/scripts/src/platform-infra-observability/render.ts @@ -104,6 +104,8 @@ export async function search(config: UniDeskConfig, options: SearchOptions): Pro endAt: new Date(endSeconds * 1000).toISOString(), businessTraceId, mode: businessTraceId === null ? "candidate-grep" : "business-trace-exact", + grepQueryInference: options.query === null && businessTraceId === null && effectiveTempoQuery !== null ? "inferred-from-grep-or-filters" : null, + grepCoverage: options.grep === null ? null : "candidate traces are fetched by tempoQuery, then each scanned trace is matched against raw trace body, span name, status message, route and full span attributes", candidateLimit: options.candidateLimit, limit: options.limit, }; @@ -143,6 +145,9 @@ function compactSearchFullResult(value: unknown): Record { tempoQuery: source.tempoQuery ?? null, pathFilter: source.pathFilter ?? null, statusFilter: source.statusFilter ?? null, + matchingActive: source.matchingActive ?? null, + grepCoverage: source.grepCoverage ?? null, + grepQueryInference: source.grepQueryInference ?? null, limit: source.limit ?? null, candidateLimit: source.candidateLimit ?? null, searchParseOk: source.searchParseOk ?? null, @@ -196,7 +201,57 @@ export function inferSearchTempoQuery(options: SearchOptions): string | null { const filters: string[] = []; if (options.path !== null) filters.push(`.http.route = ${JSON.stringify(options.path)}`); if (options.status !== null) filters.push(`.http.response.status_code = ${options.status}`); - return filters.length > 0 ? `{ ${filters.join(" && ")} }` : null; + if (filters.length > 0) return `{ ${filters.join(" && ")} }`; + const grep = options.grep?.trim() ?? ""; + if (!grep) return null; + const keyValue = grep.match(/^([A-Za-z0-9_.-]+)=(.+)$/u); + if (keyValue !== null) { + const key = keyValue[1] ?? ""; + const value = (keyValue[2] ?? "").trim(); + const traceQlKey = key === "name" || key.startsWith("resource.") ? key : `.${key.replace(/^span[.]/u, "")}`; + if (/^(?:name|resource[.][A-Za-z0-9_.-]+|[.][A-Za-z0-9_.-]+)$/u.test(traceQlKey) && value.length > 0 && value.length <= 200) return `{ ${traceQlKey} = ${traceQlLiteral(value)} }`; + } + if (grep.startsWith("/") && !grep.includes("\n") && grep.length <= 300) return `{ .http.route = ${JSON.stringify(grep)} }`; + if (/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,180}$/u.test(grep)) { + if (isKnownTraceAttributeKey(grep)) return `{ .${grep} != nil }`; + if (grep.includes(".") || grep.includes(":")) return `{ name = ${JSON.stringify(grep)} }`; + if (/^(?:hwlab|agentrun|opencode|platform|code-queue|unidesk)[A-Za-z0-9-]*$/u.test(grep) || /-(?:api|web|proxy|runner|manager|service|mgr|collector|tempo)$/u.test(grep)) return `{ resource.service.name = ${JSON.stringify(grep)} }`; + } + return null; +} + +const knownTraceAttributeKeys = new Set([ + "opencode.proxy.phase", + "opencode.proxy.streaming", + "opencode.proxy.ticket_accepted", + "opencode.proxy.sse.directory_rewrite_enabled", + "opencode.proxy.sse.directory_rewrite_from", + "opencode.proxy.sse.directory_rewrite_to", + "opencode.provider.sse.content_chunks", + "opencode.provider.sse.content_chars", + "opencode.provider.sse.output_data_lines", + "opencode.provider.sse.done_lines", + "opencode.provider.sse.json_errors", + "opencode.provider.sse.reasoning_only_choices_dropped", +]); + +function isKnownTraceAttributeKey(value: string): boolean { + return knownTraceAttributeKeys.has(value) || value.startsWith("opencode.proxy.sse.") || value.startsWith("opencode.provider.sse."); +} + +function traceQlLiteral(value: string): string { + const unquoted = stripOuterQuotes(value.trim()); + if (/^(?:true|false)$/iu.test(unquoted)) return unquoted.toLowerCase(); + if (/^-?(?:0|[1-9][0-9]*)(?:[.][0-9]+)?$/u.test(unquoted)) return unquoted; + if (unquoted === "nil") return "nil"; + return JSON.stringify(unquoted); +} + +function stripOuterQuotes(value: string): string { + if (value.length >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")))) { + return value.slice(1, -1); + } + return value; } export function businessTraceIdFromSearchText(value: string | null): string | null { @@ -584,12 +639,20 @@ export function renderSearchTable(input: { ` candidateLimit=${textValue(input.query.candidateLimit)} limit=${textValue(input.query.limit)}`, ` candidates=${textValue(input.result.candidateTraceCount)} scanned=${textValue(input.result.scannedTraceCount)} matched=${textValue(input.result.matchedTraceCount)} stopped=${textValue(input.result.scanStopped)}`, ]; + if (input.query.grep !== null) { + lines.push(` grepCoverage=${textValue(input.query.grepCoverage)}`); + if (input.query.grepQueryInference !== null) lines.push(` grepQueryInference=${textValue(input.query.grepQueryInference)}`); + } const firstTraceId = traces.length > 0 ? textValue(traces[0].traceId) : ""; lines.push("", "Next:"); if (firstTraceId.length > 0 && firstTraceId !== "-") { lines.push(` bun scripts/cli.ts platform-infra observability trace --target ${input.target.id} --trace-id ${firstTraceId}`); } lines.push(` ${buildSearchCommand(input.target, input.options, true)}`); + if (input.query.grep !== null && Number(input.result.matchedTraceCount ?? 0) === 0) { + lines.push(` explicit TraceQL: bun scripts/cli.ts platform-infra observability search --target ${input.target.id} --query '{ resource.service.name = "" }' --lookback-minutes ${textValue(input.query.lookbackMinutes)} --candidate-limit ${textValue(input.query.candidateLimit)} --limit ${textValue(input.query.limit)}`); + lines.push(` widen candidates: bun scripts/cli.ts platform-infra observability search --target ${input.target.id} --grep ${JSON.stringify(String(input.query.grep))} --lookback-minutes ${textValue(input.query.lookbackMinutes)} --candidate-limit 300 --limit ${textValue(input.query.limit)}`); + } lines.push("", "Disclosure:"); lines.push(" default view is a bounded table; use --full for structured diagnosis JSON or trace --trace-id for one trace."); return {