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
+3
View File
@@ -129,6 +129,7 @@ export type NodeWebProbeObserveCommandType =
| "gotoProjectMdtodo"
| "newSession"
| "sendPrompt"
| "validateRealtimeFanout"
| "steer"
| "cancel"
| "selectProvider"
@@ -206,10 +207,12 @@ export interface NodeWebProbeObserveOptions {
force: boolean;
commandType: NodeWebProbeObserveCommandType | null;
commandText: string | null;
commandSecondText: string | null;
commandPath: string | null;
commandLabel: string | null;
commandSessionId: string | null;
commandProvider: string | null;
commandProfile: string | null;
commandAfterRound: number | null;
commandSeverity: string | null;
commandAlternateSessionStrategy: string | null;
+2
View File
@@ -1858,6 +1858,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers)) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic)) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic)) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic)) || codeAgentRuntimeChanged;",
" codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId)) || codeAgentRuntimeChanged;",
@@ -2269,6 +2270,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector)));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic));",
" checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId));",
@@ -3,6 +3,7 @@ import { test } from "bun:test";
import { withWebObserveCommandRendered } from "../hwlab-node-web-observe-render";
import { renderWebProbeScriptResult } from "./web-observe-render";
import { webProbeScriptCommandGovernance } from "./web-observe-scripts";
test("observe command renderer separates control completion from async turn terminal state", () => {
const rendered = withWebObserveCommandRendered({
@@ -24,7 +25,21 @@ test("observe command renderer separates control completion from async turn term
});
test("web-probe script render warns to promote repeated scripts into commands", () => {
const commandPromotionHint = {
schemaVersion: "unidesk.web-probe.command-promotion.v1",
code: "prefer_repo_owned_typed_command",
promotionRequiredBeforeRepeat: true,
repoOwnedTypedCommand: false,
sourceKind: "stdin",
currentCommand: "web-probe script --node D601 --lane v03",
preferredArtifact: "repo-owned-typed-command",
preferredSurface: "web-probe observe command or a dedicated web-probe subcommand",
action: "promote-before-repeat",
message: "Before repeating this one-off script, promote the workflow to a repo-owned typed web-probe command.",
valuesRedacted: true,
};
const rendered = renderWebProbeScriptResult({
commandPromotionHint,
ok: true,
status: "pass",
command: "web-probe script --node D601 --lane v03",
@@ -34,12 +49,15 @@ test("web-probe script render warns to promote repeated scripts into commands",
warnings: [{
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",
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.",
}],
hints: [
"Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows.",
"Before rerunning this script, promote the workflow to a repo-owned typed `web-probe` command.",
],
preferredCommands: {
typedCommandCatalog: "bun scripts/cli.ts web-probe --help",
startObserver: "bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo",
mdtodoSummary: "bun scripts/cli.ts web-probe observe collect <observerId> --view project-mdtodo-summary",
},
@@ -51,10 +69,64 @@ test("web-probe script render warns to promote repeated scripts into commands",
});
const text = String(rendered.renderedText ?? "");
const marker = "UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT ";
const firstLine = text.split("\n", 1)[0] ?? "";
assert.ok(firstLine.startsWith(marker));
assert.deepEqual(JSON.parse(firstLine.slice(marker.length)), commandPromotionHint);
assert.equal(Object.keys(rendered)[0], "commandPromotionHint");
assert.match(text, /WARNINGS/u);
assert.match(text, /web_probe_script_ad_hoc_only/u);
assert.match(text, /HINTS/u);
assert.match(text, /web-probe observe command/u);
assert.match(text, /repo-owned typed `web-probe` command/u);
assert.match(text, /typedCommandCatalog: bun scripts\/cli\.ts web-probe --help/u);
assert.match(text, /startObserver: bun scripts\/cli\.ts web-probe observe start/u);
assert.match(text, /mdtodoSummary: bun scripts\/cli\.ts web-probe observe collect/u);
});
test("web-probe command governance distinguishes ad-hoc scripts from repo-owned generated commands", () => {
const base = {
action: "script" as const,
node: "D601",
lane: "v03",
url: "https://hwlab.example.test",
timeoutMs: 30000,
viewport: "1920x1080",
browserProxyMode: "auto" as const,
commandTimeoutSeconds: 60,
scriptText: "return { ok: true };",
scriptSource: {
kind: "stdin" as const,
path: null,
byteCount: 20,
sha256: "sha256:fixture",
},
};
const adHoc = webProbeScriptCommandGovernance(base);
assert.equal(adHoc.commandPromotionHint.promotionRequiredBeforeRepeat, true);
assert.equal(adHoc.commandPromotionHint.action, "promote-before-repeat");
assert.equal(adHoc.warnings.length, 1);
assert.equal(adHoc.warnings[0]?.requiredAction, "promote-before-repeat");
assert.match(adHoc.hints[0] ?? "", /repo-owned typed `web-probe` command/u);
assert.deepEqual(Object.keys(adHoc.preferredCommands)[0], "typedCommandCatalog");
assert.ok(Object.values(adHoc.preferredCommands).every((command) => command.startsWith("bun scripts/cli.ts web-probe")));
const repoOwned = webProbeScriptCommandGovernance({
...base,
commandLabel: "web-probe opencode-smoke --node D601 --lane v03",
suppressAdHocWarning: true,
generatedHints: ["OpenCode smoke is a repo-owned typed web-probe command."],
generatedPreferredCommands: {
withExpectedText: "web-probe opencode-smoke --node D601 --lane v03 --expect-text <assistant-substring>",
},
scriptSource: {
...base.scriptSource,
kind: "generated",
path: "builtin:opencode-smoke",
},
});
assert.equal(repoOwned.commandPromotionHint.promotionRequiredBeforeRepeat, false);
assert.equal(repoOwned.commandPromotionHint.action, "reuse-repo-owned-typed-command");
assert.deepEqual(repoOwned.warnings, []);
assert.doesNotMatch(repoOwned.hints.join("\n"), /temporary script|promote-before-repeat/iu);
});
+27 -2
View File
@@ -1353,6 +1353,9 @@ export function renderWebObserveStatusResult(result: Record<string, unknown>): R
const commandResult = record(result.result);
const manifest = record(observer.manifest);
const heartbeat = record(observer.heartbeat);
const exactCommand = record(observer.exactCommand);
const exactResult = record(exactCommand.result);
const exactError = record(exactCommand.error);
const tails = record(observer.tails);
const samples = webObserveArray(tails.samples);
const network = webObserveArray(tails.network);
@@ -1375,6 +1378,21 @@ export function renderWebObserveStatusResult(result: Record<string, unknown>): R
],
),
];
const exactCommandSection = Object.keys(exactCommand).length === 0 ? [] : [
"",
webObserveTable(
["COMMAND_ID", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"],
[[
exactCommand.commandId ?? "-",
exactCommand.type ?? "-",
exactCommand.phase ?? "-",
exactCommand.ok ?? "-",
exactResult.status ?? "-",
exactResult.reportSha256 ?? "-",
exactError.message ?? exactResult.reportPath ?? "-",
]],
),
];
const renderedText = [
webObserveTable(
["OBSERVER", "NODE", "LANE", "STATUS", "ALIVE", "PID", "SAMPLE", "COMMAND", "UPDATED"],
@@ -1395,6 +1413,7 @@ export function renderWebObserveStatusResult(result: Record<string, unknown>): R
["artifacts", artifacts.length, record(artifacts[artifacts.length - 1]).sha256 ?? "-"],
],
),
...exactCommandSection,
...resultSection,
"",
"NEXT",
@@ -1421,13 +1440,16 @@ export function renderWebObserveCommandResult(result: Record<string, unknown>):
["label", observerCommand.label ?? "-"],
["path", observerCommand.path ?? "-"],
["provider", observerCommand.provider ?? "-"],
["profile", observerCommand.profile ?? "-"],
["sessionId", observerCommand.sessionId ?? "-"],
["text", observerCommand.textHash === null || observerCommand.textHash === undefined ? "-" : `${observerCommand.textBytes ?? "-"}B ${observerCommand.textHash}`],
["secondText", observerCommand.secondTextHash === null || observerCommand.secondTextHash === undefined ? "-" : `${observerCommand.secondTextBytes ?? "-"}B ${observerCommand.secondTextHash}`],
],
),
"",
"NEXT",
` status: bun scripts/cli.ts web-probe observe status ${id}`,
` status: bun scripts/cli.ts web-probe observe status ${id} --command-id ${result.commandId}`,
` result: bun scripts/cli.ts web-probe observe collect ${id} --file commands/done/${result.commandId}.json`,
` analyze: bun scripts/cli.ts web-probe observe analyze ${id}`,
].join("\n");
return withWebObserveRendered(result, renderedText);
@@ -1626,6 +1648,7 @@ export function renderWebObserveAnalyzeResult(result: Record<string, unknown>):
}
export function renderWebProbeScriptResult(result: Record<string, unknown>): Record<string, unknown> {
const commandPromotionHint = record(result.commandPromotionHint);
const summary = record(result.summary);
const issueEvidence = record(result.issueEvidence);
const probe = record(result.probe);
@@ -1656,6 +1679,8 @@ export function renderWebProbeScriptResult(result: Record<string, unknown>): Rec
["exitCode", commandResult.exitCode ?? "-"],
];
const renderedText = [
`UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT ${JSON.stringify(commandPromotionHint)}`,
"",
webObserveTable(
["WEB_PROBE_SCRIPT", "NODE", "LANE", "STATUS", "OK", "URL"],
[[result.command ?? "web-probe script", result.node, result.lane, result.status, result.ok, result.url]],
@@ -1711,7 +1736,7 @@ export function renderWebProbeScriptResult(result: Record<string, unknown>): Rec
` rerun: ${result.command ?? `web-probe script --node ${result.node ?? "-"} --lane ${result.lane ?? "-"}`}`,
...Object.entries(preferredCommands).map(([name, command]) => ` ${name}: ${String(command)}`),
].join("\n");
return withWebObserveRendered(result, renderedText);
return withWebObserveRendered(Object.assign({ commandPromotionHint }, result), renderedText);
}
export function webProbeScriptRecordRows(value: Record<string, unknown>, limit: number): unknown[][] {
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";
import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts";
async function runStatusScript(stateDir: string, commandId: string): Promise<Record<string, any>> {
const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], {
env: { ...process.env, state_dir: stateDir },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
return JSON.parse(stdout) as Record<string, any>;
}
test("observe status returns one exact completed command without prompt payloads", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-status-"));
const commandId = "cmd-fixture";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await mkdir(join(stateDir, "commands", "processing"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "validateRealtimeFanout",
completedAt: "2026-07-10T12:00:00.000Z",
result: {
ok: true,
status: "passed",
profile: "pure-kafka-live",
sessionId: "ses_fixture",
traceIds: ["trc_first", "trc_second"],
runIds: ["run_fixture"],
commandIds: ["cmd_runner_first", "cmd_runner_second"],
reportPath: "/tmp/report.json",
reportSha256: "sha256:fixture",
prompt: "must-not-leak",
},
}) + "\n");
await writeFile(join(stateDir, "commands", "processing", `${commandId}.json`), JSON.stringify({
ok: null,
commandId,
type: "validateRealtimeFanout",
}) + "\n");
const status = await runStatusScript(stateDir, commandId);
assert.equal(status.exactCommand.phase, "done");
assert.equal(status.exactCommand.type, "validateRealtimeFanout");
assert.equal(status.exactCommand.result.reportSha256, "sha256:fixture");
assert.equal(status.exactCommand.result.prompt, undefined);
assert.equal(status.exactCommand.valuesRedacted, true);
});
test("observe status keeps exact command not-found structured", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-missing-"));
const status = await runStatusScript(stateDir, "cmd-missing");
assert.deepEqual(status.exactCommand, {
commandId: "cmd-missing",
phase: "not-found",
ok: false,
valuesRedacted: true,
});
});
+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.",
};
}
@@ -4,7 +4,30 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";
import { buildWebObserveCommandVisibility, resolveWebObserveActionJson } from "./web-probe-observe-actions";
import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes";
import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, resolveWebObserveActionJson } from "./web-probe-observe-actions";
test("realtime fanout runner contract derives topics, groups, and independent capabilities from owning YAML", () => {
const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01"));
const expectedKafka = profiles["pure-kafka-live"]?.expectedKafka as Record<string, any>;
assert.deepEqual(expectedKafka.topics, {
stdio: "codex-stdio.raw.v1",
agentrun: "agentrun.event.v1",
hwlab: "hwlab.event.v1",
});
assert.deepEqual(expectedKafka.groups, {
directPublish: "hwlab-v03-agentrun-event-direct-publish",
transactionalProjector: "hwlab-v03-agentrun-event-projector",
liveSse: "hwlab-v03-workbench-live-sse",
});
assert.deepEqual(expectedKafka.capabilities, {
directPublish: true,
liveKafkaSse: true,
transactionalProjector: false,
projectionOutboxRelay: false,
projectionRealtime: false,
});
});
test("web observe command visibility does not equate control completion with async turn completion", () => {
const visibility = buildWebObserveCommandVisibility({
@@ -17,6 +17,37 @@ import { commandSummaryForOutput, nodeWebObserveForceStopNodeScript, nodeWebObse
type WebObserveActionJsonContract = "gc" | "start" | "status" | "command" | "force-stop";
export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec): Record<string, Record<string, unknown>> {
const profiles = spec.webProbe?.realtimeFanoutProfiles ?? {};
if (Object.keys(profiles).length === 0) return {};
const kafka = spec.codeAgentRuntime?.kafkaEventBridge;
if (kafka === undefined) throw new Error("realtimeFanoutProfiles requires codeAgentRuntime.kafkaEventBridge in the owning YAML");
const capabilities = Object.fromEntries(Object.entries(kafka.features).map(([name, enabled]) => [name, kafka.enabled && enabled]));
return Object.fromEntries(Object.entries(profiles).map(([id, profile]) => {
if (profile.businessEvent !== kafka.hwlabEventTopic) {
throw new Error(`realtimeFanoutProfiles.${id}.businessEvent must match codeAgentRuntime.kafkaEventBridge.hwlabEventTopic`);
}
return [id, {
...profile,
expectedKafka: {
enabled: kafka.enabled,
topics: {
stdio: kafka.stdioTopic,
agentrun: kafka.agentRunEventTopic,
hwlab: kafka.hwlabEventTopic,
},
groups: {
directPublish: kafka.directPublishConsumerGroupId,
transactionalProjector: kafka.transactionalProjectorConsumerGroupId,
liveSse: kafka.hwlabEventConsumerGroupId,
},
capabilities,
valuesRedacted: true,
},
}];
}));
}
export function resolveWebObserveActionJson(
result: { stdout: string; stderr?: string; exitCode?: number | null; timedOut?: boolean },
contract: WebObserveActionJsonContract,
@@ -439,6 +470,7 @@ export function runNodeWebProbeObserveStart(
`UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON=${shellQuote(JSON.stringify(alertThresholds))}`,
`UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON=${shellQuote(JSON.stringify(browserFreezePolicy))}`,
`UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON=${shellQuote(JSON.stringify(projectManagement))}`,
`UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON=${shellQuote(JSON.stringify(nodeWebProbeRealtimeFanoutProfiles(spec)))}`,
...(authLogin === null
? []
: [
@@ -583,7 +615,7 @@ export function readNodeWebProbeObserveRemoteStatus(
const script = [
"set -eu",
nodeWebObserveResolveStateDirShell(options),
nodeWebObserveStatusNodeScript(tailLines, options.node, options.lane),
nodeWebObserveStatusNodeScript(tailLines, options.node, options.lane, options.collectCommandId),
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, timeoutSeconds);
const statusResolution = resolveWebObserveActionJson(result, "status");
@@ -668,6 +700,15 @@ export function buildWebObserveCommandVisibility(input: {
export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record<string, unknown> | RenderedCliResult {
const type = options.commandType ?? (stopCommand ? "stop" : null);
if (type === null) throw new Error("web-probe observe command requires --type");
if (type === "validateRealtimeFanout") {
if (!options.commandProfile?.trim()) throw new Error("validateRealtimeFanout requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[options.commandProfile.trim()] === undefined) {
throw new Error(`validateRealtimeFanout profile is not declared by the owning YAML: ${options.commandProfile.trim()}`);
}
if (!options.commandProvider?.trim()) throw new Error("validateRealtimeFanout requires --provider");
if (!options.commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!options.commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
const commandId = `cmd-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
const payload = {
id: commandId,
@@ -676,9 +717,11 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
source: "cli",
path: options.commandPath,
text: options.commandText,
secondText: options.commandSecondText,
label: options.commandLabel,
sessionId: options.commandSessionId,
provider: options.commandProvider,
profile: options.commandProfile,
afterRound: options.commandAfterRound,
severity: options.commandSeverity,
alternateSessionStrategy: options.commandAlternateSessionStrategy,
@@ -124,10 +124,12 @@ function collectOptions(): NodeWebProbeObserveOptions {
force: false,
commandType: null,
commandText: null,
commandSecondText: null,
commandPath: null,
commandLabel: null,
commandSessionId: null,
commandProvider: null,
commandProfile: null,
commandAfterRound: null,
commandSeverity: null,
commandAlternateSessionStrategy: null,
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes";
import { parseNodeWebProbeObserveOptions } from "./web-probe-observe";
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
function parseRealtimeCommand(extra: string[]) {
return parseNodeWebProbeObserveOptions(
["command", "--type", "validateRealtimeFanout", ...extra],
"NC01",
"v03",
spec,
"webobs-fixture",
null,
);
}
test("validateRealtimeFanout parses the YAML profile and two independent prompts", () => {
const options = parseRealtimeCommand([
"--profile", "pure-kafka-live",
"--provider", "gpt.pika",
"--text", "first turn",
"--second-text", "second turn",
]);
assert.equal(options.commandType, "validateRealtimeFanout");
assert.equal(options.commandProfile, "pure-kafka-live");
assert.equal(options.commandProvider, "gpt.pika");
assert.equal(options.commandText, "first turn");
assert.equal(options.commandSecondText, "second turn");
assert.deepEqual(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.debugStreams, ["stdio", "agentrun", "hwlab"]);
assert.equal(spec.webProbe?.realtimeFanoutProfiles?.["pure-kafka-live"]?.fromBeginning, false);
});
test("validateRealtimeFanout refuses body as a substitute for the second prompt", () => {
assert.throws(
() => parseRealtimeCommand([
"--profile", "pure-kafka-live",
"--provider", "gpt.pika",
"--text", "first turn",
"--body", "not a second prompt",
]),
/requires --second-text/u,
);
});
test("validateRealtimeFanout rejects profiles absent from the owning YAML", () => {
assert.throws(
() => parseRealtimeCommand([
"--profile", "projection-mode",
"--provider", "gpt.pika",
"--text", "first turn",
"--second-text", "second turn",
]),
/profile is not declared by the owning YAML/u,
);
});
+21 -2
View File
@@ -419,10 +419,12 @@ export function parseNodeWebProbeObserveOptions(
"--job-id",
"--type",
"--text",
"--second-text",
"--path",
"--label",
"--session-id",
"--provider",
"--profile",
"--account-id",
"--account",
"--from-account-id",
@@ -501,7 +503,10 @@ export function parseNodeWebProbeObserveOptions(
throw new Error("web-probe observe command accepts either --text or --text-stdin, not both");
}
const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption;
const commandSecondText = optionValue(args, "--second-text") ?? null;
const commandSourceId = optionValue(args, "--source-id") ?? null;
const commandProfile = optionValue(args, "--profile") ?? null;
const commandProvider = optionValue(args, "--provider") ?? null;
const commandAccountId = optionValue(args, "--account-id") ?? optionValue(args, "--account") ?? null;
const commandFromAccountId = optionValue(args, "--from-account-id") ?? null;
const commandToAccountId = optionValue(args, "--to-account-id") ?? null;
@@ -540,6 +545,8 @@ export function parseNodeWebProbeObserveOptions(
const commandBlocking = args.includes("--blocking") ? true : args.includes("--non-blocking") ? false : null;
for (const [label, value] of [
["--severity", commandSeverity],
["--profile", commandProfile],
["--second-text", commandSecondText],
["--alternate-session-strategy", commandAlternateSessionStrategy],
["--expected-sentinel-range", commandExpectedSentinelRange],
["--finding-id", commandFindingId],
@@ -563,6 +570,15 @@ export function parseNodeWebProbeObserveOptions(
] as const) {
if (value !== null && (value.includes("\0") || value.length > 500)) throw new Error(`unsafe web-probe observe ${label}: expected 1-500 non-NUL chars`);
}
if (observeActionRaw === "command" && commandType === "validateRealtimeFanout") {
if (!commandProfile?.trim()) throw new Error("validateRealtimeFanout requires --profile");
if (spec.webProbe?.realtimeFanoutProfiles?.[commandProfile.trim()] === undefined) {
throw new Error(`validateRealtimeFanout profile is not declared by the owning YAML: ${commandProfile.trim()}`);
}
if (!commandProvider?.trim()) throw new Error("validateRealtimeFanout requires --provider");
if (!commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn");
if (!commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn");
}
return {
action: "observe",
observeAction: observeActionRaw,
@@ -606,10 +622,12 @@ export function parseNodeWebProbeObserveOptions(
force: args.includes("--force"),
commandType,
commandText,
commandSecondText,
commandPath: optionValue(args, "--path") ?? null,
commandLabel: optionValue(args, "--label") ?? null,
commandSessionId: optionValue(args, "--session-id") ?? null,
commandProvider: optionValue(args, "--provider") ?? null,
commandProvider,
commandProfile,
commandAfterRound,
commandSeverity,
commandAlternateSessionStrategy,
@@ -651,6 +669,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "goto"
|| value === "newSession"
|| value === "sendPrompt"
|| value === "validateRealtimeFanout"
|| value === "steer"
|| value === "cancel"
|| value === "selectProvider"
@@ -686,7 +705,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
+1
View File
@@ -1627,6 +1627,7 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti
expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled));
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector)));
expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers);
expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic);
expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic);
expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic);
expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId);