From cd566c9036bb17683e4f5d8470c07b0b477b917b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 15:38:22 +0000 Subject: [PATCH] feat: add short ids for web observer commands --- scripts/src/hwlab-node-help.ts | 9 +- scripts/src/hwlab-node-impl.ts | 228 ++++++++++++++++++++++++++++++--- 2 files changed, 217 insertions(+), 20 deletions(-) diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index d7050f9a..6438ae41 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -68,10 +68,10 @@ export function hwlabNodeWebProbeHelp(): Record { "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/ --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/ --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/", - "bun scripts/cli.ts hwlab nodes web-probe observe analyze --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/", + "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 { "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 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.", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 4ea39259..9bf6c0e3 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -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 [...]"); } 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 { + 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 = {}; + 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; + 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 { + 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): string | null { + return options.id ?? options.jobId; +} + +function webObserveIdFromStatus(status: Record | null, options: Pick): 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): 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): 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 { + 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 | null, id: string | null): Record | 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');