diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index b53265d0..a839eb16 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -5,7 +5,7 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H # UniDesk WebDev -本技能是 UniDesk/HWLAB Web 开发、浏览器复测和 Playwright 回归的入口。需求真相源仍是 `project-management/PJ2026-01/specs/`;本技能只规定如何开发、采集、复现和验收。 +本技能是 UniDesk/HWLAB Web 开发、浏览器复测和 Playwright 回归的入口,并遵循 Skill(cli-spec)。需求真相源仍是 `project-management/PJ2026-01/specs/`;本技能只规定如何开发、采集、复现和验收。 ## 快速规则 @@ -30,6 +30,8 @@ description: UniDesk Web 开发与浏览器验证技能。用户处理 UniDesk/H - Issue closeout 引用 observer id、command id、stateDir/report SHA、screenshot SHA 和关键字段摘要;不要粘贴截图二进制、完整 JSON、Secret、cookie 或无界日志。 - 取证 drill-down 先用 `observe collect` / `observe analyze` 的 bounded view;需要截图路径、SHA、traceId 或 command detail 时优先查 `control.jsonl`、analysis 摘要或专用 artifact view,避免直接 `rg samples.jsonl`,因为页面快照是超长单行 JSON,容易触发输出截断和噪声。 - Workbench 三态/卡死离线调查优先用 `observe collect --view workbench-triad --trace-id `,它把 DOM turn/message/trace rows、Final Response、Workbench REST terminal evidence、browser freeze blocker 和 analyzer findings 聚合到一个有界视图;只有需要样本细节时再用 `--finding` 或 `trace-frame` 下钻。 +- `observe collect --view turn-summary --command-id ` 必须按 commandId 精确返回单轮;不存在时保持结构化 `not-found`,禁止回退全量轮次。输出中 observer control 完成、turn submission 与异步 turn terminal 是三个独立证据字段,control completed 不等于业务 turn terminal。 +- `workbench-triad` 的本地 `network.jsonl` 没有匹配响应时,结论必须写作 `artifact evidence missing`,并明确这不能证明业务 API 未发生;将 control artifact 中的 OTel trace reference 作为独立证据引用,再走 `$unidesk-otel` 受控诊断核验完整业务链路。 - Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary`;`performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。 - Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible`、`reportFullscreenVisible`、报告 deep link 和截图 SHA。 - MDTODO → Workbench launch 验收必须引用 `launchWorkbenchFromMdtodo` 的 command result,确认文件/任务选择、Workbench session、launch/chat 状态和 OTel trace header;不要只凭页面最终停在 Workbench URL 判断通过。 diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index 7a6a17f9..27708390 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -320,7 +320,8 @@ function turnSummaryRows(){ const prompts=promptCommands(); if(prompts.length===0){ const allTraceIds=traceIdsFromSamples(samples); - return [{round:0,commandId:null,commandType:null,userPreview:'(无 sendPrompt control 记录)',userHash:null,userBytes:null,traceId:allTraceIds[0]||null,status:statusFor(samples,allTraceIds[0]||null),elapsedSeconds:null,recentUpdateSeconds:null,marks:'-',firstSeq:samples[0]?.seq??null,lastSeq:samples[samples.length-1]?.seq??null,lastTs:samples[samples.length-1]?.ts??null,finalResponse:finalResponseFor(samples,allTraceIds[0]||null),valuesRedacted:true}]; + const turnStatus=statusFor(samples,allTraceIds[0]||null); + return [{round:0,commandId:null,commandType:null,controlExecutionStatus:'not-applicable',turnSubmissionStatus:'not-observed',turnStatus,turnTerminalObserved:terminalFinalStatus(turnStatus),userPreview:'(无 sendPrompt control 记录)',userHash:null,userBytes:null,traceId:allTraceIds[0]||null,status:turnStatus,elapsedSeconds:null,recentUpdateSeconds:null,marks:'-',firstSeq:samples[0]?.seq??null,lastSeq:samples[samples.length-1]?.seq??null,lastTs:samples[samples.length-1]?.ts??null,finalResponse:finalResponseFor(samples,allTraceIds[0]||null),valuesRedacted:true}]; } return prompts.map((prompt,index)=>{ const segment=segmentFor(index,prompts); const segmentControls=controlsFor(index,prompts); const failedBeforeTrace=promptFailedBeforeTrace(prompt,segment); const selected=failedBeforeTrace?{traceId:null,columns:[]}:choosePrimaryTraceId(index,segment,prompt); const traceId=selected.traceId; const user=userMessageFor(segment,prompt); @@ -329,19 +330,30 @@ function turnSummaryRows(){ const texts=textsFor(segment,traceId); const elapsedValues=texts.map(parseElapsed).filter((value)=>value!==null); const recentValues=texts.map(parseRecent).filter((value)=>value!==null); const marks=unique([...segmentControls.map((item)=>item.type==='cancel'?'cancel':item.type==='steer'?'steer':null),failedBeforeTrace?'command-failed':null]).join(',')||'-'; - return {round:index+1,commandId:prompt.commandId||null,commandType:prompt.type||null,sessionId:prompt.sessionId||null,userPreview:user.preview,userHash:user.textHash,userBytes:user.textBytes,traceId,traceIds:failedBeforeTrace?[]:unique([traceId,...selected.columns.map((column)=>column.traceId),...traceIdsFromSamples(segment)]).slice(0,8),status:failedBeforeTrace?'command-failed':statusFor(segment,traceId),elapsedSeconds:failedBeforeTrace?null:(elapsedValues.length?Math.max(...elapsedValues):null),recentUpdateSeconds:failedBeforeTrace?null:(recentValues.length?Math.max(...recentValues):null),marks,firstSeq:segment[0]?.seq??primaryColumn?.firstSeq??null,lastSeq:failedBeforeTrace?(segment[segment.length-1]?.seq??null):(traceSample?.seq??primaryColumn?.lastSeq??segment[segment.length-1]?.seq??null),lastTs:failedBeforeTrace?(segment[segment.length-1]?.ts??null):(traceSample?.ts??segment[segment.length-1]?.ts??null),finalResponse:failedBeforeTrace?emptyFinalResponse():finalResponseFor(segment,traceId,user),valuesRedacted:true}; + const controlExecutionStatus=prompt.phase==='failed'?'failed':prompt.phase==='completed'?'completed':'processing'; + const turnSubmissionStatus=failedBeforeTrace?'failed':commandTraceId(prompt)?'accepted':prompt.phase==='completed'?'not-observed':'pending'; + const turnStatus=failedBeforeTrace?'not-started':statusFor(segment,traceId); + return {round:index+1,commandId:prompt.commandId||null,commandType:prompt.type||null,sessionId:prompt.sessionId||null,controlExecutionStatus,turnSubmissionStatus,turnStatus,turnTerminalObserved:terminalFinalStatus(turnStatus),userPreview:user.preview,userHash:user.textHash,userBytes:user.textBytes,traceId,traceIds:failedBeforeTrace?[]:unique([traceId,...selected.columns.map((column)=>column.traceId),...traceIdsFromSamples(segment)]).slice(0,8),status:turnStatus,elapsedSeconds:failedBeforeTrace?null:(elapsedValues.length?Math.max(...elapsedValues):null),recentUpdateSeconds:failedBeforeTrace?null:(recentValues.length?Math.max(...recentValues):null),marks,firstSeq:segment[0]?.seq??primaryColumn?.firstSeq??null,lastSeq:failedBeforeTrace?(segment[segment.length-1]?.seq??null):(traceSample?.seq??primaryColumn?.lastSeq??segment[segment.length-1]?.seq??null),lastTs:failedBeforeTrace?(segment[segment.length-1]?.ts??null):(traceSample?.ts??segment[segment.length-1]?.ts??null),finalResponse:failedBeforeTrace?emptyFinalResponse():finalResponseFor(segment,traceId,user),valuesRedacted:true}; }); } +function selectTurnSummaryRows(rows){ + if(!requestedCommandId)return {ok:true,status:'collected',filter:{mode:'all',requestedCommandId:null,matchedCount:rows.length,availableTurnCount:rows.length,exact:true,valuesRedacted:true},rows,error:null}; + const matched=rows.filter((row)=>String(row.commandId||'')===String(requestedCommandId)); + if(matched.length>0)return {ok:true,status:'matched',filter:{mode:'exact-command-id',requestedCommandId,matchedCount:matched.length,availableTurnCount:rows.length,exact:true,valuesRedacted:true},rows:matched,error:null}; + return {ok:false,status:'not-found',filter:{mode:'exact-command-id',requestedCommandId,matchedCount:0,availableTurnCount:rows.length,exact:true,valuesRedacted:true},rows:[],error:{code:'turn-summary-command-not-found',message:'requested commandId was not found; unrelated turns were not returned',requestedCommandId,availableTurnCount:rows.length,valuesRedacted:true}}; +} function pad(value,width){const text=short(value,width); return text+Array(Math.max(0,width-text.length)+1).join(' ')} -function renderTurnSummary(rows){ +function renderTurnSummary(rows,selection){ const prompts=promptCommands(); const anchorSessionId=authoritativeSessionIdForPrompts(prompts)||rows.find((row)=>row.sessionId)?.sessionId||samples[0]?.routeSessionId||samples[0]?.activeSessionId||samples[samples.length-1]?.routeSessionId||samples[samples.length-1]?.activeSessionId||'-'; const observedSessions=unique(samples.flatMap((sample)=>[sample.routeSessionId,sample.activeSessionId])); const driftSuffix=observedSessions.length>1?' sessionDrift='+observedSessions.length:''; const title='Workbench Session '+anchorSessionId+' observer '+(manifest.jobId||'-')+driftSuffix; - const header='轮次 用户消息 Trace 状态 耗时/最近 标记 Final Response'; - const body=rows.map((row)=>pad(row.round,5)+' '+pad((row.userHash?row.userHash.slice(0,18)+' ':'')+row.userPreview,32)+' '+pad(row.traceId||'-',12)+' '+pad(row.status,10)+' '+pad(fmtDuration(row.elapsedSeconds)+'/'+(row.recentUpdateSeconds===null?'-':String(row.recentUpdateSeconds)+'s'),13)+' '+pad(row.marks,11)+' '+short(row.finalResponse?.preview||'(空内容)',80)).join('\\n'); - return [title,'=======================================================',header,body||'(空内容)','', 'NEXT', ' detail: bun scripts/cli.ts web-probe observe collect '+(manifest.jobId||'')+' --view trace-frame --trace-id --sample-seq '].join('\\n'); + const filterLine=selection?.filter?.mode==='exact-command-id'?'filter commandId='+String(selection.filter.requestedCommandId||'-')+' exact=true matched='+String(selection.filter.matchedCount??0):'filter commandId=- mode=all matched='+String(rows.length); + const header='轮次 用户消息 Trace Control Submit Async turn 耗时/最近 Final Response'; + const body=rows.map((row)=>pad(row.round,5)+' '+pad((row.userHash?row.userHash.slice(0,14)+' ':'')+row.userPreview,27)+' '+pad(row.traceId||'-',12)+' '+pad(row.controlExecutionStatus||'-',10)+' '+pad(row.turnSubmissionStatus||'-',12)+' '+pad(row.turnStatus||row.status||'-',11)+' '+pad(fmtDuration(row.elapsedSeconds)+'/'+(row.recentUpdateSeconds===null?'-':String(row.recentUpdateSeconds)+'s'),13)+' '+short(row.finalResponse?.preview||'(空内容)',80)).join('\\n'); + const empty=selection?.status==='not-found'?'NOT_FOUND turn-summary-command-not-found; unrelated turns were not returned':'(空内容)'; + return [title,'=======================================================',filterLine,header,body||empty,'', 'DISCLOSURE', ' Control=observer browser action; Submit=turn admission evidence; Async turn=independently sampled business lifecycle.', '', 'NEXT', ' detail: bun scripts/cli.ts web-probe observe collect '+(manifest.jobId||'')+' --view trace-frame --trace-id --sample-seq '].join('\\n'); } function diagnosticRows(label,items,limit=6){ const selected=items.slice(-limit); @@ -642,6 +654,50 @@ function networkEvidenceForTrace(traceId){ const compactNet=(item)=>item?{ts:item.ts||null,sampleSeq:item.sampleSeq??null,status:item.status??null,pathKind:pathKindForNetwork(item),traceId:traceIdFromUrl(item.url||item.requestUrl),urlPath:pathOnly(item.url||item.requestUrl),failure:item.failure||item.errorText||null,bodySummary:item.bodySummary?{terminalStatusCount:item.bodySummary.terminalStatusCount??null,runningStatusCount:item.bodySummary.runningStatusCount??null,traceEventLikeCount:item.bodySummary.traceEventLikeCount??null,finalTextByteCount:item.bodySummary.finalTextByteCount??null,terminalEvidenceCount:item.bodySummary.terminalEvidenceCount??null,valuesRedacted:true}:null,valuesRedacted:true}:null; return {requestCount:requests.length,responseCount:responses.length,failedCount:failed.length,terminalResponseCount:terminalResponses.length,turnResponseCount:byKind('workbench-turn').length,traceEventsResponseCount:byKind('workbench-trace-events').length,latestTurnResponse:compactNet(latest(byKind('workbench-turn'))),latestTraceEventsResponse:compactNet(latest(byKind('workbench-trace-events'))),failed:failed.slice(-2).map(compactNet),valuesRedacted:true}; } +function commandOtelTraceId(item){ + return item?.otelTraceId||item?.detail?.chatSubmit?.otelTraceId||item?.detail?.otelTraceId||item?.result?.chatSubmit?.otelTraceId||item?.result?.otelTraceId||null; +} +function otelEvidenceForTrace(traceId,row){ + const matched=control.filter((item)=>{ + if(row?.commandId&&String(item?.commandId||'')===String(row.commandId))return true; + return traceId&&commandTraceId(item)===traceId; + }); + const source=matched.slice().reverse().find((item)=>commandOtelTraceId(item)||commandTraceId(item))||null; + const otelTraceId=commandOtelTraceId(source); + return { + status:otelTraceId?'trace-reference-present':traceId?'business-trace-reference-only':'trace-reference-missing', + businessTraceId:traceId||null, + otelTraceId:otelTraceId||null, + coverageStatus:'not-evaluated-by-offline-artifact-view', + businessTerminalStatus:null, + businessTerminalObserved:false, + evidenceScope:'control-artifact-reference-only', + diagnoseCommand:traceId?'bun scripts/cli.ts platform-infra observability diagnose-code-agent --target --business-trace-id '+traceId:null, + valuesRedacted:true + }; +} +function reconcileTriadEvidence(traceId,row,networkEvidence){ + const localStatus=networkEvidence.responseCount>0?'captured':networkEvidence.requestCount>0||networkEvidence.failedCount>0?'partial':'missing'; + const artifactMissing=localStatus==='missing'; + const otel=otelEvidenceForTrace(traceId,row); + return { + status:artifactMissing?'artifact-evidence-missing':localStatus==='partial'?'artifact-evidence-partial':'artifact-evidence-captured', + localApiArtifact:{ + status:localStatus, + requestCount:networkEvidence.requestCount, + responseCount:networkEvidence.responseCount, + failedCount:networkEvidence.failedCount, + sourceFile:'network.jsonl', + valuesRedacted:true + }, + otel, + businessApiOccurrence:networkEvidence.responseCount>0?'observed-in-local-artifact':'not-determined-by-local-artifact', + businessApiAbsenceProven:false, + interpretationCode:artifactMissing?'local-api-artifact-missing-requires-otel-reconciliation':'local-api-artifact-present', + message:artifactMissing?'artifact evidence missing: observer network.jsonl did not capture a matching API response; this does not mean the business API did not occur. Reconcile with controlled OTel evidence.':'local API artifact evidence is present; OTel remains an independent business lifecycle source.', + valuesRedacted:true + }; +} function traceDomSnapshot(traceId){ const matched=samples.filter((sample)=>!traceId||traceIdsFromSamples([sample]).includes(traceId)); const latest=matched.slice(-1)[0]||samples[samples.length-1]||null; @@ -683,6 +739,7 @@ function workbenchTriadView(rows){ const dom=traceDomSnapshot(traceId); const freeze=browserFreezeEvidence(); const recoveryAuthority=recoveryAuthorityFromFindings(); + const evidenceReconciliation=reconcileTriadEvidence(traceId,row,networkEvidence); const statusGap={ traceId, turnSummaryStatus:row?.status??null, @@ -695,8 +752,8 @@ function workbenchTriadView(rows){ browserFreezeBlocker:freeze.blockerCount>0, valuesRedacted:true }; - const compactRow=row?{round:row.round??null,commandId:row.commandId??null,sessionId:row.sessionId??null,traceId:row.traceId??null,status:row.status??null,elapsedSeconds:row.elapsedSeconds??null,recentUpdateSeconds:row.recentUpdateSeconds??null,marks:row.marks??null,firstSeq:row.firstSeq??null,lastSeq:row.lastSeq??null,lastTs:row.lastTs??null,finalResponse:row.finalResponse??null,valuesRedacted:true}:null; - return {traceId,row:compactRow,dom,networkEvidence,freeze,recoveryAuthority,findings, statusGap,renderedText:renderWorkbenchTriad({traceId,row:compactRow,dom,networkEvidence,freeze,recoveryAuthority,findings,statusGap}),valuesRedacted:true}; + const compactRow=row?{round:row.round??null,commandId:row.commandId??null,sessionId:row.sessionId??null,traceId:row.traceId??null,controlExecutionStatus:row.controlExecutionStatus??null,turnSubmissionStatus:row.turnSubmissionStatus??null,turnStatus:row.turnStatus??row.status??null,turnTerminalObserved:row.turnTerminalObserved===true,status:row.status??null,elapsedSeconds:row.elapsedSeconds??null,recentUpdateSeconds:row.recentUpdateSeconds??null,marks:row.marks??null,firstSeq:row.firstSeq??null,lastSeq:row.lastSeq??null,lastTs:row.lastTs??null,finalResponse:row.finalResponse??null,valuesRedacted:true}:null; + return {traceId,row:compactRow,dom,networkEvidence,evidenceReconciliation,freeze,recoveryAuthority,findings, statusGap,renderedText:renderWorkbenchTriad({traceId,row:compactRow,dom,networkEvidence,evidenceReconciliation,freeze,recoveryAuthority,findings,statusGap}),valuesRedacted:true}; } function renderWorkbenchTriad(input){ const lines=['Workbench triad offline diagnosis '+(manifest.jobId||'-'),'=======================================================']; @@ -705,6 +762,12 @@ function renderWorkbenchTriad(input){ const turnSummary=input.networkEvidence.latestTurnResponse?.bodySummary||{}; const traceSummary=input.networkEvidence.latestTraceEventsResponse?.bodySummary||{}; lines.push('api evidence responses='+String(input.networkEvidence.responseCount)+' terminalResponses='+String(input.networkEvidence.terminalResponseCount)+' failed='+String(input.networkEvidence.failedCount)+' turnTerminal='+String(turnSummary.terminalEvidenceCount??'-')+' turnFinalBytes='+String(turnSummary.finalTextByteCount??'-')+' traceEvents='+String(traceSummary.traceEventLikeCount??'-')+' traceTerminal='+String(traceSummary.terminalEvidenceCount??'-')); + const reconciliation=input.evidenceReconciliation||{}; + const localApi=reconciliation.localApiArtifact||{}; + const otel=reconciliation.otel||{}; + lines.push('evidence reconciliation status='+String(reconciliation.status||'-')+' localApiArtifact='+String(localApi.status||'-')+' businessApiAbsenceProven='+String(reconciliation.businessApiAbsenceProven===true)+' otel='+String(otel.status||'-')+' otelCoverage='+String(otel.coverageStatus||'-')); + if(reconciliation.message)lines.push(String(reconciliation.message)); + if(otel.otelTraceId)lines.push('otel trace reference='+String(otel.otelTraceId)+' scope='+String(otel.evidenceScope||'-')); if(input.networkEvidence.latestTurnResponse) lines.push('latest turn API '+String(input.networkEvidence.latestTurnResponse.ts||'-')+' status='+String(input.networkEvidence.latestTurnResponse.status??'-')+' sample='+String(input.networkEvidence.latestTurnResponse.sampleSeq??'-')+' path='+String(input.networkEvidence.latestTurnResponse.urlPath||'-')); if(input.networkEvidence.latestTraceEventsResponse) lines.push('latest trace events API '+String(input.networkEvidence.latestTraceEventsResponse.ts||'-')+' status='+String(input.networkEvidence.latestTraceEventsResponse.status??'-')+' sample='+String(input.networkEvidence.latestTraceEventsResponse.sampleSeq??'-')+' path='+String(input.networkEvidence.latestTraceEventsResponse.urlPath||'-')); const recovery=input.recoveryAuthority||{}; @@ -985,6 +1048,7 @@ function renderProjectSummary(project){ return lines.join('\\n'); } const rows=turnSummaryRows(); +const turnSelection=selectTurnSummaryRows(rows); if(view==='performance-summary'){ const perf=performanceSummaryFromReport(); console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,summary:perf.summary,evidenceMode:perf.evidenceMode,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,renderedText:renderPerformanceSummary(perf),sourceFiles:['performance-events.jsonl','network.jsonl','samples.jsonl','artifacts.jsonl','analysis/report.json','commands/pending/*.json','commands/processing/*.json','heartbeat.json','manifest.json'],drillDown:'bun scripts/cli.ts web-probe observe collect '+String(manifest.jobId||'')+' --view files --file analysis/report.json',valuesRedacted:true})); @@ -1003,7 +1067,7 @@ if(view==='project-summary'||view==='project-mdtodo-summary'){ process.exit(0); } if(view==='turn-summary'){ - console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,turnCount:rows.length,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,skippedFiles:skippedFiles.slice(0,20),rows:rows.slice(0,80),renderedText:renderTurnSummary(rows),sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2)); + console.log(JSON.stringify({ok:turnSelection.ok,command:'web-probe-observe collect',view,status:turnSelection.status,stateDir:dir,turnCount:turnSelection.rows.length,availableTurnCount:rows.length,filter:turnSelection.filter,error:turnSelection.error,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,skippedFiles:skippedFiles.slice(0,20),rows:turnSelection.rows.slice(0,80),renderedText:renderTurnSummary(turnSelection.rows,turnSelection),sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2)); process.exit(0); } if(view==='timeline'){ @@ -1013,7 +1077,7 @@ if(view==='timeline'){ } if(view==='workbench-triad'){ const triad=workbenchTriadView(rows); - console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,skippedFiles:skippedFiles.slice(0,8),traceId:triad.traceId,dom:triad.dom,networkEvidence:triad.networkEvidence,freeze:triad.freeze,recoveryAuthority:triad.recoveryAuthority,findings:triad.findings,statusGap:triad.statusGap,renderedText:triad.renderedText,sourceFiles:['samples.jsonl','network.jsonl','browser-process.jsonl','analysis/report.json'],valuesRedacted:true})); + console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,artifactFileCount:files.length,skippedFileCount:skippedFiles.length,skippedFiles:skippedFiles.slice(0,8),traceId:triad.traceId,dom:triad.dom,networkEvidence:triad.networkEvidence,evidenceReconciliation:triad.evidenceReconciliation,freeze:triad.freeze,recoveryAuthority:triad.recoveryAuthority,findings:triad.findings,statusGap:triad.statusGap,renderedText:triad.renderedText,sourceFiles:['samples.jsonl','network.jsonl','browser-process.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true})); process.exit(0); } const frame=renderTraceFrame(selectSample(rows),rows); diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index 1d1dd642..778fa6eb 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -205,6 +205,8 @@ function renderWebObserveStatusTable(value: Record): string { function renderWebObserveCommandTable(value: Record): string { const observerCommand = record(value.observerCommand); const observer = record(value.observer); + const control = record(value.control); + const asyncTurn = record(value.asyncTurn); const result = record(observer?.result); const error = record(observer?.error); const details = record(error?.details); @@ -216,13 +218,14 @@ function renderWebObserveCommandTable(value: Record): string { `web-probe observe command (${webObserveText(value.status)})`, "", ...renderWebObserveWrapperContract(value), - webObserveTable(["OBSERVER", "COMMAND", "TYPE", "STATUS", "TEXT_BYTES", "TEXT_HASH", "DETAIL"], [[ + webObserveTable(["OBSERVER", "COMMAND", "TYPE", "CONTROL", "TURN_SUBMIT", "TURN_TERMINAL", "TRACE", "DETAIL"], [[ id, value.commandId, observerCommand?.type, - observer?.ok === false ? "failed" : observer?.completedAt !== undefined ? "completed" : value.status, - observerCommand?.textBytes, - webObserveShort(webObserveText(observerCommand?.textHash), 24), + control?.executionStatus ?? (observer?.ok === false ? "failed" : observer?.completedAt !== undefined ? "completed" : value.status), + asyncTurn?.submissionStatus ?? "not-observed", + asyncTurn?.terminalObserved === true ? asyncTurn.terminalStatus : "not-observed", + webObserveShort(webObserveText(asyncTurn?.traceId), 24), webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80), ]]), ...(readiness !== null ? [ @@ -254,10 +257,12 @@ function renderWebObserveCommandTable(value: Record): string { "Next:", ` bun scripts/cli.ts web-probe observe status ${id}`, ` bun scripts/cli.ts web-probe observe analyze ${id}`, + ` bun scripts/cli.ts web-probe observe collect ${id} --view turn-summary --command-id ${webObserveText(value.commandId)}`, "", "Disclosure:", " default view is a bounded command result; pass --full to expose the complete command result JSON.", - " use observe status/analyze for sampled state.", + " CONTROL completed means the observer browser action finished; it does not imply the asynchronous business turn reached a terminal state.", + " TURN_SUBMIT and TURN_TERMINAL are separate evidence fields; use the exact command-id turn-summary for sampled terminal state.", ]; return lines.join("\n"); } diff --git a/scripts/src/hwlab-node/web-observe-collect-compact.ts b/scripts/src/hwlab-node/web-observe-collect-compact.ts index fa61d9a6..eaf7f712 100644 --- a/scripts/src/hwlab-node/web-observe-collect-compact.ts +++ b/scripts/src/hwlab-node/web-observe-collect-compact.ts @@ -9,6 +9,10 @@ export function compactObserveCollectForRaw(collect: Record | n return { round: row.round ?? null, commandId: row.commandId ?? null, + controlExecutionStatus: row.controlExecutionStatus ?? null, + turnSubmissionStatus: row.turnSubmissionStatus ?? null, + turnStatus: row.turnStatus ?? row.status ?? null, + turnTerminalObserved: row.turnTerminalObserved === true, userHash: row.userHash ?? null, userBytes: row.userBytes ?? null, traceId: row.traceId ?? null, @@ -191,8 +195,12 @@ export function compactObserveCollectForRaw(collect: Record | n ok: collect.ok !== false, command: collect.command, view: collect.view, + status: collect.status, stateDir: collect.stateDir, turnCount: collect.turnCount, + availableTurnCount: collect.availableTurnCount, + filter: observeRecord(collect.filter), + error: observeRecord(collect.error), artifactFileCount: collect.artifactFileCount ?? collect.fileCount, skippedFileCount: collect.skippedFileCount, skippedFiles: Array.isArray(collect.skippedFiles) ? collect.skippedFiles.slice(0, 8) : undefined, @@ -209,6 +217,7 @@ export function compactObserveCollectForRaw(collect: Record | n traceId: collect.traceId, finalResponse: collect.finalResponse, statusGap: observeRecord(collect.statusGap), + evidenceReconciliation: observeRecord(collect.evidenceReconciliation), dom: observeRecord(collect.dom), networkEvidence: collectView === "workbench-triad" ? slimNetworkEvidence(collect.networkEvidence) : observeRecord(collect.networkEvidence), freeze: collectView === "workbench-triad" ? slimFreeze(collect.freeze) : observeRecord(collect.freeze), diff --git a/scripts/src/hwlab-node/web-observe-collect-turn-visibility.test.ts b/scripts/src/hwlab-node/web-observe-collect-turn-visibility.test.ts new file mode 100644 index 00000000..afbd7b7f --- /dev/null +++ b/scripts/src/hwlab-node/web-observe-collect-turn-visibility.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "bun:test"; + +import { nodeWebObserveCollectViewNodeScript, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect"; + +function shellQuote(value: string): string { + return `\'${value.replace(/\'/g, `\'\\\'\'`)}\'`; +} + +async function writeJsonl(path: string, rows: Array>): Promise { + await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : "")); +} + +async function fixtureState(): Promise { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-turn-visibility-")); + await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-turn-visibility" }) + "\n"); + await writeJsonl(join(stateDir, "control.jsonl"), [ + { type: "sendPrompt", phase: "started", commandId: "cmd-first", sessionId: "ses-fixture", ts: "2026-07-10T01:00:00.000Z", input: { textPreview: "FIRST_UNRELATED", textHash: "sha256:first", textBytes: 15 } }, + { type: "sendPrompt", phase: "completed", commandId: "cmd-first", sessionId: "ses-fixture", ts: "2026-07-10T01:00:01.000Z", detail: { chatSubmit: { status: 202, traceId: "trc_first", otelTraceId: "otel-first" } } }, + { type: "sendPrompt", phase: "started", commandId: "cmd-second", sessionId: "ses-fixture", ts: "2026-07-10T01:01:00.000Z", input: { textPreview: "SECOND_TARGET", textHash: "sha256:second", textBytes: 13 } }, + { type: "sendPrompt", phase: "completed", commandId: "cmd-second", sessionId: "ses-fixture", ts: "2026-07-10T01:01:01.000Z", detail: { chatSubmit: { status: 202, traceId: "trc_second", otelTraceId: "otel-second" } } }, + ]); + await writeJsonl(join(stateDir, "samples.jsonl"), [ + { seq: 1, ts: "2026-07-10T01:00:02.000Z", routeSessionId: "ses-fixture", turns: [{ traceId: "trc_first", status: "completed", text: "completed" }], messages: [{ traceId: "trc_first", role: "assistant", status: "completed", text: "FIRST_FINAL" }], traceRows: [] }, + { seq: 2, ts: "2026-07-10T01:01:02.000Z", routeSessionId: "ses-fixture", turns: [{ traceId: "trc_second", status: "completed", text: "completed" }], messages: [{ traceId: "trc_second", role: "assistant", status: "completed", text: "SECOND_FINAL" }], traceRows: [] }, + ]); + await writeJsonl(join(stateDir, "network.jsonl"), []); + await writeJsonl(join(stateDir, "browser-process.jsonl"), []); + return stateDir; +} + +function collect(stateDir: string, view: NodeWebProbeObserveCollectView, input: { commandId?: string; traceId?: string } = {}): Record { + const script = nodeWebObserveCollectViewNodeScript({ + maxFiles: 100, + view, + traceId: input.traceId ?? null, + sampleSeq: null, + timestamp: null, + turn: null, + commandId: input.commandId ?? null, + windowMs: null, + }); + const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], { + cwd: join(import.meta.dir, "../../.."), + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout) as Record; +} + +test("turn-summary command-id selects exactly one turn and separates control from async state", async () => { + const output = collect(await fixtureState(), "turn-summary", { commandId: "cmd-second" }); + assert.equal(output.ok, true); + assert.equal(output.status, "matched"); + assert.equal(output.turnCount, 1); + assert.equal(output.availableTurnCount, 2); + const filter = output.filter as Record; + assert.equal(filter.mode, "exact-command-id"); + assert.equal(filter.matchedCount, 1); + const rows = output.rows as Array>; + assert.equal(rows.length, 1); + assert.equal(rows[0].round, 2); + assert.equal(rows[0].commandId, "cmd-second"); + assert.equal(rows[0].controlExecutionStatus, "completed"); + assert.equal(rows[0].turnSubmissionStatus, "accepted"); + assert.equal(rows[0].turnStatus, "completed"); + assert.equal(rows[0].turnTerminalObserved, true); + assert.doesNotMatch(String(output.renderedText), /FIRST_UNRELATED|FIRST_FINAL/u); + assert.match(String(output.renderedText), /Control.*Submit.*Async turn/u); +}); + +test("turn-summary missing command-id is structured not-found without all-turn fallback", async () => { + const output = collect(await fixtureState(), "turn-summary", { commandId: "cmd-missing" }); + assert.equal(output.ok, false); + assert.equal(output.status, "not-found"); + assert.equal(output.turnCount, 0); + assert.deepEqual(output.rows, []); + const error = output.error as Record; + assert.equal(error.code, "turn-summary-command-not-found"); + assert.doesNotMatch(String(output.renderedText), /FIRST_UNRELATED|SECOND_TARGET|FIRST_FINAL|SECOND_FINAL/u); + assert.match(String(output.renderedText), /unrelated turns were not returned/u); +}); + +test("workbench-triad explains missing local API artifact and keeps OTel as independent evidence", async () => { + const output = collect(await fixtureState(), "workbench-triad", { traceId: "trc_second" }); + const reconciliation = output.evidenceReconciliation as Record; + assert.equal(reconciliation.status, "artifact-evidence-missing"); + assert.equal(reconciliation.businessApiAbsenceProven, false); + const localApi = reconciliation.localApiArtifact as Record; + assert.equal(localApi.status, "missing"); + const otel = reconciliation.otel as Record; + assert.equal(otel.status, "trace-reference-present"); + assert.equal(otel.otelTraceId, "otel-second"); + assert.equal(otel.coverageStatus, "not-evaluated-by-offline-artifact-view"); + assert.match(String(output.renderedText), /artifact evidence missing/u); + assert.match(String(output.renderedText), /does not mean the business API did not occur/u); +}); diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index a4bba7bc..37e37dbb 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -1,8 +1,28 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; +import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render"; import { renderWebProbeScriptResult } from "./web-observe-render"; +test("observe command renderer separates control completion from async turn terminal state", () => { + const rendered = withWebObserveCommandRendered({ + ok: true, + status: "control-completed", + command: "web-probe observe command", + id: "webobs-fixture", + commandId: "cmd-fixture", + observerCommand: { type: "sendPrompt" }, + observer: { ok: true, completedAt: "2026-07-10T01:00:01.000Z", result: { mark: "submitted" } }, + control: { executionStatus: "completed", businessTurnTerminalImplied: false }, + asyncTurn: { applicable: true, submissionStatus: "accepted", terminalStatus: null, terminalObserved: false, traceId: "trc_fixture" }, + }); + const text = String(rendered.renderedText); + assert.match(text, /CONTROL.*TURN_SUBMIT.*TURN_TERMINAL/u); + assert.match(text, /completed.*accepted.*not-observed.*trc_fixture/u); + assert.match(text, /does not imply the asynchronous business turn reached a terminal state/u); + assert.match(text, /--view turn-summary --command-id cmd-fixture/u); +}); + test("web-probe script render warns to promote repeated scripts into commands", () => { const rendered = renderWebProbeScriptResult({ ok: true, 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 8d202c1f..e754dbaf 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -4,7 +4,32 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; -import { resolveWebObserveActionJson } from "./web-probe-observe-actions"; +import { buildWebObserveCommandVisibility, resolveWebObserveActionJson } from "./web-probe-observe-actions"; + +test("web observe command visibility does not equate control completion with async turn completion", () => { + const visibility = buildWebObserveCommandVisibility({ + commandType: "sendPrompt", + exitCode: 0, + commandResult: { + ok: true, + completedAt: "2026-07-10T01:00:01.000Z", + result: { + chatSubmit: { + status: 202, + traceId: "trc_fixture", + otelTraceId: "otel_fixture", + }, + }, + }, + }); + assert.equal(visibility.status, "control-completed"); + assert.equal(visibility.control.executionStatus, "completed"); + assert.equal(visibility.control.businessTurnTerminalImplied, false); + assert.equal(visibility.asyncTurn.submissionStatus, "accepted"); + assert.equal(visibility.asyncTurn.terminalStatus, null); + assert.equal(visibility.asyncTurn.terminalObserved, false); + assert.equal(visibility.asyncTurn.terminalStatusSource, "not-evaluated-by-control-command"); +}); test("web observe action JSON recovery accepts concrete start contract", () => { const resolved = resolveWebObserveActionJson({ diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 25603cae..59ee09d2 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -603,6 +603,68 @@ export function webObserveShort(value: string, maxLength: number): string { return `${value.slice(0, maxLength - 1)}~`; } +export function buildWebObserveCommandVisibility(input: { + commandType: string; + commandResult: Record | null; + exitCode: number | null; +}): { + status: string; + control: Record; + asyncTurn: Record; +} { + const commandResult = input.commandResult; + const executionStatus = input.exitCode !== 0 || commandResult === null + ? "blocked" + : commandResult.ok === false + ? "failed" + : commandResult.completedAt !== undefined && commandResult.completedAt !== null + ? "completed" + : commandResult.queued === true || commandResult.waitTimedOut === true + ? "queued" + : "accepted"; + const actionResult = record(commandResult?.result); + const chatSubmit = record(actionResult.chatSubmit); + const traceId = typeof chatSubmit.traceId === "string" ? chatSubmit.traceId : null; + const otelTraceId = typeof chatSubmit.otelTraceId === "string" ? chatSubmit.otelTraceId : null; + const httpStatus = typeof chatSubmit.status === "number" ? chatSubmit.status : null; + const turnApplicable = input.commandType === "sendPrompt" || input.commandType === "steer"; + const submissionAccepted = chatSubmit.sideEffectObserved === true + || httpStatus !== null && httpStatus >= 200 && httpStatus < 300 + || traceId !== null; + const submissionStatus = !turnApplicable + ? "not-applicable" + : executionStatus === "blocked" + ? "not-submitted" + : executionStatus === "failed" + ? "failed" + : submissionAccepted + ? "accepted" + : executionStatus === "queued" + ? "pending" + : "not-observed"; + return { + status: "control-" + executionStatus, + control: { + executionStatus, + completedAt: commandResult?.completedAt ?? null, + completionMeaning: executionStatus === "completed" ? "observer-browser-control-action-finished" : null, + businessTurnTerminalImplied: false, + valuesRedacted: true, + }, + asyncTurn: { + applicable: turnApplicable, + submissionStatus, + submissionHttpStatus: httpStatus, + terminalStatus: null, + terminalObserved: false, + terminalStatusSource: turnApplicable ? "not-evaluated-by-control-command" : "not-applicable", + traceId, + otelTraceId, + valuesRedacted: true, + }, + }; +} + export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record | RenderedCliResult { const type = options.commandType ?? (stopCommand ? "stop" : null); if (type === null) throw new Error("web-probe observe command requires --type"); @@ -682,9 +744,14 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption : "graceful-stop-queued"; return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, reason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result); } + const visibility = buildWebObserveCommandVisibility({ + commandType: type, + commandResult, + exitCode: result.exitCode, + }); const payloadResult = { ok: result.exitCode === 0 && commandResult !== null && commandResult.ok !== false, - status: result.exitCode === 0 && commandResult !== null ? (waitMs > 0 ? "completed-or-queued" : "queued") : "blocked", + status: visibility.status, command: webObserveCommandLabel(stopCommand ? "stop" : "command", options), id: webObserveIdFromOptions(options), node: options.node, @@ -693,6 +760,8 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption commandId, observerCommand: commandSummaryForOutput(payload), observer: commandResult, + control: visibility.control, + asyncTurn: visibility.asyncTurn, wrapper: buildWebObserveWrapperForObserveOptions(stopCommand ? "stop" : "command", options, spec.workspace, { commandType: type }), stdoutJson: commandResolution.diagnostics, result: compactCommandResult(result), diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts index c9ba5407..e429b73b 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -49,6 +49,37 @@ describe("web-probe observe collect child JSON recovery", () => { }); }); +test("propagates structured turn-summary not-found status without treating it as collected", () => { + const options = collectOptions(); + options.collectView = "turn-summary"; + options.collectCommandId = "cmd-missing"; + const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, { + command: ["trans", "JD01:/workspace", "sh"], + cwd: "/repo", + exitCode: 0, + stdout: JSON.stringify({ + ok: false, + command: "web-probe-observe collect", + view: "turn-summary", + status: "not-found", + stateDir: "/remote/state", + filter: { mode: "exact-command-id", requestedCommandId: "cmd-missing", matchedCount: 0 }, + error: { code: "turn-summary-command-not-found" }, + rows: [], + renderedText: "NOT_FOUND turn-summary-command-not-found; unrelated turns were not returned", + valuesRedacted: true, + }), + stderr: "", + signal: null, + timedOut: false, + } satisfies CommandResult); + expect(payload.ok).toBe(false); + expect(payload.status).toBe("not-found"); + const collect = payload.collect as Record; + expect(collect.status).toBe("not-found"); + expect(collect.rows).toEqual([]); +}); + function collectOptions(): NodeWebProbeObserveOptions { return { action: "observe", diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.ts b/scripts/src/hwlab-node/web-probe-observe-collect.ts index 7341f66d..79643a0c 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.ts @@ -52,9 +52,14 @@ export function buildNodeWebProbeObserveCollectPayload( const degradedReason = collect === null ? collectDegradedReason(stdoutResolution.diagnostics) : null; + const collectStatus = collect === null ? null : stringOrNull(collect.status); return { ok: result.exitCode === 0 && collect !== null && collect.ok !== false, - status: result.exitCode === 0 && collect !== null ? "collected" : "blocked", + status: result.exitCode === 0 && collect !== null + ? collect.ok === false + ? collectStatus ?? "blocked" + : collectStatus ?? "collected" + : "blocked", command: webObserveCommandLabel("collect", options), id: webObserveIdFromOptions(options), node: options.node,