From e9c91c78e66729ca4cb3f1687f8895bdead94b4d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 7 Jul 2026 00:38:50 +0000 Subject: [PATCH] feat: add monitor evidence inspect cli --- .agents/skills/unidesk-monitor/SKILL.md | 4 + docs/reference/observability.md | 2 + scripts/src/help.ts | 4 +- scripts/src/hwlab-node-help.ts | 3 + .../hwlab-node-web-sentinel-cicd-shared.ts | 14 ++ scripts/src/hwlab-node-web-sentinel-cicd.ts | 3 +- scripts/src/hwlab-node-web-sentinel-p5.ts | 199 ++++++++++++++++++ scripts/src/hwlab-node/web-probe-observe.ts | 137 +++++++++++- 8 files changed, 356 insertions(+), 10 deletions(-) diff --git a/.agents/skills/unidesk-monitor/SKILL.md b/.agents/skills/unidesk-monitor/SKILL.md index 737c5234..375c2d87 100644 --- a/.agents/skills/unidesk-monitor/SKILL.md +++ b/.agents/skills/unidesk-monitor/SKILL.md @@ -30,6 +30,8 @@ bun scripts/cli.ts web-probe sentinel control-plane status --node --lane bun scripts/cli.ts web-probe sentinel validate --node --lane --sentinel bun scripts/cli.ts web-probe sentinel dashboard verify --node --lane --sentinel bun scripts/cli.ts web-probe sentinel dashboard screenshot --node --lane --sentinel +bun scripts/cli.ts web-probe sentinel inspect-url 'https://monitor.pikapython.com/?run=' +bun scripts/cli.ts web-probe sentinel inspect-id --node --lane --sentinel bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary --raw bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary --full @@ -41,6 +43,8 @@ trans :k3s kubectl -n create job --from=cronjob/` 解析 `run`、`observer`、`traceId`、`sampleSeq`、`turn`、`commandId`、`artifact`、finding 和 report SHA;裸 id 用 `web-probe sentinel inspect-id --node --lane --sentinel ` 做类型识别和歧义提示。`inspect-*` 应从 YAML/source-of-truth 解析 node/lane/sentinel,并输出短表、摘要和 Next 命令;run id 证据继续落到 `web-probe sentinel report --run --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=` 只显示 WBC-003,但 `web-probe sentinel report --run --view findings --raw` 能从 `analysis/report.json` 读出 red/amber analyzer findings,根因是索引或 artifact 可见性遮盖,不是业务没有产生 warning/error。此时应回填或重建这条既有 run 的 report index,并保留原有 report views;不要通过启动新的哨兵 run 来解释旧记录。 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index f4f98f05..ed6ba1d7 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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.", }; } diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 3a393d71..025e8d6b 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -83,6 +83,8 @@ export function hwlabNodeWebProbeHelp(): Record { "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 ", @@ -105,6 +107,7 @@ export function hwlabNodeWebProbeHelp(): Record { "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 `; 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.", diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts index 04638dfe..e78c9e43 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts @@ -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; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index c88756ad..69d9ee5d 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -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); diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index 7256175c..65d006e7 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -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): 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 | 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[] } { + const ids: Record[] = []; + 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 { + 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[] }, 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[] }): readonly Record[] { + return evidence.ids.flatMap((item) => commandsForEvidenceId(state, item)); +} + +function commandsForEvidenceId(state: SentinelCicdState, item: Record): readonly Record[] { + const kind = stringAtNullable(item, "kind") ?? "unknown"; + const value = stringAtNullable(item, "value") ?? ""; + 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 --node ${state.spec.nodeId} --lane ${state.spec.lane} --view trace-frame --trace-id ${shellQuote(value)} --sample-seq `, valuesRedacted: true }, + ]; + } + if (kind === "command") { + return [{ kind, view: "observe-command-window", command: `bun scripts/cli.ts web-probe observe collect --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 --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[] }): Record { + 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 --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --raw`, + valuesRedacted: true, + }; +} + +function monitorWebHttpDiagnosticPlan(state: SentinelCicdState, input: string): Record { + 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 { + 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, diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 9d3ea37b..41d7de6d 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -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;