feat(web-probe): add multi-sentinel registry

This commit is contained in:
Codex
2026-06-26 12:42:04 +00:00
parent 7b3df965cc
commit 4e0f1cba21
25 changed files with 1038 additions and 140 deletions
+46 -19
View File
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
@@ -11,6 +12,7 @@ import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
@@ -18,6 +20,7 @@ const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig {
readonly node: string;
readonly lane: string;
readonly sentinelId: string;
readonly plan: WebProbeSentinelConfigPlan;
readonly runtime: Record<string, unknown>;
readonly scenarios: readonly Record<string, unknown>[];
@@ -35,6 +38,7 @@ export interface WebProbeSentinelServiceConfig {
export interface WebProbeSentinelServiceOptions {
readonly spec: HwlabRuntimeLaneSpec;
readonly sentinelId?: string | null;
readonly stateRootOverride?: string;
readonly portOverride?: number;
readonly hostOverride?: string;
@@ -83,19 +87,19 @@ export interface WebProbeSentinelService {
}
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): WebProbeSentinelServiceConfig {
const sentinel = spec.observability.webProbe?.sentinel;
if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
const plan = webProbeSentinelConfigPlan(spec, "status");
const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime));
const scenarios = arrayTarget(readConfigRefTarget(sentinel.configRefs.scenarios));
const reportViews = recordTarget(readConfigRefTarget(sentinel.configRefs.reportViews));
const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure));
const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd));
const sentinel = resolveWebProbeSentinel(spec, options.sentinelId ?? null);
const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime));
const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios));
const reportViews = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews));
const publicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure));
const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd));
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
const yamlSqlitePath = stringAt(runtime, "sqlite.path");
return {
node: spec.nodeId,
lane: spec.lane,
sentinelId: sentinel.id,
plan,
runtime,
scenarios,
@@ -167,6 +171,7 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
ok: true,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
status: "observed",
configReady: config.plan.ok,
scheduler: schedulerSummary(config, db),
@@ -388,7 +393,7 @@ function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, sche
command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir <stateDir>`,
};
const ok = Object.values(checks).every((check) => check.ok === true);
return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, checks, valuesRedacted: true };
return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, sentinelId: config.sentinelId, checks, valuesRedacted: true };
}
function checkWritable(stateRoot: string): Record<string, unknown> {
@@ -432,6 +437,16 @@ function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario
const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "<observerId>", "--type", type];
if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider"));
if (type === "sendPrompt") argv.push("--text-stdin");
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
const accountId = stringOrNull(item.accountId);
if (accountId !== null) argv.push("--account-id", accountId);
}
if (type === "switchSessions") {
const fromAccountId = stringOrNull(item.fromAccountId);
const toAccountId = stringOrNull(item.toAccountId);
if (fromAccountId !== null) argv.push("--from-account-id", fromAccountId);
if (toAccountId !== null) argv.push("--to-account-id", toAccountId);
}
return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep;
});
const analyze: CommandPlanStep = {
@@ -459,28 +474,29 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
const heartbeat = record(readMetadata(db, "scheduler.heartbeat"));
const heartbeatAt = stringOrNull(heartbeat.at);
const heartbeatAge = heartbeatAt === null ? -1 : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000));
const labels = `node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}",sentinel="${metricLabel(config.sentinelId)}"`;
const lines = [
"# HELP web_probe_sentinel_config_ready Config reference graph is ready.",
"# TYPE web_probe_sentinel_config_ready gauge",
`web_probe_sentinel_config_ready{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${config.plan.ok ? 1 : 0}`,
`web_probe_sentinel_config_ready{${labels}} ${config.plan.ok ? 1 : 0}`,
"# HELP web_probe_sentinel_health Healthy status of the sentinel service.",
"# TYPE web_probe_sentinel_health gauge",
`web_probe_sentinel_health{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${health.ok === true ? 1 : 0}`,
`web_probe_sentinel_health{${labels}} ${health.ok === true ? 1 : 0}`,
"# HELP web_probe_sentinel_runs_total Runs indexed by status.",
"# TYPE web_probe_sentinel_runs_total gauge",
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{status="${metricLabel(status)}"} ${count}`),
...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{${labels},status="${metricLabel(status)}"} ${count}`),
"# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.",
"# TYPE web_probe_sentinel_active_runs gauge",
`web_probe_sentinel_active_runs ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
`web_probe_sentinel_active_runs{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
"# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.",
"# TYPE web_probe_sentinel_recent_findings gauge",
`web_probe_sentinel_recent_findings ${sumColumn(db, "runs", "finding_count")}`,
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
"# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.",
"# TYPE web_probe_sentinel_maintenance_active gauge",
`web_probe_sentinel_maintenance_active ${maintenance.active ? 1 : 0}`,
`web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`,
"# HELP web_probe_sentinel_scheduler_heartbeat_age_seconds Scheduler heartbeat age.",
"# TYPE web_probe_sentinel_scheduler_heartbeat_age_seconds gauge",
`web_probe_sentinel_scheduler_heartbeat_age_seconds ${heartbeatAge}`,
`web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
];
return `${lines.join("\n")}\n`;
}
@@ -525,6 +541,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
status: dashboardOverallStatus(health, latestRun, severityCounts),
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl),
configReady: config.plan.ok,
health,
@@ -566,6 +583,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
filters,
page: {
limit: page.limit,
@@ -594,6 +612,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
summary: record(stored.summary),
findings,
@@ -605,9 +624,9 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
publicOrigin: stringOrNull(stored.publicOrigin),
},
commands: {
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view summary`,
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view turn-summary`,
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view trace-frame`,
summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view summary`,
turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view turn-summary`,
traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view trace-frame`,
},
redaction: record(config.reportViews.redaction),
traceability: runTraceability(config, row),
@@ -651,6 +670,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
filters,
page: {
limit,
@@ -677,6 +697,7 @@ function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
views,
view: view ?? null,
@@ -717,6 +738,7 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
status: stringOrNull(row.status),
node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane,
sentinelId: config.sentinelId,
observer_id: stringOrNull(row.observer_id),
observerId: stringOrNull(row.observer_id),
state_dir: stringOrNull(row.state_dir),
@@ -1233,6 +1255,11 @@ function arrayTarget(value: unknown): Record<string, unknown>[] {
return value.map(recordTarget);
}
function scenarioArrayTarget(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) return value.map(recordTarget);
return [recordTarget(value)];
}
function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value;
for (const segment of path.split(".")) {