diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index 8b825f8e..fb9d555c 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -165,14 +165,15 @@ function renderWebObserveStatusTable(value: Record): string { ] : []), ...(Object.keys(exactCommand).length > 0 ? [ "Exact command:", - webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[ + webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "PROFILE_SHA", "REPORT_SHA", "DETAIL"], [[ webObserveShort(webObserveText(exactCommand.commandId), 40), exactCommand.type, exactCommand.phase, exactCommand.ok, exactResult?.status, + webObserveShort(webObserveText(exactResult?.profileSha256 ?? exactErrorDetails?.profileSha256), 32), webObserveShort(webObserveText(exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256), 32), - webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath), 96), + webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath ?? exactResult?.profilePath ?? exactErrorDetails?.profilePath), 96), ]]), "", ] : []), @@ -436,6 +437,7 @@ function renderWebObserveCommandTable(value: Record): string { const details = nullableRecord(error?.details); const replayEvidence = result ?? details; const replayScreenshot = record(result?.screenshot ?? details?.screenshot); + const performanceCapture = observerCommand?.type === "performanceCapture" ? result ?? details : null; const rawReadiness = record(error?.navigationReadiness) ?? record(details?.readiness) ?? record(details?.readinessAfterWait) ?? record(details?.readinessBeforeClick); const readiness = record(rawReadiness?.snapshot) ?? rawReadiness; const id = webObserveText(value.id); @@ -454,6 +456,17 @@ function renderWebObserveCommandTable(value: Record): string { webObserveShort(webObserveText(asyncTurn?.traceId), 24), webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80), ]]), + ...(performanceCapture !== null ? [ + "", + "Performance capture:", + webObserveTable(["CAPTURE", "DURATION_MS", "PROFILE_SHA", "REPORT_SHA", "COLLECT"], [[ + performanceCapture.captureId, + performanceCapture.durationMs, + webObserveShort(webObserveText(performanceCapture.profileSha256), 32), + webObserveShort(webObserveText(performanceCapture.reportSha256), 32), + `observe collect ${id} --file ${webObserveText(performanceCapture.reportPath)}`, + ]]), + ] : []), ...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && replayEvidence !== null ? [ "", ...renderWorkbenchKafkaDebugReplayEvidence( diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index ed3d35ad..a0213c0a 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -25,6 +25,36 @@ test("observe command renderer separates control completion from async turn term assert.match(text, /--view turn-summary --command-id cmd-fixture/u); }); +test("observe command renderer exposes bounded performance capture artifact drill-down", () => { + const rendered = withWebObserveCommandRendered({ + ok: true, + status: "control-completed", + command: "web-probe observe command", + id: "webobs-fixture", + commandId: "cmd-performance", + observerCommand: { type: "performanceCapture" }, + observer: { + ok: true, + completedAt: "2026-07-13T12:00:00.000Z", + result: { + captureId: "cpu-fixture", + durationMs: 1200, + profileSha256: "sha256:profile", + reportPath: ".state/web-observe/performance/cpu-fixture/summary.json", + reportSha256: "sha256:report", + }, + }, + control: { executionStatus: "completed", businessTurnTerminalImplied: false }, + asyncTurn: { applicable: false, submissionStatus: "not-applicable", terminalStatus: null, terminalObserved: false }, + }); + const text = String(rendered.renderedText); + assert.match(text, /Performance capture:/u); + assert.match(text, /sha256:profile/u); + assert.match(text, /sha256:report/u); + assert.match(text, /observe collect webobs-fixture --file .*summary\.json/u); + assert.doesNotMatch(text, /nodes.*callFrame/u); +}); + test("observe status renderer exposes the exact command phase and failed report evidence", () => { const rendered = withWebObserveStatusRendered({ ok: true, diff --git a/scripts/src/hwlab-node/web-observe-scripts.test.ts b/scripts/src/hwlab-node/web-observe-scripts.test.ts index 78e69a86..e20b8416 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.test.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; -import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts"; +import { nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell } from "./web-observe-scripts"; async function runStatusScript(stateDir: string, commandId: string): Promise> { const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], { @@ -58,6 +58,77 @@ test("observe status returns one exact completed command without prompt payloads assert.equal(status.exactCommand.valuesRedacted, true); }); +test("observe status summarizes performance capture artifact without embedding CPU profile", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-status-")); + const commandId = "cmd-performance"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "performanceCapture", + completedAt: "2026-07-13T12:00:00.000Z", + result: { + ok: true, + captureId: "cpu-fixture", + durationMs: 1200, + profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] }, + artifact: { + path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile", + sha256: "sha256:profile", + summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json", + summarySha256: "sha256:report", + }, + }, + })); + + const status = await runStatusScript(stateDir, commandId); + assert.equal(status.exactCommand.result.captureId, "cpu-fixture"); + assert.equal(status.exactCommand.result.profileSha256, "sha256:profile"); + assert.equal(status.exactCommand.result.reportSha256, "sha256:report"); + assert.equal(JSON.stringify(status).includes("large-profile-payload"), false); +}); + +test("observe command wait emits bounded performance capture summary", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-wait-")); + const commandId = "cmd-performance"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "performanceCapture", + completedAt: "2026-07-13T12:00:00.000Z", + result: { + ok: true, + captureId: "cpu-fixture", + durationMs: 1200, + profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] }, + artifact: { + path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile", + sha256: "sha256:profile", + summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json", + summarySha256: "sha256:report", + }, + }, + })); + const child = Bun.spawn(["bash", "-lc", nodeWebObserveWaitCommandShell(commandId, 1000)], { + env: { ...process.env, state_dir: stateDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert.equal(exitCode, 0, stderr); + const summary = JSON.parse(stdout); + assert.equal(summary.status, "done"); + assert.equal(summary.result.profileSha256, "sha256:profile"); + assert.equal(summary.result.reportSha256, "sha256:report"); + assert.equal(stdout.includes("large-profile-payload"), false); + assert.ok(Buffer.byteLength(stdout) < 2048); +}); + test("observe status preserves bounded existing-session refresh evidence", async () => { const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-")); const commandId = "cmd-existing-refresh"; diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 6bcda0ea..841d7852 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -101,6 +101,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;}; const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};}; const compactScreenshot=(value)=>value&&typeof value==='object'?{path:value.path||null,sha256:value.sha256||null,available:value.available??null,valuesRedacted:true}:null; + const compactPerformanceCapture=(value)=>{const item=value&&typeof value==='object'?value:null;const artifact=item&&item.artifact&&typeof item.artifact==='object'?item.artifact:null;if(!item&&!artifact)return{};return{captureId:item&&item.captureId||artifact&&artifact.captureId||null,durationMs:item&&item.durationMs!==undefined?item.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||null,reportSha256:artifact&&artifact.summarySha256||null,valuesRedacted:true};}; const commandSummary=(name)=>{ const root=commandDir(name); let entries=[]; try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{} @@ -115,7 +116,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, if(!parsed)continue; const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null; const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null; - return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true}; + return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(result):{profile:result.profile||null}),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(error.details):{profile:error.details.profile||null}),valuesRedacted:true}:null}:null,valuesRedacted:true}; } return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true}; }; @@ -425,12 +426,22 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number ].join("\n"); } const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000)); + const printCommandSummary = `node -e ${shellQuote(` +const fs=require('fs'); +const file=process.argv[1]; +const phase=process.argv[2]; +const parsed=JSON.parse(fs.readFileSync(file,'utf8')); +const result=parsed&&parsed.result&&typeof parsed.result==='object'?parsed.result:null; +const artifact=result&&result.artifact&&typeof result.artifact==='object'?result.artifact:null; +const summary={ok:parsed.ok!==false,queued:false,commandId:parsed.commandId||parsed.id||null,type:parsed.type||null,status:phase,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,captureId:result.captureId||artifact&&artifact.captureId||null,durationMs:result.durationMs!==undefined?result.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||result.reportPath||null,reportSha256:artifact&&artifact.summarySha256||result.reportSha256||null,valuesRedacted:true}:null,error:parsed.error?{name:parsed.error.name||null,message:String(parsed.error.message||'').replace(/\\s+/g,' ').slice(0,200),valuesRedacted:true}:null,valuesRedacted:true}; +console.log(JSON.stringify(summary)); +`)}`; return [ `command_id=${shellQuote(commandId)}`, `deadline=$(( $(date +%s) + ${waitSeconds} ))`, "while [ \"$(date +%s)\" -le \"$deadline\" ]; do", - " if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi", - " if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi", + ` if [ -f "$state_dir/commands/done/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/done/\${command_id}.json" done; exit 0; fi`, + ` if [ -f "$state_dir/commands/failed/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/failed/\${command_id}.json" failed; exit 2; fi`, " sleep 1", "done", "printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"", diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index 149facfc..3a699328 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -110,6 +110,32 @@ test("web observe action JSON recovery accepts concrete start contract", () => { assert.equal(resolved.diagnostics.stdoutContractAccepted, true); }); +test("web observe action JSON recovery accepts bounded performance capture command summary", () => { + const resolved = resolveWebObserveActionJson({ + stdout: JSON.stringify({ + ok: true, + queued: false, + commandId: "cmd-performance", + type: "performanceCapture", + status: "done", + result: { + captureId: "cpu-fixture", + profileSha256: "sha256:profile", + reportSha256: "sha256:report", + valuesRedacted: true, + }, + valuesRedacted: true, + }), + stderr: "", + exitCode: 0, + timedOut: false, + }, "command"); + + assert.equal(resolved.source, "stdout"); + assert.equal(resolved.parsed?.commandId, "cmd-performance"); + assert.equal(resolved.diagnostics.stdoutContractAccepted, true); +}); + test("web observe action JSON recovery reads dump wrapper for status contract", async () => { const dir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-status-dump-")); const dumpPath = join(dir, "status.json");