Merge pull request #529 from pikasTech/feat/web-probe-observe-id

feat: add short ids for web observer commands
This commit is contained in:
Lyon
2026-06-20 23:39:00 +08:00
committed by GitHub
2 changed files with 217 additions and 20 deletions
+5 -4
View File
@@ -68,10 +68,10 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'",
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000",
"bun scripts/cli.ts hwlab nodes web-probe observe command --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/<run> --type selectProvider --provider codex-api",
"bun scripts/cli.ts hwlab nodes web-probe observe command --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/<run> --type sendPrompt --text 'ping'",
"bun scripts/cli.ts hwlab nodes web-probe observe status --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/<run>",
"bun scripts/cli.ts hwlab nodes web-probe observe analyze --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/<run>",
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type selectProvider --provider codex-api",
"bun scripts/cli.ts hwlab nodes web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
"bun scripts/cli.ts hwlab nodes web-probe observe status webobs-xxxx",
"bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ waitWorkbenchReady, fetchJson, fetchApiMatrix, recordStep, collectText, safeEvaluate, screenshot }) => {\n const ready = await waitWorkbenchReady();\n const workspace = await fetchJson('/v1/workbench/workspace?projectId=prj_hwpod_workbench');\n const apiMatrix = await fetchApiMatrix(['/v1/workbench/workspace?projectId=prj_hwpod_workbench', '/auth/session']);\n const workspaceText = await collectText('#workspace');\n const evaluated = await safeEvaluate(({ a, b }) => ({ sum: a + b }), { a: 1, b: 2 });\n await screenshot('workbench.png');\n recordStep('workbench-summary', { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, apiMatrixOk: apiMatrix.ok });\n return { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, workspaceText, evaluated };\n};\nJS",
],
actions: {
@@ -83,6 +83,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.",
"Issue-ready evidence is available under issueEvidence and summary.issueEvidence; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.",
"observe sampling is passive by default: it records DOM summaries and natural page request/response/requestfailed events with observerInitiated=false; it does not actively fetch Workbench APIs, reload, switch sessions, route/intercept, or call repair helpers.",
"observe start registers a local UniDesk-side observer id under .state/web-observe/index.json; after start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
"observe command actions are explicit user/control actions and are appended to control.jsonl; use --type selectProvider/sendPrompt/goto/screenshot/mark/stop and keep prompt text out of issue comments by citing textHash/textBytes.",
"observe analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser.",
"Use recordStep(name, data) or fetchApiMatrix(paths) to keep structured partial evidence when a later step fails.",
+212 -16
View File
@@ -1,8 +1,8 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
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";
@@ -63,6 +63,7 @@ type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "sendProm
interface NodeWebProbeObserveOptions {
action: "observe";
observeAction: NodeWebProbeObserveAction;
id: string | null;
node: string;
lane: string;
url: string;
@@ -88,6 +89,20 @@ interface NodeWebProbeObserveOptions {
type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions;
interface WebObserveIndexEntry {
id: string;
node: string;
lane: string;
workspace: string;
stateDir: string;
url: string;
targetPath: string;
status: string | null;
pid: number | null;
startedAt: string | null;
updatedAt: string;
}
type NodeObservabilityAction = "plan" | "apply" | "status" | "workbench-summary" | "performance-summary";
interface NodeObservabilityOptions {
@@ -5927,13 +5942,26 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof par
function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe") throw new Error("web-probe usage: run|script|observe --node NODE --lane vNN [--url URL]");
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
const indexed = normalized.id === null ? null : readWebObserveIndexEntry(normalized.id);
const node = optionValue(args, "--node") ?? indexed?.node;
const lane = optionValue(args, "--lane") ?? indexed?.lane;
if (node === undefined || lane === undefined) {
throw new Error("web-probe observe requires --node/--lane for start, or a known observer id from observe start for status|command|stop|collect|analyze");
}
assertNodeId(node);
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
return parseNodeWebProbeObserveOptions(normalized.args, node, lane, spec, normalized.id, indexed);
}
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
if (actionRaw === "observe") return parseNodeWebProbeObserveOptions(args.slice(1), node, lane, spec);
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
@@ -6032,7 +6060,23 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
};
}
function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: string, spec: HwlabRuntimeLaneSpec): NodeWebProbeObserveOptions {
function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } {
const [observeActionRaw, maybeId, ...rest] = args;
if (observeActionRaw !== "start" && maybeId !== undefined && !maybeId.startsWith("--")) {
if (!isSafeWebObserveJobId(maybeId)) throw new Error(`unsafe web-probe observe id: ${maybeId}`);
return { args: [observeActionRaw, ...rest], id: maybeId };
}
return { args, id: null };
}
function parseNodeWebProbeObserveOptions(
args: string[],
node: string,
lane: string,
spec: HwlabRuntimeLaneSpec,
observeId: string | null,
indexed: WebObserveIndexEntry | null,
): NodeWebProbeObserveOptions {
const [observeActionRaw] = args;
if (
observeActionRaw !== "start"
@@ -6042,7 +6086,7 @@ function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: str
&& observeActionRaw !== "collect"
&& observeActionRaw !== "analyze"
) {
throw new Error("web-probe observe usage: observe start|status|command|stop|collect|analyze --node NODE --lane vNN [--state-dir DIR|--job-id ID]");
throw new Error("web-probe observe usage: observe start --node NODE --lane vNN [...]; observe status|command|stop|collect|analyze <id> [...]");
}
assertKnownOptions(args.slice(1), new Set([
"--node",
@@ -6068,8 +6112,8 @@ function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: str
]), new Set(["--force"]));
const commandTypeRaw = optionValue(args, "--type") ?? null;
const commandType = commandTypeRaw === null ? null : parseNodeWebProbeObserveCommandType(commandTypeRaw);
const stateDir = optionValue(args, "--state-dir") ?? null;
const jobId = optionValue(args, "--job-id") ?? null;
const stateDir = optionValue(args, "--state-dir") ?? indexed?.stateDir ?? null;
const jobId = optionValue(args, "--job-id") ?? observeId ?? indexed?.id ?? null;
if (stateDir !== null && !isSafeWebObserveStateDir(stateDir)) throw new Error(`unsafe web-probe observe --state-dir: ${stateDir}`);
if (jobId !== null && !isSafeWebObserveJobId(jobId)) throw new Error(`unsafe web-probe observe --job-id: ${jobId}`);
if (observeActionRaw !== "start" && stateDir === null && jobId === null) {
@@ -6078,6 +6122,7 @@ function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: str
return {
action: "observe",
observeAction: observeActionRaw,
id: observeId ?? jobId,
node,
lane,
url: optionValue(args, "--url") ?? spec.publicWebUrl,
@@ -6457,6 +6502,22 @@ function runNodeWebProbeObserveStart(
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const started = parseJsonObject(result.stdout);
const observerId = typeof started?.jobId === "string" ? started.jobId : jobId;
const index = result.exitCode === 0 && started?.ok === true
? upsertWebObserveIndexEntry({
id: observerId,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
stateDir,
url: options.url,
targetPath: options.targetPath,
status: "running",
pid: typeof started.pid === "number" ? started.pid : null,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
: null;
return {
ok: result.exitCode === 0 && started?.ok === true,
status: result.exitCode === 0 && started?.ok === true ? "started" : "blocked",
@@ -6466,8 +6527,11 @@ function runNodeWebProbeObserveStart(
workspace: spec.workspace,
url: options.url,
targetPath: options.targetPath,
id: observerId,
credential,
observer: started,
observer: withWebObserveShortcuts(started, observerId),
index,
next: webObserveNextCommands(observerId),
result: compactCommandResultRedacted(result, [material.password ?? ""]),
valuesRedacted: true,
};
@@ -6481,14 +6545,21 @@ function runNodeWebProbeObserveStatus(options: NodeWebProbeObserveOptions, spec:
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const status = parseJsonObject(result.stdout);
const observerId = webObserveIdFromStatus(status, options);
const index = status?.ok !== false && observerId !== null && options.stateDir !== null
? upsertWebObserveIndexEntry(webObserveIndexEntryFromOptions(options, spec, observerId, status))
: null;
return {
ok: result.exitCode === 0 && status?.ok !== false,
status: result.exitCode === 0 ? "observed" : "blocked",
command: `hwlab nodes web-probe observe status --node ${options.node} --lane ${options.lane}`,
command: webObserveCommandLabel("status", options),
id: observerId,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
observer: status,
observer: withWebObserveShortcuts(status, observerId),
index,
next: observerId === null ? null : webObserveNextCommands(observerId),
result: compactCommandResult(result),
valuesRedacted: true,
};
@@ -6529,7 +6600,8 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
return {
ok: killResult.exitCode === 0,
status: killResult.exitCode === 0 ? "forced-stop-requested" : "blocked",
command: `hwlab nodes web-probe observe stop --node ${options.node} --lane ${options.lane}`,
command: webObserveCommandLabel("stop", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
@@ -6543,9 +6615,8 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
return {
ok: result.exitCode === 0 && commandResult?.ok !== false,
status: result.exitCode === 0 ? (options.waitMs > 0 ? "completed-or-queued" : "queued") : "blocked",
command: stopCommand
? `hwlab nodes web-probe observe stop --node ${options.node} --lane ${options.lane}`
: `hwlab nodes web-probe observe command --node ${options.node} --lane ${options.lane}`,
command: webObserveCommandLabel(stopCommand ? "stop" : "command", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
@@ -6568,7 +6639,8 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
return {
ok: result.exitCode === 0 && collect?.ok !== false,
status: result.exitCode === 0 ? "collected" : "blocked",
command: `hwlab nodes web-probe observe collect --node ${options.node} --lane ${options.lane}`,
command: webObserveCommandLabel("collect", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
@@ -6593,7 +6665,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
return {
ok: result.exitCode === 0 && analysis?.ok === true,
status: result.exitCode === 0 && analysis?.ok === true ? "analyzed" : "blocked",
command: `hwlab nodes web-probe observe analyze --node ${options.node} --lane ${options.lane}`,
command: webObserveCommandLabel("analyze", options),
id: webObserveIdFromOptions(options),
node: options.node,
lane: options.lane,
workspace: spec.workspace,
@@ -6620,6 +6693,129 @@ function nodeWebObserveResolveStateDirShell(options: Pick<NodeWebProbeObserveOpt
].join("\n");
}
function webObserveIndexPath(): string {
return rootPath(".state/web-observe/index.json");
}
function readWebObserveIndex(): Record<string, WebObserveIndexEntry> {
const path = webObserveIndexPath();
if (!existsSync(path)) return {};
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`web-probe observe index is invalid: ${path}`);
const entries: Record<string, WebObserveIndexEntry> = {};
for (const [id, value] of Object.entries(parsed)) {
if (!isSafeWebObserveJobId(id)) continue;
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
const recordValue = value as Record<string, unknown>;
const stateDir = typeof recordValue.stateDir === "string" ? recordValue.stateDir : "";
const node = typeof recordValue.node === "string" ? recordValue.node : "";
const lane = typeof recordValue.lane === "string" ? recordValue.lane : "";
const workspace = typeof recordValue.workspace === "string" ? recordValue.workspace : "";
if (!isSafeWebObserveStateDir(stateDir) || node.length === 0 || lane.length === 0 || workspace.length === 0) continue;
entries[id] = {
id,
node,
lane,
workspace,
stateDir,
url: typeof recordValue.url === "string" ? recordValue.url : "",
targetPath: typeof recordValue.targetPath === "string" ? recordValue.targetPath : "",
status: typeof recordValue.status === "string" ? recordValue.status : null,
pid: typeof recordValue.pid === "number" ? recordValue.pid : null,
startedAt: typeof recordValue.startedAt === "string" ? recordValue.startedAt : null,
updatedAt: typeof recordValue.updatedAt === "string" ? recordValue.updatedAt : "",
};
}
return entries;
}
function readWebObserveIndexEntry(id: string): WebObserveIndexEntry | null {
return readWebObserveIndex()[id] ?? null;
}
function upsertWebObserveIndexEntry(entry: WebObserveIndexEntry): Record<string, unknown> {
const path = webObserveIndexPath();
try {
const current = readWebObserveIndex();
mkdirSync(dirname(path), { recursive: true });
const next = { ...current, [entry.id]: entry };
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
renameSync(tmp, path);
return {
ok: true,
id: entry.id,
path,
statusCommand: `bun scripts/cli.ts hwlab nodes web-probe observe status ${entry.id}`,
valuesRedacted: true,
};
} catch (error) {
return {
ok: false,
id: entry.id,
path,
error: error instanceof Error ? error.message : String(error),
fallback: `bun scripts/cli.ts hwlab nodes web-probe observe status --node ${entry.node} --lane ${entry.lane} --state-dir ${entry.stateDir}`,
valuesRedacted: true,
};
}
}
function webObserveIdFromOptions(options: Pick<NodeWebProbeObserveOptions, "id" | "jobId">): string | null {
return options.id ?? options.jobId;
}
function webObserveIdFromStatus(status: Record<string, unknown> | null, options: Pick<NodeWebProbeObserveOptions, "id" | "jobId">): string | null {
const manifest = record(status?.manifest);
const heartbeat = record(status?.heartbeat);
const manifestJobId = typeof manifest.jobId === "string" ? manifest.jobId : null;
const heartbeatJobId = typeof heartbeat.jobId === "string" ? heartbeat.jobId : null;
return manifestJobId ?? heartbeatJobId ?? webObserveIdFromOptions(options);
}
function webObserveIndexEntryFromOptions(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, id: string, status: Record<string, unknown>): WebObserveIndexEntry {
const manifest = record(status.manifest);
const heartbeat = record(status.heartbeat);
return {
id,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
stateDir: options.stateDir ?? "",
url: typeof manifest.baseUrl === "string" ? manifest.baseUrl : options.url,
targetPath: typeof manifest.targetPath === "string" ? manifest.targetPath : options.targetPath,
status: typeof manifest.status === "string" ? manifest.status : null,
pid: typeof status.pid === "number" ? status.pid : null,
startedAt: typeof manifest.startedAt === "string" ? manifest.startedAt : null,
updatedAt: typeof heartbeat.updatedAt === "string" ? heartbeat.updatedAt : new Date().toISOString(),
};
}
function webObserveCommandLabel(action: NodeWebProbeObserveAction, options: Pick<NodeWebProbeObserveOptions, "id" | "jobId" | "node" | "lane">): string {
const id = webObserveIdFromOptions(options);
return id === null
? `hwlab nodes web-probe observe ${action} --node ${options.node} --lane ${options.lane}`
: `hwlab nodes web-probe observe ${action} ${id}`;
}
function webObserveNextCommands(id: string): Record<string, string> {
return {
status: `bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
command: `bun scripts/cli.ts hwlab nodes web-probe observe command ${id} --type mark --label checkpoint`,
stop: `bun scripts/cli.ts hwlab nodes web-probe observe stop ${id}`,
analyze: `bun scripts/cli.ts hwlab nodes web-probe observe analyze ${id}`,
};
}
function withWebObserveShortcuts(value: Record<string, unknown> | null, id: string | null): Record<string, unknown> | null {
if (value === null || id === null) return value;
return {
...value,
id,
next: webObserveNextCommands(id),
};
}
function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string): string {
return `node -e ${shellQuote(`
const fs=require('fs'),path=require('path');