feat: expose web observe wrapper contract (#894)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -121,6 +121,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx
|
||||
- `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不切换 control session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 command,observer 页面只同步到同一 session URL 后被动采样,并按默认 180000ms 周期整页刷新同一 session 来模拟用户往返;周期刷新只作用于 observer,不得改变 control active session 或作为通过条件。两页的 `pageRole`、`pageId`、`sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession`、`selectProvider`、`sendPrompt`、`steer`、`cancel`、`goto`、`screenshot`、`mark`、`stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`;长 prompt 必须优先用 `sendPrompt --text-stdin` 或 `steer --text-stdin`,不要为了绕开 shell quoting 退回裸 Playwright 或临时脚本。
|
||||
- `observe command --type steer` 和 `--type cancel` 是显式用户/control action:steer 复用当前 Workbench composer 的运行中 turn 引导路径,cancel 复用同一 composer 主按钮的取消路径。二者必须进入 `control.jsonl`,不能用后端私有 API、AgentRun direct cancel 或测试后门替代。`selectProjectSource`、`selectMdtodoFile`、`selectMdtodoTask` 和 `launchWorkbenchFromTask` 也是显式用户/control action,只能使用页面公开 `data-*` id、正式按钮和 YAML 允许的自然 API;它们通过 opaque public id 与 Workbench 关联,不能读取内部 store、私有后端或把 mdtodo 页面包含进 Workbench。
|
||||
- `observe collect --view turn-summary` 是第一层 CLI 阅读视图:只从 `samples.jsonl`、`control.jsonl` 和已有 `analysis/report.json` 按需渲染同一 session 的多 turn 摘要,包含用户消息 preview/hash、traceId、状态、耗时/最近更新时间、steer/cancel 标记和 Final Response 摘要。`observe collect --view trace-frame --trace-id <id> --sample-seq <n>` 是第二层 CLI 阅读视图:从同一采样帧渲染单帧 trace 文字截图,并固定输出 `Final Response` 区块。`observe collect --view project-summary` 从同一 artifact 渲染项目管理 / mdtodo DOM 采样、Workbench launch command、捕获到的 `x-hwlab-otel-trace-id` 和 Tempo drill-down 命令。collect 视图不是采样器新增保存物,不构成第二事实源。
|
||||
- `observe start/status/command/collect/analyze` 默认输出包含 `Wrapper contract` 区块;该区块证明 Web 哨兵只能 wrap 现有 observe CLI verb、现有 runner/analyzer 和既有 artifact contract,不新增第二套 Playwright runner、analyzer、状态机或私有 web-probe API。
|
||||
- `trace-frame` 出现 `(无 trace rows;这是 blocker...)` 时,必须先看同一输出中的 `TRACE DIAGNOSTIC`:记录 pageRole/pageId、traceRows/turns/messages 数量、sampleTraceIds、尾部 traceRow/turn/message 归属。若目标 trace 的 turn/message/final 存在但 traceRows 全部属于旧 trace,应按 Workbench read model authority 分裂登记到架构/业务 issue(例:HWLAB #2124),不得把旧 traceRows 当作新 turn 通过证据,也不得让 analyzer 的聚合计数压过 CLI trace 视图。
|
||||
- analyzer finding 不得压过 CLI `trace-frame` 人工视图。尤其 `trace-assistant-message-duplicates-final-response` 只有在 `trace-frame` 中同一 completed turn 可见多条相同 assistant final rows 时才按业务 bug 处理;如果 `trace-frame` 只有一条 assistant final row、后面固定 `Final Response` 区块正确且 API messages/turns 对齐,该 amber 归类为 analyzer 精度问题,应登记/修工具,不得阻止业务 closeout。
|
||||
- 若 `observe status` 显示 PID still alive 但 heartbeat/sample 不推进、`commands/pending/*.json` 不被消费,或 `observe stop --force` 只是继续排队 stop command,应先按 web-probe runner 工具缺陷处理(例:UniDesk #874),用 route 只读确认 PID/heartbeat 后清理进程;不要把 pending command、未触发的 cancel 或 runner stale 混入 Workbench 业务结论。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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";
|
||||
@@ -14,6 +15,8 @@ import { nodeWebObserveAnalyzerSource } from "./hwlab-node-web-observe-analyzer-
|
||||
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 { 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";
|
||||
@@ -8189,6 +8192,7 @@ function runNodeWebProbeObserveStart(
|
||||
id: observerId,
|
||||
credential,
|
||||
observer: withWebObserveShortcuts(started, observerId),
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("start", options, spec.workspace, { id: observerId, jobId: observerId, stateDir }),
|
||||
index,
|
||||
next: webObserveNextCommands(observerId),
|
||||
result: compactCommandResultRedacted(result, [material.password ?? ""]),
|
||||
@@ -8223,6 +8227,7 @@ function runNodeWebProbeObserveStatus(options: NodeWebProbeObserveOptions, spec:
|
||||
workspace: spec.workspace,
|
||||
degradedReason,
|
||||
observer: withWebObserveShortcuts(status, observerId),
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("status", options, spec.workspace, { id: observerId, stateDir: webObserveWrapperStateDirFromStatus(status, options.stateDir) }),
|
||||
index,
|
||||
next: observerId === null ? null : webObserveNextCommands(observerId),
|
||||
result: compactCommandResultWithStdoutTail(result),
|
||||
@@ -8323,6 +8328,7 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec
|
||||
commandId,
|
||||
observerCommand: commandSummaryForOutput(payload),
|
||||
observer: commandResult,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions(stopCommand ? "stop" : "command", options, spec.workspace, { commandType: type }),
|
||||
result: compactCommandResult(result),
|
||||
full: options.full,
|
||||
valuesRedacted: true,
|
||||
@@ -8356,6 +8362,7 @@ function runNodeWebProbeObserveForceStop(
|
||||
commandId,
|
||||
observerCommand: commandSummaryForOutput(payload),
|
||||
observer: forcePayload,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("stop", options, spec.workspace, { commandType: "stop" }),
|
||||
forceReason: reason,
|
||||
preflightObserver: preflightStatus,
|
||||
preflightResult: preflightResult === null ? null : compactCommandResult(preflightResult),
|
||||
@@ -8397,6 +8404,7 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
|
||||
requestedGrep: options.collectGrep,
|
||||
degradedReason: collect === null ? "collect-json-parse-failed" : null,
|
||||
collect,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("collect", options, spec.workspace),
|
||||
result: collect === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
@@ -8834,6 +8842,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
|
||||
analysis: failureAnalysis,
|
||||
failure: analysisFailure,
|
||||
alertThresholds,
|
||||
wrapper: buildWebObserveWrapperForObserveOptions("analyze", options, spec.workspace),
|
||||
result: analysis === null ? compactCommandResultWithStdoutTail(result) : compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
@@ -9957,6 +9966,7 @@ function renderWebObserveStartResult(result: Record<string, unknown>): Record<st
|
||||
]],
|
||||
),
|
||||
"",
|
||||
...renderWebObserveWrapperContract(result),
|
||||
webObserveTable(
|
||||
["URL", "TARGET_PATH", "STATE_DIR"],
|
||||
[[result.url, result.targetPath, webObserveShort(webObserveText(stateDir), 96)]],
|
||||
@@ -10101,6 +10111,8 @@ function renderWebObserveAnalyzeResult(result: Record<string, unknown>): Record<
|
||||
["OBSERVER", "NODE", "LANE", "STATUS", "REPORT_JSON", "REPORT_MD"],
|
||||
[[id, result.node, result.lane, result.status, analysis.reportJsonSha256, analysis.reportMdSha256]],
|
||||
),
|
||||
"",
|
||||
...renderWebObserveWrapperContract(result),
|
||||
...(blockerRows.length > 0
|
||||
? ["", webObserveTable(["ANALYZE_BLOCKER", "VALUE"], blockerRows)]
|
||||
: []),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// Responsibility: CLI text rendering for hwlab nodes web-probe observe status/command/collect.
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { renderWebObserveWrapperContract } from "./hwlab-node-web-observe-wrapper-render";
|
||||
|
||||
export function withWebObserveStatusRendered(value: Record<string, unknown>): RenderedCliResult {
|
||||
return {
|
||||
@@ -63,6 +65,7 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe status (${webObserveText(value.status)})`,
|
||||
"",
|
||||
...renderWebObserveWrapperContract(value),
|
||||
webObserveTable(["ID", "NODE", "LANE", "LIVENESS", "ALIVE", "PID", "SAMPLE", "CMD_SEQ", "HB_AGE_S", "STALE", "UPDATED", "TARGET"], [[
|
||||
value.id,
|
||||
value.node,
|
||||
@@ -189,6 +192,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe command (${webObserveText(value.status)})`,
|
||||
"",
|
||||
...renderWebObserveWrapperContract(value),
|
||||
webObserveTable(["OBSERVER", "COMMAND", "TYPE", "STATUS", "TEXT_BYTES", "TEXT_HASH", "DETAIL"], [[
|
||||
id,
|
||||
value.commandId,
|
||||
@@ -223,6 +227,7 @@ function renderWebObserveCollectTable(value: Record<string, unknown>): string {
|
||||
return [
|
||||
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
|
||||
"",
|
||||
...renderWebObserveWrapperContract(value),
|
||||
collect.renderedText,
|
||||
"",
|
||||
"Source:",
|
||||
@@ -254,6 +259,7 @@ function renderWebObserveCollectTable(value: Record<string, unknown>): string {
|
||||
const lines = [
|
||||
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
|
||||
"",
|
||||
...renderWebObserveWrapperContract(value),
|
||||
webObserveTable(["ID", "NODE", "LANE", "MODE", "STATE_DIR"], [[
|
||||
value.id,
|
||||
value.node,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// Responsibility: Bounded CLI rendering for the observe wrapper contract.
|
||||
|
||||
export function renderWebObserveWrapperContract(value: Record<string, unknown>): string[] {
|
||||
const wrapper = nullableRecord(value.wrapper);
|
||||
if (wrapper === null) return [];
|
||||
const cli = record(wrapper.cli);
|
||||
const artifacts = record(wrapper.artifacts);
|
||||
const invariants = record(wrapper.invariants);
|
||||
return [
|
||||
"Wrapper contract:",
|
||||
webObserveTable(["SPEC", "MODE", "VERB", "RUNNER", "ANALYZER", "STATE_DIR"], [[
|
||||
webObserveShort(webObserveText(wrapper.specRef), 56),
|
||||
wrapper.mode,
|
||||
wrapper.action,
|
||||
invariants.reusesExistingObserveRunner === true && invariants.createsSecondPlaywrightRunner === false ? "existing" : "blocked",
|
||||
invariants.reusesExistingObserveAnalyzer === true && invariants.createsSecondAnalyzer === false ? "existing" : "blocked",
|
||||
webObserveShort(webObserveText(artifacts.stateDir), 88),
|
||||
]]),
|
||||
` cli: ${webObserveShort(webObserveText(cli.commandShape), 140)}`,
|
||||
"",
|
||||
];
|
||||
}
|
||||
|
||||
function webObserveTable(headers: string[], rows: unknown[][]): string {
|
||||
const stringRows = rows.map((row) => headers.map((_, index) => webObserveCell(row[index])));
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...stringRows.map((row) => row[index]?.length ?? 0)));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...stringRows.map(renderRow)].join("\n");
|
||||
}
|
||||
|
||||
function webObserveCell(value: unknown, maxLength = 96): string {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
const compact = text.replace(/\s+/gu, " ").trim();
|
||||
if (compact.length === 0) return "-";
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, Math.max(1, maxLength - 1))}...`;
|
||||
}
|
||||
|
||||
function webObserveText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function webObserveShort(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
if (maxLength <= 1) return value.slice(0, maxLength);
|
||||
return `${value.slice(0, maxLength - 1)}~`;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// Responsibility: Stable wrapper contract for the existing hwlab nodes web-probe observe CLI verbs.
|
||||
|
||||
export type WebObserveWrapperAction = "start" | "status" | "command" | "stop" | "collect" | "analyze";
|
||||
|
||||
export interface WebObserveWrapperInput {
|
||||
readonly action: WebObserveWrapperAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly workspace: string;
|
||||
readonly id: string | null;
|
||||
readonly jobId: string | null;
|
||||
readonly stateDir: string | null;
|
||||
readonly url: string | null;
|
||||
readonly targetPath: string | null;
|
||||
readonly commandType: string | null;
|
||||
readonly commandTimeoutSeconds: number | null;
|
||||
readonly collectView: string | null;
|
||||
readonly collectFile: string | null;
|
||||
readonly collectTraceId: string | null;
|
||||
readonly collectSampleSeq: number | null;
|
||||
readonly analyzeArchivePrefix: string | null;
|
||||
}
|
||||
|
||||
export interface WebObserveWrapperOptionsLike {
|
||||
readonly id: string | null;
|
||||
readonly jobId: string | null;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly url: string;
|
||||
readonly targetPath: string;
|
||||
readonly stateDir: string | null;
|
||||
readonly commandType: string | null;
|
||||
readonly commandTimeoutSeconds: number;
|
||||
readonly collectView: string;
|
||||
readonly collectFile: string | null;
|
||||
readonly collectTraceId: string | null;
|
||||
readonly collectSampleSeq: number | null;
|
||||
readonly analyzeArchivePrefix: string | null;
|
||||
}
|
||||
|
||||
export interface WebObserveWrapperOverrides {
|
||||
readonly id?: string | null;
|
||||
readonly jobId?: string | null;
|
||||
readonly stateDir?: string | null;
|
||||
readonly commandType?: string | null;
|
||||
}
|
||||
|
||||
export const WEB_OBSERVE_WRAPPER_SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
|
||||
|
||||
const WEB_OBSERVE_ARTIFACT_CONTRACT = [
|
||||
{ path: "manifest.json", producer: "existing-observe-runner", purpose: "observer identity and immutable run settings" },
|
||||
{ path: "heartbeat.json", producer: "existing-observe-runner", purpose: "bounded liveness and current browser state" },
|
||||
{ path: "samples.jsonl", producer: "existing-observe-runner", purpose: "DOM, trace, session, timing, and final response samples" },
|
||||
{ path: "control.jsonl", producer: "existing-observe-runner", purpose: "explicit user/control actions and command results" },
|
||||
{ path: "network.jsonl", producer: "existing-observe-runner", purpose: "request, response, requestfailed, and timing evidence" },
|
||||
{ path: "console.jsonl", producer: "existing-observe-runner", purpose: "browser console/runtime evidence" },
|
||||
{ path: "artifacts.jsonl", producer: "existing-observe-runner", purpose: "screenshots and auxiliary artifact index" },
|
||||
{ path: "commands/{pending,processing,done,failed}/*.json", producer: "observe-command-cli", purpose: "durable command queue handoff" },
|
||||
{ path: "analysis/report.json", producer: "existing-observe-analyzer", purpose: "offline machine-readable findings" },
|
||||
{ path: "analysis/report.md", producer: "existing-observe-analyzer", purpose: "offline human-readable report" },
|
||||
] as const;
|
||||
|
||||
const WEB_OBSERVE_CLI_DEFAULT_DISCLOSURE = {
|
||||
targetPath: "/workbench",
|
||||
viewport: "1440x900",
|
||||
sampleIntervalMs: 5000,
|
||||
screenshotIntervalMs: 300000,
|
||||
observerRefreshIntervalMs: 180000,
|
||||
maxSamples: 0,
|
||||
commandTimeoutSeconds: 55,
|
||||
waitMs: 0,
|
||||
tailLines: 5,
|
||||
maxFiles: 80,
|
||||
collectView: "files",
|
||||
source: "current observe CLI parser defaults; P2 must move sentinel policy into YAML configRefs",
|
||||
valuesRedacted: true,
|
||||
} as const;
|
||||
|
||||
export function buildWebObserveWrapperContract(input: WebObserveWrapperInput): Record<string, unknown> {
|
||||
const observerId = input.id ?? input.jobId;
|
||||
const stateDir = input.stateDir;
|
||||
return {
|
||||
specRef: WEB_OBSERVE_WRAPPER_SPEC_REF,
|
||||
adapter: "web-probe-observe-cli-wrapper",
|
||||
adapterVersion: 1,
|
||||
mode: "wrapper-only",
|
||||
action: input.action,
|
||||
node: input.node,
|
||||
lane: input.lane,
|
||||
workspace: input.workspace,
|
||||
id: observerId,
|
||||
jobId: input.jobId ?? observerId,
|
||||
cli: {
|
||||
family: "bun scripts/cli.ts hwlab nodes web-probe observe",
|
||||
commandShape: webObserveWrapperCommandShape(input),
|
||||
verb: input.action,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
effectiveParameters: {
|
||||
url: input.url,
|
||||
targetPath: input.targetPath,
|
||||
commandType: input.commandType,
|
||||
commandTimeoutSeconds: input.commandTimeoutSeconds,
|
||||
collectView: input.collectView,
|
||||
collectFile: input.collectFile,
|
||||
collectTraceId: input.collectTraceId,
|
||||
collectSampleSeq: input.collectSampleSeq,
|
||||
analyzeArchivePrefix: input.analyzeArchivePrefix,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
cliDefaults: WEB_OBSERVE_CLI_DEFAULT_DISCLOSURE,
|
||||
artifacts: {
|
||||
stateDir,
|
||||
rootKnown: stateDir !== null,
|
||||
manifest: artifactPath(stateDir, "manifest.json"),
|
||||
heartbeat: artifactPath(stateDir, "heartbeat.json"),
|
||||
samples: artifactPath(stateDir, "samples.jsonl"),
|
||||
control: artifactPath(stateDir, "control.jsonl"),
|
||||
network: artifactPath(stateDir, "network.jsonl"),
|
||||
console: artifactPath(stateDir, "console.jsonl"),
|
||||
analysisReportJson: artifactPath(stateDir, "analysis/report.json"),
|
||||
analysisReportMd: artifactPath(stateDir, "analysis/report.md"),
|
||||
contract: WEB_OBSERVE_ARTIFACT_CONTRACT,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
invariants: {
|
||||
wrapperOnly: true,
|
||||
reusesExistingObserveRunner: true,
|
||||
reusesExistingObserveAnalyzer: true,
|
||||
createsSecondPlaywrightRunner: false,
|
||||
createsSecondAnalyzer: false,
|
||||
createsSecondStateMachine: false,
|
||||
usesPrivateWebProbeApi: false,
|
||||
collectViewsRenderExistingArtifactsOnly: true,
|
||||
analyzeReadsExistingArtifactsOnly: true,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
nextStageBoundary: {
|
||||
p2: "YAML/configRefs must own sentinel policy and scenario cadence.",
|
||||
p3: "The persistent service may orchestrate this wrapper, not call Playwright or analyzer internals directly.",
|
||||
valuesRedacted: true,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebObserveWrapperForObserveOptions(
|
||||
action: WebObserveWrapperAction,
|
||||
options: WebObserveWrapperOptionsLike,
|
||||
workspace: string,
|
||||
overrides: WebObserveWrapperOverrides = {},
|
||||
): Record<string, unknown> {
|
||||
const id = overrides.id ?? options.id ?? options.jobId;
|
||||
return buildWebObserveWrapperContract({
|
||||
action,
|
||||
node: options.node,
|
||||
lane: options.lane,
|
||||
workspace,
|
||||
id,
|
||||
jobId: overrides.jobId ?? options.jobId ?? id,
|
||||
stateDir: overrides.stateDir ?? options.stateDir,
|
||||
url: options.url,
|
||||
targetPath: options.targetPath,
|
||||
commandType: overrides.commandType ?? options.commandType,
|
||||
commandTimeoutSeconds: options.commandTimeoutSeconds,
|
||||
collectView: options.collectView,
|
||||
collectFile: options.collectFile,
|
||||
collectTraceId: options.collectTraceId,
|
||||
collectSampleSeq: options.collectSampleSeq,
|
||||
analyzeArchivePrefix: options.analyzeArchivePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
export function webObserveWrapperStateDirFromStatus(status: Record<string, unknown> | null, explicitStateDir: string | null): string | null {
|
||||
const heartbeat = record(status?.heartbeat);
|
||||
const manifest = record(status?.manifest);
|
||||
const heartbeatStateDir = typeof heartbeat.stateDir === "string" ? heartbeat.stateDir : null;
|
||||
const manifestStateDir = typeof manifest.stateDir === "string" ? manifest.stateDir : null;
|
||||
return explicitStateDir ?? heartbeatStateDir ?? manifestStateDir;
|
||||
}
|
||||
|
||||
function artifactPath(stateDir: string | null, relative: string): string | null {
|
||||
return stateDir === null ? null : `${stateDir}/${relative}`;
|
||||
}
|
||||
|
||||
function webObserveWrapperCommandShape(input: WebObserveWrapperInput): string {
|
||||
const base = ["bun", "scripts/cli.ts", "hwlab", "nodes", "web-probe", "observe", input.action];
|
||||
const observerId = input.id ?? input.jobId;
|
||||
const parts = observerId === null
|
||||
? [...base, "--node", input.node, "--lane", input.lane]
|
||||
: [...base, observerId];
|
||||
if (observerId === null && input.stateDir !== null && input.action !== "start") {
|
||||
parts.push("--state-dir", input.stateDir);
|
||||
}
|
||||
if (input.action === "start" && input.targetPath !== null) {
|
||||
parts.push("--target-path", input.targetPath);
|
||||
}
|
||||
if (input.action === "command" && input.commandType !== null) {
|
||||
parts.push("--type", input.commandType);
|
||||
}
|
||||
if (input.action === "collect") {
|
||||
if (input.collectView !== null) parts.push("--view", input.collectView);
|
||||
if (input.collectFile !== null) parts.push("--file", input.collectFile);
|
||||
if (input.collectTraceId !== null) parts.push("--trace-id", input.collectTraceId);
|
||||
if (input.collectSampleSeq !== null) parts.push("--sample-seq", String(input.collectSampleSeq));
|
||||
}
|
||||
if (input.action === "analyze" && input.analyzeArchivePrefix !== null) {
|
||||
parts.push("--archive-prefix", input.analyzeArchivePrefix);
|
||||
}
|
||||
return parts.map(shellWord).join(" ");
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) return value;
|
||||
return `'${value.replace(/'/gu, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
Reference in New Issue
Block a user