800 lines
57 KiB
TypeScript
800 lines
57 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. web-observe-scripts module for scripts/src/hwlab-node-impl.ts.
|
|
|
|
// Moved mechanically from scripts/src/hwlab-node-impl.ts:10515-11200 for #903.
|
|
|
|
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
|
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { repoRoot, rootPath, type Config } from "../config";
|
|
import { runCommand, type CommandResult } from "../command";
|
|
import { startJob } from "../jobs";
|
|
import { classifySshTcpPoolFailure } from "../ssh";
|
|
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
|
|
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
|
|
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
|
|
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
|
|
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
|
|
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
|
|
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
|
|
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
|
|
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
|
|
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
|
|
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
|
|
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
|
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
|
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
|
import type { RenderedCliResult } from "../output";
|
|
|
|
import type { BootstrapAdminPasswordMaterial, NodeWebProbeScriptOptions, RuntimeSecretSpec } from "./entry";
|
|
import type { NodeWebProbeHostProxyEnv } from "./web-probe-observe";
|
|
import { isSafeWebProbeScriptArtifactPath, isSafeWebProbeScriptReportPath, isSafeWebProbeScriptRunDir, runTransWorkspaceStdinScript } from "./public-exposure";
|
|
import { compactCommandResultRedacted, nullableRecord, parseJsonObject, redactKnownSecrets, shellQuote } from "./utils";
|
|
import { renderWebProbeScriptResult } from "./web-observe-render";
|
|
import { nodeWebProbeHostProxyEnv } from "./web-probe-observe";
|
|
|
|
export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string): string {
|
|
return `node -e ${shellQuote(`
|
|
const fs=require('fs'),path=require('path');
|
|
const dir=process.env.state_dir||process.argv[1];
|
|
const node=process.argv[2];
|
|
const lane=process.argv[3];
|
|
const tailN=Math.min(${tailLines},3);
|
|
const readJson=(name)=>{try{return JSON.parse(fs.readFileSync(path.join(dir,name),'utf8'))}catch{return null}};
|
|
const readJsonPath=(file)=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return null}};
|
|
const tailJsonl=(name)=>{try{const file=path.join(dir,name); const st=fs.statSync(file); const maxBytes=Math.min(st.size,8*1024*1024); const fd=fs.openSync(file,'r'); try{const buf=Buffer.alloc(maxBytes); fs.readSync(fd,buf,0,maxBytes,st.size-maxBytes); const lines=buf.toString('utf8').split(/\\r?\\n/).filter(Boolean); if(st.size>maxBytes&&lines.length>0) lines.shift(); return lines.slice(-tailN).map(line=>{try{return JSON.parse(line)}catch{return {parseError:true, rawTail:line.slice(-500)}}});}finally{fs.closeSync(fd)}}catch{return []}};
|
|
const short=(value)=>String(value||'').slice(0,160);
|
|
const compactReadiness=(value)=>{const raw=value&&typeof value==='object'?value:null; const snapshot=raw&&raw.snapshot&&typeof raw.snapshot==='object'?raw.snapshot:raw; return raw?{reason:short(raw.reason||snapshot.reason,80),path:short(snapshot.path,120),search:short(snapshot.search,120),blockedReason:short(snapshot.blockedReason,80),readyState:short(snapshot.readyState,24),workbenchShellVisible:snapshot.workbenchShellVisible===true,sessionCreatePresent:snapshot.sessionCreatePresent===true,sessionCreateVisible:snapshot.sessionCreateVisible===true,sessionRailPresent:snapshot.sessionRailPresent===true,sessionRailCollapsed:snapshot.sessionRailCollapsed??null,commandInputPresent:snapshot.commandInputPresent===true,activeTabPresent:snapshot.activeTabPresent===true,loginVisible:snapshot.loginVisible===true,bodyTextBytes:snapshot.bodyTextBytes??null,bodyTextHash:short(snapshot.bodyTextHash,80),valuesRedacted:true}:null};
|
|
const errorReadiness=(error)=>{const details=error&&error.details&&typeof error.details==='object'?error.details:null; return compactReadiness((error&&error.navigationReadiness)||details&&details.readiness||details&&details.readinessAfterWait||details&&details.readinessBeforeClick)};
|
|
const compactManifest=(item)=>item?{jobId:item.jobId,status:item.status,specRef:item.specRef,baseUrl:item.baseUrl,targetPath:item.targetPath,network:item.network,pageAuthority:item.pageAuthority,sampling:item.sampling,safety:item.safety,startedAt:item.startedAt,completedAt:item.completedAt,error:item.error?{message:short(item.error.message),readiness:errorReadiness(item.error),auth:item.error.auth?{lastRetryLabel:item.error.auth.lastRetryLabel||null,retryExhausted:item.error.auth.retryExhausted===true,lastError:short(item.error.auth.lastError||'')}:null}:null}:null;
|
|
const compactAuth=(auth)=>auth?{phase:auth.phase||null,lastRetryLabel:auth.lastRetryLabel||null,retryAttempt:auth.retryAttempt??null,retryMaxAttempts:auth.retryMaxAttempts??null,retryDelayMs:auth.retryDelayMs??null,lastStatus:auth.lastStatus??null,lastStatusText:auth.lastStatusText||null,retryable:auth.retryable??null,cookiePresent:auth.cookiePresent??null,retryExhausted:auth.retryExhausted===true,lastError:short(auth.lastError||'')}:null;
|
|
const compactHeartbeat=(item,diag)=>item?{ok:item.ok,jobId:item.jobId,pid:item.pid,stateDir:item.stateDir,status:item.status,pageId:item.pageId,baseUrl:item.baseUrl,currentUrl:item.currentUrl,sampleSeq:item.sampleSeq,commandSeq:item.commandSeq,activeCommandId:item.activeCommandId,auth:compactAuth(item.auth),updatedAt:item.updatedAt,uptimeMs:item.uptimeMs,ageSeconds:diag.heartbeatAgeSeconds,stale:diag.heartbeatStale,staleAfterSeconds:diag.heartbeatStaleAfterSeconds,effectiveLiveness:diag.effectiveLiveness,error:item.error?{message:short(item.error.message),readiness:errorReadiness(item.error),auth:compactAuth(item.error.auth)}:null}:null;
|
|
const retryLabel=(detail)=>detail&&detail.auth?detail.auth.lastRetryLabel||'':detail&&detail.result?detail.result.lastRetryLabel||'':detail&&detail.error&&detail.error.auth?detail.error.auth.lastRetryLabel||'':'';
|
|
const detailText=(detail)=>{const readiness=detail&&detail.error?errorReadiness(detail.error):null; return detail&&detail.error?short((detail.error.message||'')+(readiness&&readiness.reason?' readiness='+readiness.reason:'')+(readiness&&readiness.blockedReason?' blocked='+readiness.blockedReason:'')+(detail.error.auth&&detail.error.auth.lastError?' '+detail.error.auth.lastError:'')):detail&&detail.result?short([detail.result.statusText,detail.result.retryExhausted?'retry-exhausted':'',detail.result.degradedReason?'degraded='+detail.result.degradedReason:''].filter(Boolean).join(' ')):''};
|
|
const compactControl=(item)=>({ts:item.ts,seq:item.seq,phase:item.phase,type:item.type,commandId:item.commandId,durationMs:item.detail&&item.detail.durationMs||null,retry:retryLabel(item.detail),detail:detailText(item.detail)});
|
|
const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[],sessionRail:item.sessionRail?{visibleCount:item.sessionRail.visibleCount??null,fallbackTitleCount:item.sessionRail.fallbackTitleCount??null,fallbackTitleRatio:item.sessionRail.fallbackTitleRatio??null,fallbackItems:Array.isArray(item.sessionRail.fallbackItems)?item.sessionRail.fallbackItems.slice(0,4).map((fallback)=>({sessionIdPrefix:fallback.sessionIdPrefix??null,titlePreview:fallback.titlePreview??null,active:fallback.active===true})):[]}:null});
|
|
const compactNetwork=(item)=>({ts:item.ts,type:item.type,method:item.method,url:short(item.url),status:item.status||null,failure:item.failure?short(item.failure):null});
|
|
const commandDir=(name)=>path.join(dir,'commands',name);
|
|
const commandSummary=(name)=>{
|
|
const root=commandDir(name); let entries=[];
|
|
try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{}
|
|
const items=entries.slice(0,12).map((entry)=>{const file=path.join(root,entry); const parsed=readJsonPath(file)||{}; let stat=null; try{stat=fs.statSync(file)}catch{}; const createdAt=parsed.createdAt||parsed.abandonedAt||parsed.completedAt||parsed.failedAt||null; const createdMs=Date.parse(String(createdAt||'')); const ageSeconds=Number.isFinite(createdMs)?Math.max(0,Math.round((Date.now()-createdMs)/1000)):null; return {id:parsed.id||parsed.commandId||entry.replace(/[.]json$/,''),type:parsed.type||null,createdAt,ageSeconds,mtime:stat?stat.mtime.toISOString():null};});
|
|
const ages=items.map((item)=>item.ageSeconds).filter((value)=>Number.isFinite(value));
|
|
return {count:entries.length,oldestAgeSeconds:ages.length?Math.max(...ages):null,items};
|
|
};
|
|
const pidText=fs.existsSync(path.join(dir,'pid'))?fs.readFileSync(path.join(dir,'pid'),'utf8').trim():null;
|
|
let alive=false; if(pidText){try{process.kill(Number(pidText),0); alive=true}catch{}}
|
|
const manifest=readJson('manifest.json');
|
|
const heartbeat=readJson('heartbeat.json');
|
|
const terminal=/^(completed|failed|force-stopped|stopped|abandoned)$/u.test(String((heartbeat&&heartbeat.status)||(manifest&&manifest.status)||''));
|
|
const sampleIntervalMs=Number(manifest&&manifest.sampling&&manifest.sampling.sampleIntervalMs)||5000;
|
|
const staleAfterMs=Math.max(60000,sampleIntervalMs*3);
|
|
const updatedRaw=heartbeat&&(heartbeat.updatedAt||heartbeat.lastSampleAt)||null;
|
|
const updatedMs=Date.parse(String(updatedRaw||''));
|
|
const heartbeatAgeSeconds=Number.isFinite(updatedMs)?Math.max(0,Math.round((Date.now()-updatedMs)/1000)):null;
|
|
const heartbeatStale=alive&&!terminal&&(!Number.isFinite(updatedMs)||Date.now()-updatedMs>staleAfterMs);
|
|
const commandsPending=commandSummary('pending');
|
|
const commandsProcessing=commandSummary('processing');
|
|
const commandsAbandoned=commandSummary('abandoned');
|
|
const commandsFailed=commandSummary('failed');
|
|
const diagnostics={heartbeatAgeSeconds,heartbeatStale,heartbeatStaleAfterSeconds:Math.round(staleAfterMs/1000),heartbeatUpdatedAt:updatedRaw,terminal,processAlive:alive,effectiveLiveness:heartbeatStale?'stale':alive?'alive':'not-running',commandBacklog:commandsPending.count+commandsProcessing.count,oldestPendingAgeSeconds:commandsPending.oldestAgeSeconds,oldestProcessingAgeSeconds:commandsProcessing.oldestAgeSeconds,valuesRedacted:true};
|
|
const commands={pendingCount:commandsPending.count,processingCount:commandsProcessing.count,abandonedCount:commandsAbandoned.count,failedCount:commandsFailed.count,pending:commandsPending.items,processing:commandsProcessing.items,abandoned:commandsAbandoned.items.slice(0,6),failed:commandsFailed.items.slice(0,6),valuesRedacted:true};
|
|
console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir:dir,pid:pidText?Number(pidText):null,processAlive:alive,manifest:compactManifest(manifest),heartbeat:compactHeartbeat(heartbeat,diagnostics),diagnostics,commands,tails:{control:tailJsonl('control.jsonl').map(compactControl),samples:tailJsonl('samples.jsonl').map(compactSample),network:tailJsonl('network.jsonl').map(compactNetwork)},next:{command:'bun scripts/cli.ts web-probe observe command --node '+node+' --lane '+lane+' --state-dir '+dir+' --type mark --label checkpoint',stop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir,forceStop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir+' --force',analyze:'bun scripts/cli.ts web-probe observe analyze --node '+node+' --lane '+lane+' --state-dir '+dir},valuesRedacted:true}));
|
|
`)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)}`;
|
|
}
|
|
|
|
export function nodeWebObserveForceStopNodeScript(reason: string, forcedByCommandId: string): string {
|
|
return `node -e ${shellQuote(`
|
|
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
|
const dir=process.argv[1],reason=process.argv[2],forcedByCommandId=process.argv[3];
|
|
const now=new Date().toISOString();
|
|
const short=(value,max=160)=>value===undefined||value===null?null:String(value).replace(/\\s+/g,' ').slice(0,max);
|
|
const shaText=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
|
|
const readJson=(file)=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return null}};
|
|
const writeJson=(file,value)=>{fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700}); fs.writeFileSync(file,JSON.stringify(value,null,2)+'\\n',{mode:0o600});};
|
|
const appendJsonl=(file,value)=>{fs.appendFileSync(file,JSON.stringify(value)+'\\n',{mode:0o600});};
|
|
const summarizeCommand=(command,id)=>({id,type:command&&command.type||null,createdAt:command&&command.createdAt||null,source:command&&command.source||null,path:command&&command.path||null,label:command&&command.label||null,sessionId:command&&command.sessionId||null,provider:command&&command.provider||null,textHash:typeof(command&&command.text)==='string'?shaText(command.text):null,textBytes:typeof(command&&command.text)==='string'?Buffer.byteLength(command.text):null,valuesRedacted:true});
|
|
const moveCommands=(bucket)=>{
|
|
const source=path.join(dir,'commands',bucket); let names=[];
|
|
try{names=fs.readdirSync(source).filter((name)=>name.endsWith('.json')).sort();}catch{}
|
|
const moved=[];
|
|
for(const name of names){
|
|
const file=path.join(source,name);
|
|
const parsed=readJson(file)||{};
|
|
const id=String(parsed.id||parsed.commandId||name.replace(/[.]json$/,'')).replace(/[^A-Za-z0-9_.-]/g,'-')||name.replace(/[.]json$/,'');
|
|
const record={ok:false,status:'abandoned',commandId:id,type:parsed.type||null,abandonedAt:now,reason,sourceBucket:bucket,forcedByCommandId,command:summarizeCommand(parsed,id),valuesRedacted:true};
|
|
writeJson(path.join(dir,'commands','abandoned',id+'.json'),record);
|
|
try{fs.unlinkSync(file)}catch{}
|
|
appendJsonl(path.join(dir,'control.jsonl'),{ts:now,seq:null,phase:'abandoned',type:parsed.type||null,commandId:id,detail:{reason,sourceBucket:bucket,forcedByCommandId,valuesRedacted:true},valuesRedacted:true});
|
|
moved.push({id,type:parsed.type||null,sourceBucket:bucket,createdAt:parsed.createdAt||null});
|
|
}
|
|
return moved;
|
|
};
|
|
const pidFile=path.join(dir,'pid');
|
|
const pidText=fs.existsSync(pidFile)?fs.readFileSync(pidFile,'utf8').trim():'';
|
|
const pid=Number(pidText);
|
|
const alive=()=>Number.isFinite(pid)&&pid>0?(()=>{try{process.kill(pid,0); return true;}catch{return false;}})():false;
|
|
const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
|
|
const aliveBefore=alive();
|
|
let termSent=false,killSent=false;
|
|
if(aliveBefore){try{process.kill(pid,'SIGTERM'); termSent=true;}catch{}}
|
|
for(let i=0;i<10&&alive();i+=1) sleep(250);
|
|
if(alive()){try{process.kill(pid,'SIGKILL'); killSent=true;}catch{}}
|
|
for(let i=0;i<8&&alive();i+=1) sleep(250);
|
|
const pending=moveCommands('pending');
|
|
const processing=moveCommands('processing');
|
|
const aliveAfter=alive();
|
|
const manifestPath=path.join(dir,'manifest.json');
|
|
const heartbeatPath=path.join(dir,'heartbeat.json');
|
|
const manifest=readJson(manifestPath)||{};
|
|
const heartbeat=readJson(heartbeatPath)||{};
|
|
writeJson(manifestPath,{...manifest,ok:false,status:'force-stopped',forceStoppedAt:now,forceStop:{reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true}});
|
|
writeJson(heartbeatPath,{...heartbeat,ok:false,status:'force-stopped',updatedAt:now,forceStoppedAt:now,forceStop:{reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true}});
|
|
appendJsonl(path.join(dir,'control.jsonl'),{ts:now,seq:null,phase:'completed',type:'forceStop',commandId:forcedByCommandId,detail:{reason,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true},valuesRedacted:true});
|
|
console.log(JSON.stringify({ok:!aliveAfter,command:'web-probe-observe force-stop',stateDir:dir,forced:true,reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,abandoned:[...pending,...processing].slice(0,20),valuesRedacted:true}));
|
|
`)} "$state_dir" ${shellQuote(reason)} ${shellQuote(forcedByCommandId)}`;
|
|
}
|
|
|
|
export function nodeWebObserveCollectNodeScript(maxFiles: number, collectFile: string | null, collectFinding: string | null, collectGrep: string | null): string {
|
|
return `node -e ${shellQuote(`
|
|
const fs=require('fs'),path=require('path'),crypto=require('crypto');
|
|
const dir=process.argv[1]; const findingFilter=process.argv[3]||''; let selected=(process.argv[2]||'') || (findingFilter ? 'analysis/report.json' : ''); const grepText=process.argv[4]||''; const maxFiles=${maxFiles}; const maxReadBytes=64*1024; const maxJsonlTailBytes=1024*1024; const maxTextPreviewBytes=4096; const maxGrepContextLines=24; const maxGrepLineText=180;
|
|
const shaFile=(file)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
const shaText=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
|
|
const safeRel=(value)=>Boolean(value)&&!path.isAbsolute(value)&&!value.includes('..')&&!value.includes('\\\\')&&value.split('/').every((part)=>part&&part!=='.'&&part!=='..');
|
|
const short=(value,max=180)=>value===undefined||value===null?undefined:String(value).replace(/\\s+/g,' ').slice(0,max);
|
|
const grepLines=(text,pattern)=>{
|
|
if(!pattern) return null;
|
|
const needle=String(pattern).toLowerCase();
|
|
const lines=String(text||'').split(/\\r?\\n/);
|
|
const contexts=[]; let matchCount=0; const emitted=new Set();
|
|
for(let index=0; index<lines.length; index++){
|
|
if(!lines[index].toLowerCase().includes(needle)) continue;
|
|
matchCount++;
|
|
for(let offset=-2; offset<=2; offset++){
|
|
const lineIndex=index+offset;
|
|
if(lineIndex<0||lineIndex>=lines.length) continue;
|
|
if(emitted.has(lineIndex)) continue;
|
|
emitted.add(lineIndex);
|
|
contexts.push({lineNo:lineIndex+1,match:offset===0,text:short(lines[lineIndex],maxGrepLineText)});
|
|
if(contexts.length>=maxGrepContextLines) break;
|
|
}
|
|
if(contexts.length>=maxGrepContextLines) break;
|
|
}
|
|
return {patternSha256:shaText(pattern),matchCount,returnedLineCount:contexts.length,truncated:matchCount>0&&contexts.length>=maxGrepContextLines,lines:contexts,valuesRedacted:true};
|
|
};
|
|
const slimObject=(value,maxKeys=24)=>{
|
|
if(!value||typeof value!=='object'||Array.isArray(value)) return value??null;
|
|
const out={};
|
|
for(const key of Object.keys(value).slice(0,maxKeys)){
|
|
const item=value[key];
|
|
if(item===null||item===undefined||typeof item==='number'||typeof item==='boolean'||typeof item==='string') out[key]=short(item,160);
|
|
else if(Array.isArray(item)) out[key]={arrayLength:item.length};
|
|
else out[key]={keys:Object.keys(item).slice(0,12)};
|
|
}
|
|
return out;
|
|
};
|
|
const sensitiveKey=(key)=>/token|secret|password|passwd|authorization|cookie|api[-_]?key|session/i.test(String(key||''));
|
|
const slimJsonValue=(value,depth=0)=>{
|
|
if(value===null||value===undefined) return value??null;
|
|
if(typeof value==='number'||typeof value==='boolean') return value;
|
|
if(typeof value==='string') return short(value,360);
|
|
if(depth>=4) return Array.isArray(value)?{arrayLength:value.length}:{keys:Object.keys(value||{}).slice(0,20)};
|
|
if(Array.isArray(value)) return value.slice(0,12).map((item)=>slimJsonValue(item,depth+1)).concat(value.length>12?[{omitted:value.length-12}]:[]);
|
|
if(typeof value==='object'){
|
|
const out={};
|
|
const keys=Object.keys(value).slice(0,40);
|
|
for(const key of keys) out[key]=sensitiveKey(key)?'[redacted]':slimJsonValue(value[key],depth+1);
|
|
const omitted=Object.keys(value).length-keys.length;
|
|
if(omitted>0) out.__omittedKeys=omitted;
|
|
return out;
|
|
}
|
|
return short(value,180);
|
|
};
|
|
const slimFinding=(item)=>{
|
|
if(!item||typeof item!=='object') return null;
|
|
const samples=Array.isArray(item.samples)?item.samples.length:null;
|
|
const groups=Array.isArray(item.groups)?item.groups.length:null;
|
|
const evidence=item.evidence&&typeof item.evidence==='object'?slimObject(item.evidence,10):null;
|
|
return {
|
|
kind:short(item.kind||item.code||item.id,80),
|
|
severity:short(item.severity||item.level,24),
|
|
count:item.count??item.sampleCount??null,
|
|
summary:short(item.summary||item.message,240),
|
|
rootCause:short(item.rootCause,140),
|
|
rootCauseStatus:short(item.rootCauseStatus,90),
|
|
rootCauseConfidence:short(item.rootCauseConfidence,40),
|
|
nextAction:short(item.nextAction,240),
|
|
evidenceSummary:evidence?short(JSON.stringify(evidence),240):null,
|
|
sampleRows:samples,
|
|
groupRows:groups
|
|
};
|
|
};
|
|
const firstArray=(...values)=>values.find((value)=>Array.isArray(value))||[];
|
|
const compactMetric=(value)=>value===undefined||value===null?undefined:(typeof value==='object'?short(JSON.stringify(slimObject(value,8)),160):short(value,160));
|
|
const compactProjectionPair=(messageCount,traceCount)=>messageCount===undefined&&traceCount===undefined?undefined:'msg='+String(messageCount??'-')+' trace='+String(traceCount??'-');
|
|
const firstNestedRef=(value)=>value&&typeof value==='object'?(value.control&&typeof value.control==='object'?value.control:(value.observer&&typeof value.observer==='object'?value.observer:null)):null;
|
|
const compactTraceList=(value)=>{
|
|
if(Array.isArray(value)) return value.length>0?value.slice(0,4).map((item)=>String(item)).join(','):undefined;
|
|
if(value===undefined||value===null||value==='') return undefined;
|
|
return String(value);
|
|
};
|
|
const traceIdsFromRows=(rows)=>{
|
|
if(!Array.isArray(rows)) return undefined;
|
|
const ids=[];
|
|
for(const row of rows){
|
|
if(!row||typeof row!=='object') continue;
|
|
const value=row.traceId??row.businessTraceId??row.id;
|
|
if(value&&!ids.includes(String(value))) ids.push(String(value));
|
|
if(ids.length>=4) break;
|
|
}
|
|
return ids.length>0?ids:undefined;
|
|
};
|
|
const messageStatusesFromRows=(rows)=>{
|
|
if(!Array.isArray(rows)) return undefined;
|
|
const values=[];
|
|
for(const row of rows){
|
|
if(!row||typeof row!=='object') continue;
|
|
const status=row.status??row.dataStatus??row.messageStatus;
|
|
const trace=row.traceId??row.businessTraceId;
|
|
const text=[status,trace].filter(Boolean).join(':');
|
|
if(text&&!values.includes(text)) values.push(String(text));
|
|
if(values.length>=4) break;
|
|
}
|
|
return values.length>0?values:undefined;
|
|
};
|
|
const slimFindingSample=(value)=>{
|
|
if(!value||typeof value!=='object') return null;
|
|
const refValue=firstNestedRef(value)||{};
|
|
const controlCount=compactProjectionPair(value.controlMessageCount,value.controlTraceRowCount)??value.controlValue??value.beforeMessageCount??value.beforeValue;
|
|
const observerCount=compactProjectionPair(value.observerMessageCount,value.observerTraceRowCount)??value.observerValue??value.afterMessageCount??value.afterValue;
|
|
const trace=compactTraceList(value.traceIds)??compactTraceList(value.missingTraceIdsInObserver)??compactTraceList(value.controlTraceIds)??compactTraceList(value.observerTraceIds)??value.traceId??refValue.traceId;
|
|
return {
|
|
seq:value.seq??value.sampleSeq??value.fromSeq??value.controlSeq??refValue.seq??null,
|
|
ts:short(value.ts??value.sampleTs??value.fromTs??value.controlTs??refValue.ts,32),
|
|
diffKind:short(value.diffKind??value.kind??value.type??value.metric??value.anomaly,48),
|
|
fromValue:value.fromValue??value.beforeValue??null,
|
|
toValue:value.toValue??value.afterValue??null,
|
|
fromDigest:short(value.fromDigest,80),
|
|
toDigest:short(value.toDigest,80),
|
|
previousSeq:value.previousSeq??null,
|
|
previousTs:short(value.previousTs,32),
|
|
delta:value.delta??null,
|
|
sampleDeltaSeconds:value.sampleDeltaSeconds??null,
|
|
allowedIncreaseSeconds:value.allowedIncreaseSeconds??null,
|
|
excessiveIncreaseSeconds:value.excessiveIncreaseSeconds??null,
|
|
promptIndex:value.promptIndex??null,
|
|
event:short(value.event??value.anomaly??(value.steerUsed===true?'steer':Array.isArray(value.submitModes)?value.submitModes.join(','):undefined),48),
|
|
expectedPattern:short(value.expectedPattern,96),
|
|
control:compactMetric(controlCount??(value.fromMessageCount!==undefined||value.fromTraceRowCount!==undefined?compactProjectionPair(value.fromMessageCount,value.fromTraceRowCount):undefined)),
|
|
observer:compactMetric(observerCount??(value.toMessageCount!==undefined||value.toTraceRowCount!==undefined?compactProjectionPair(value.toMessageCount,value.toTraceRowCount):undefined)),
|
|
sessionId:short(value.sessionId??value.routeSessionId??value.activeSessionId??value.controlSessionId??value.observerSessionId??refValue.routeSessionId??refValue.activeSessionId,64),
|
|
path:short(value.path??value.urlPath??value.url??value.controlPath??value.observerPath??refValue.url,120),
|
|
trace:short(trace,120),
|
|
detail:short(value.detail??(value.durationText!==undefined||value.activityText!==undefined||value.textPreview!==undefined?['duration='+String(value.durationText??'-'),'activity='+String(value.activityText??'-'),'status='+String(value.status??'-'),'preview='+String(value.textPreview??'-')].join(' '):undefined)??(Array.isArray(value.submitModes)||Array.isArray(value.responseStatuses)?['modes='+String(Array.isArray(value.submitModes)?value.submitModes.join(','):'-'),'statuses='+String(Array.isArray(value.responseStatuses)?value.responseStatuses.join(','):'-'),'failure='+String(value.failureKind??'-')].join(' '):undefined)??(value.fromDigest||value.toDigest?'digest '+String(value.fromDigest??'-')+' -> '+String(value.toDigest??'-'):undefined)??(value.messageTextDigestDiff?'messageDigest '+String(value.controlMessageDigest??'-')+' != '+String(value.observerMessageDigest??'-'):undefined)??value.summary??value.message??value.preview??value.expectedPattern,180),
|
|
valuesRedacted:true
|
|
};
|
|
};
|
|
const slimFindingDetail=(parsed,id)=>{
|
|
if(!id||!parsed||typeof parsed!=='object'||!Array.isArray(parsed.findings)) return null;
|
|
const finding=parsed.findings.find((item)=>item&&typeof item==='object'&&String(item.id??item.kind??item.code??'')===id);
|
|
if(!finding) return {id,found:false,samples:[],valuesRedacted:true};
|
|
const sampleSource=Array.isArray(finding.samples)?'samples':Array.isArray(finding.groups)?'groups':Array.isArray(finding.examples)?'examples':Array.isArray(finding.rows)?'rows':Array.isArray(finding.rounds)?'rounds':'none';
|
|
const samples=firstArray(finding.samples,finding.groups,finding.examples,finding.rows,finding.rounds).slice(0,4).map(slimFindingSample).filter(Boolean);
|
|
return {id:short(finding.id??finding.kind??finding.code,80),found:true,severity:short(finding.severity??finding.level,24),count:finding.count??finding.sampleCount??samples.length,summary:short(finding.summary??finding.message,180),sampleSource,samples,valuesRedacted:true};
|
|
};
|
|
const slimRunnerError=(item)=>item&&typeof item==='object'?{ts:short(item.ts||item.at,32),type:short(item.type,48),retry:item.retry||item.lastRetryLabel||null,retryExhausted:item.retryExhausted===true,attemptCount:item.attemptCount??(Array.isArray(item.attempts)?item.attempts.length:null),lastFailureKind:short(item.lastFailureKind||item.failureKind,80),lastReadinessReason:short(item.lastReadinessReason,80),message:short(item.message||item.lastError,240)}:null;
|
|
const collectFallbackCandidates=(requested)=>{
|
|
const candidates=[];
|
|
if(findingFilter&&requested!=='analysis/report.json') candidates.push('analysis/report.json');
|
|
if(requested==='analysis/report.json'||findingFilter||requested.startsWith('analysis/')){
|
|
try{
|
|
const analysisDir=path.join(dir,'analysis');
|
|
const names=fs.readdirSync(analysisDir).filter((name)=>name.endsWith('.json')).map((name)=>({name,mtime:fs.statSync(path.join(analysisDir,name)).mtimeMs})).sort((a,b)=>b.mtime-a.mtime);
|
|
for(const item of names) candidates.push('analysis/'+item.name);
|
|
}catch{}
|
|
}
|
|
return [...new Set(candidates)].filter((item)=>item&&item!==requested&&safeRel(item));
|
|
};
|
|
const slimJsonl=(value)=>{
|
|
if(!value||typeof value!=='object'||Array.isArray(value)) return {value:short(value)};
|
|
const readiness=value.readiness&&typeof value.readiness==='object'?value.readiness:null;
|
|
const snapshot=value.snapshot&&typeof value.snapshot==='object'?value.snapshot:null;
|
|
const error=value.error&&typeof value.error==='object'?value.error:null;
|
|
return {
|
|
ts:short(value.ts||value.timestamp||value.at,32),
|
|
seq:value.seq,
|
|
type:short(value.type,48),
|
|
phase:short(value.phase,32),
|
|
commandId:short(value.commandId,40),
|
|
commandType:short(value.commandType||value.command?.type,40),
|
|
pageRole:short(value.pageRole,20),
|
|
path:short(value.path||value.routePath||snapshot?.path,80),
|
|
routeSessionId:short(value.routeSessionId||snapshot?.routeSessionId,48),
|
|
activeSessionId:short(value.activeSessionId||snapshot?.activeSessionId,48),
|
|
messageCount:value.messageCount??(Array.isArray(value.messages)?value.messages.length:undefined)??snapshot?.messageCount,
|
|
messageStatuses:short(compactTraceList(messageStatusesFromRows(value.messages)),160),
|
|
traceIds:short(compactTraceList(value.traceIds??traceIdsFromRows(value.traceRows)),160),
|
|
traceEventCount:value.traceEventCount??snapshot?.traceEventCount,
|
|
traceRowCount:value.traceRowCount??(Array.isArray(value.traceRows)?value.traceRows.length:undefined)??snapshot?.traceRowCount,
|
|
loadingCount:value.loadingCount??(Array.isArray(value.loadings)?value.loadings.length:undefined)??snapshot?.loadingCount,
|
|
method:short(value.method,12),
|
|
status:value.status,
|
|
url:short(value.url,120),
|
|
failure:short(value.failure||value.failureText||value.errorText,120),
|
|
readiness:readiness?{ok:readiness.ok,reason:short(readiness.reason,80),path:short(readiness.path,80),domReady:readiness.domReady,workbenchShellVisible:readiness.workbenchShellVisible,sessionCreateVisible:readiness.sessionCreateVisible,commandInputVisible:readiness.commandInputVisible}:undefined,
|
|
message:short(value.message||error?.message||value.text,180)
|
|
};
|
|
};
|
|
if(selected){
|
|
if(!safeRel(selected)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'unsafe-file',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
|
|
let file=path.join(dir,selected); let relative=path.relative(dir,file);
|
|
if(relative.startsWith('..')||path.isAbsolute(relative)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-outside-state-dir',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile()){
|
|
const fallback=collectFallbackCandidates(selected).find((item)=>{const candidate=path.join(dir,item); return fs.existsSync(candidate)&&fs.statSync(candidate).isFile();});
|
|
if(fallback){selected=fallback; file=path.join(dir,selected); relative=path.relative(dir,file);}
|
|
}
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile()){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-not-found',file:selected,fallbackCandidates:collectFallbackCandidates(selected).slice(0,8),valuesRedacted:true},null,2)); process.exit(1);}
|
|
const st=fs.statSync(file); const raw=fs.readFileSync(file); const truncated=raw.length>maxReadBytes; const isJsonl=selected.endsWith('.jsonl'); const isJson=selected.endsWith('.json');
|
|
const tailReadBytes=isJsonl?maxJsonlTailBytes:maxReadBytes;
|
|
const text=(isJsonl?raw.slice(Math.max(0,raw.length-tailReadBytes)):raw.slice(0,Math.min(raw.length,maxReadBytes))).toString('utf8');
|
|
const fullTextForGrep=grepText?raw.toString('utf8'):'';
|
|
const grep=grepText?grepLines(fullTextForGrep,grepText):null;
|
|
const lines=text.split(/\\r?\\n/).filter(Boolean);
|
|
if(isJsonl&&truncated&&lines.length>0) lines.shift();
|
|
let jsonSummary=null; let jsonContent=null;
|
|
if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); const inferredFindingFilter=findingFilter||(grepText&&Array.isArray(parsed&&parsed.findings)&&parsed.findings.some((item)=>item&&typeof item==='object'&&String(item.id??item.kind??item.code??'')===grepText)?grepText:null); jsonContent=raw.length<=4096?slimJsonValue(parsed):null; jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,24):[],counts:slimObject(parsed&&parsed.counts,16),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics,16),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts,16),pagePerformance:slimObject(parsed&&parsed.pagePerformance,16),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-4).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,6).map(slimFinding).filter(Boolean):[],findingDetail:slimFindingDetail(parsed,inferredFindingFilter)}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}}
|
|
const jsonlTail=isJsonl&&!grep?lines.slice(-8).map((line,index)=>{try{return slimJsonl(JSON.parse(line))}catch(error){return {parseError:true,index,lineTail:line.slice(-240),error:String(error&&error.message||error).slice(0,160)}}}):null;
|
|
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||isJson||jsonSummary||grep?undefined:text.slice(0,maxTextPreviewBytes),contentTruncated:!isJsonl&&!isJson&&!jsonSummary&&!grep&&text.length>maxTextPreviewBytes,jsonlTail,jsonSummary,jsonContent,grep,lineCount:lines.length},valuesRedacted:true}));
|
|
process.exit(0);
|
|
}
|
|
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 selectedFiles=out.slice(0,maxFiles);
|
|
const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}});
|
|
const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0);
|
|
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
|
|
`)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")} ${shellQuote(collectGrep ?? "")}`;
|
|
}
|
|
|
|
export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number): string {
|
|
if (waitMs <= 0) {
|
|
return [
|
|
`printf '{"ok":true,"queued":true,"commandId":%s,"stateDir":%s}\\n' ${shellQuote(JSON.stringify(commandId))} "$(node -e "console.log(JSON.stringify(process.argv[1]))" "$state_dir")"`,
|
|
].join("\n");
|
|
}
|
|
const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000));
|
|
return [
|
|
`command_id=${shellQuote(commandId)}`,
|
|
`deadline=$(( $(date +%s) + ${waitSeconds} ))`,
|
|
"while [ \"$(date +%s)\" -le \"$deadline\" ]; do",
|
|
" if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi",
|
|
" if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi",
|
|
" sleep 1",
|
|
"done",
|
|
"printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function commandSummaryForOutput(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const text = typeof payload.text === "string" ? payload.text : null;
|
|
const title = typeof payload.title === "string" ? payload.title : null;
|
|
const body = typeof payload.body === "string" ? payload.body : null;
|
|
const opaque = (value: unknown) => typeof value === "string" && value.length > 0
|
|
? {
|
|
hash: `sha256:${createHash("sha256").update(value).digest("hex")}`,
|
|
preview: value.length <= 18 ? value : `${value.slice(0, 10)}...${value.slice(-5)}`,
|
|
bytes: Buffer.byteLength(value),
|
|
}
|
|
: null;
|
|
return {
|
|
id: payload.id,
|
|
type: payload.type,
|
|
path: payload.path ?? null,
|
|
label: payload.label ?? null,
|
|
sessionId: payload.sessionId ?? null,
|
|
provider: payload.provider ?? null,
|
|
durationMs: payload.durationMs ?? null,
|
|
sourceId: opaque(payload.sourceId),
|
|
fileRef: opaque(payload.fileRef),
|
|
taskRef: opaque(payload.taskRef),
|
|
taskId: payload.taskId ?? null,
|
|
titleHash: title === null ? null : `sha256:${createHash("sha256").update(title).digest("hex")}`,
|
|
titleBytes: title === null ? null : Buffer.byteLength(title),
|
|
bodyHash: body === null ? null : `sha256:${createHash("sha256").update(body).digest("hex")}`,
|
|
bodyBytes: body === null ? null : Buffer.byteLength(body),
|
|
status: payload.status ?? null,
|
|
hwpodId: opaque(payload.hwpodId),
|
|
nodeId: opaque(payload.nodeId),
|
|
root: opaque(payload.root),
|
|
textHash: text === null ? null : `sha256:${createHash("sha256").update(text).digest("hex")}`,
|
|
textBytes: text === null ? null : Buffer.byteLength(text),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
export function isSafeWebObserveStateDir(value: string): boolean {
|
|
return value.length > 0
|
|
&& !value.includes("\0")
|
|
&& !value.includes("..")
|
|
&& /^\.state\/web-observe\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\d{4}\/\d{2}\/\d{2}\/[A-Za-z0-9_.TZ-]+_[A-Za-z0-9_.-]+_webobs-[A-Za-z0-9_.-]+$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebObserveCollectFile(value: string): boolean {
|
|
return value.length > 0
|
|
&& value.length <= 240
|
|
&& !value.includes("\0")
|
|
&& !value.includes("..")
|
|
&& !value.startsWith("/")
|
|
&& !value.includes("\\")
|
|
&& value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")
|
|
&& /^[A-Za-z0-9_.\/-]+$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebObserveTraceId(value: string): boolean {
|
|
return /^trc_[A-Za-z0-9_-]{6,120}$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebObserveFindingId(value: string): boolean {
|
|
return value.length > 0
|
|
&& value.length <= 120
|
|
&& !value.includes("\0")
|
|
&& /^[A-Za-z0-9_.:-]+$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebObserveArchivePrefix(value: string): boolean {
|
|
return value.length > 0
|
|
&& value.length <= 120
|
|
&& !value.includes("\0")
|
|
&& !value.includes("..")
|
|
&& /^[A-Za-z0-9_.-]+$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebObserveJobId(value: string): boolean {
|
|
return /^webobs-[A-Za-z0-9_.-]+$/u.test(value);
|
|
}
|
|
|
|
export function safeWebObserveSegment(value: string): string {
|
|
return value.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "item";
|
|
}
|
|
|
|
export function safeWebObserveTargetSegment(value: string): string {
|
|
const segment = value.replace(/^https?:\/\//u, "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "");
|
|
return segment.slice(0, 48) || "workbench";
|
|
}
|
|
|
|
export function runNodeWebProbeScript(
|
|
options: NodeWebProbeScriptOptions,
|
|
spec: HwlabRuntimeLaneSpec,
|
|
secretSpec: RuntimeSecretSpec,
|
|
material: BootstrapAdminPasswordMaterial,
|
|
credential: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`;
|
|
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
|
|
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath);
|
|
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
|
const commandTimedOut = result.timedOut || result.exitCode === 124;
|
|
const stdoutReport = parseJsonObject(result.stdout);
|
|
const runPaths = webProbeScriptRunPathsFromStderr(result.stderr);
|
|
const recoveredReport = stdoutReport === null ? readNodeWebProbeScriptReport(options, spec, runPaths.reportPath) : null;
|
|
const recoveredArtifacts = stdoutReport === null || commandTimedOut ? readNodeWebProbeScriptArtifacts(options, spec, runPaths.runDir) : null;
|
|
const parsedReport = stdoutReport ?? recoveredReport?.report ?? null;
|
|
const report = compactWebProbeScriptResult(parsedReport);
|
|
const passed = result.exitCode === 0 && report?.ok === true;
|
|
const summary = nullableRecord(report?.summary);
|
|
const stdoutBytes = Buffer.byteLength(result.stdout, "utf8");
|
|
const outputFailureKind = parsedReport === null
|
|
? commandTimedOut
|
|
? "web-probe-command-timeout"
|
|
: stdoutBytes > 64 * 1024
|
|
? "web-probe-output-too-large"
|
|
: "web-probe-report-parse-failed"
|
|
: null;
|
|
const degradedReason = commandTimedOut
|
|
? "web-probe-command-timeout"
|
|
: typeof summary?.degradedReason === "string"
|
|
? summary.degradedReason
|
|
: typeof report?.failureKind === "string"
|
|
? report.failureKind
|
|
: outputFailureKind
|
|
? outputFailureKind
|
|
: commandTimedOut
|
|
? "web-probe-command-timeout"
|
|
: null;
|
|
const failureKind = commandTimedOut
|
|
? "web-probe-command-timeout"
|
|
: typeof summary?.failureKind === "string"
|
|
? summary.failureKind
|
|
: typeof report?.failureKind === "string"
|
|
? report.failureKind
|
|
: outputFailureKind;
|
|
const effectiveSummary = summary !== null ? {
|
|
...summary,
|
|
transportTimedOut: commandTimedOut,
|
|
recoveredFrom: stdoutReport !== null ? "stdout" : recoveredReport?.source ?? null,
|
|
} : (outputFailureKind === null ? null : {
|
|
ok: false,
|
|
status: "blocked",
|
|
degradedReason: outputFailureKind,
|
|
failureKind: outputFailureKind,
|
|
failedCondition: outputFailureKind === "web-probe-output-too-large"
|
|
? "remote web-probe stdout exceeded the bounded summary parser input"
|
|
: outputFailureKind === "web-probe-command-timeout"
|
|
? "remote web-probe command timed out before stdout returned a parseable report"
|
|
: "remote web-probe stdout did not contain a parseable JSON report",
|
|
runDir: runPaths.runDir,
|
|
reportPath: runPaths.reportPath,
|
|
reportLoad: recoveredReport === null ? null : {
|
|
source: recoveredReport.source,
|
|
path: recoveredReport.path,
|
|
degradedReason: recoveredReport.degradedReason,
|
|
result: recoveredReport.result === null ? null : compactCommandResultRedacted(recoveredReport.result, [material.password ?? ""]),
|
|
},
|
|
screenshots: recoveredArtifacts?.screenshots ?? [],
|
|
artifacts: recoveredArtifacts?.artifacts ?? null,
|
|
stdoutBytes,
|
|
exitCode: result.exitCode,
|
|
stderrTail: result.stderr.trim().slice(-2000),
|
|
valuesRedacted: true,
|
|
});
|
|
const issueEvidence = nullableRecord(report?.issueEvidence) ?? nullableRecord(effectiveSummary?.issueEvidence);
|
|
const compactResult = compactCommandResultRedacted(result, [material.password ?? ""]);
|
|
if (outputFailureKind !== null) {
|
|
compactResult.stdoutTail = redactKnownSecrets(result.stdout.slice(-2000), [material.password ?? ""]);
|
|
compactResult.stderrTail = redactKnownSecrets(result.stderr.slice(-2000), [material.password ?? ""]);
|
|
}
|
|
return renderWebProbeScriptResult({
|
|
ok: passed,
|
|
status: passed ? "pass" : "blocked",
|
|
command: commandLabel,
|
|
node: options.node,
|
|
lane: options.lane,
|
|
workspace: spec.workspace,
|
|
url: options.url,
|
|
network: webProbeProxy.summary,
|
|
credential,
|
|
scriptSource: options.scriptSource,
|
|
degradedReason,
|
|
failureKind,
|
|
summary: effectiveSummary,
|
|
issueEvidence,
|
|
probe: report,
|
|
reportLoad: stdoutReport !== null ? { source: "stdout", path: report?.reportPath ?? null, degradedReason: null } : recoveredReport === null ? null : {
|
|
source: recoveredReport.source,
|
|
path: recoveredReport.path,
|
|
degradedReason: recoveredReport.degradedReason,
|
|
result: recoveredReport.result === null ? null : compactCommandResultRedacted(recoveredReport.result, [material.password ?? ""]),
|
|
},
|
|
warnings: webProbeScriptGovernanceWarnings(options),
|
|
hints: webProbeScriptGovernanceHints(options),
|
|
preferredCommands: webProbeScriptPreferredCommands(options),
|
|
recoveredArtifacts: recoveredArtifacts === null ? null : {
|
|
source: recoveredArtifacts.source,
|
|
degradedReason: recoveredArtifacts.degradedReason,
|
|
result: recoveredArtifacts.result === null ? null : compactCommandResultRedacted(recoveredArtifacts.result, [material.password ?? ""]),
|
|
artifacts: recoveredArtifacts.artifacts,
|
|
},
|
|
result: compactResult,
|
|
valuesRedacted: true,
|
|
});
|
|
}
|
|
|
|
function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record<string, unknown>[] {
|
|
if (options.suppressAdHocWarning === true) return [];
|
|
return [{
|
|
code: "web_probe_script_ad_hoc_only",
|
|
severity: "warning",
|
|
message: "web-probe script is for one-off exploration; repeated or high-frequency checks must be promoted to a typed command instead of rerunning temporary scripts.",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
valuesRedacted: true,
|
|
}];
|
|
}
|
|
|
|
function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] {
|
|
if (options.generatedHints !== undefined) return options.generatedHints;
|
|
return [
|
|
"Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows; use `observe collect/analyze` for repeated evidence reads.",
|
|
"If the same script is needed more than once, add or extend a reusable command type in the web-probe observe command surface.",
|
|
`For this target, start with: bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`,
|
|
];
|
|
}
|
|
|
|
function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record<string, string> {
|
|
if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands;
|
|
return {
|
|
startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`,
|
|
mdtodoSummary: "bun scripts/cli.ts web-probe observe collect <observerId> --view project-mdtodo-summary",
|
|
analyze: "bun scripts/cli.ts web-probe observe analyze <observerId>",
|
|
addCommandType: "Add a repo-owned web-probe observe command type before repeating this script.",
|
|
};
|
|
}
|
|
|
|
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string {
|
|
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
|
|
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
|
|
return [
|
|
"set -eu",
|
|
"run_root=.state/web-probe-script",
|
|
"mkdir -p \"$run_root\"",
|
|
"run_dir=$(mktemp -d \"$run_root/run.XXXXXX\")",
|
|
"report_file=\"$run_dir/web-probe-script-report.json\"",
|
|
"chmod 700 \"$run_dir\"",
|
|
"printf 'UNIDESK_WEB_PROBE_RUN_DIR=%s\\n' \"$run_dir\" >&2",
|
|
"printf 'UNIDESK_WEB_PROBE_REPORT_PATH=%s\\n' \"$report_file\" >&2",
|
|
"user_script=\"$run_dir/user-script.mjs\"",
|
|
"runner=\"$run_dir/runner.mjs\"",
|
|
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$user_script" ${shellQuote(userScriptB64)}`,
|
|
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$runner" ${shellQuote(runnerB64)}`,
|
|
[
|
|
...webProbeProxy.envAssignments,
|
|
...(playwrightBrowsersPath === undefined ? [] : [
|
|
`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(playwrightBrowsersPath)}`,
|
|
]),
|
|
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
|
|
`HWLAB_WEB_USER=${shellQuote(username)}`,
|
|
`HWLAB_WEB_PASS=${shellQuote(password)}`,
|
|
"UNIDESK_WEB_PROBE_RUN_DIR=\"$run_dir\"",
|
|
"UNIDESK_WEB_PROBE_USER_SCRIPT=\"$user_script\"",
|
|
`UNIDESK_WEB_PROBE_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
|
|
`UNIDESK_WEB_PROBE_VIEWPORT=${shellQuote(options.viewport)}`,
|
|
`UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE=${shellQuote(options.browserProxyMode)}`,
|
|
"node \"$runner\"",
|
|
].join(" "),
|
|
].join("\n");
|
|
}
|
|
|
|
export function webProbeScriptRunPathsFromStderr(stderr: string): { runDir: string | null; reportPath: string | null } {
|
|
const runDir = lineValueFromText(stderr, "UNIDESK_WEB_PROBE_RUN_DIR");
|
|
const markedReportPath = lineValueFromText(stderr, "UNIDESK_WEB_PROBE_REPORT_PATH");
|
|
const reportPath = markedReportPath ?? (runDir === null ? null : `${runDir.replace(/\/$/u, "")}/web-probe-script-report.json`);
|
|
return {
|
|
runDir: isSafeWebProbeScriptRunDir(runDir) ? runDir : null,
|
|
reportPath: isSafeWebProbeScriptReportPath(reportPath) ? reportPath : null,
|
|
};
|
|
}
|
|
|
|
export function lineValueFromText(text: string, name: string): string | null {
|
|
const pattern = new RegExp(`^${name}=([^\\r\\n]+)$`, "mu");
|
|
const match = text.match(pattern);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
export function readNodeWebProbeScriptReport(
|
|
options: NodeWebProbeScriptOptions,
|
|
spec: HwlabRuntimeLaneSpec,
|
|
reportPath: string | null,
|
|
): { source: string; report: Record<string, unknown> | null; result: CommandResult | null; degradedReason: string | null; path: string | null } | null {
|
|
if (!reportPath) return null;
|
|
if (!isSafeWebProbeScriptReportPath(reportPath)) return { source: "unsafe-path", report: null, result: null, degradedReason: "web-probe-report-path-invalid", path: reportPath };
|
|
const script = [
|
|
"set -eu",
|
|
`test -f ${shellQuote(reportPath)}`,
|
|
`node - ${shellQuote(reportPath)} <<'NODE'`,
|
|
"const fs = require('fs');",
|
|
"const reportPath = process.argv[2];",
|
|
"const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));",
|
|
"function rec(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }",
|
|
"function compact(value, depth = 0) {",
|
|
" if (value === null || value === undefined) return value ?? null;",
|
|
" if (typeof value === 'string') return value.replace(/\\s+/gu, ' ').trim().slice(0, 120);",
|
|
" if (typeof value === 'number' || typeof value === 'boolean') return value;",
|
|
" if (depth >= 3) return '[max-depth]';",
|
|
" if (Array.isArray(value)) return value.slice(0, 4).map((item) => compact(item, depth + 1));",
|
|
" if (typeof value === 'object') {",
|
|
" const out = {};",
|
|
" for (const [key, nested] of Object.entries(value).slice(0, 8)) out[key] = compact(nested, depth + 1);",
|
|
" return out;",
|
|
" }",
|
|
" return String(value).slice(0, 240);",
|
|
"}",
|
|
"function compactSummary(value) {",
|
|
" const summary = rec(value);",
|
|
" return {",
|
|
" ok: summary.ok === true,",
|
|
" status: typeof summary.status === 'string' ? summary.status : null,",
|
|
" degradedReason: typeof summary.degradedReason === 'string' ? summary.degradedReason : null,",
|
|
" failureKind: typeof summary.failureKind === 'string' ? summary.failureKind : null,",
|
|
" failedCondition: typeof summary.failedCondition === 'string' ? compact(summary.failedCondition) : null,",
|
|
" nextAction: typeof summary.nextAction === 'string' ? compact(summary.nextAction) : null,",
|
|
" baseUrl: typeof summary.baseUrl === 'string' ? summary.baseUrl : null,",
|
|
" finalUrl: typeof summary.finalUrl === 'string' ? summary.finalUrl : null,",
|
|
" lastUrl: typeof summary.lastUrl === 'string' ? summary.lastUrl : null,",
|
|
" scriptSha256: typeof summary.scriptSha256 === 'string' ? summary.scriptSha256 : null,",
|
|
" runDir: typeof summary.runDir === 'string' ? summary.runDir : null,",
|
|
" reportPath: typeof summary.reportPath === 'string' ? summary.reportPath : null,",
|
|
" reportSha256: typeof summary.reportSha256 === 'string' ? summary.reportSha256 : null,",
|
|
" lastScreenshot: compact(summary.lastScreenshot),",
|
|
" screenshots: compact(summary.screenshots),",
|
|
" apiMatrix: compact(summary.apiMatrix),",
|
|
" stepCount: typeof summary.stepCount === 'number' ? summary.stepCount : null,",
|
|
" lastStep: compact(summary.lastStep),",
|
|
" issueEvidence: compact(summary.issueEvidence),",
|
|
" valuesRedacted: true,",
|
|
" };",
|
|
"}",
|
|
"const scriptBlock = rec(report.script);",
|
|
"const compactReport = {",
|
|
" ok: report.ok === true,",
|
|
" status: typeof report.status === 'string' ? report.status : null,",
|
|
" summary: compactSummary(report.summary),",
|
|
" issueEvidence: compact(report.issueEvidence ?? rec(report.summary).issueEvidence),",
|
|
" baseUrl: typeof report.baseUrl === 'string' ? report.baseUrl : null,",
|
|
" finalUrl: typeof report.finalUrl === 'string' ? report.finalUrl : null,",
|
|
" lastUrl: typeof report.lastUrl === 'string' ? report.lastUrl : null,",
|
|
" scriptSha256: typeof report.scriptSha256 === 'string' ? report.scriptSha256 : null,",
|
|
" runDir: typeof report.runDir === 'string' ? report.runDir : null,",
|
|
" reportPath: typeof report.reportPath === 'string' ? report.reportPath : reportPath,",
|
|
" reportSha256: typeof report.reportSha256 === 'string' ? report.reportSha256 : null,",
|
|
" auth: compact(report.auth),",
|
|
" script: { ok: scriptBlock.ok === true, result: compact(scriptBlock.result), stepCount: Array.isArray(scriptBlock.steps) ? scriptBlock.steps.length : null },",
|
|
" steps: Array.isArray(report.steps) ? report.steps.slice(-5).map((item) => compact(item)) : [],",
|
|
" failureKind: typeof report.failureKind === 'string' ? report.failureKind : null,",
|
|
" guidance: typeof report.guidance === 'string' ? report.guidance : null,",
|
|
" lastScreenshot: compact(report.lastScreenshot),",
|
|
" readiness: compact(report.readiness),",
|
|
" artifacts: compact(report.artifacts),",
|
|
" error: typeof report.error === 'string' ? report.error : null,",
|
|
" errorMessage: typeof report.errorMessage === 'string' ? report.errorMessage : null,",
|
|
" safety: compact(report.safety),",
|
|
" valuesRedacted: true,",
|
|
"};",
|
|
"console.log(JSON.stringify(compactReport));",
|
|
"NODE",
|
|
].join("\n");
|
|
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, 55);
|
|
if (result.exitCode !== 0 || result.timedOut) {
|
|
return { source: "report-file", report: null, result, degradedReason: result.timedOut ? "web-probe-command-timeout" : "web-probe-report-read-failed", path: reportPath };
|
|
}
|
|
const report = parseJsonObject(result.stdout);
|
|
return {
|
|
source: "report-file",
|
|
report,
|
|
result,
|
|
degradedReason: report === null ? "web-probe-report-parse-failed" : null,
|
|
path: reportPath,
|
|
};
|
|
}
|
|
|
|
export function readNodeWebProbeScriptArtifacts(
|
|
options: NodeWebProbeScriptOptions,
|
|
spec: HwlabRuntimeLaneSpec,
|
|
runDir: string | null,
|
|
): { source: string; artifacts: Record<string, unknown> | null; screenshots: Record<string, unknown>[]; result: CommandResult | null; degradedReason: string | null } | null {
|
|
if (!runDir) return null;
|
|
if (!isSafeWebProbeScriptRunDir(runDir)) return { source: "unsafe-path", artifacts: null, screenshots: [], result: null, degradedReason: "web-probe-run-dir-invalid" };
|
|
const script = [
|
|
"set -eu",
|
|
`test -d ${shellQuote(runDir)}`,
|
|
`find ${shellQuote(runDir)} -maxdepth 1 -type f \\( -name '*.png' -o -name '*.json' \\) -printf '%p\\t%s\\n' | tail -n 30`,
|
|
].join("\n");
|
|
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, 55);
|
|
if (result.exitCode !== 0 || result.timedOut) {
|
|
return { source: "run-dir", artifacts: null, screenshots: [], result, degradedReason: result.timedOut ? "web-probe-command-timeout" : "web-probe-artifact-list-failed" };
|
|
}
|
|
const items = result.stdout.split(/\r?\n/u)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [path, sizeText] = line.split("\t");
|
|
const byteCount = Number(sizeText);
|
|
return {
|
|
kind: path.endsWith(".png") ? "screenshot" : "json",
|
|
path,
|
|
byteCount: Number.isFinite(byteCount) ? byteCount : null,
|
|
};
|
|
})
|
|
.filter((item) => isSafeWebProbeScriptArtifactPath(String(item.path)));
|
|
const screenshots = items.filter((item) => item.kind === "screenshot");
|
|
return {
|
|
source: "run-dir",
|
|
artifacts: { runDir, count: items.length, items },
|
|
screenshots,
|
|
result,
|
|
degradedReason: null,
|
|
};
|
|
}
|