fix: 修复 Web observe turn 可见性
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user