feat(web-probe): add multi-sentinel registry
This commit is contained in:
@@ -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(".")) {
|
||||
|
||||
Reference in New Issue
Block a user