feat: add monitor evidence inspect cli

This commit is contained in:
Codex
2026-07-07 00:38:50 +00:00
parent acc21b191b
commit e9c91c78e6
8 changed files with 356 additions and 10 deletions
+4
View File
@@ -30,6 +30,8 @@ bun scripts/cli.ts web-probe sentinel control-plane status --node <node> --lane
bun scripts/cli.ts web-probe sentinel validate --node <node> --lane <lane> --sentinel <id>
bun scripts/cli.ts web-probe sentinel dashboard verify --node <node> --lane <lane> --sentinel <id>
bun scripts/cli.ts web-probe sentinel dashboard screenshot --node <node> --lane <lane> --sentinel <id>
bun scripts/cli.ts web-probe sentinel inspect-url 'https://monitor.pikapython.com/?run=<runId>'
bun scripts/cli.ts web-probe sentinel inspect-id <id> --node <node> --lane <lane> --sentinel <id>
bun scripts/cli.ts web-probe sentinel report --node <node> --lane <lane> --sentinel <id> --latest --view summary
bun scripts/cli.ts web-probe sentinel report --node <node> --lane <lane> --sentinel <id> --latest --view summary --raw
bun scripts/cli.ts web-probe sentinel report --node <node> --lane <lane> --sentinel <id> --latest --view summary --full
@@ -41,6 +43,8 @@ trans <node>:k3s kubectl -n <namespace> create job --from=cronjob/<quick-verify-
For WebUI manual validation, use `web-probe sentinel dashboard trigger` so the remote browser clicks the monitor-web button. Direct `kubectl create job --from=cronjob/...` is a last-resort diagnostic only, not acceptance evidence. For k3s cadence validation, first use the controlled control-plane status/trigger commands, then inspect the rendered CronJob in the target k3s namespace. Persistent cadence changes must be made through YAML/GitOps and redeployed.
When a user gives a monitor-web URL or an evidence id from the page, first use `web-probe sentinel inspect-url` or `web-probe sentinel inspect-id`. These commands map `run`, `observer`, `traceId`, `sampleSeq`, `turn`, `commandId`, `artifact` and finding ids to controlled report/observe drill-down commands. Do not use public monitor-web HTTP API probes as HWLAB business evidence; reserve them for explicit monitor-web shell/API/render diagnostics and label them separately.
For long Workbench/user-path evidence, use the normal Web probe surface:
```bash
+2
View File
@@ -33,6 +33,8 @@ Web/Workbench trace、Web 哨兵和 `web-probe observe` 的人工判定入口以
Web 哨兵 dashboard/API 展示问题的第一事实源是 sentinel runner 的 `/api/overview``/api/runs``/api/runs/{id}``/api/findings``web-probe sentinel dashboard verify|screenshot` 远程浏览器证据。OTel/Tempo 查询不到 `hwlab-web-probe-sentinel` service span 或具体 `sentinel-run-*` id 时,只能说明当前 instrumentation 或保留窗口没有覆盖这条 dashboard/API 路径;不得因此把 UI/API 口径问题判为已追穿,也不得阻塞已由 API/DOM 证据定位的修复。需要继续追 runner 内部链路时,应把缺少 Web 哨兵 span 作为 instrumentation 问题登记到对应治理 issue。
收到 monitor-web 链接、页面上下文或用户消息里的证据 id 时,调查入口必须先走受控 CLI drill-down。URL 用 `web-probe sentinel inspect-url <url>` 解析 `run``observer``traceId``sampleSeq``turn``commandId``artifact`、finding 和 report SHA;裸 id 用 `web-probe sentinel inspect-id <id> --node <node> --lane <lane> --sentinel <id>` 做类型识别和歧义提示。`inspect-*` 应从 YAML/source-of-truth 解析 node/lane/sentinel,并输出短表、摘要和 Next 命令;run id 证据继续落到 `web-probe sentinel report --run <runId> --view summary|findings|trace-frame`observer/trace/command 证据继续落到 `web-probe observe collect|analyze` 的有界 view。只有明确诊断 monitor-web 公网 shell/API/render 自身故障时,才使用 HTTP 探测,并在结论中标注为 monitor-web 诊断,不得把 public API curl 结果当作 HWLAB 业务证据。
`web-probe sentinel dashboard verify` 必须区分页面渲染证据和 API 可达性证据。远程浏览器已经拿到 HTML shell、`data-monitor-ready=true`、目标 run/曲线 DOM 和内存/request 图表时,页面二次 `fetch("/api/*")` 遇到瞬时 `ERR_NETWORK_CHANGED`、abort 或同类浏览器网络抖动,不得直接归类为 dashboard render failed;应优先保留浏览器 DOM 证据,并用受控 runner service API 读取 `/api/overview``/api/runs``/api/runs/{id}` 作为显式 `service-fallback` 证据。fallback 只能修正 verifier 证据来源和合同判断,不能遮盖真实页面 shell 未 ready、图表未渲染、sentinelId/route 不匹配、目标 run 不一致、页面 error、horizontal overflow 或 service API 本身失败。
Web 哨兵 findings 可见性要同时核对 runner API 和已有 observe artifact。若某个 run 的公开 `/api/report?view=findings&run=<id>` 只显示 WBC-003,但 `web-probe sentinel report --run <id> --view findings --raw` 能从 `analysis/report.json` 读出 red/amber analyzer findings,根因是索引或 artifact 可见性遮盖,不是业务没有产生 warning/error。此时应回填或重建这条既有 run 的 report index,并保留原有 report views;不要通过启动新的哨兵 run 来解释旧记录。
+3 -1
View File
@@ -737,8 +737,10 @@ function webProbeHelpSummary(): unknown {
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary",
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts web-probe sentinel inspect-url 'https://monitor.pikapython.com/?run=sentinel-run-xxxx'",
"bun scripts/cli.ts web-probe sentinel inspect-id trc_xxxx --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
],
description: "Run target node/lane HWLAB Cloud Web probes, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel YAML-first control through a single top-level implementation.",
description: "Run target node/lane HWLAB Cloud Web probes, long observe/analyze sessions, project-management MDTODO commands, and Web sentinel YAML-first control through a single top-level implementation. Monitor-web links and evidence ids should enter through sentinel inspect-url/inspect-id before report or observe drill-down.",
};
}
+3
View File
@@ -83,6 +83,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x",
"bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel dashboard trigger --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
"bun scripts/cli.ts web-probe sentinel inspect-url 'https://monitor.pikapython.com/?run=sentinel-run-xxxx'",
"bun scripts/cli.ts web-probe sentinel inspect-id webobs-xxxx --node JD01 --lane v03 --sentinel jd01-web-probe-sentinel",
"bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --raw",
"bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --full",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id <id>",
@@ -105,6 +107,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"performanceCapture records an explicit bounded Chrome CPU profile and drains LongTask/LoAF/event-loop-gap artifacts; performance-summary reads existing artifacts only.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
"For monitor-web links or page evidence ids, start with `web-probe sentinel inspect-url` or `inspect-id`; default evidence must be controlled report/observe CLI output, not direct public API curl.",
"`web-probe sentinel report --raw` returns bounded issue evidence JSON, including report/artifact SHA and root-cause signal summaries when available; use `--full` for the full indexed service payload.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel <id>`; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
@@ -126,6 +126,20 @@ export type WebProbeSentinelOptions =
readonly full: boolean;
readonly timeoutSeconds: number;
}
| {
readonly kind: "inspect";
readonly action: "inspect-url" | "inspect-id";
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly input: string;
readonly view: WebProbeSentinelReportView;
readonly raw: boolean;
readonly full: boolean;
readonly diagnoseMonitorWeb: boolean;
readonly resolutionWarnings: readonly string[];
readonly timeoutSeconds: number;
}
| {
readonly kind: "dashboard";
readonly action: WebProbeSentinelDashboardAction;
+2 -1
View File
@@ -22,7 +22,7 @@ import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-c
import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output";
import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelInspect, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe";
import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel";
import {
@@ -158,6 +158,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
if (options.kind === "publish") return runSentinelPublishCurrent(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
if (options.kind === "validate") return runSentinelValidate(state, options);
if (options.kind === "inspect") return runSentinelInspect(state, options);
if (options.kind === "dashboard") return runSentinelDashboard(state, options);
if (options.kind === "error") return runSentinelErrorDetail(state, options);
return runSentinelReport(state, options);
+199
View File
@@ -263,6 +263,205 @@ export function runSentinelErrorDetail(state: SentinelCicdState, options: Extrac
return rendered(response.ok && body.ok !== false, command, renderSentinelErrorDetailResult({ command, node: state.spec.nodeId, lane: state.spec.lane, sentinelId: state.sentinelId, response, body, valuesRedacted: true }));
}
export function runSentinelInspect(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "inspect" }>): RenderedCliResult {
const command = `web-probe sentinel ${options.action}`;
const evidence = parseMonitorEvidenceInput(options.input);
const runId = firstEvidenceValue(evidence, "run");
const traceId = firstEvidenceValue(evidence, "trace");
const sampleSeq = firstEvidenceValue(evidence, "sample-seq");
let reportPayload: Record<string, unknown> | null = null;
if (runId !== null) {
const query = new URLSearchParams({ view: options.view, run: runId });
if (traceId !== null) query.set("traceId", traceId);
if (sampleSeq !== null) query.set("sampleSeq", sampleSeq);
const report = callSentinelService(state, "GET", `/api/report?${query.toString()}`, null, options.timeoutSeconds);
const body = record(report.bodyJson);
const artifactSummary = readSentinelReportArtifactSummary(state, body, Math.min(options.timeoutSeconds, SENTINEL_REPORT_ARTIFACT_READ_TIMEOUT_SECONDS));
reportPayload = compactSentinelReportRawPayload(state, body, report, artifactSummary);
}
const payload = {
ok: reportPayload === null ? true : reportPayload.ok !== false,
command,
mode: options.action,
input: options.input,
inputKind: evidence.inputKind,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
monitorWebHttpDiagnostic: options.diagnoseMonitorWeb ? monitorWebHttpDiagnosticPlan(state, options.input) : null,
warnings: options.resolutionWarnings,
evidenceIds: evidence.ids,
evidenceCommands: monitorEvidenceCommands(state, evidence),
report: reportPayload,
next: monitorInspectNext(state, evidence),
valuesRedacted: true,
};
if (options.full || options.raw) return rendered(payload.ok, command, JSON.stringify(payload, null, 2));
return rendered(payload.ok, command, renderMonitorInspect(payload));
}
type MonitorEvidenceKind = "run" | "observer" | "trace" | "sample-seq" | "turn" | "command" | "artifact" | "report-sha" | "finding" | "unknown";
function parseMonitorEvidenceInput(input: string): { readonly inputKind: "url" | "id"; readonly ids: readonly Record<string, unknown>[] } {
const ids: Record<string, unknown>[] = [];
const url = parseEvidenceUrl(input);
if (url !== null) {
const addParam = (name: string, kind: MonitorEvidenceKind, normalizedName = name) => {
const value = url.searchParams.get(name);
if (value !== null && value.trim().length > 0) ids.push({ kind, name: normalizedName, value: value.trim(), source: "url-query", valuesRedacted: true });
};
addParam("run", "run");
addParam("runId", "run", "run");
addParam("observer", "observer");
addParam("observerId", "observer", "observer");
addParam("traceId", "trace", "traceId");
addParam("sampleSeq", "sample-seq", "sampleSeq");
addParam("turn", "turn");
addParam("commandId", "command", "commandId");
addParam("artifact", "artifact");
addParam("file", "artifact", "artifact");
addParam("reportSha", "report-sha", "reportSha");
addParam("finding", "finding");
addParam("findingId", "finding", "finding");
return { inputKind: "url", ids: ids.length > 0 ? ids : [{ kind: "unknown", value: input, source: "url", valuesRedacted: true }] };
}
return { inputKind: "id", ids: [classifyMonitorEvidenceId(input)] };
}
function parseEvidenceUrl(input: string): URL | null {
try {
return new URL(input);
} catch {
return null;
}
}
function classifyMonitorEvidenceId(input: string): Record<string, unknown> {
const value = input.trim();
const kind: MonitorEvidenceKind = /^sentinel-run-[A-Za-z0-9_-]+$/u.test(value) ? "run"
: /^webobs-[A-Za-z0-9_-]+$/u.test(value) ? "observer"
: /^trc[_-][A-Za-z0-9_-]+$/u.test(value) ? "trace"
: /^cmd[_-][A-Za-z0-9_-]+$/u.test(value) || /^command[_-][A-Za-z0-9_-]+$/u.test(value) ? "command"
: /^sha256[:_-]?[0-9a-f]{64}$/iu.test(value) ? "report-sha"
: /^[0-9]+$/u.test(value) ? "sample-seq"
: "unknown";
return { kind, value, source: "bare-id", valuesRedacted: true };
}
function firstEvidenceValue(evidence: { readonly ids: readonly Record<string, unknown>[] }, kind: MonitorEvidenceKind): string | null {
for (const item of evidence.ids) {
if (item.kind === kind) {
const value = stringAtNullable(item, "value");
if (value !== null) return value;
}
}
return null;
}
function monitorEvidenceCommands(state: SentinelCicdState, evidence: { readonly ids: readonly Record<string, unknown>[] }): readonly Record<string, unknown>[] {
return evidence.ids.flatMap((item) => commandsForEvidenceId(state, item));
}
function commandsForEvidenceId(state: SentinelCicdState, item: Record<string, unknown>): readonly Record<string, unknown>[] {
const kind = stringAtNullable(item, "kind") ?? "unknown";
const value = stringAtNullable(item, "value") ?? "<id>";
const base = `bun scripts/cli.ts web-probe sentinel report --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`;
const observe = `bun scripts/cli.ts web-probe observe collect ${shellQuote(value)} --node ${state.spec.nodeId} --lane ${state.spec.lane}`;
if (kind === "run") {
return [
{ kind, view: "summary", command: `${base} --run ${shellQuote(value)} --view summary`, valuesRedacted: true },
{ kind, view: "findings", command: `${base} --run ${shellQuote(value)} --view findings`, valuesRedacted: true },
{ kind, view: "raw", command: `${base} --run ${shellQuote(value)} --view summary --raw`, valuesRedacted: true },
];
}
if (kind === "observer") {
return [
{ kind, view: "turn-summary", command: `${observe} --view turn-summary`, valuesRedacted: true },
{ kind, view: "timeline", command: `${observe} --view timeline`, valuesRedacted: true },
{ kind, view: "analyze", command: `bun scripts/cli.ts web-probe observe analyze ${shellQuote(value)} --node ${state.spec.nodeId} --lane ${state.spec.lane}`, valuesRedacted: true },
];
}
if (kind === "trace") {
return [
{ kind, view: "sentinel-trace-frame", command: `${base} --view trace-frame --trace-id ${shellQuote(value)}`, valuesRedacted: true },
{ kind, view: "observe-trace-frame", command: `bun scripts/cli.ts web-probe observe collect <observerId> --node ${state.spec.nodeId} --lane ${state.spec.lane} --view trace-frame --trace-id ${shellQuote(value)} --sample-seq <seq>`, valuesRedacted: true },
];
}
if (kind === "command") {
return [{ kind, view: "observe-command-window", command: `bun scripts/cli.ts web-probe observe collect <observerId> --node ${state.spec.nodeId} --lane ${state.spec.lane} --view timeline --command-id ${shellQuote(value)}`, valuesRedacted: true }];
}
if (kind === "sample-seq" || kind === "turn") {
return [{ kind, view: "sentinel-trace-frame", command: `${base} --view trace-frame --sample-seq ${shellQuote(value)}`, valuesRedacted: true }];
}
if (kind === "artifact") {
return [{ kind, view: "observe-file", command: `bun scripts/cli.ts web-probe observe collect <observerId> --node ${state.spec.nodeId} --lane ${state.spec.lane} --view files --file ${shellQuote(value)}`, valuesRedacted: true }];
}
if (kind === "finding") {
return [{ kind, view: "report-findings", command: `${base} --view findings --raw`, valuesRedacted: true }];
}
return [{ kind, view: "classification-needed", command: `bun scripts/cli.ts web-probe sentinel inspect-id ${shellQuote(value)} --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --raw`, valuesRedacted: true }];
}
function monitorInspectNext(state: SentinelCicdState, evidence: { readonly ids: readonly Record<string, unknown>[] }): Record<string, unknown> {
const runId = firstEvidenceValue(evidence, "run");
const observerId = firstEvidenceValue(evidence, "observer");
return {
reportSummary: runId === null ? null : `bun scripts/cli.ts web-probe sentinel report --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --run ${shellQuote(runId)} --view summary`,
reportFindings: runId === null ? null : `bun scripts/cli.ts web-probe sentinel report --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --run ${shellQuote(runId)} --view findings`,
observerTurnSummary: observerId === null ? null : `bun scripts/cli.ts web-probe observe collect ${shellQuote(observerId)} --node ${state.spec.nodeId} --lane ${state.spec.lane} --view turn-summary`,
raw: `bun scripts/cli.ts web-probe sentinel inspect-id <id> --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --raw`,
valuesRedacted: true,
};
}
function monitorWebHttpDiagnosticPlan(state: SentinelCicdState, input: string): Record<string, unknown> {
const publicBaseUrl = stringAtNullable(state.publicExposure, "publicBaseUrl");
return {
purpose: "monitor-web-shell-api-render-diagnostic-only",
note: "HTTP probes are monitor-web evidence, not HWLAB business evidence; prefer the controlled report/observe commands for run, observer, trace, command and artifact ids.",
input,
publicBaseUrl,
suggestedChecks: [
publicBaseUrl === null ? null : `${publicBaseUrl.replace(/\/$/u, "")}/api/overview`,
publicBaseUrl === null ? null : `${publicBaseUrl.replace(/\/$/u, "")}/api/runs`,
].filter((item): item is string => item !== null),
valuesRedacted: true,
};
}
function renderMonitorInspect(payload: Record<string, unknown>): string {
const ids = Array.isArray(payload.evidenceIds) ? payload.evidenceIds.map(record) : [];
const commands = Array.isArray(payload.evidenceCommands) ? payload.evidenceCommands.map(record) : [];
const report = record(payload.report);
const run = record(report.run);
const findings = Array.isArray(report.findings) ? report.findings.map(record) : [];
return [
"Monitor Evidence Inspect",
"=======================================================",
`target=${payload.node}/${payload.lane}/${payload.sentinelId} inputKind=${payload.inputKind}`,
Array.isArray(payload.warnings) && payload.warnings.length > 0 ? `warnings=${payload.warnings.join("; ")}` : "",
"",
"IDs",
table(["KIND", "VALUE", "SOURCE"], ids.map((item) => [
item.kind ?? "-",
item.value ?? "-",
item.source ?? "-",
])),
"",
report.ok === undefined ? "" : "Report",
report.ok === undefined ? "" : `run=${run.id ?? "-"} observer=${run.observerId ?? "-"} status=${run.status ?? "-"} report=${run.reportJsonSha256 ?? "-"} findings=${run.findingCount ?? findings.length}`,
findings.length === 0 ? "" : table(["ID", "SEVERITY", "COUNT", "SUMMARY"], findings.slice(0, 8).map((item) => [
item.id ?? "-",
item.severity ?? "-",
item.count ?? "-",
reportText(item.summary, 100) ?? "-",
])),
"",
"Next",
commands.length === 0 ? "-" : commands.slice(0, 8).map((item) => ` ${item.view ?? item.kind}: ${item.command}`).join("\n"),
].filter((line) => line !== "").join("\n");
}
function compactSentinelReportRawPayload(
state: SentinelCicdState,
body: Record<string, unknown>,
+129 -8
View File
@@ -56,8 +56,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "dashboard"
&& sentinelActionRaw !== "error"
&& sentinelActionRaw !== "report"
&& sentinelActionRaw !== "inspect-url"
&& sentinelActionRaw !== "inspect-id"
) {
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report|inspect-url|inspect-id --node NODE --lane vNN [--dry-run|--confirm]");
}
assertKnownOptions(args, new Set([
"--node",
@@ -83,20 +85,47 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--source-stage-ref",
"--source-mirror-commit",
"--source-authority",
]), new Set(["--dry-run", "--confirm", "--wait", "--rerun", "--manual-recovery", "--recovery", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page"]));
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
]), new Set(["--dry-run", "--confirm", "--wait", "--rerun", "--manual-recovery", "--recovery", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page", "--diagnose-monitor-web"]));
const nodeOption = optionValue(args, "--node") ?? null;
const laneOption = optionValue(args, "--lane") ?? null;
const inspectMode = sentinelActionRaw === "inspect-url" || sentinelActionRaw === "inspect-id";
let node = nodeOption ?? "";
let lane = laneOption ?? "";
const sentinelId = optionValue(args, "--sentinel") ?? optionValue(args, "--sentinel-id") ?? null;
if (sentinelId !== null && !/^[a-z0-9][a-z0-9-]{1,80}$/u.test(sentinelId)) throw new Error(`--sentinel must be a stable lowercase sentinel id, got ${sentinelId}`);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run");
if (confirm && dryRun) throw new Error("web-probe sentinel accepts only one of --confirm or --dry-run");
const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600);
const sourceOverride = parseWebProbeSentinelSourceOverride(args);
let sentinel: WebProbeSentinelOptions;
if (inspectMode) {
const input = args[1];
if (input === undefined || input.startsWith("--")) throw new Error(`web-probe sentinel ${sentinelActionRaw} requires a URL or id argument`);
const resolved = resolveWebProbeSentinelInspectTarget(input, sentinelActionRaw, nodeOption, laneOption, sentinelId);
node = resolved.node;
lane = resolved.lane;
sentinel = {
kind: "inspect",
action: sentinelActionRaw,
node,
lane,
sentinelId: resolved.sentinelId,
input,
view: parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary"),
raw: args.includes("--raw"),
full: args.includes("--full"),
diagnoseMonitorWeb: args.includes("--diagnose-monitor-web"),
resolutionWarnings: resolved.warnings,
timeoutSeconds,
};
} else {
node = requiredOption(args, "--node");
lane = requiredOption(args, "--lane");
assertNodeId(node);
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
}
if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") {
sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
@@ -164,7 +193,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
const errorId = requiredOption(args, "--error-id");
if (!/^wbe_[a-f0-9]{20}$/u.test(errorId)) throw new Error(`web-probe sentinel error get --error-id must look like wbe_<20 hex>, got ${errorId}`);
sentinel = { kind: "error", action: "get", node, lane, sentinelId, errorId, raw: args.includes("--raw"), full: args.includes("--full"), timeoutSeconds };
} else {
} else if (sentinelActionRaw === "report") {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest");
const runId = optionValue(args, "--run") ?? optionValue(args, "--run-id") ?? null;
@@ -196,6 +225,98 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
};
}
function resolveWebProbeSentinelInspectTarget(
input: string,
action: "inspect-url" | "inspect-id",
nodeOption: string | null,
laneOption: string | null,
sentinelOption: string | null,
): { readonly node: string; readonly lane: string; readonly sentinelId: string | null; readonly warnings: readonly string[] } {
if ((nodeOption === null) !== (laneOption === null)) throw new Error(`web-probe sentinel ${action} requires both --node and --lane when overriding target`);
if (nodeOption !== null && laneOption !== null) {
assertNodeId(nodeOption);
assertLane(laneOption);
if (!isHwlabRuntimeLane(laneOption)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${laneOption}`);
const spec = hwlabRuntimeLaneSpecForNode(laneOption, nodeOption);
const fallbackSentinel = spec.observability.webProbe?.monitorRoot?.sentinelId ?? null;
return { node: nodeOption, lane: laneOption, sentinelId: sentinelOption ?? fallbackSentinel, warnings: [] };
}
const candidates = webProbeMonitorRootCandidates();
const url = parseMonitorInspectUrl(input);
const urlSentinel = url === null ? null : sentinelIdFromMonitorUrl(url);
const desiredSentinel = sentinelOption ?? urlSentinel;
const matched = candidates.filter((candidate) => {
if (desiredSentinel !== null && candidate.sentinelId !== desiredSentinel) return false;
if (url === null) return true;
return monitorUrlMatchesRoot(url, candidate.publicBaseUrl);
});
if (matched.length === 0) {
const hint = candidates.slice(0, 8).map((candidate) => `${candidate.node}/${candidate.lane}/${candidate.sentinelId}`).join(", ");
throw new Error(`web-probe sentinel ${action} could not map input to a YAML monitorRoot; pass --node and --lane. candidates: ${hint}`);
}
const selected = matched.length === 1 ? matched[0] : preferredMonitorRootCandidate(matched);
if (selected === null) {
const hint = matched.slice(0, 8).map((candidate) => `--node ${candidate.node} --lane ${candidate.lane} --sentinel ${candidate.sentinelId}`).join(" | ");
throw new Error(`web-probe sentinel ${action} target is ambiguous; pass one target explicitly. candidates: ${hint}`);
}
const warnings = matched.length > 1
? [`multiple YAML monitorRoot entries matched; selected ${selected.node}/${selected.lane}/${selected.sentinelId} by source-of-truth preference`]
: [];
return { node: selected.node, lane: selected.lane, sentinelId: selected.sentinelId, warnings };
}
function webProbeMonitorRootCandidates(): readonly { readonly node: string; readonly lane: string; readonly sentinelId: string; readonly publicBaseUrl: string }[] {
const candidates: { node: string; lane: string; sentinelId: string; publicBaseUrl: string }[] = [];
for (const lane of hwlabRuntimeLaneIds()) {
for (const node of hwlabRuntimeNodeIds()) {
let spec: HwlabRuntimeLaneSpec;
try {
spec = hwlabRuntimeLaneSpecForNode(lane, node);
} catch {
continue;
}
const root = spec.observability.webProbe?.monitorRoot;
if (root?.enabled !== true) continue;
candidates.push({ node: spec.nodeId, lane: spec.lane, sentinelId: root.sentinelId, publicBaseUrl: root.publicBaseUrl });
}
}
return candidates;
}
function parseMonitorInspectUrl(input: string): URL | null {
try {
return new URL(input);
} catch {
return null;
}
}
function sentinelIdFromMonitorUrl(url: URL): string | null {
const query = url.searchParams.get("sentinel") ?? url.searchParams.get("sentinelId");
if (query !== null && /^[a-z0-9][a-z0-9-]{1,80}$/u.test(query)) return query;
const match = /\/sentinels\/([a-z0-9][a-z0-9-]{1,80})(?:\/|$)/u.exec(url.pathname);
return match?.[1] ?? null;
}
function monitorUrlMatchesRoot(url: URL, publicBaseUrl: string): boolean {
let base: URL;
try {
base = new URL(publicBaseUrl);
} catch {
return false;
}
if (url.origin !== base.origin) return false;
const basePath = base.pathname.replace(/\/+$/u, "");
if (basePath.length === 0) return true;
return url.pathname === basePath || url.pathname.startsWith(`${basePath}/`);
}
function preferredMonitorRootCandidate(candidates: readonly { readonly node: string; readonly lane: string; readonly sentinelId: string }[]): { readonly node: string; readonly lane: string; readonly sentinelId: string } | null {
return candidates.find((candidate) => candidate.node === "JD01" && candidate.lane === "v03" && candidate.sentinelId === "jd01-web-probe-sentinel")
?? candidates.find((candidate) => candidate.node === "JD01" && candidate.lane === "v03")
?? null;
}
function parseWebProbeSentinelSourceOverride(args: string[]): WebProbeSentinelSourceOverrideOptions {
const sourceCommit = optionValue(args, "--source-commit") ?? null;
const sourceStageRef = optionValue(args, "--source-stage-ref") ?? null;