refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,752 @@
|
||||
// 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 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),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),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)=>detail&&detail.error?short((detail.error.message||'')+(detail.error.auth&&detail.error.auth.lastError?' '+detail.error.auth.lastError:'')):detail&&detail.result?short([detail.result.statusText,detail.result.retryExhausted?'retry-exhausted':''].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 hwlab nodes web-probe observe command --node '+node+' --lane '+lane+' --state-dir '+dir+' --type mark --label checkpoint',stop:'bun scripts/cli.ts hwlab nodes web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir,forceStop:'bun scripts/cli.ts hwlab nodes web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir+' --force',analyze:'bun scripts/cli.ts hwlab nodes 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;
|
||||
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),
|
||||
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,
|
||||
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 webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
|
||||
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.password ?? "", webProbeProxy);
|
||||
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: `hwlab nodes web-probe script --node ${options.node} --lane ${options.lane}`,
|
||||
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 ?? ""]),
|
||||
},
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, password: string, webProbeProxy: NodeWebProbeHostProxyEnv): 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,
|
||||
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
|
||||
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
|
||||
`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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user