feat: add web-probe observe control views (#848)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-25 03:38:05 +08:00
committed by GitHub
parent 922f8f3f63
commit baf0c2bb05
8 changed files with 5756 additions and 5419 deletions
@@ -0,0 +1,169 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts.
import { shellQuote } from "./ssh";
export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame";
export function parseNodeWebProbeObserveCollectView(value: string): NodeWebProbeObserveCollectView {
if (value === "files" || value === "turn-summary" || value === "trace-frame") return value;
throw new Error(`web-probe observe collect --view must be files, turn-summary, or trace-frame; got ${value}`);
}
export function nodeWebObserveCollectViewNodeScript(input: {
maxFiles: number;
view: NodeWebProbeObserveCollectView;
traceId: string | null;
sampleSeq: number | null;
timestamp: string | null;
turn: number | null;
}): string {
const requestedSampleSeq = input.sampleSeq === null ? "null" : String(input.sampleSeq);
const requestedTurn = input.turn === null ? "null" : String(input.turn);
return `node -e ${shellQuote(`
const fs=require('fs'),path=require('path'),crypto=require('crypto');
const dir=process.argv[1]; const maxFiles=${input.maxFiles}; const view=${JSON.stringify(input.view)}; const requestedTraceId=${JSON.stringify(input.traceId)}; const requestedSampleSeq=${requestedSampleSeq}; const requestedTimestamp=${JSON.stringify(input.timestamp)}; const requestedTurn=${requestedTurn};
const sha=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
const short=(value,limit=96)=>{const text=String(value||'').replace(/\\s+/gu,' ').trim(); return text.length>limit?text.slice(0,Math.max(1,limit-1))+'...':text;};
const textOf=(value)=>String(value?.text||value?.textPreview||value?.preview||'');
const readJson=(rel)=>{try{return JSON.parse(fs.readFileSync(path.join(dir,rel),'utf8'))}catch{return null}};
const readJsonl=(rel)=>{try{return fs.readFileSync(path.join(dir,rel),'utf8').split(/\\r?\\n/u).filter(Boolean).map((line)=>{try{return JSON.parse(line)}catch{return {parseError:true,rawHash:sha(line)}}})}catch{return []}};
const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}};
walk(dir);
const files=out.slice(0,maxFiles).map(file=>{const st=fs.statSync(file); const hash=crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); return {path:file,relative:path.relative(dir,file),byteCount:st.size,sha256:'sha256:'+hash}});
const totalBytes=files.reduce((sum,item)=>sum+item.byteCount,0);
if(view==='files'){
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,fileCount:files.length,totalBytes,files,valuesRedacted:true},null,2));
process.exit(0);
}
const samples=readJsonl('samples.jsonl');
const control=readJsonl('control.jsonl');
const manifest=readJson('manifest.json')||{};
function unique(values){return Array.from(new Set(values.filter(Boolean)));}
function tsMs(value){const ms=Date.parse(String(value||'')); return Number.isFinite(ms)?ms:null}
function promptCommands(){
const map=new Map();
for(const item of control){
if((item.type!=='sendPrompt'&&item.type!=='steer')||(item.phase!=='started'&&item.phase!=='completed'&&item.phase!=='failed')) continue;
const id=item.commandId||item.seq||String(map.size+1);
const existing=map.get(id)||{};
map.set(id,{...existing,...item,input:{...(existing.input||{}),...(item.input||{})},firstTs:existing.firstTs||item.ts,lastTs:item.ts});
}
return Array.from(map.values()).filter((item)=>tsMs(item.firstTs)!==null).sort((a,b)=>tsMs(a.firstTs)-tsMs(b.firstTs));
}
function segmentFor(index,prompts){
const start=tsMs(prompts[index]?.firstTs); const end=index+1<prompts.length?tsMs(prompts[index+1].firstTs):Infinity;
return samples.filter((sample)=>{const ms=tsMs(sample.ts); return ms!==null&&ms>=start&&ms<end});
}
function controlsFor(index,prompts){
const start=tsMs(prompts[index]?.firstTs); const end=index+1<prompts.length?tsMs(prompts[index+1].firstTs):Infinity;
return control.filter((item)=>{const ms=tsMs(item.ts); return ms!==null&&ms>=start&&ms<end});
}
function firstTraceId(text){const match=String(text||'').match(/\\btrc_[A-Za-z0-9_-]+\\b/u); return match?match[0]:null}
function traceIdsFromSamples(items){
const ids=[];
for(const sample of items){
for(const turn of Array.isArray(sample.turns)?sample.turns:[]) ids.push(turn.traceId);
for(const row of Array.isArray(sample.traceRows)?sample.traceRows:[]) ids.push(row.traceId||firstTraceId(textOf(row)));
for(const message of Array.isArray(sample.messages)?sample.messages:[]) ids.push(message.traceId||firstTraceId(textOf(message)));
}
return unique(ids);
}
function parseElapsed(text){
const values=[]; const raw=String(text||'');
for(const m of raw.matchAll(/(?:耗时|总耗时|total\\s*=?)\\s*(?:(\\d+)\\s*(?:小时|h)\\s*)?(?:(\\d+)\\s*(?:分|分钟|m)\\s*)?(?:(\\d+)\\s*(?:秒|s))?/giu)){
const total=Number(m[1]||0)*3600+Number(m[2]||0)*60+Number(m[3]||0); if(total>0) values.push(total);
}
for(const m of raw.matchAll(/\\b(?:total|elapsed)\\s*=\\s*(?:(\\d{1,2}):)?(\\d{1,2}):(\\d{2})\\b/giu)){
values.push(Number(m[1]||0)*3600+Number(m[2]||0)*60+Number(m[3]||0));
}
return values.length?Math.max(...values):null;
}
function parseRecent(text){const m=String(text||'').match(/最近\\s*(\\d+)\\s*(秒|分钟|分|小时)前/u); if(!m)return null; const n=Number(m[1]); return m[2]==='小时'?n*3600:m[2]==='秒'?n:n*60}
function fmtDuration(seconds){if(seconds===null||seconds===undefined||!Number.isFinite(Number(seconds)))return '-'; const value=Math.max(0,Math.round(Number(seconds))); const h=Math.floor(value/3600), m=Math.floor((value%3600)/60), s=value%60; return (h>0?String(h).padStart(2,'0')+':':'')+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0')}
function terminalText(text){return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|completed|failed|canceled|cancelled|terminal/iu.test(String(text||''))}
function statusFor(items){
const texts=items.flatMap((sample)=>[...(Array.isArray(sample.turns)?sample.turns:[]),...(Array.isArray(sample.traceRows)?sample.traceRows:[]),...(Array.isArray(sample.messages)?sample.messages:[])].map(textOf));
const joined=texts.join('\\n');
const lastTurn=[].concat(...items.map((sample)=>Array.isArray(sample.turns)?sample.turns:[])).slice(-1)[0]||null;
const raw=String(lastTurn?.status||'').toLowerCase();
if(/cancel/iu.test(joined)||raw.includes('cancel'))return 'canceled';
if(/failed|error|agentrun:error/iu.test(joined)||raw.includes('fail'))return 'failed';
if(texts.some(terminalText)||raw.includes('complete'))return 'completed';
if(/running|运行中|Code Agent\\s*耗时|最近\\s*\\d+/iu.test(joined)||raw.includes('running'))return 'running';
return 'unknown';
}
function userMessageFor(items,prompt){
const hash=prompt?.input?.textHash||null;
for(const sample of items){
for(const message of Array.isArray(sample.messages)?sample.messages:[]){
if(hash&&message.textHash===hash)return {preview:short(textOf(message),90),textHash:message.textHash||hash,textBytes:message.textBytes??prompt?.input?.textBytes??null};
}
}
return {preview:short(prompt?.input?.textPreview||'',90)||'(hash only)',textHash:hash,textBytes:prompt?.input?.textBytes??null};
}
function finalResponseFor(items,traceId){
const turns=[].concat(...items.map((sample)=>Array.isArray(sample.turns)?sample.turns:[]));
const candidates=(traceId?turns.filter((turn)=>turn.traceId===traceId):turns).slice().reverse();
for(const turn of candidates){
const status=String(turn.status||'').toLowerCase(); const text=textOf(turn);
if(status.includes('running')&&!terminalText(text))continue;
if(!terminalText(text)&&!status.match(/complete|fail|cancel|block/u))continue;
const preview=short(text,180);
return preview?{preview,textHash:turn.textHash||sha(text),textBytes:turn.textBytes??Buffer.byteLength(text),empty:false}:{preview:'(空内容)',textHash:null,textBytes:0,empty:true};
}
return {preview:'(空内容)',textHash:null,textBytes:0,empty:true};
}
function turnSummaryRows(){
const prompts=promptCommands();
if(prompts.length===0){
const allTraceIds=traceIdsFromSamples(samples);
return [{round:0,commandId:null,commandType:null,userPreview:'(无 sendPrompt/steer control 记录)',userHash:null,userBytes:null,traceId:allTraceIds[0]||null,status:statusFor(samples),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 ids=traceIdsFromSamples(segment); const traceId=ids[0]||null; const user=userMessageFor(segment,prompt);
const texts=segment.flatMap((sample)=>[...(Array.isArray(sample.turns)?sample.turns:[]),...(Array.isArray(sample.traceRows)?sample.traceRows:[]),...(Array.isArray(sample.messages)?sample.messages:[])].map(textOf));
const elapsedValues=texts.map(parseElapsed).filter((value)=>value!==null); const recentValues=texts.map(parseRecent).filter((value)=>value!==null);
const marks=unique([prompt.type==='steer'?'steer':null,...segmentControls.map((item)=>item.type==='cancel'?'cancel':item.type==='steer'?'steer':null)]).join(',')||'-';
return {round:index+1,commandId:prompt.commandId||null,commandType:prompt.type||null,userPreview:user.preview,userHash:user.textHash,userBytes:user.textBytes,traceId,status:statusFor(segment),elapsedSeconds:elapsedValues.length?Math.max(...elapsedValues):null,recentUpdateSeconds:recentValues.length?Math.max(...recentValues):null,marks,firstSeq:segment[0]?.seq??null,lastSeq:segment[segment.length-1]?.seq??null,lastTs:segment[segment.length-1]?.ts??null,finalResponse:finalResponseFor(segment,traceId),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){
const title='Workbench Session '+(samples[samples.length-1]?.routeSessionId||samples[samples.length-1]?.activeSessionId||'-')+' observer '+(manifest.jobId||'-');
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 hwlab nodes web-probe observe collect '+(manifest.jobId||'<observer>')+' --view trace-frame --trace-id <traceId> --sample-seq <seq>'].join('\\n');
}
function selectSample(rows){
if(requestedSampleSeq!==null){const exact=samples.find((sample)=>Number(sample.seq)===requestedSampleSeq); if(exact)return exact;}
if(requestedTimestamp){const target=tsMs(requestedTimestamp); if(target!==null){const before=samples.filter((sample)=>{const ms=tsMs(sample.ts); return ms!==null&&ms<=target}).slice(-1)[0]; if(before)return before;}}
if(requestedTurn!==null&&rows[requestedTurn-1]?.lastSeq!==null){const byTurn=samples.find((sample)=>Number(sample.seq)===Number(rows[requestedTurn-1].lastSeq)); if(byTurn)return byTurn;}
if(requestedTraceId){const byTrace=samples.filter((sample)=>traceIdsFromSamples([sample]).includes(requestedTraceId)).slice(-1)[0]; if(byTrace)return byTrace;}
return samples[samples.length-1]||null;
}
function renderTraceFrame(sample,rows){
if(!sample)return {ok:false,renderedText:'TRACE_FRAME_BLOCKER\\nno sample matched --sample-seq/--timestamp/--trace-id/--turn',blocker:'sample-not-found'};
const sampleTraceIds=traceIdsFromSamples([sample]); const traceId=requestedTraceId||sampleTraceIds[0]||rows.find((row)=>row.lastSeq===sample.seq)?.traceId||null;
const traceRows=(Array.isArray(sample.traceRows)?sample.traceRows:[]).filter((row)=>!traceId||row.traceId===traceId||!row.traceId||textOf(row).includes(traceId));
const turns=(Array.isArray(sample.turns)?sample.turns:[]).filter((turn)=>!traceId||turn.traceId===traceId||textOf(turn).includes(traceId));
const texts=[...turns.map(textOf),...traceRows.map(textOf)];
const elapsed=Math.max(-1,...texts.map(parseElapsed).filter((value)=>value!==null)); const recent=Math.max(-1,...texts.map(parseRecent).filter((value)=>value!==null));
const status=statusFor([sample]); const finalResponse=finalResponseFor([sample],traceId);
const visibleTraceRows=traceRows.slice(-24);
const rowLines=visibleTraceRows.map((row,index)=>{const text=textOf(row); return short((row.status?row.status+' ':'')+text,180)||('row#'+index+' '+(row.textHash||'-'));});
if(traceRows.length>visibleTraceRows.length) rowLines.unshift('(已省略 '+(traceRows.length-visibleTraceRows.length)+' 条较早 trace rows;需要原始数据请看 samples.jsonl)');
const visibleTurns=turns.slice(-6);
const assistantLines=visibleTurns.map((turn)=>'助手消息 '+(turn.status||'-')+' '+short(textOf(turn),180));
if(turns.length>visibleTurns.length) assistantLines.unshift('(已省略 '+(turns.length-visibleTurns.length)+' 条较早 assistant turn summaries)');
const rendered=['Code Agent 耗时 '+(elapsed>=0?fmtDuration(elapsed):'-')+' 最近 '+(recent>=0?String(recent)+' 秒前':'-')+' '+status+'','=======================================================','sample seq='+(sample.seq??'-')+' ts='+(sample.ts||'-')+' traceId='+(traceId||'-')+' routeSession='+(sample.routeSessionId||'-')+' activeSession='+(sample.activeSessionId||'-'),...(rowLines.length?rowLines:['(无 trace rows;这是 blocker,不能当业务通过证据)']),...(assistantLines.length?assistantLines:[]),'==========================','Final Response',finalResponse.preview||'(空内容)'].join('\\n');
return {ok:rowLines.length>0,renderedText:rendered,blocker:rowLines.length>0?null:'trace-rows-missing',sampleSeq:sample.seq??null,traceId,finalResponse,valuesRedacted:true};
}
const rows=turnSummaryRows();
if(view==='turn-summary'){
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,turnCount:rows.length,rows:rows.slice(0,80),renderedText:renderTurnSummary(rows),sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2));
process.exit(0);
}
const frame=renderTraceFrame(selectSample(rows),rows);
console.log(JSON.stringify({ok:frame.ok!==false,command:'web-probe-observe collect',view,stateDir:dir,...frame,sourceFiles:['samples.jsonl','control.jsonl','analysis/report.json'],valuesRedacted:true},null,2));
`)} "$state_dir"`;
}