diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 8801c193..cff55632 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -93,6 +93,7 @@ long prompt EOF bun scripts/cli.ts web-probe observe status webobs-xxxx --tail-lines 6 bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary +bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --command-id cmd-xxxx bun scripts/cli.ts web-probe observe collect webobs-xxxx --view trace-frame --trace-id trc_xxx --sample-seq 42 bun scripts/cli.ts web-probe observe stop webobs-xxxx bun scripts/cli.ts web-probe observe analyze webobs-xxxx @@ -108,6 +109,8 @@ bun scripts/web-probe-sentinel-service.ts --node D601 --lane v03 --state-root .s `observe analyze` 的 duplicate final response 判定必须以 trace-frame 可见行事实为准。`observe collect --view trace-frame` 固定渲染的 `Final Response` 区块是 summary,不是第二条业务 assistant message;只有同一 trace-frame 中出现两个可见 assistant final rows 且内容重复时,才应报告 duplicate finding,并在证据中写明 `finalResponseSummaryBlockCounted=false`。 +`observe collect --view timeline` 用于 issue closeout 前的 artifact drill-down:它只读取现有 `control.jsonl`、`samples.jsonl` 和 `commands/{pending,processing,done,failed,abandoned}/*.json`,默认输出 bounded timeline、关键 metadata、脱敏 disclosure 和下一步命令。按 `--command-id`、`--turn`、`--trace-id`、`--sample-seq`、`--timestamp` 或 `--window-ms` 缩小窗口;需要完整原始内容时必须显式使用 `--raw` 或 `--view files --file ...`。 + 项目管理 / MDTODO 页面同样优先使用 `observe`,不要退回一次性 Playwright 脚本。MDTODO 主动编辑验收必须把常见动作沉淀成 `observe command`,同一 observer 串联 source 配置、HWPOD probe/reindex、文件/任务选择、Rxx 树操作、编辑写回和 Workbench launch: ```bash diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 1d69c5a3..317aaac1 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -690,6 +690,7 @@ function webProbeHelpSummary(): unknown { "bun scripts/cli.ts web-probe run --node D601 --lane v03 --wait-messages-ms 1000", "bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary", + "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run", ], description: "Run target node/lane HWLAB Cloud Web probes, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel YAML-first control through a single top-level implementation.", diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 1165a3c9..8a42d642 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -49,6 +49,7 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe observe command webobs-xxxx --type launchWorkbenchFromMdtodo --task R1.1", "bun scripts/cli.ts web-probe observe status webobs-xxxx", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary", + "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --command-id cmd-xxxx", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary", "bun scripts/cli.ts web-probe observe analyze webobs-xxxx", "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run", diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index e763cc8b..787ab9b3 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -2,11 +2,11 @@ // Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts. import { shellQuote } from "./ssh"; -export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame" | "project-summary" | "project-mdtodo-summary"; +export type NodeWebProbeObserveCollectView = "files" | "turn-summary" | "trace-frame" | "timeline" | "project-summary" | "project-mdtodo-summary"; export function parseNodeWebProbeObserveCollectView(value: string): NodeWebProbeObserveCollectView { - if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "project-summary" || value === "project-mdtodo-summary") return value; - throw new Error(`web-probe observe collect --view must be files, turn-summary, trace-frame, project-summary, or project-mdtodo-summary; got ${value}`); + if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "timeline" || value === "project-summary" || value === "project-mdtodo-summary") return value; + throw new Error(`web-probe observe collect --view must be files, turn-summary, trace-frame, timeline, project-summary, or project-mdtodo-summary; got ${value}`); } export function nodeWebObserveCollectViewNodeScript(input: { @@ -16,12 +16,15 @@ export function nodeWebObserveCollectViewNodeScript(input: { sampleSeq: number | null; timestamp: string | null; turn: number | null; + commandId: string | null; + windowMs: number | null; }): string { const requestedSampleSeq = input.sampleSeq === null ? "null" : String(input.sampleSeq); const requestedTurn = input.turn === null ? "null" : String(input.turn); + const requestedWindowMs = input.windowMs === null ? "null" : String(input.windowMs); 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 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 requestedCommandId=${JSON.stringify(input.commandId)}; const requestedWindowMs=${requestedWindowMs}; 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||''); @@ -349,6 +352,181 @@ function renderTraceFrame(sample,rows){ 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||'-'),...bodyRows,'==========================','Final Response',finalResponse.preview||'(空内容)'].join('\\n'); return {ok:!missingRows,renderedText:rendered,blocker:missingRows?'trace-rows-missing':null,sampleSeq:sample.seq??null,traceId,finalResponse,traceDiagnostic:missingRows?{pageRole:sample.pageRole||null,pageId:sample.pageId||null,traceRows:Array.isArray(sample.traceRows)?sample.traceRows.length:0,turns:Array.isArray(sample.turns)?sample.turns.length:0,messages:Array.isArray(sample.messages)?sample.messages.length:0,sampleTraceIds:traceIdsFromSamples([sample]).slice(0,12)}:null,valuesRedacted:true}; } +function firstPresent(values){ + for(const value of values){ + if(value!==null&&value!==undefined&&String(value)!=='') return value; + } + return null; +} +function commandIdOf(item){return item?.commandId||item?.id||item?.detail?.commandId||item?.input?.commandId||null} +function controlSessionId(item){ + const detail=item?.detail&&typeof item.detail==='object'?item.detail:{}; + return commandSessionId(item)||detail.sessionId||detail.result?.sessionId||detail.observer?.sessionId||null; +} +function controlTraceId(item){ + const detail=item?.detail&&typeof item.detail==='object'?item.detail:{}; + return commandTraceId(item)||detail.traceId||detail.chatSubmit?.traceId||detail.result?.traceId||detail.result?.chatSubmit?.traceId||null; +} +function safeListJsonFiles(rel){ + const root=path.join(dir,rel); let entries=[]; + try{entries=fs.readdirSync(root,{withFileTypes:true});}catch{return [];} + return entries.filter((entry)=>entry.isFile()&&entry.name.endsWith('.json')).map((entry)=>rel+'/'+entry.name); +} +function readCommandFiles(){ + const buckets=['pending','processing','done','failed','abandoned']; + const rows=[]; + for(const bucket of buckets){ + for(const rel of safeListJsonFiles('commands/'+bucket)){ + const data=readJson(rel)||{}; let stat=null; + try{stat=fs.statSync(path.join(dir,rel));}catch{} + const name=path.basename(rel); + rows.push({bucket,relative:rel,name,data,mtime:stat?stat.mtime.toISOString():null,valuesRedacted:true}); + } + } + return rows.sort((a,b)=>(tsMs(commandFileTs(a))??0)-(tsMs(commandFileTs(b))??0)||a.relative.localeCompare(b.relative)); +} +function commandFileId(row){return row?.data?.id||row?.data?.commandId||String(row?.name||'').replace(/[.]json$/u,'')||null} +function commandFileType(row){return row?.data?.type||row?.data?.commandType||row?.data?.input?.type||row?.data?.result?.type||null} +function commandFileTs(row){return firstPresent([row?.data?.createdAt,row?.data?.completedAt,row?.data?.failedAt,row?.data?.abandonedAt,row?.data?.ts,row?.mtime])} +function commandFileSessionId(row){ + const data=row?.data||{}; const result=data.result&&typeof data.result==='object'?data.result:{}; const command=data.command&&typeof data.command==='object'?data.command:{}; + return data.sessionId||data.input?.sessionId||command.sessionId||result.sessionId||result.observer?.sessionId||sessionIdFromUrl(result.afterUrl)||null; +} +function commandFileTraceId(row){ + const data=row?.data||{}; const result=data.result&&typeof data.result==='object'?data.result:{}; const error=data.error&&typeof data.error==='object'?data.error:{}; + return data.traceId||data.input?.traceId||result.traceId||result.chatSubmit?.traceId||error.traceId||null; +} +function commandFileSummary(row){ + const data=row?.data||{}; const result=data.result&&typeof data.result==='object'?data.result:{}; const error=data.error&&typeof data.error==='object'?data.error:{}; + const parts=['file='+short(row.relative,48),'ok='+String(data.ok===true),'bucket='+row.bucket]; + const duration=firstPresent([result.durationMs,result.observer?.durationMs,error.durationMs]); + if(duration!==null) parts.push('durationMs='+String(duration)); + const status=firstPresent([result.status,result.phase,error.name,error.failureKind]); + if(status!==null) parts.push('status='+short(status,32)); + const message=firstPresent([error.message,result.message,result.error?.message]); + if(message!==null) parts.push('message='+short(message,120)); + return parts.join(' '); +} +function controlSummary(item){ + const detail=item?.detail&&typeof item.detail==='object'?item.detail:{}; + const input=item?.input&&typeof item.input==='object'?item.input:{}; + const error=detail.error&&typeof detail.error==='object'?detail.error:null; + const parts=[]; + if(input.textHash||input.hash) parts.push('textHash='+short(input.textHash||input.hash,24)); + if(input.textBytes!==undefined) parts.push('textBytes='+String(input.textBytes)); + if(detail.durationMs!==undefined) parts.push('durationMs='+String(detail.durationMs)); + if(detail.retry||detail.retryLabel) parts.push('retry='+short(detail.retry||detail.retryLabel,24)); + if(error?.message) parts.push('error='+short(error.message,120)); + const afterPath=pathOnly(item?.afterUrl||detail.afterUrl); + if(afterPath) parts.push('afterPath='+short(afterPath,64)); + return parts.join(' ')||'-'; +} +function sampleSummary(sample){ + const traceIds=traceIdsFromSamples([sample]).slice(0,4); + const counts='turns='+(Array.isArray(sample.turns)?sample.turns.length:0)+' traceRows='+(Array.isArray(sample.traceRows)?sample.traceRows.length:0)+' messages='+(Array.isArray(sample.messages)?sample.messages.length:0); + const pathText=sample.path||pathOnly(sample.url)||pathOnly(sample.currentUrl)||'-'; + return 'path='+short(pathText,64)+' pageRole='+short(sample.pageRole||'-',18)+' '+counts+' traceIds='+(traceIds.join(',')||'-'); +} +function isoOrNull(ms){return ms===null||ms===Infinity||ms===-Infinity?null:new Date(ms).toISOString()} +function timelineAnchor(rows,commandFiles){ + if(requestedCommandId){ + const controls=control.filter((item)=>String(commandIdOf(item)||'')===requestedCommandId); + const files=commandFiles.filter((item)=>String(commandFileId(item)||'')===requestedCommandId); + const times=[...controls.map((item)=>tsMs(item.ts)),...files.map((item)=>tsMs(commandFileTs(item)))].filter((value)=>value!==null); + const start=times.length?Math.min(...times):null; const end=times.length?Math.max(...times):null; + return {mode:'command-id',value:requestedCommandId,startMs:start,endMs:end,anchorMs:end??start,matchedControlCount:controls.length,matchedCommandFileCount:files.length,valuesRedacted:true}; + } + if(requestedTurn!==null&&rows[requestedTurn-1]){ + const row=rows[requestedTurn-1]; const segment=samples.filter((sample)=>{const seq=numOrNull(sample.seq); return seq!==null&&row.firstSeq!==null&&row.lastSeq!==null&&seq>=Number(row.firstSeq)&&seq<=Number(row.lastSeq);}); + const times=[tsMs(row.lastTs),...segment.map((sample)=>tsMs(sample.ts))].filter((value)=>value!==null); + const start=times.length?Math.min(...times):null; const end=times.length?Math.max(...times):null; + return {mode:'turn',value:String(requestedTurn),commandId:row.commandId||null,traceId:row.traceId||null,startMs:start,endMs:end,anchorMs:end??start,valuesRedacted:true}; + } + if(requestedTraceId){ + const matched=samples.filter((sample)=>traceIdsFromSamples([sample]).includes(requestedTraceId)); + const times=matched.map((sample)=>tsMs(sample.ts)).filter((value)=>value!==null); + const start=times.length?Math.min(...times):null; const end=times.length?Math.max(...times):null; + return {mode:'trace-id',value:requestedTraceId,startMs:start,endMs:end,anchorMs:end??start,matchedSampleCount:matched.length,valuesRedacted:true}; + } + if(requestedSampleSeq!==null){ + const sample=samples.find((item)=>Number(item.seq)===requestedSampleSeq)||null; + const anchorMs=tsMs(sample?.ts); + return {mode:'sample-seq',value:String(requestedSampleSeq),startMs:anchorMs,endMs:anchorMs,anchorMs,valuesRedacted:true}; + } + if(requestedTimestamp){ + const anchorMs=tsMs(requestedTimestamp); + return {mode:'timestamp',value:requestedTimestamp,startMs:anchorMs,endMs:anchorMs,anchorMs,valuesRedacted:true}; + } + const latestTimes=[...samples.map((sample)=>tsMs(sample.ts)),...control.map((item)=>tsMs(item.ts)),...commandFiles.map((item)=>tsMs(commandFileTs(item)))].filter((value)=>value!==null); + const anchorMs=latestTimes.length?Math.max(...latestTimes):null; + return {mode:'latest',value:null,startMs:anchorMs,endMs:anchorMs,anchorMs,valuesRedacted:true}; +} +function timelineWindow(anchor){ + const windowMs=requestedWindowMs??120000; + if(anchor.startMs!==null&&anchor.endMs!==null&&anchor.startMs!==anchor.endMs){ + return {fromMs:Math.max(0,anchor.startMs-1000),toMs:anchor.endMs+1000,windowMs,anchorMode:anchor.mode,valuesRedacted:true}; + } + if(anchor.anchorMs!==null){ + return {fromMs:Math.max(0,anchor.anchorMs-windowMs),toMs:anchor.anchorMs+windowMs,windowMs,anchorMode:anchor.mode,valuesRedacted:true}; + } + return {fromMs:null,toMs:null,windowMs,anchorMode:anchor.mode,valuesRedacted:true}; +} +function inTimelineWindow(ts,window){ + const ms=tsMs(ts); + if(ms===null) return window.fromMs===null&&window.toMs===null; + if(window.fromMs!==null&&mswindow.toMs) return false; + return true; +} +function timelineEventRows(anchor,window,commandFiles){ + const commandId=anchor.mode==='command-id'?anchor.value:null; + const matchedControl=control.filter((item)=>(commandId&&String(commandIdOf(item)||'')===commandId)||inTimelineWindow(item.ts,window)); + const matchedSamples=samples.filter((sample)=>inTimelineWindow(sample.ts,window)); + const matchedCommands=commandFiles.filter((item)=>(commandId&&String(commandFileId(item)||'')===commandId)||inTimelineWindow(commandFileTs(item),window)); + const rows=[ + ...matchedControl.map((item)=>({ts:item.ts||null,seq:item.seq??null,kind:'control',phase:item.phase||'-',type:item.type||'-',commandId:commandIdOf(item),sessionId:controlSessionId(item),traceId:controlTraceId(item),summary:controlSummary(item),valuesRedacted:true})), + ...matchedSamples.map((sample)=>({ts:sample.ts||null,seq:sample.seq??null,kind:'sample',phase:'sample',type:sample.reason||sample.pageRole||'-',commandId:sample.commandId||null,sessionId:sample.routeSessionId||sample.activeSessionId||null,traceId:traceIdsFromSamples([sample])[0]||null,summary:sampleSummary(sample),valuesRedacted:true})), + ...matchedCommands.map((item)=>({ts:commandFileTs(item),seq:null,kind:'command-file',phase:item.bucket,type:commandFileType(item)||'-',commandId:commandFileId(item),sessionId:commandFileSessionId(item),traceId:commandFileTraceId(item),summary:commandFileSummary(item),valuesRedacted:true})), + ].filter((item)=>item.ts!==null||item.commandId!==null) + .sort((a,b)=>(tsMs(a.ts)??0)-(tsMs(b.ts)??0)||String(a.kind).localeCompare(String(b.kind))||String(a.commandId||'').localeCompare(String(b.commandId||''))); + const cap=12; + let visible=rows; + if(rows.length>cap){ + const evidence=rows.filter((row)=>row.kind!=='sample'); + const sampleRows=rows.filter((row)=>row.kind==='sample'); + visible=evidence.length>=cap?evidence.slice(evidence.length-cap):[...evidence,...sampleRows.slice(sampleRows.length-(cap-evidence.length))] + .sort((a,b)=>(tsMs(a.ts)??0)-(tsMs(b.ts)??0)||String(a.kind).localeCompare(String(b.kind))||String(a.commandId||'').localeCompare(String(b.commandId||''))); + } + return {rows:visible,omittedRowCount:Math.max(0,rows.length-visible.length),matchedControlCount:matchedControl.length,matchedSampleCount:matchedSamples.length,matchedCommandFileCount:matchedCommands.length,valuesRedacted:true}; +} +function renderTimeline(anchor,window,eventRows){ + const lines=['Observe artifact timeline '+(manifest.jobId||'-'),'=======================================================']; + lines.push('stateDir='+dir); + lines.push('anchor='+anchor.mode+' value='+(anchor.value||'-')+' from='+(isoOrNull(window.fromMs)||'-')+' to='+(isoOrNull(window.toMs)||'-')+' windowMs='+String(window.windowMs)); + lines.push('counts control='+String(eventRows.matchedControlCount)+' samples='+String(eventRows.matchedSampleCount)+' commandFiles='+String(eventRows.matchedCommandFileCount)+' shown='+String(eventRows.rows.length)+' omitted='+String(eventRows.omittedRowCount)); + lines.push('', 'TIMELINE'); + lines.push(pad('TS',24)+' '+pad('SEQ',5)+' '+pad('KIND',12)+' '+pad('PHASE',12)+' '+pad('TYPE',24)+' '+pad('COMMAND',28)+' '+pad('SESSION',18)+' '+pad('TRACE',18)+' SUMMARY'); + if(eventRows.rows.length===0) lines.push('(no artifact rows matched; widen --window-ms or choose another --command-id/--timestamp/--turn)'); + for(const row of eventRows.rows){ + lines.push(pad(row.ts||'-',24)+' '+pad(row.seq??'-',5)+' '+pad(row.kind,12)+' '+pad(row.phase,12)+' '+pad(row.type,24)+' '+pad(row.commandId||'-',28)+' '+pad(row.sessionId||'-',18)+' '+pad(row.traceId||'-',18)+' '+short(row.summary,100)); + } + const id=manifest.jobId||''; + lines.push('', 'NEXT'); + lines.push(' turn summary: bun scripts/cli.ts web-probe observe collect '+id+' --view turn-summary'); + lines.push(' trace frame: bun scripts/cli.ts web-probe observe collect '+id+' --view trace-frame --trace-id --sample-seq '); + lines.push(' command window: bun scripts/cli.ts web-probe observe collect '+id+' --view timeline --command-id '); + lines.push(' raw control grep: bun scripts/cli.ts web-probe observe collect '+id+' --view files --file control.jsonl --grep '); + lines.push('', 'DISCLOSURE valuesRedacted=true valuesPrinted=false source=existing observe artifacts'); + return lines.join('\\n'); +} +function timelineView(rows){ + const commandFiles=readCommandFiles(); + const anchor=timelineAnchor(rows,commandFiles); + const window=timelineWindow(anchor); + const eventRows=timelineEventRows(anchor,window,commandFiles); + const timelineRows=eventRows.rows.map((row)=>({ts:row.ts?short(row.ts,32):null,seq:row.seq??null,kind:row.kind,phase:short(row.phase,24),type:short(row.type,40),commandId:row.commandId?short(row.commandId,64):null,sessionId:row.sessionId?short(row.sessionId,64):null,traceId:row.traceId?short(row.traceId,64):null,summary:short(row.summary,140),valuesRedacted:true})); + return {anchor:{...anchor,from:isoOrNull(anchor.startMs),to:isoOrNull(anchor.endMs),startMs:undefined,endMs:undefined,anchorMs:undefined},window:{from:isoOrNull(window.fromMs),to:isoOrNull(window.toMs),windowMs:window.windowMs,anchorMode:window.anchorMode,valuesRedacted:true},counts:{control:control.length,samples:samples.length,commandFiles:commandFiles.length,matchedControl:eventRows.matchedControlCount,matchedSamples:eventRows.matchedSampleCount,matchedCommandFiles:eventRows.matchedCommandFileCount,shown:timelineRows.length,omitted:eventRows.omittedRowCount,valuesRedacted:true},timelineRows,renderedText:renderTimeline(anchor,window,eventRows),valuesRedacted:true}; +} function pathOnly(value){try{return value?new URL(String(value),'http://x').pathname:null}catch{return null}} function projectSummaryFromSamples(){ const projectSamples=samples.filter((sample)=>sample?.projectManagement&&typeof sample.projectManagement==='object'); @@ -419,6 +597,11 @@ 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); } +if(view==='timeline'){ + const timeline=timelineView(rows); + console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',view,stateDir:dir,...timeline,sourceFiles:['samples.jsonl','control.jsonl','commands/pending/*.json','commands/processing/*.json','commands/done/*.json','commands/failed/*.json','commands/abandoned/*.json'],valuesRedacted:true})); + 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"`; diff --git a/scripts/src/hwlab-node-web-observe-wrapper.ts b/scripts/src/hwlab-node-web-observe-wrapper.ts index d7ccda8b..5adf7ff9 100644 --- a/scripts/src/hwlab-node-web-observe-wrapper.ts +++ b/scripts/src/hwlab-node-web-observe-wrapper.ts @@ -19,6 +19,10 @@ export interface WebObserveWrapperInput { readonly collectFile: string | null; readonly collectTraceId: string | null; readonly collectSampleSeq: number | null; + readonly collectTimestamp: string | null; + readonly collectTurn: number | null; + readonly collectCommandId: string | null; + readonly collectWindowMs: number | null; readonly analyzeArchivePrefix: string | null; } @@ -36,6 +40,10 @@ export interface WebObserveWrapperOptionsLike { readonly collectFile: string | null; readonly collectTraceId: string | null; readonly collectSampleSeq: number | null; + readonly collectTimestamp: string | null; + readonly collectTurn: number | null; + readonly collectCommandId: string | null; + readonly collectWindowMs: number | null; readonly analyzeArchivePrefix: string | null; } @@ -106,6 +114,10 @@ export function buildWebObserveWrapperContract(input: WebObserveWrapperInput): R collectFile: input.collectFile, collectTraceId: input.collectTraceId, collectSampleSeq: input.collectSampleSeq, + collectTimestamp: input.collectTimestamp, + collectTurn: input.collectTurn, + collectCommandId: input.collectCommandId, + collectWindowMs: input.collectWindowMs, analyzeArchivePrefix: input.analyzeArchivePrefix, valuesRedacted: true, }, @@ -168,6 +180,10 @@ export function buildWebObserveWrapperForObserveOptions( collectFile: options.collectFile, collectTraceId: options.collectTraceId, collectSampleSeq: options.collectSampleSeq, + collectTimestamp: options.collectTimestamp, + collectTurn: options.collectTurn, + collectCommandId: options.collectCommandId, + collectWindowMs: options.collectWindowMs, analyzeArchivePrefix: options.analyzeArchivePrefix, }); } @@ -204,6 +220,10 @@ function webObserveWrapperCommandShape(input: WebObserveWrapperInput): string { if (input.collectFile !== null) parts.push("--file", input.collectFile); if (input.collectTraceId !== null) parts.push("--trace-id", input.collectTraceId); if (input.collectSampleSeq !== null) parts.push("--sample-seq", String(input.collectSampleSeq)); + if (input.collectTimestamp !== null) parts.push("--timestamp", input.collectTimestamp); + if (input.collectTurn !== null) parts.push("--turn", String(input.collectTurn)); + if (input.collectCommandId !== null) parts.push("--command-id", input.collectCommandId); + if (input.collectWindowMs !== null) parts.push("--window-ms", String(input.collectWindowMs)); } if (input.action === "analyze" && input.analyzeArchivePrefix !== null) { parts.push("--archive-prefix", input.analyzeArchivePrefix); diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 7087db72..3a43ecb9 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -152,6 +152,8 @@ export interface NodeWebProbeObserveOptions { collectSampleSeq: number | null; collectTimestamp: string | null; collectTurn: number | null; + collectCommandId: string | null; + collectWindowMs: number | null; analyzeArchivePrefix: string | null; analyzeTailSamples: number | null; full: boolean; diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index d311def0..74b04288 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -189,6 +189,8 @@ export function parseNodeWebProbeObserveOptions( "--sample-seq", "--timestamp", "--turn", + "--command-id", + "--window-ms", "--compact-raw", "--archive-prefix", "--tail-samples", @@ -237,6 +239,11 @@ export function parseNodeWebProbeObserveOptions( const collectTurnRaw = optionValue(args, "--turn") ?? null; const collectTurn = collectTurnRaw === null ? null : Number(collectTurnRaw); if (collectTurn !== null && (!Number.isInteger(collectTurn) || collectTurn < 1)) throw new Error("unsafe web-probe observe --turn: expected positive integer"); + const collectCommandId = optionValue(args, "--command-id") ?? null; + if (collectCommandId !== null && (!/^[A-Za-z0-9_.:-]+$/u.test(collectCommandId) || collectCommandId.length > 120)) throw new Error("unsafe web-probe observe --command-id: expected 1-120 safe command id chars"); + const collectWindowMsRaw = optionValue(args, "--window-ms") ?? null; + const collectWindowMs = collectWindowMsRaw === null ? null : Number(collectWindowMsRaw); + if (collectWindowMs !== null && (!Number.isInteger(collectWindowMs) || collectWindowMs < 1000 || collectWindowMs > 86_400_000)) throw new Error("unsafe web-probe observe --window-ms: expected integer 1000-86400000"); const analyzeArchivePrefix = optionValue(args, "--archive-prefix") ?? null; if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`); const analyzeTailSamplesRaw = optionValue(args, "--tail-samples") ?? null; @@ -308,6 +315,8 @@ export function parseNodeWebProbeObserveOptions( collectSampleSeq, collectTimestamp, collectTurn, + collectCommandId, + collectWindowMs, analyzeArchivePrefix, analyzeTailSamples, full: args.includes("--full"), @@ -1159,6 +1168,8 @@ export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOption sampleSeq: options.collectSampleSeq, timestamp: options.collectTimestamp, turn: options.collectTurn, + commandId: options.collectCommandId, + windowMs: options.collectWindowMs, }); const script = [ "set -eu", @@ -1179,6 +1190,8 @@ export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOption view: options.collectView, requestedFile: options.collectFile, requestedGrep: options.collectGrep, + requestedCommandId: options.collectCommandId, + requestedWindowMs: options.collectWindowMs, degradedReason: collect === null ? "collect-json-parse-failed" : null, collect: compactRaw ? compactObserveCollectForRaw(collect) : collect, wrapper: compactRaw @@ -1217,14 +1230,33 @@ function compactObserveCollectForRaw(collect: Record | null): R valuesRedacted: true, }; }) : undefined; + const timelineRows = Array.isArray(collect.timelineRows) ? collect.timelineRows.slice(0, 12).map((item) => { + const row = observeRecord(item); + return { + ts: row.ts ?? null, + seq: row.seq ?? null, + kind: row.kind ?? null, + phase: row.phase ?? null, + type: row.type ?? null, + commandId: row.commandId ?? null, + sessionId: row.sessionId ?? null, + traceId: row.traceId ?? null, + summary: row.summary ?? null, + valuesRedacted: true, + }; + }) : undefined; return { ok: collect.ok !== false, command: collect.command, view: collect.view, stateDir: collect.stateDir, turnCount: collect.turnCount, + anchor: observeRecord(collect.anchor), + window: observeRecord(collect.window), + counts: observeRecord(collect.counts), ...(rows === undefined ? {} : { rows }), - renderedText: typeof collect.renderedText === "string" ? collect.renderedText : undefined, + ...(timelineRows === undefined ? {} : { timelineRows }), + renderedText: collect.view === "timeline" ? undefined : typeof collect.renderedText === "string" ? collect.renderedText : undefined, sourceFiles: Array.isArray(collect.sourceFiles) ? collect.sourceFiles : undefined, blocker: collect.blocker, sampleSeq: collect.sampleSeq,