feat: add web probe sentinel config plan (#895)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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 <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。
|
||||
- `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 业务结论。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -51,6 +51,8 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"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<string, unknown> {
|
||||
"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<string, unknown> {
|
||||
"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 }).",
|
||||
|
||||
@@ -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<typeof par
|
||||
|
||||
function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe") throw new Error("web-probe usage: run|script|observe --node NODE --lane vNN [--url URL]");
|
||||
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|observe|sentinel --node NODE --lane vNN [--url URL]");
|
||||
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
|
||||
if (actionRaw === "observe") {
|
||||
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
|
||||
const explicitNode = optionValue(args, "--node");
|
||||
@@ -7427,6 +7437,26 @@ function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSentinelOptions {
|
||||
const [sentinelActionRaw] = args;
|
||||
if (sentinelActionRaw !== "plan" && sentinelActionRaw !== "status") {
|
||||
throw new Error("web-probe sentinel usage: sentinel plan|status --node NODE --lane vNN [--dry-run]");
|
||||
}
|
||||
assertKnownOptions(args, new Set(["--node", "--lane"]), new Set(["--dry-run"]));
|
||||
const node = requiredOption(args, "--node");
|
||||
assertNodeId(node);
|
||||
const lane = requiredOption(args, "--lane");
|
||||
assertLane(lane);
|
||||
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
|
||||
return {
|
||||
action: "sentinel",
|
||||
sentinelAction: sentinelActionRaw,
|
||||
node,
|
||||
lane,
|
||||
dryRun: args.includes("--dry-run"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } {
|
||||
const [observeActionRaw, maybeId, ...rest] = args;
|
||||
if (observeActionRaw !== "start" && maybeId !== undefined && !maybeId.startsWith("--")) {
|
||||
@@ -7653,10 +7683,11 @@ function assertKnownOptions(args: string[], valueOptions: Set<string>, flagOptio
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown> {
|
||||
function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown> | 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);
|
||||
|
||||
@@ -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<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
||||
}
|
||||
|
||||
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<HwlabRuntimeWebProbeSentinelConfigRefKey, string>;
|
||||
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`);
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<HwlabRuntimeWebProbeSentinelConfigRefKey, RequiredTargetShape> = {
|
||||
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<string>();
|
||||
const scenarioProviders = new Set<string>();
|
||||
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<string, string> {
|
||||
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<string, unknown> | null {
|
||||
return ref !== undefined && isRecord(ref.target) ? ref.target : null;
|
||||
}
|
||||
|
||||
function arrayTarget(ref: InternalConfigRefStatus | undefined): Record<string, unknown>[] {
|
||||
return ref !== undefined && Array.isArray(ref.target) ? ref.target.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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)}~`;
|
||||
}
|
||||
Reference in New Issue
Block a user