feat: add monitor evidence inspect cli
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user