feat(web-probe): add typed Kafka live fanout validation

This commit is contained in:
Codex
2026-07-10 11:52:05 +02:00
parent b9e804014b
commit a496268713
20 changed files with 1544 additions and 25 deletions
+68 -12
View File
@@ -36,12 +36,13 @@ import { compactCommandResultRedacted, nullableRecord, redactKnownSecrets, shell
import { renderWebProbeScriptResult } from "./web-observe-render";
import { nodeWebProbeHostProxyEnv } from "./web-probe-observe";
export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string): string {
export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string, commandId: string | null = null): 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 exactCommandId=process.argv[4]||'';
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}};
@@ -65,6 +66,17 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
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 exactCommand=()=>{
if(!exactCommandId)return null;
for(const bucket of ['done','failed','abandoned','processing','pending']){
const parsed=readJsonPath(path.join(commandDir(bucket),exactCommandId+'.json'));
if(!parsed)continue;
const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null;
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
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');
@@ -82,8 +94,8 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
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)}`;
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,exactCommand:exactCommand(),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)} ${shellQuote(commandId ?? "")}`;
}
export function nodeWebObserveForceStopNodeScript(reason: string, forcedByCommandId: string): string {
@@ -385,6 +397,7 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number
export function commandSummaryForOutput(payload: Record<string, unknown>): Record<string, unknown> {
const text = typeof payload.text === "string" ? payload.text : null;
const secondText = typeof payload.secondText === "string" ? payload.secondText : 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
@@ -401,6 +414,7 @@ export function commandSummaryForOutput(payload: Record<string, unknown>): Recor
label: payload.label ?? null,
sessionId: payload.sessionId ?? null,
provider: payload.provider ?? null,
profile: payload.profile ?? null,
durationMs: payload.durationMs ?? null,
sourceId: opaque(payload.sourceId),
fileRef: opaque(payload.fileRef),
@@ -416,6 +430,8 @@ export function commandSummaryForOutput(payload: Record<string, unknown>): Recor
root: opaque(payload.root),
textHash: text === null ? null : `sha256:${createHash("sha256").update(text).digest("hex")}`,
textBytes: text === null ? null : Buffer.byteLength(text),
secondTextHash: secondText === null ? null : `sha256:${createHash("sha256").update(secondText).digest("hex")}`,
secondTextBytes: secondText === null ? null : Buffer.byteLength(secondText),
valuesRedacted: true,
};
}
@@ -478,6 +494,7 @@ export function runNodeWebProbeScript(
credential: Record<string, unknown>,
): Record<string, unknown> {
const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`;
const commandGovernance = webProbeScriptCommandGovernance(options);
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);
@@ -570,6 +587,7 @@ export function runNodeWebProbeScript(
compactResult.stderrTail = redactKnownSecrets(result.stderr.slice(-2000), [material.password ?? ""]);
}
return renderWebProbeScriptResult({
commandPromotionHint: commandGovernance.commandPromotionHint,
ok: passed,
status: passed ? "pass" : "blocked",
command: commandLabel,
@@ -593,9 +611,9 @@ export function runNodeWebProbeScript(
stdoutRecovery: recoveredReport.stdoutRecovery ?? null,
},
stdoutRecovery: stdoutResolution.diagnostics,
warnings: webProbeScriptGovernanceWarnings(options),
hints: webProbeScriptGovernanceHints(options),
preferredCommands: webProbeScriptPreferredCommands(options),
warnings: commandGovernance.warnings,
hints: commandGovernance.hints,
preferredCommands: commandGovernance.preferredCommands,
recoveredArtifacts: recoveredArtifacts === null ? null : {
source: recoveredArtifacts.source,
degradedReason: recoveredArtifacts.degradedReason,
@@ -607,34 +625,72 @@ export function runNodeWebProbeScript(
});
}
function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record<string, unknown>[] {
export function webProbeScriptCommandGovernance(options: NodeWebProbeScriptOptions): {
commandPromotionHint: Record<string, unknown>;
warnings: Record<string, unknown>[];
hints: string[];
preferredCommands: Record<string, string>;
} {
return {
commandPromotionHint: webProbeScriptCommandPromotionHint(options),
warnings: webProbeScriptGovernanceWarnings(options),
hints: webProbeScriptGovernanceHints(options),
preferredCommands: webProbeScriptPreferredCommands(options),
};
}
export function webProbeScriptCommandPromotionHint(options: NodeWebProbeScriptOptions): Record<string, unknown> {
const repoOwnedTypedCommand = options.suppressAdHocWarning === true;
const currentCommand = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`;
return {
schemaVersion: "unidesk.web-probe.command-promotion.v1",
code: "prefer_repo_owned_typed_command",
promotionRequiredBeforeRepeat: !repoOwnedTypedCommand,
repoOwnedTypedCommand,
sourceKind: options.scriptSource.kind,
currentCommand,
preferredArtifact: "repo-owned-typed-command",
preferredSurface: "web-probe observe command or a dedicated web-probe subcommand",
action: repoOwnedTypedCommand ? "reuse-repo-owned-typed-command" : "promote-before-repeat",
message: repoOwnedTypedCommand
? "This probe is already a repo-owned typed command; reuse its public command instead of copying the generated script."
: "Before repeating this one-off script, promote the workflow to a repo-owned typed web-probe command.",
valuesRedacted: true,
};
}
export 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.",
requiredAction: "promote-before-repeat",
preferredArtifact: "repo-owned-typed-command",
preferredSurface: "web-probe observe command or a dedicated web-probe subcommand",
temporaryScriptScope: "one-off-exploration-only",
message: "web-probe script is a one-off exploration escape hatch; before repeating it, promote the workflow to a repo-owned typed command instead of rerunning temporary scripts.",
node: options.node,
lane: options.lane,
valuesRedacted: true,
}];
}
function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] {
export function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] {
if (options.generatedHints !== undefined) return options.generatedHints;
return [
"Before rerunning this script, promote the workflow to a repo-owned typed `web-probe` command so auth, artifacts, output bounds and evidence contracts remain reusable.",
"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> {
export function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record<string, string> {
if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands;
return {
typedCommandCatalog: "bun scripts/cli.ts web-probe --help",
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.",
};
}