fix: 修复 Web observe turn 可见性

This commit is contained in:
Codex
2026-07-10 07:01:23 +02:00
parent f128d73663
commit 5e8d3201ec
10 changed files with 350 additions and 19 deletions
+74 -10
View File
@@ -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||'<observer>')+' --view trace-frame --trace-id <traceId> --sample-seq <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||'<observer>')+' --view trace-frame --trace-id <traceId> --sample-seq <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 <node> --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||'<observer>')+' --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);
+10 -5
View File
@@ -205,6 +205,8 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
function renderWebObserveCommandTable(value: Record<string, unknown>): 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, unknown>): 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, unknown>): 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");
}
@@ -9,6 +9,10 @@ export function compactObserveCollectForRaw(collect: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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),
@@ -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<Record<string, unknown>>): Promise<void> {
await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : ""));
}
async function fixtureState(): Promise<string> {
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<string, unknown> {
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<string, unknown>;
}
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<string, unknown>;
assert.equal(filter.mode, "exact-command-id");
assert.equal(filter.matchedCount, 1);
const rows = output.rows as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
assert.equal(reconciliation.status, "artifact-evidence-missing");
assert.equal(reconciliation.businessApiAbsenceProven, false);
const localApi = reconciliation.localApiArtifact as Record<string, unknown>;
assert.equal(localApi.status, "missing");
const otel = reconciliation.otel as Record<string, unknown>;
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);
});
@@ -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,
@@ -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({
@@ -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<string, unknown> | null;
exitCode: number | null;
}): {
status: string;
control: Record<string, unknown>;
asyncTurn: Record<string, unknown>;
} {
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<string, unknown> | 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),
@@ -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<string, unknown>;
expect(collect.status).toBe("not-found");
expect(collect.rows).toEqual([]);
});
function collectOptions(): NodeWebProbeObserveOptions {
return {
action: "observe",
@@ -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,