From fd874ceef509ec27b854da53ba588fa1117828f5 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:45:24 +0800 Subject: [PATCH] feat: expose web observe wrapper contract (#894) Co-authored-by: Codex --- .agents/skills/unidesk-webdev/SKILL.md | 1 + scripts/src/hwlab-node-impl.ts | 12 + scripts/src/hwlab-node-web-observe-render.ts | 6 + .../hwlab-node-web-observe-wrapper-render.ts | 60 +++++ scripts/src/hwlab-node-web-observe-wrapper.ts | 221 ++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 scripts/src/hwlab-node-web-observe-wrapper-render.ts create mode 100644 scripts/src/hwlab-node-web-observe-wrapper.ts diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 660462e6..3afd2a93 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -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 --sample-seq ` 是第二层 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 业务结论。 diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 9753f3d0..cff1f038 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -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): Record): 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)] : []), diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index f945f100..2a49ca04 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -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): RenderedCliResult { return { @@ -63,6 +65,7 @@ function renderWebObserveStatusTable(value: Record): 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 { 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 { return [ `hwlab nodes web-probe observe collect (${webObserveText(value.status)})`, "", + ...renderWebObserveWrapperContract(value), collect.renderedText, "", "Source:", @@ -254,6 +259,7 @@ function renderWebObserveCollectTable(value: Record): 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, diff --git a/scripts/src/hwlab-node-web-observe-wrapper-render.ts b/scripts/src/hwlab-node-web-observe-wrapper-render.ts new file mode 100644 index 00000000..10012acd --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-wrapper-render.ts @@ -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[] { + 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function nullableRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} diff --git a/scripts/src/hwlab-node-web-observe-wrapper.ts b/scripts/src/hwlab-node-web-observe-wrapper.ts new file mode 100644 index 00000000..3376d952 --- /dev/null +++ b/scripts/src/hwlab-node-web-observe-wrapper.ts @@ -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 { + 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 { + 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 | 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +}