fix: add bounded web-probe observe timeline drilldown (#937)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -93,6 +93,7 @@ long prompt
|
|||||||
EOF
|
EOF
|
||||||
bun scripts/cli.ts web-probe observe status webobs-xxxx --tail-lines 6
|
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 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 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 stop webobs-xxxx
|
||||||
bun scripts/cli.ts web-probe observe analyze 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 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:
|
项目管理 / MDTODO 页面同样优先使用 `observe`,不要退回一次性 Playwright 脚本。MDTODO 主动编辑验收必须把常见动作沉淀成 `observe command`,同一 observer 串联 source 配置、HWPOD probe/reindex、文件/任务选择、Rxx 树操作、编辑写回和 Workbench launch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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 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 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 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",
|
"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.",
|
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.",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
|||||||
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type launchWorkbenchFromMdtodo --task R1.1",
|
"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 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 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 collect webobs-xxxx --view project-mdtodo-summary",
|
||||||
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
|
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
|
||||||
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
|
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
// Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts.
|
// Responsibility: Offline CLI view renderers for HWLAB web-probe observe artifacts.
|
||||||
import { shellQuote } from "./ssh";
|
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 {
|
export function parseNodeWebProbeObserveCollectView(value: string): NodeWebProbeObserveCollectView {
|
||||||
if (value === "files" || value === "turn-summary" || value === "trace-frame" || value === "project-summary" || value === "project-mdtodo-summary") return 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, project-summary, or project-mdtodo-summary; got ${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: {
|
export function nodeWebObserveCollectViewNodeScript(input: {
|
||||||
@@ -16,12 +16,15 @@ export function nodeWebObserveCollectViewNodeScript(input: {
|
|||||||
sampleSeq: number | null;
|
sampleSeq: number | null;
|
||||||
timestamp: string | null;
|
timestamp: string | null;
|
||||||
turn: number | null;
|
turn: number | null;
|
||||||
|
commandId: string | null;
|
||||||
|
windowMs: number | null;
|
||||||
}): string {
|
}): string {
|
||||||
const requestedSampleSeq = input.sampleSeq === null ? "null" : String(input.sampleSeq);
|
const requestedSampleSeq = input.sampleSeq === null ? "null" : String(input.sampleSeq);
|
||||||
const requestedTurn = input.turn === null ? "null" : String(input.turn);
|
const requestedTurn = input.turn === null ? "null" : String(input.turn);
|
||||||
|
const requestedWindowMs = input.windowMs === null ? "null" : String(input.windowMs);
|
||||||
return `node -e ${shellQuote(`
|
return `node -e ${shellQuote(`
|
||||||
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
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 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 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 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');
|
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};
|
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&&ms<window.fromMs) return false;
|
||||||
|
if(window.toMs!==null&&ms>window.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||'<observer>';
|
||||||
|
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 <traceId> --sample-seq <seq>');
|
||||||
|
lines.push(' command window: bun scripts/cli.ts web-probe observe collect '+id+' --view timeline --command-id <commandId>');
|
||||||
|
lines.push(' raw control grep: bun scripts/cli.ts web-probe observe collect '+id+' --view files --file control.jsonl --grep <commandId>');
|
||||||
|
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 pathOnly(value){try{return value?new URL(String(value),'http://x').pathname:null}catch{return null}}
|
||||||
function projectSummaryFromSamples(){
|
function projectSummaryFromSamples(){
|
||||||
const projectSamples=samples.filter((sample)=>sample?.projectManagement&&typeof sample.projectManagement==='object');
|
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));
|
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);
|
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);
|
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));
|
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"`;
|
`)} "$state_dir"`;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface WebObserveWrapperInput {
|
|||||||
readonly collectFile: string | null;
|
readonly collectFile: string | null;
|
||||||
readonly collectTraceId: string | null;
|
readonly collectTraceId: string | null;
|
||||||
readonly collectSampleSeq: number | 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;
|
readonly analyzeArchivePrefix: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +40,10 @@ export interface WebObserveWrapperOptionsLike {
|
|||||||
readonly collectFile: string | null;
|
readonly collectFile: string | null;
|
||||||
readonly collectTraceId: string | null;
|
readonly collectTraceId: string | null;
|
||||||
readonly collectSampleSeq: number | 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;
|
readonly analyzeArchivePrefix: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +114,10 @@ export function buildWebObserveWrapperContract(input: WebObserveWrapperInput): R
|
|||||||
collectFile: input.collectFile,
|
collectFile: input.collectFile,
|
||||||
collectTraceId: input.collectTraceId,
|
collectTraceId: input.collectTraceId,
|
||||||
collectSampleSeq: input.collectSampleSeq,
|
collectSampleSeq: input.collectSampleSeq,
|
||||||
|
collectTimestamp: input.collectTimestamp,
|
||||||
|
collectTurn: input.collectTurn,
|
||||||
|
collectCommandId: input.collectCommandId,
|
||||||
|
collectWindowMs: input.collectWindowMs,
|
||||||
analyzeArchivePrefix: input.analyzeArchivePrefix,
|
analyzeArchivePrefix: input.analyzeArchivePrefix,
|
||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
},
|
},
|
||||||
@@ -168,6 +180,10 @@ export function buildWebObserveWrapperForObserveOptions(
|
|||||||
collectFile: options.collectFile,
|
collectFile: options.collectFile,
|
||||||
collectTraceId: options.collectTraceId,
|
collectTraceId: options.collectTraceId,
|
||||||
collectSampleSeq: options.collectSampleSeq,
|
collectSampleSeq: options.collectSampleSeq,
|
||||||
|
collectTimestamp: options.collectTimestamp,
|
||||||
|
collectTurn: options.collectTurn,
|
||||||
|
collectCommandId: options.collectCommandId,
|
||||||
|
collectWindowMs: options.collectWindowMs,
|
||||||
analyzeArchivePrefix: options.analyzeArchivePrefix,
|
analyzeArchivePrefix: options.analyzeArchivePrefix,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -204,6 +220,10 @@ function webObserveWrapperCommandShape(input: WebObserveWrapperInput): string {
|
|||||||
if (input.collectFile !== null) parts.push("--file", input.collectFile);
|
if (input.collectFile !== null) parts.push("--file", input.collectFile);
|
||||||
if (input.collectTraceId !== null) parts.push("--trace-id", input.collectTraceId);
|
if (input.collectTraceId !== null) parts.push("--trace-id", input.collectTraceId);
|
||||||
if (input.collectSampleSeq !== null) parts.push("--sample-seq", String(input.collectSampleSeq));
|
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) {
|
if (input.action === "analyze" && input.analyzeArchivePrefix !== null) {
|
||||||
parts.push("--archive-prefix", input.analyzeArchivePrefix);
|
parts.push("--archive-prefix", input.analyzeArchivePrefix);
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export interface NodeWebProbeObserveOptions {
|
|||||||
collectSampleSeq: number | null;
|
collectSampleSeq: number | null;
|
||||||
collectTimestamp: string | null;
|
collectTimestamp: string | null;
|
||||||
collectTurn: number | null;
|
collectTurn: number | null;
|
||||||
|
collectCommandId: string | null;
|
||||||
|
collectWindowMs: number | null;
|
||||||
analyzeArchivePrefix: string | null;
|
analyzeArchivePrefix: string | null;
|
||||||
analyzeTailSamples: number | null;
|
analyzeTailSamples: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ export function parseNodeWebProbeObserveOptions(
|
|||||||
"--sample-seq",
|
"--sample-seq",
|
||||||
"--timestamp",
|
"--timestamp",
|
||||||
"--turn",
|
"--turn",
|
||||||
|
"--command-id",
|
||||||
|
"--window-ms",
|
||||||
"--compact-raw",
|
"--compact-raw",
|
||||||
"--archive-prefix",
|
"--archive-prefix",
|
||||||
"--tail-samples",
|
"--tail-samples",
|
||||||
@@ -237,6 +239,11 @@ export function parseNodeWebProbeObserveOptions(
|
|||||||
const collectTurnRaw = optionValue(args, "--turn") ?? null;
|
const collectTurnRaw = optionValue(args, "--turn") ?? null;
|
||||||
const collectTurn = collectTurnRaw === null ? null : Number(collectTurnRaw);
|
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");
|
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;
|
const analyzeArchivePrefix = optionValue(args, "--archive-prefix") ?? null;
|
||||||
if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`);
|
if (analyzeArchivePrefix !== null && !isSafeWebObserveArchivePrefix(analyzeArchivePrefix)) throw new Error(`unsafe web-probe observe --archive-prefix: ${analyzeArchivePrefix}`);
|
||||||
const analyzeTailSamplesRaw = optionValue(args, "--tail-samples") ?? null;
|
const analyzeTailSamplesRaw = optionValue(args, "--tail-samples") ?? null;
|
||||||
@@ -308,6 +315,8 @@ export function parseNodeWebProbeObserveOptions(
|
|||||||
collectSampleSeq,
|
collectSampleSeq,
|
||||||
collectTimestamp,
|
collectTimestamp,
|
||||||
collectTurn,
|
collectTurn,
|
||||||
|
collectCommandId,
|
||||||
|
collectWindowMs,
|
||||||
analyzeArchivePrefix,
|
analyzeArchivePrefix,
|
||||||
analyzeTailSamples,
|
analyzeTailSamples,
|
||||||
full: args.includes("--full"),
|
full: args.includes("--full"),
|
||||||
@@ -1159,6 +1168,8 @@ export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOption
|
|||||||
sampleSeq: options.collectSampleSeq,
|
sampleSeq: options.collectSampleSeq,
|
||||||
timestamp: options.collectTimestamp,
|
timestamp: options.collectTimestamp,
|
||||||
turn: options.collectTurn,
|
turn: options.collectTurn,
|
||||||
|
commandId: options.collectCommandId,
|
||||||
|
windowMs: options.collectWindowMs,
|
||||||
});
|
});
|
||||||
const script = [
|
const script = [
|
||||||
"set -eu",
|
"set -eu",
|
||||||
@@ -1179,6 +1190,8 @@ export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOption
|
|||||||
view: options.collectView,
|
view: options.collectView,
|
||||||
requestedFile: options.collectFile,
|
requestedFile: options.collectFile,
|
||||||
requestedGrep: options.collectGrep,
|
requestedGrep: options.collectGrep,
|
||||||
|
requestedCommandId: options.collectCommandId,
|
||||||
|
requestedWindowMs: options.collectWindowMs,
|
||||||
degradedReason: collect === null ? "collect-json-parse-failed" : null,
|
degradedReason: collect === null ? "collect-json-parse-failed" : null,
|
||||||
collect: compactRaw ? compactObserveCollectForRaw(collect) : collect,
|
collect: compactRaw ? compactObserveCollectForRaw(collect) : collect,
|
||||||
wrapper: compactRaw
|
wrapper: compactRaw
|
||||||
@@ -1217,14 +1230,33 @@ function compactObserveCollectForRaw(collect: Record<string, unknown> | null): R
|
|||||||
valuesRedacted: true,
|
valuesRedacted: true,
|
||||||
};
|
};
|
||||||
}) : undefined;
|
}) : 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 {
|
return {
|
||||||
ok: collect.ok !== false,
|
ok: collect.ok !== false,
|
||||||
command: collect.command,
|
command: collect.command,
|
||||||
view: collect.view,
|
view: collect.view,
|
||||||
stateDir: collect.stateDir,
|
stateDir: collect.stateDir,
|
||||||
turnCount: collect.turnCount,
|
turnCount: collect.turnCount,
|
||||||
|
anchor: observeRecord(collect.anchor),
|
||||||
|
window: observeRecord(collect.window),
|
||||||
|
counts: observeRecord(collect.counts),
|
||||||
...(rows === undefined ? {} : { rows }),
|
...(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,
|
sourceFiles: Array.isArray(collect.sourceFiles) ? collect.sourceFiles : undefined,
|
||||||
blocker: collect.blocker,
|
blocker: collect.blocker,
|
||||||
sampleSeq: collect.sampleSeq,
|
sampleSeq: collect.sampleSeq,
|
||||||
|
|||||||
Reference in New Issue
Block a user