From 9309efabb8777e4766f87784b11e62c44f1dca1a Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:07:22 +0800 Subject: [PATCH] feat: add web probe sentinel config plan (#895) Co-authored-by: Codex --- .agents/skills/unidesk-webdev/SKILL.md | 3 + config/hwlab-node-lanes.yaml | 11 + .../cicd.d601-v03.yaml | 21 + .../prompt-set.dsflash-go.yaml | 15 + .../public-exposure.d601-v03.yaml | 34 ++ .../report-views.yaml | 22 + .../runtime.d601-v03.yaml | 24 + .../scenarios.workbench.yaml | 26 + .../secrets.d601-v03.yaml | 34 ++ scripts/src/hwlab-node-help.ts | 6 + scripts/src/hwlab-node-impl.ts | 37 +- scripts/src/hwlab-node-lanes.ts | 63 +++ scripts/src/hwlab-node-web-sentinel-config.ts | 493 ++++++++++++++++++ 13 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml create mode 100644 config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/report-views.yaml create mode 100644 config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml create mode 100644 config/hwlab-web-probe-sentinel/scenarios.workbench.yaml create mode 100644 config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml create mode 100644 scripts/src/hwlab-node-web-sentinel-config.ts diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 3afd2a93..e0d26cc1 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -96,6 +96,8 @@ bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view turn bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view trace-frame --trace-id trc_xxx --sample-seq 42 bun scripts/cli.ts hwlab nodes web-probe observe stop webobs-xxxx bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx +bun scripts/cli.ts hwlab nodes web-probe sentinel plan --node D601 --lane v03 --dry-run +bun scripts/cli.ts hwlab nodes web-probe sentinel status --node D601 --lane v03 ``` `observe analyze` 的 duplicate final response 判定必须以 trace-frame 可见行事实为准。`observe collect --view trace-frame` 固定渲染的 `Final Response` 区块是 summary,不是第二条业务 assistant message;只有同一 trace-frame 中出现两个可见 assistant final rows 且内容重复时,才应报告 duplicate finding,并在证据中写明 `finalResponseSummaryBlockCounted=false`。 @@ -122,6 +124,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx - `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。 +- `web-probe sentinel plan|status` 只读取 `observability.webProbe.sentinel.enabled/configRefs` 和 owning YAML,渲染 redacted 配置引用图、文件 hash、缺失字段和跨 ref 冲突;它不启动浏览器、不读取 Secret 值、不保存采样结果,也不是第二套 runner/analyzer。真正的采样和判定仍以 `observe start|command|collect|analyze` artifacts 为准。 - `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/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 93382364..cc53cc09 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -215,6 +215,17 @@ lanes: path: /v1/web-performance/metrics scrapeMode: pod-loopback publicRawMetrics: denied + webProbe: + sentinel: + enabled: true + configRefs: + runtime: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime + scenarios: config/hwlab-web-probe-sentinel/scenarios.workbench.yaml#sentinel.scenarios + promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet + reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews + publicExposure: config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml#sentinel.publicExposure + cicd: config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd + secrets: config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml#sentinel.secrets workbench: enabled: true summaryPath: /v1/web-performance/summary diff --git a/config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml b/config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml new file mode 100644 index 00000000..95abb43a --- /dev/null +++ b/config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml @@ -0,0 +1,21 @@ +version: 1 +kind: HwlabWebProbeSentinelCicd +metadata: + id: d601-v03-web-probe-sentinel-cicd + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + cicd: + controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets.D601.lanes.v03 + gitopsPath: deploy/gitops/node/d601/web-probe-sentinel + image: + repository: 127.0.0.1:5000/hwlab/web-probe-sentinel + tagSource: source-commit + envRecipeRef: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime + maintenance: + startCommand: sentinel maintenance start + stopCommand: sentinel maintenance stop + targetValidation: + scenarioId: workbench-dsflash-go-tool-call-10x + maxSeconds: 120 + serviceUnavailableVerifyMode: pure-observe-cli diff --git a/config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml b/config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml new file mode 100644 index 00000000..d1fdf566 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml @@ -0,0 +1,15 @@ +version: 1 +kind: HwlabWebProbeSentinelPromptSet +metadata: + id: d601-v03-web-probe-sentinel-prompt-set + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + promptSet: + id: dsflash-go-tool-call-10x + providerProfile: dsflash-go + providerProfileMode: exact + promptSourceRef: hwlab/web-probe-sentinel-dsflash-go.env + promptSourceKey: DSFLASH_GO_TOOL_CALL_10X_PROMPTS_JSON + promptCount: 10 + redaction: hash-and-byte-count diff --git a/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml new file mode 100644 index 00000000..b08fb223 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml @@ -0,0 +1,34 @@ +version: 1 +kind: HwlabWebProbeSentinelPublicExposure +metadata: + id: d601-v03-web-probe-sentinel-public-exposure + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + publicExposure: + enabled: true + mode: pk01-caddy-frp + publicBaseUrl: https://monitor.pikapython.com + hostname: monitor.pikapython.com + expectedA: 82.156.23.220 + frpc: + serverAddr: 82.156.23.220 + serverPort: 22000 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + secretName: hwlab-web-probe-sentinel-frpc + secretKey: frpc.toml + tokenKey: token + httpProxy: + name: hwlab-d601-v03-web-probe-sentinel + remotePort: 22098 + localIP: hwlab-web-probe-sentinel.hwlab-v03.svc.cluster.local + localPort: 8080 + caddy: + route: PK01 + configPath: /etc/caddy/Caddyfile + serviceName: caddy + email: ops@pikapython.com + tls: auto + responseHeaderTimeoutSeconds: 600 + managedBlockOwner: hwlab-web-probe-sentinel-d601-v03 diff --git a/config/hwlab-web-probe-sentinel/report-views.yaml b/config/hwlab-web-probe-sentinel/report-views.yaml new file mode 100644 index 00000000..9202f2e6 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/report-views.yaml @@ -0,0 +1,22 @@ +version: 1 +kind: HwlabWebProbeSentinelReportViews +metadata: + id: d601-v03-web-probe-sentinel-report-views + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + reportViews: + defaultView: summary + views: + - summary + - turn-summary + - findings + - trace-frame + pageSize: 20 + maxPageSize: 100 + rawAccess: explicit-only + redaction: + prompt: hash-and-byte-count + assistantFinal: summary-and-hash + providerPayload: denied + secrets: denied diff --git a/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml new file mode 100644 index 00000000..22e5657b --- /dev/null +++ b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml @@ -0,0 +1,24 @@ +version: 1 +kind: HwlabWebProbeSentinelRuntime +metadata: + id: d601-v03-web-probe-sentinel-runtime + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + runtime: + target: + node: D601 + lane: v03 + publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl + observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinel + namespace: hwlab-v03 + serviceAccountName: hwlab-web-probe-sentinel + deploymentName: hwlab-web-probe-sentinel + serviceName: hwlab-web-probe-sentinel + servicePort: 8080 + pvcName: hwlab-web-probe-sentinel-state + stateRoot: /var/lib/web-probe-sentinel + imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel:yaml-p2 + replicas: 1 + healthPath: /api/health + metricsPath: /metrics diff --git a/config/hwlab-web-probe-sentinel/scenarios.workbench.yaml b/config/hwlab-web-probe-sentinel/scenarios.workbench.yaml new file mode 100644 index 00000000..c906c233 --- /dev/null +++ b/config/hwlab-web-probe-sentinel/scenarios.workbench.yaml @@ -0,0 +1,26 @@ +version: 1 +kind: HwlabWebProbeSentinelScenarios +metadata: + id: d601-v03-web-probe-sentinel-scenarios + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + scenarios: + - id: workbench-dsflash-go-tool-call-10x + enabled: true + cadence: 10m + observeTargetPath: /workbench + sampleIntervalMs: 5000 + screenshotIntervalMs: 60000 + maxRunSeconds: 1200 + providerProfile: dsflash-go + providerProfileMode: exact + promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet + reportViewRef: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews + commandSequence: + - type: newSession + - type: selectProvider + provider: dsflash-go + - type: sendPrompt + promptSource: promptSet + repeat: 10 diff --git a/config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml b/config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml new file mode 100644 index 00000000..a02b804a --- /dev/null +++ b/config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml @@ -0,0 +1,34 @@ +version: 1 +kind: HwlabWebProbeSentinelSecrets +metadata: + id: d601-v03-web-probe-sentinel-secrets + owner: UniDesk + specRef: PJ2026-01060508 +sentinel: + secrets: + sources: + - purpose: bootstrap-admin + sourceRef: hwlab/d601-v03-bootstrap-admin.env + sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD + - purpose: prompt-set + sourceRef: hwlab/web-probe-sentinel-dsflash-go.env + sourceKey: DSFLASH_GO_TOOL_CALL_10X_PROMPTS_JSON + - purpose: frp-token + sourceRef: platform-infra/pk01-frp.env + sourceKey: FRP_TOKEN + runtimeSecrets: + - name: hwlab-web-probe-sentinel-bootstrap + namespace: hwlab-v03 + data: + - sourcePurpose: bootstrap-admin + targetKey: bootstrap-admin-password + - name: hwlab-web-probe-sentinel-prompt-set + namespace: hwlab-v03 + data: + - sourcePurpose: prompt-set + targetKey: prompts.json + - name: hwlab-web-probe-sentinel-frpc + namespace: hwlab-v03 + data: + - sourcePurpose: frp-token + targetKey: token diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 7ee7397f..97a0d2f6 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -51,6 +51,8 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm", "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000", + "bun scripts/cli.ts hwlab nodes web-probe sentinel plan --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes web-probe sentinel status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes observability plan --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes observability status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes observability apply --node D601 --lane v03 --dry-run", @@ -92,12 +94,15 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view trace-frame --trace-id trc_xxx --sample-seq 42", "bun scripts/cli.ts hwlab nodes web-probe observe collect webobs-xxxx --view project-summary", "bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx", + "bun scripts/cli.ts hwlab nodes web-probe sentinel plan --node D601 --lane v03 --dry-run", + "bun scripts/cli.ts hwlab nodes web-probe sentinel status --node D601 --lane v03", "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: { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page plus gotoStable/reloadStable/gotoCurrentStable/safeReload/fetchJson/safeFetchJson/fetchApiMatrix/recordStep/collectText/safeEvaluate/waitWorkbenchReady/screenshotOnError/summarizeWorkspace/summarizeConversation helpers and must not handle secrets itself.", observe: "Start, inspect, control, stop, collect, and analyze a pure-client long-running Workbench observer on the target host. The observer runs a control page plus a passive observer page in a shared-auth browser context, receives commands through stateDir/commands files, writes JSONL artifacts, and does not expose any inbound service API.", + sentinel: "Render the YAML-first service wrapper configRef graph for the production web-probe sentinel. This reads observability.webProbe.sentinel.enabled/configRefs and validates owning YAML presence, shape, redacted hashes, and cross-ref consistency without starting a browser or reading secret values.", }, notes: [ "The default probe URL, browser proxy mode, observe/analyze alert thresholds, and project-management observe behavior come from config/hwlab-node-lanes.yaml webProbe; pass --url only when intentionally overriding the YAML-selected origin.", @@ -113,6 +118,7 @@ export function hwlabNodeWebProbeHelp(): Record { "observe analyze scans every sampled DOM point, extracts Workbench timing text such as 总耗时/total and 最近 N 秒/分前, and writes a sample point vs turn timing report: each Markdown table row starts with the timestamp, followed by each turn's 总耗时(s) and 最近更新(s). Timing series are reported for post-processing/manual analysis instead of auto-judged from status tail output.", "observe analyze also reports visible “加载中” count, owner attribution, concurrent loading owners, and continuous visible segments; fixes must reduce real loading latency, not reveal incomplete content early to make this metric disappear.", "script/observe support --browser-proxy-mode auto|direct; use direct for A/B evidence when frontend RUM is slow but OTel server spans are absent or fast, instead of falling back to raw Playwright.", + "sentinel plan/status is a configuration visibility command for the service wrapper; observe start/status/command/collect/analyze remain the sampling and analysis truth.", "Use recordStep(name, data) or fetchApiMatrix(paths) to keep structured partial evidence when a later step fails.", "Use reloadStable(), gotoCurrentStable(), or safeReload() for bounded retries around page reload/current-URL navigation jitter such as ERR_NETWORK_CHANGED.", "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index cff1f038..8f6c1465 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -17,6 +17,7 @@ import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectVie 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 { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered, type WebProbeSentinelConfigAction } from "./hwlab-node-web-sentinel-config"; 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"; @@ -112,7 +113,15 @@ interface NodeWebProbeObserveOptions { commandTaskRef: string | null; } -type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions; +interface NodeWebProbeSentinelOptions { + action: "sentinel"; + sentinelAction: WebProbeSentinelConfigAction; + node: string; + lane: string; + dryRun: boolean; +} + +type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions | NodeWebProbeSentinelOptions; interface WebObserveIndexEntry { id: string; @@ -7298,7 +7307,8 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType, flagOptio } } -function runNodeWebProbe(options: NodeWebProbeOptions): Record { +function runNodeWebProbe(options: NodeWebProbeOptions): Record | RenderedCliResult { const lane = options.lane; if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`); const spec = hwlabRuntimeLaneSpecForNode(lane, options.node); + if (options.action === "sentinel") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.sentinelAction)); if (options.action === "observe" && options.observeAction !== "start") return runNodeWebProbeObserve(options, spec, null, null, null); const secretSpec = runtimeSecretSpec({ node: options.node, lane }); const material = readBootstrapAdminPasswordMaterial(secretSpec); diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 256f9af9..998f7b71 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.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 source-of-truth parsing for HWLAB node/lane Workbench observability. import { readFileSync } from "node:fs"; import { rootPath } from "./config"; @@ -131,6 +132,15 @@ export interface HwlabRuntimeWebProbeSpec { readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec; } +export type HwlabRuntimeWebProbeSentinelConfigRefKey = "runtime" | "scenarios" | "promptSet" | "reportViews" | "publicExposure" | "cicd" | "secrets"; + +export const HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS = ["runtime", "scenarios", "promptSet", "reportViews", "publicExposure", "cicd", "secrets"] as const satisfies readonly HwlabRuntimeWebProbeSentinelConfigRefKey[]; + +export interface HwlabRuntimeWebProbeSentinelSpec { + readonly enabled: boolean; + readonly configRefs: Record; +} + export interface HwlabRuntimeWebProbeAlertThresholdsSpec { readonly sameOriginApiSlowMs: number; readonly partialApiSlowMs: number; @@ -163,10 +173,15 @@ export interface HwlabRuntimeObservabilitySpec { readonly traceExplorerUrlTemplate?: string; readonly metricsEndpoint?: HwlabRuntimeObservabilityMetricsEndpointSpec; readonly workbench?: HwlabRuntimeObservabilityWorkbenchSpec; + readonly webProbe?: HwlabRuntimeObservabilityWebProbeSpec; readonly recordingRules: readonly HwlabRuntimeObservabilityRecordingRuleSpec[]; readonly warningAlerts: readonly HwlabRuntimeObservabilityWarningAlertSpec[]; } +export interface HwlabRuntimeObservabilityWebProbeSpec { + readonly sentinel?: HwlabRuntimeWebProbeSentinelSpec; +} + export interface HwlabRuntimeObservabilityMetricsEndpointSpec { readonly serviceName: string; readonly containerName: string; @@ -732,6 +747,41 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec }; } +function webProbeSentinelConfig(value: unknown, path: string): HwlabRuntimeWebProbeSentinelSpec { + const raw = asRecord(value, path); + const allowed = new Set(["enabled", "configRefs"]); + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; root sentinel YAML may only contain enabled/configRefs`); + } + const refs = asRecord(raw.configRefs, `${path}.configRefs`); + const allowedRefKeys = new Set(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS); + for (const key of Object.keys(refs)) { + if (!allowedRefKeys.has(key as HwlabRuntimeWebProbeSentinelConfigRefKey)) throw new Error(`${path}.configRefs.${key} is not a supported sentinel configRef`); + } + const configRefs = Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => { + const ref = stringField(refs, key, `${path}.configRefs`); + validateConfigRef(ref, `${path}.configRefs.${key}`); + return [key, ref]; + })) as Record; + return { + enabled: booleanField(raw, "enabled", path), + configRefs, + }; +} + +function validateConfigRef(ref: string, path: string): void { + const [file, fragment, extra] = ref.split("#"); + if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) { + throw new Error(`${path} must use path/to/file.yaml#object.path syntax`); + } + if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { + throw new Error(`${path} must reference a repo-relative config/*.yaml file without ..`); + } + if (!/^[A-Za-z0-9_.\-[\]]+$/u.test(fragment)) { + throw new Error(`${path} has an unsupported YAML path fragment`); + } +} + function webProbeProjectManagementConfig(value: unknown, path: string): HwlabRuntimeWebProbeProjectManagementSpec { const raw = asRecord(value, path); const targetPaths = nonEmptyStringArrayField(raw, "targetPaths", path); @@ -853,11 +903,24 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab traceExplorerUrlTemplate, metricsEndpoint: observabilityMetricsEndpointConfig(raw.metricsEndpoint, `${path}.metricsEndpoint`), workbench: observabilityWorkbenchConfig(raw.workbench, `${path}.workbench`), + webProbe: observabilityWebProbeConfig(raw.webProbe, `${path}.webProbe`), recordingRules, warningAlerts, }; } +function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + const allowed = new Set(["sentinel"]); + for (const key of Object.keys(raw)) { + if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel`); + } + return { + ...(raw.sentinel === undefined ? {} : { sentinel: webProbeSentinelConfig(raw.sentinel, `${path}.sentinel`) }), + }; +} + function validateTraceExplorerUrlTemplate(template: string, path: string): void { if (!template.includes("{trace_id}")) throw new Error(`${path} must include {trace_id}`); if (template.startsWith("//")) throw new Error(`${path} must not be protocol-relative`); diff --git a/scripts/src/hwlab-node-web-sentinel-config.ts b/scripts/src/hwlab-node-web-sentinel-config.ts new file mode 100644 index 00000000..a107963a --- /dev/null +++ b/scripts/src/hwlab-node-web-sentinel-config.ts @@ -0,0 +1,493 @@ +// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. +// Responsibility: Redacted YAML configRef graph for hwlab nodes web-probe sentinel plan/status. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { rootPath } from "./config"; +import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes"; +import type { RenderedCliResult } from "./output"; + +export type WebProbeSentinelConfigAction = "plan" | "status"; + +export interface WebProbeSentinelConfigPlan { + readonly ok: boolean; + readonly command: string; + readonly status: "ready" | "blocked" | "disabled"; + readonly node: string; + readonly lane: string; + readonly rootPath: string; + readonly enabled: boolean; + readonly refs: readonly WebProbeSentinelConfigRefStatus[]; + readonly conflicts: readonly string[]; + readonly next: Record; + readonly valuesRedacted: true; +} + +export interface WebProbeSentinelConfigRefStatus { + readonly key: HwlabRuntimeWebProbeSentinelConfigRefKey; + readonly ref: string; + readonly file: string; + readonly path: string; + readonly present: boolean; + readonly targetPresent: boolean; + readonly targetKind: "object" | "array" | "scalar" | "null" | "missing"; + readonly sha256: string | null; + readonly byteCount: number | null; + readonly missingFields: readonly string[]; + readonly conflicts: readonly string[]; + readonly summary: string; + readonly error: string | null; +} + +interface InternalConfigRefStatus extends WebProbeSentinelConfigRefStatus { + readonly target: unknown; +} + +interface RequiredTargetShape { + readonly kind: "object" | "array"; + readonly requiredPaths: readonly string[]; +} + +const REQUIRED_TARGET_SHAPES: Record = { + runtime: { + kind: "object", + requiredPaths: [ + "target.node", + "target.lane", + "target.publicOriginRef", + "target.observeWrapperRef", + "namespace", + "serviceAccountName", + "deploymentName", + "serviceName", + "servicePort", + "pvcName", + "stateRoot", + "imageRef", + "replicas", + "healthPath", + "metricsPath", + ], + }, + scenarios: { + kind: "array", + requiredPaths: [ + "id", + "enabled", + "cadence", + "observeTargetPath", + "sampleIntervalMs", + "screenshotIntervalMs", + "maxRunSeconds", + "providerProfile", + "providerProfileMode", + "promptSetRef", + "reportViewRef", + "commandSequence[0].type", + ], + }, + promptSet: { + kind: "object", + requiredPaths: ["id", "providerProfile", "providerProfileMode", "promptSourceRef", "promptSourceKey", "promptCount", "redaction"], + }, + reportViews: { + kind: "object", + requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "redaction.prompt", "redaction.secrets"], + }, + publicExposure: { + kind: "object", + requiredPaths: [ + "enabled", + "mode", + "publicBaseUrl", + "hostname", + "expectedA", + "frpc.serverAddr", + "frpc.serverPort", + "frpc.tokenSourceRef", + "frpc.tokenSourceKey", + "frpc.secretName", + "frpc.secretKey", + "frpc.httpProxy.name", + "frpc.httpProxy.localIP", + "frpc.httpProxy.localPort", + "caddy.route", + "caddy.configPath", + "caddy.serviceName", + "caddy.managedBlockOwner", + ], + }, + cicd: { + kind: "object", + requiredPaths: [ + "controlPlaneConfigRef", + "gitopsPath", + "image.repository", + "image.tagSource", + "image.envRecipeRef", + "maintenance.startCommand", + "maintenance.stopCommand", + "targetValidation.scenarioId", + "targetValidation.maxSeconds", + "targetValidation.serviceUnavailableVerifyMode", + ], + }, + secrets: { + kind: "object", + requiredPaths: [ + "sources[0].purpose", + "sources[0].sourceRef", + "sources[0].sourceKey", + "runtimeSecrets[0].name", + "runtimeSecrets[0].namespace", + "runtimeSecrets[0].data[0].sourcePurpose", + "runtimeSecrets[0].data[0].targetKey", + ], + }, +}; + +export function webProbeSentinelConfigPlan(spec: HwlabRuntimeLaneSpec, action: WebProbeSentinelConfigAction): WebProbeSentinelConfigPlan { + const sentinel = spec.observability.webProbe?.sentinel; + const command = `hwlab nodes web-probe sentinel ${action} --node ${spec.nodeId} --lane ${spec.lane}`; + const rootConfigPath = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`; + if (sentinel === undefined) { + return { + ok: false, + command, + status: "blocked", + node: spec.nodeId, + lane: spec.lane, + rootPath: rootConfigPath, + enabled: false, + refs: [], + conflicts: [`${rootConfigPath} is missing`], + next: sentinelNext(spec.nodeId, spec.lane), + valuesRedacted: true, + }; + } + + const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, sentinel.configRefs[key])); + const conflicts = sentinel.enabled ? crossReferenceConflicts(spec, refs) : []; + const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null); + const ok = sentinel.enabled && !refBlocked && conflicts.length === 0; + return { + ok, + command, + status: sentinel.enabled ? ok ? "ready" : "blocked" : "disabled", + node: spec.nodeId, + lane: spec.lane, + rootPath: rootConfigPath, + enabled: sentinel.enabled, + refs: refs.map(stripInternalTarget), + conflicts, + next: sentinelNext(spec.nodeId, spec.lane), + valuesRedacted: true, + }; +} + +export function withWebProbeSentinelConfigRendered(value: WebProbeSentinelConfigPlan): RenderedCliResult { + return { + ok: value.ok, + command: value.command, + contentType: "text/plain", + renderedText: renderWebProbeSentinelConfigPlan(value), + }; +} + +function readSentinelConfigRef(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: string): InternalConfigRefStatus { + const parsed = parseConfigRef(ref); + if (parsed.error !== null) return emptyRefStatus(key, ref, parsed.file, parsed.path, parsed.error); + const absPath = rootPath(parsed.file); + if (!existsSync(absPath)) return emptyRefStatus(key, ref, parsed.file, parsed.path, `${parsed.file} does not exist`); + try { + const text = readFileSync(absPath, "utf8"); + const sha256 = `sha256:${createHash("sha256").update(text).digest("hex")}`; + const doc = Bun.YAML.parse(text) as unknown; + const target = valueAtPath(doc, parsed.path); + const targetKind = target === undefined ? "missing" : targetKindOf(target); + const missingFields = target === undefined ? ["target"] : missingFieldsForTarget(key, target); + return { + key, + ref, + file: parsed.file, + path: parsed.path, + present: true, + targetPresent: target !== undefined, + targetKind, + sha256, + byteCount: Buffer.byteLength(text), + missingFields, + conflicts: [], + summary: summarizeTarget(key, target), + error: null, + target, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return emptyRefStatus(key, ref, parsed.file, parsed.path, message); + } +} + +function parseConfigRef(ref: string): { readonly file: string; readonly path: string; readonly error: string | null } { + const [file, path, extra] = ref.split("#"); + if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { + return { file: file ?? "", path: path ?? "", error: "configRef must use path/to/file.yaml#object.path" }; + } + if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { + return { file, path, error: "configRef file must be repo-relative config/*.yaml without .." }; + } + return { file, path, error: null }; +} + +function emptyRefStatus(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: string, file: string, path: string, error: string): InternalConfigRefStatus { + return { + key, + ref, + file, + path, + present: false, + targetPresent: false, + targetKind: "missing", + sha256: null, + byteCount: null, + missingFields: ["target"], + conflicts: [], + summary: "-", + error, + target: undefined, + }; +} + +function missingFieldsForTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] { + const shape = REQUIRED_TARGET_SHAPES[key]; + if (shape.kind === "array") { + if (!Array.isArray(target)) return [`expected ${shape.kind}`]; + if (target.length === 0) return ["[0]"]; + return target.flatMap((item, index) => shape.requiredPaths + .filter((path) => valueAtPath(item, path) === undefined) + .map((path) => `[${index}].${path}`)); + } + if (!isRecord(target)) return [`expected ${shape.kind}`]; + return shape.requiredPaths.filter((path) => valueAtPath(target, path) === undefined); +} + +function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly InternalConfigRefStatus[]): string[] { + const byKey = new Map(refs.map((ref) => [ref.key, ref])); + const conflicts: string[] = []; + const runtime = recordTarget(byKey.get("runtime")); + const scenarios = arrayTarget(byKey.get("scenarios")); + const promptSet = recordTarget(byKey.get("promptSet")); + const cicd = recordTarget(byKey.get("cicd")); + const secrets = recordTarget(byKey.get("secrets")); + + if (runtime !== null) { + requireEquals(conflicts, byKey.get("runtime"), "target.node", spec.nodeId, `selected node ${spec.nodeId}`); + requireEquals(conflicts, byKey.get("runtime"), "target.lane", spec.lane, `selected lane ${spec.lane}`); + requireEquals(conflicts, byKey.get("runtime"), "namespace", spec.runtimeNamespace, `selected namespace ${spec.runtimeNamespace}`); + } + + const promptSetRef = byKey.get("promptSet")?.ref ?? null; + const reportViewsRef = byKey.get("reportViews")?.ref ?? null; + const scenarioIds = new Set(); + const scenarioProviders = new Set(); + for (const [index, scenario] of scenarios.entries()) { + const id = stringAt(scenario, "id"); + if (id !== null) scenarioIds.add(id); + const provider = stringAt(scenario, "providerProfile"); + if (provider !== null) scenarioProviders.add(provider); + if (promptSetRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].promptSetRef`, promptSetRef, "promptSet configRef"); + if (reportViewsRef !== null) requireEquals(conflicts, byKey.get("scenarios"), `[${index}].reportViewRef`, reportViewsRef, "reportViews configRef"); + } + + const promptProvider = promptSet === null ? null : stringAt(promptSet, "providerProfile"); + if (promptProvider !== null && scenarioProviders.size > 0 && !scenarioProviders.has(promptProvider)) { + conflicts.push(`${byKey.get("promptSet")?.file}#${byKey.get("promptSet")?.path}.providerProfile=${promptProvider} does not match scenario providerProfile set ${Array.from(scenarioProviders).join(",")}`); + } + + const validationScenarioId = cicd === null ? null : stringAt(cicd, "targetValidation.scenarioId"); + if (validationScenarioId !== null && !scenarioIds.has(validationScenarioId)) { + conflicts.push(`${byKey.get("cicd")?.file}#${byKey.get("cicd")?.path}.targetValidation.scenarioId=${validationScenarioId} is not declared in scenarios`); + } + + const runtimeNamespace = runtime === null ? null : stringAt(runtime, "namespace"); + const runtimeSecrets = secrets === null ? [] : arrayAt(secrets, "runtimeSecrets"); + if (runtimeNamespace !== null) { + for (const [index, item] of runtimeSecrets.entries()) { + const namespace = stringAt(item, "namespace"); + if (namespace !== null && namespace !== runtimeNamespace) { + conflicts.push(`${byKey.get("secrets")?.file}#${byKey.get("secrets")?.path}.runtimeSecrets[${index}].namespace=${namespace} does not match runtime namespace ${runtimeNamespace}`); + } + } + } + return conflicts; +} + +function requireEquals(conflicts: string[], ref: InternalConfigRefStatus | undefined, path: string, expected: string, expectedLabel: string): void { + if (ref === undefined) return; + const actual = stringAt(ref.target, path); + if (actual !== null && actual !== expected) { + conflicts.push(`${ref.file}#${ref.path}.${path}=${actual} does not match ${expectedLabel}`); + } +} + +function stripInternalTarget(ref: InternalConfigRefStatus): WebProbeSentinelConfigRefStatus { + return { + key: ref.key, + ref: ref.ref, + file: ref.file, + path: ref.path, + present: ref.present, + targetPresent: ref.targetPresent, + targetKind: ref.targetKind, + sha256: ref.sha256, + byteCount: ref.byteCount, + missingFields: ref.missingFields, + conflicts: ref.conflicts, + summary: ref.summary, + error: ref.error, + }; +} + +function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string { + if (target === undefined) return "target=missing"; + if (key === "scenarios" && Array.isArray(target)) { + const ids = target.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4); + const cadences = target.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4); + return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"}`; + } + if (!isRecord(target)) return `kind=${targetKindOf(target)}`; + if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`; + if (key === "promptSet") return `id=${textAt(target, "id")} provider=${textAt(target, "providerProfile")} prompts=${textAt(target, "promptCount")} source=${textAt(target, "promptSourceRef")}:${textAt(target, "promptSourceKey")}`; + if (key === "reportViews") return `default=${textAt(target, "defaultView")} views=${arrayAt(target, "views").length}`; + if (key === "publicExposure") return `enabled=${textAt(target, "enabled")} mode=${textAt(target, "mode")} url=${textAt(target, "publicBaseUrl")}`; + if (key === "cicd") return `gitops=${textAt(target, "gitopsPath")} image=${textAt(target, "image.repository")}:${textAt(target, "image.tagSource")}`; + if (key === "secrets") return `sources=${arrayAt(target, "sources").length} runtimeSecrets=${arrayAt(target, "runtimeSecrets").length}`; + return `keys=${Object.keys(target).length}`; +} + +function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): string { + const blocked = value.ok ? [] : [ + "", + "Blocked detail:", + sentinelTable(["KIND", "VALUE"], [ + ...value.conflicts.map((item) => ["conflict", short(item, 140)]), + ...value.refs.flatMap((ref) => [ + ...(ref.error === null ? [] : [[`${ref.key}.error`, short(ref.error, 140)]]), + ...(ref.missingFields.length === 0 ? [] : [[`${ref.key}.missing`, short(ref.missingFields.join(","), 140)]]), + ...(ref.conflicts.length === 0 ? [] : [[`${ref.key}.conflict`, short(ref.conflicts.join(" | "), 140)]]), + ]), + ]), + ]; + return [ + `hwlab nodes web-probe sentinel ${commandAction(value.command)} (${value.status})`, + "", + sentinelTable(["NODE", "LANE", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.enabled, value.ok, value.rootPath]]), + "", + sentinelTable( + ["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"], + value.refs.map((ref) => [ + ref.key, + ref.present, + ref.targetPresent, + ref.targetKind, + ref.sha256 === null ? "-" : `${ref.sha256.slice(0, 19)}...`, + ref.missingFields.length === 0 ? "-" : short(ref.missingFields.join(","), 52), + short(ref.summary, 90), + ]), + ), + "", + sentinelTable( + ["KEY", "FILE", "PATH", "BYTES"], + value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]), + ), + ...blocked, + "", + "NEXT", + ` plan: ${value.next.plan}`, + ` status: ${value.next.status}`, + "DISCLOSURE", + " valuesRedacted=true; secret values and full YAML objects are not printed.", + ].join("\n"); +} + +function sentinelNext(node: string, lane: string): Record { + return { + plan: `bun scripts/cli.ts hwlab nodes web-probe sentinel plan --node ${node} --lane ${lane} --dry-run`, + status: `bun scripts/cli.ts hwlab nodes web-probe sentinel status --node ${node} --lane ${lane}`, + }; +} + +function valueAtPath(value: unknown, path: string): unknown { + let current: unknown = value; + for (const segment of path.split(".")) { + if (segment.length === 0) return undefined; + const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment); + if (match === null) return undefined; + if (match[1] !== undefined) { + if (!isRecord(current)) return undefined; + current = current[match[1]]; + } + if (match[2] !== undefined) { + if (!Array.isArray(current)) return undefined; + current = current[Number(match[2])]; + } + } + return current; +} + +function stringAt(value: unknown, path: string): string | null { + const found = valueAtPath(value, path); + return typeof found === "string" && found.length > 0 ? found : null; +} + +function textAt(value: unknown, path: string): string { + const found = valueAtPath(value, path); + if (typeof found === "string") return found; + if (typeof found === "number" || typeof found === "boolean") return String(found); + return "-"; +} + +function arrayAt(value: unknown, path: string): unknown[] { + const found = valueAtPath(value, path); + return Array.isArray(found) ? found : []; +} + +function recordTarget(ref: InternalConfigRefStatus | undefined): Record | null { + return ref !== undefined && isRecord(ref.target) ? ref.target : null; +} + +function arrayTarget(ref: InternalConfigRefStatus | undefined): Record[] { + return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function targetKindOf(value: unknown): "object" | "array" | "scalar" | "null" { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (isRecord(value)) return "object"; + return "scalar"; +} + +function commandAction(command: string): string { + return command.includes(" status ") ? "status" : "plan"; +} + +function sentinelTable(headers: string[], rows: unknown[][]): string { + const normalized = [headers, ...rows.map((row) => row.map((cell) => sentinelText(cell)))]; + const widths = headers.map((_, index) => Math.max(...normalized.map((row) => sentinelText(row[index] ?? "").length))); + return normalized.map((row) => row.map((cell, index) => sentinelText(cell).padEnd(widths[index])).join(" ").trimEnd()).join("\n"); +} + +function sentinelText(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value).replace(/\s+/gu, " ").trim(); +} + +function short(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)}~`; +}