Merge pull request #1218 from pikasTech/fix/1206-web-sentinel-boundaries

fix: isolate D518 web sentinel runners
This commit is contained in:
Lyon
2026-06-28 16:35:55 +08:00
committed by GitHub
10 changed files with 225 additions and 85 deletions
+29 -1
View File
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p12-cadence-scheduler-monitor-web.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -2067,6 +2068,8 @@ function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extrac
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
`export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID=${shellQuote(state.sentinelId)}`,
`export UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX=${shellQuote(stringAtNullable(state.publicExposure, "routePrefix") ?? "/")}`,
`export UNIDESK_SENTINEL_DASHBOARD_PLAYWRIGHT_MODULE=${shellQuote(`${state.spec.workspace}/node_modules/playwright/index.mjs`)}`,
"export PLAYWRIGHT_BROWSERS_PATH=0",
"if command -v chromium >/dev/null 2>&1; then",
@@ -2149,6 +2152,8 @@ const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || "";
const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/";
if (!url) throw new Error("missing dashboard URL");
@@ -2353,6 +2358,23 @@ const dom = await page.evaluate(async () => {
&& chartCounts.error === latestRunCounts.error
&& chartCounts.warning === latestRunCounts.warning
&& chartCounts.total === latestRunCounts.total;
const datasetSentinelId = root?.getAttribute("data-sentinel-id") || "";
const finalPath = new URL(window.location.href).pathname.replace(/\/+$/u, "") || "/";
const expectedPath = expectedRoutePrefix.replace(/\/+$/u, "") || "/";
const routePrefixMatches = expectedPath === "/" ? finalPath === "/" : finalPath === expectedPath || finalPath.startsWith(expectedPath + "/");
const sentinelBoundary = {
expectedSentinelId,
expectedRoutePrefix,
datasetSentinelId,
overviewSentinelId: overviewPayload?.sentinelId || null,
runsSentinelId: runsPayload?.sentinelId || null,
finalPath,
routePrefixMatches,
datasetMatches: expectedSentinelId ? datasetSentinelId === expectedSentinelId : true,
overviewMatches: expectedSentinelId ? overviewPayload?.sentinelId === expectedSentinelId : true,
runsPayloadMatches: expectedSentinelId ? runsPayload?.sentinelId === expectedSentinelId : true,
runRowsMatch: expectedSentinelId ? runs.every((run) => (run?.sentinelId || expectedSentinelId) === expectedSentinelId) : true,
};
const statusText = text(".status-strip");
const doc = document.documentElement;
const body = document.body;
@@ -2389,10 +2411,11 @@ const dom = await page.evaluate(async () => {
dataset: root ? {
node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"),
sentinelId: root.getAttribute("data-sentinel-id"),
sentinelId: datasetSentinelId,
basePath: root.getAttribute("data-base-path"),
contractVersion: root.getAttribute("data-contract-version"),
} : {},
sentinelBoundary,
title: document.title,
finalUrl: window.location.href,
statusText: text(".topbar .pill"),
@@ -2567,6 +2590,11 @@ const ok = !navigationError
&& httpStatus < 300
&& dom.shell === true
&& dom.ready === true
&& dom.sentinelBoundary?.datasetMatches === true
&& dom.sentinelBoundary?.overviewMatches === true
&& dom.sentinelBoundary?.runsPayloadMatches === true
&& dom.sentinelBoundary?.runRowsMatch === true
&& dom.sentinelBoundary?.routePrefixMatches === true
&& dom.errorVisible !== true
&& dom.trendCurve === true
&& dom.chartCounts?.ok === true
+115 -57
View File
@@ -3,6 +3,7 @@
// 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.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-28-p13-1206-multi-runner-boundaries.
// 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";
@@ -122,8 +123,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
const config = loadWebProbeSentinelServiceConfig(options.spec, options);
mkdirSync(config.stateRoot, { recursive: true });
const db = new Database(config.sqlitePath);
initializeIndex(db);
const restored = markInterruptedRuns(db, nowIso());
initializeIndex(db, config);
const restored = markInterruptedRuns(config, db, nowIso());
const schedulerEnabled = options.schedulerEnabled ?? true;
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
let schedulerHeartbeatAt = nowIso();
@@ -178,14 +179,14 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
configReady: config.plan.ok,
scheduler: schedulerSummary(config, db),
maintenance: this.maintenance(),
runs: runCounts(db),
runs: runCounts(config, db),
latestRuns: this.runs(8),
valuesRedacted: true,
};
},
runs(limit = 20) {
return db.query("SELECT id, scenario_id, status, node, lane, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, interrupted_at FROM runs ORDER BY created_at DESC LIMIT ?")
.all(limit) as Record<string, unknown>[];
return db.query("SELECT id, sentinel_id, scenario_id, status, node, lane, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, interrupted_at FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY created_at DESC LIMIT ?")
.all(config.sentinelId, config.node, config.lane, limit) as Record<string, unknown>[];
},
overview() {
return dashboardOverview(config, db, this.health(), this.maintenance());
@@ -244,8 +245,8 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
const runId = `sentinel-run-${Date.now()}-${randomUUID().slice(0, 8)}`;
const commandPlan = buildObserveCommandPlan(config, scenario);
const createdAt = nowIso();
db.query("INSERT INTO runs (id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(runId, scenarioId, config.node, config.lane, "planned", this.maintenance().active ? 1 : 0, createdAt, createdAt, JSON.stringify({ reason, commandPlan, valuesRedacted: true }));
db.query("INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, maintenance, created_at, updated_at, command_plan_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(runId, config.sentinelId, scenarioId, config.node, config.lane, "planned", this.maintenance().active ? 1 : 0, createdAt, createdAt, JSON.stringify({ reason, commandPlan, valuesRedacted: true }));
return { ok: true, runId, scenarioId, status: "planned", commandPlanSha256: sha256Json(commandPlan), valuesRedacted: true };
},
recordRun(input: Record<string, unknown>) {
@@ -291,6 +292,8 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
const url = new URL(request.url);
const routeMismatch = sentinelRouteMismatch(service.config, url.pathname);
if (routeMismatch !== null) return jsonResponse(routeMismatch, 409);
const pathname = normalizedSentinelRequestPath(service, url.pathname);
if (request.method === "GET" && pathname === "/api/health") {
const health = service.health();
@@ -359,6 +362,28 @@ function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathnam
return pathname;
}
function sentinelRouteMismatch(config: WebProbeSentinelServiceConfig, pathname: string): Record<string, unknown> | null {
const match = /^\/sentinels\/([^/]+)/u.exec(pathname);
if (match === null) return null;
const routeSegment = decodeURIComponent(match[1]);
const nodePrefix = `${config.node.toLowerCase()}-`;
const routeSentinelId = routeSegment.startsWith(nodePrefix) ? routeSegment.slice(nodePrefix.length) : routeSegment;
if (routeSentinelId === config.sentinelId) return null;
return {
ok: false,
error: "sentinel-route-mismatch",
code: "sentinel-route-mismatch",
blocker: true,
routeSegment,
routeSentinelId,
serviceSentinelId: config.sentinelId,
node: config.node,
lane: config.lane,
message: "route sentinelId does not match the selected sentinel runner; refusing cross-sentinel fallback",
valuesRedacted: true,
};
}
function publicExposurePath(publicExposure: Record<string, unknown>): string {
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
if (publicBaseUrl === null) return "";
@@ -378,7 +403,7 @@ function normalizedDashboardAssetPath(pathname: string): string {
return pathname;
}
function initializeIndex(db: Database): void {
function initializeIndex(db: Database, config: WebProbeSentinelServiceConfig): void {
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS metadata (
@@ -388,6 +413,7 @@ function initializeIndex(db: Database): void {
);
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
sentinel_id TEXT NOT NULL DEFAULT '',
scenario_id TEXT NOT NULL,
node TEXT NOT NULL,
lane TEXT NOT NULL,
@@ -405,6 +431,7 @@ function initializeIndex(db: Database): void {
);
CREATE TABLE IF NOT EXISTS findings (
run_id TEXT NOT NULL,
sentinel_id TEXT NOT NULL DEFAULT '',
finding_id TEXT NOT NULL,
severity TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
@@ -412,11 +439,25 @@ function initializeIndex(db: Database): void {
report_json_sha256 TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_updated ON runs(sentinel_id, node, lane, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_sentinel_scenario ON runs(sentinel_id, scenario_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_run ON findings(sentinel_id, run_id);
CREATE INDEX IF NOT EXISTS idx_findings_sentinel_code ON findings(sentinel_id, finding_id, created_at DESC);
`);
ensureColumn(db, "runs", "sentinel_id", "TEXT");
ensureColumn(db, "findings", "sentinel_id", "TEXT");
db.query("UPDATE runs SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
db.query("UPDATE findings SET sentinel_id = ? WHERE sentinel_id IS NULL OR sentinel_id = ''").run(config.sentinelId);
}
function markInterruptedRuns(db: Database, at: string): number {
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE status IN ('queued', 'running', 'analyzing')").run(at, at);
function ensureColumn(db: Database, table: "runs" | "findings", column: string, definition: string): void {
const rows = db.query(`PRAGMA table_info(${table})`).all() as Record<string, unknown>[];
if (rows.some((row) => stringOrNull(row.name) === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
function markInterruptedRuns(config: WebProbeSentinelServiceConfig, db: Database, at: string): number {
const result = db.query("UPDATE runs SET status = 'interrupted', interrupted_at = ?, updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status IN ('queued', 'running', 'analyzing')").run(at, at, config.sentinelId, config.node, config.lane);
return Number(result.changes ?? 0);
}
@@ -563,7 +604,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
enabledScenarios: config.scenarios.filter((item) => boolAt(item, "enabled")).map((item) => stringAt(item, "id")),
intervalMs: config.schedulerIntervalMs,
maxConcurrentRuns: config.maxConcurrentRuns,
activeRuns: countWhere(db, "status IN ('queued', 'running', 'analyzing')"),
activeRuns: countWhere(config, db, "status IN ('queued', 'running', 'analyzing')"),
plannedRuns: planned.count,
oldestPlannedRunId: planned.oldestRunId,
oldestPlannedRunScenarioId: planned.oldestScenarioId,
@@ -578,7 +619,7 @@ function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database):
}
function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, health: Record<string, unknown>, maintenance: MaintenanceState): string {
const counts = runCounts(db);
const counts = runCounts(config, db);
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));
@@ -595,10 +636,10 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
...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{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
`web_probe_sentinel_active_runs{${labels}} ${countWhere(config, 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{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
`web_probe_sentinel_recent_findings{${labels}} ${sumColumn(config, 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{${labels}} ${maintenance.active ? 1 : 0}`,
@@ -607,7 +648,7 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
`web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
"# HELP web_probe_sentinel_planned_runs Planned runs waiting for host cadence execution.",
"# TYPE web_probe_sentinel_planned_runs gauge",
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(db, "status = 'planned'")}`,
`web_probe_sentinel_planned_runs{${labels}} ${countWhere(config, db, "status = 'planned'")}`,
"# HELP web_probe_sentinel_oldest_planned_run_age_seconds Oldest planned run age, or -1 when no planned run exists.",
"# TYPE web_probe_sentinel_oldest_planned_run_age_seconds gauge",
`web_probe_sentinel_oldest_planned_run_age_seconds{${labels}} ${plannedRunBacklog(config, db).oldestAgeSeconds ?? -1}`,
@@ -624,10 +665,21 @@ function plannedRunBacklog(config: WebProbeSentinelServiceConfig, db: Database):
readonly staleAfterSeconds: number;
readonly stale: boolean;
} {
const activeWhere = "status = 'planned' AND NOT EXISTS (SELECT 1 FROM runs newer WHERE newer.scenario_id = runs.scenario_id AND newer.status <> 'planned' AND newer.created_at > runs.created_at)";
const count = countWhere(db, activeWhere);
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
.get() as Record<string, unknown> | null;
const activeWhere = [
"status = 'planned'",
"AND NOT EXISTS (",
"SELECT 1 FROM runs newer",
"WHERE newer.sentinel_id = runs.sentinel_id",
"AND newer.node = runs.node",
"AND newer.lane = runs.lane",
"AND newer.scenario_id = runs.scenario_id",
"AND newer.status <> 'planned'",
"AND newer.created_at > runs.created_at",
")",
].join(" ");
const count = countWhere(config, db, activeWhere);
const oldest = db.query(`SELECT id, scenario_id, created_at FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${activeWhere} ORDER BY created_at ASC LIMIT 1`)
.get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
const oldestCreatedAt = stringOrNull(oldest?.created_at);
const oldestAgeSeconds = oldestCreatedAt === null ? null : ageSeconds(oldestCreatedAt);
const staleAfterSeconds = Math.max(60, Math.round(config.schedulerIntervalMs / 1000));
@@ -665,8 +717,9 @@ function readMetadata(db: Database, key: string): Record<string, unknown> | null
}
}
function runCounts(db: Database): Record<string, number> {
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[];
function runCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
const rows = db.query("SELECT status, COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? GROUP BY status")
.all(config.sentinelId, config.node, config.lane) as { status: string; count: number }[];
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
@@ -689,7 +742,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
scheduler: schedulerSummary(config, db),
maintenance,
latestRun,
runCounts: runCounts(db),
runCounts: runCounts(config, db),
severityCounts,
freshness: {
latestRunUpdatedAt: latestUpdatedAt,
@@ -719,7 +772,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
const filters = dashboardRunFilters(url);
const page = dashboardPage(url, config);
const sort = dashboardRunSort(url);
const where = runWhereClause(filters);
const where = runWhereClause(config, filters);
const sql = `SELECT * FROM runs ${where.sql} ORDER BY ${sort.sql} LIMIT ? OFFSET ?`;
const rows = db.query(sql).all(...where.params, page.limit + 1, page.offset) as Record<string, unknown>[];
const visibleRows = rows.slice(0, page.limit);
@@ -750,7 +803,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
}
function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> {
const row = readRunRow(db, runId);
const row = readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const stored = readMetadata(db, `run.report.${runId}`) ?? {};
const findings = findingsForRun(config, db, runId, dashboardPageSize(config));
@@ -783,7 +836,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record<string, unknown> {
const limit = dashboardPage(url, config).limit;
const filters = dashboardFindingFilters(url);
const where = findingWhereClause(filters);
const where = findingWhereClause(config, filters);
const queryLimit = Math.min(dashboardMaxPageSize(config), Math.max(limit + 1, limit * 4));
const rows = db.query(`
SELECT f.finding_id,
@@ -799,7 +852,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
`).all(...where.params, queryLimit) as Record<string, unknown>[];
const severityFilter = stringOrNull(filters.severity);
const items = rows.map((row) => {
const latestRun = latestRunForFinding(db, row);
const latestRun = latestRunForFinding(config, db, row);
const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id));
return enrichFindingWithCheck(config, {
code: stringOrNull(row.finding_id),
@@ -847,7 +900,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
}
function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string | null, url: URL): Record<string, unknown> {
const row = readRunRow(db, runId);
const row = readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const requestedViews = view === null ? stringArrayAt(config.reportViews, "views") : [view];
const maxBytes = Math.min(numberParam(url, "maxBytes", DASHBOARD_MAX_TEXT_BYTES), 64_000);
@@ -940,7 +993,6 @@ function dashboardOverallStatus(health: Record<string, unknown>, latestRun: Reco
if (health.ok !== true) return "degraded";
const latestStatus = latestRun === null ? null : stringOrNull(latestRun.status);
if (latestStatus !== null && /blocked|failed|error|timeout/iu.test(latestStatus)) return "blocked";
if (severityRank(maxSeverityFromCounts(severityCounts)) >= 3) return "blocked";
if (Object.values(severityCounts).some((count) => count > 0)) return "warning";
return latestRun === null ? "idle" : "healthy";
}
@@ -970,9 +1022,9 @@ function dashboardFindingFilters(url: URL): Record<string, unknown> {
};
}
function runWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = [];
const params: (string | number)[] = [];
function runWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = ["sentinel_id = ?", "node = ?", "lane = ?"];
const params: (string | number)[] = [config.sentinelId, config.node, config.lane];
const status = stringOrNull(filters.status);
if (status !== null) {
clauses.push("status = ?");
@@ -1016,9 +1068,9 @@ function runWhereClause(filters: Record<string, unknown>): { readonly sql: strin
return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params };
}
function findingWhereClause(filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = [];
const params: (string | number)[] = [];
function findingWhereClause(config: WebProbeSentinelServiceConfig, filters: Record<string, unknown>): { readonly sql: string; readonly params: readonly (string | number)[] } {
const clauses: string[] = ["r.sentinel_id = ?", "r.node = ?", "r.lane = ?", "f.sentinel_id = ?"];
const params: (string | number)[] = [config.sentinelId, config.node, config.lane, config.sentinelId];
const code = stringOrNull(filters.code);
if (code !== null) {
clauses.push("f.finding_id = ?");
@@ -1073,13 +1125,14 @@ function dashboardMaxPageSize(config: WebProbeSentinelServiceConfig): number {
return Math.max(1, Math.min(200, numberOr(config.reportViews.maxPageSize, 100)));
}
function readRunRow(db: Database, runId: string): Record<string, unknown> | null {
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
function readRunRow(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, unknown> | null {
return db.query("SELECT * FROM runs WHERE id = ? AND sentinel_id = ? AND node = ? AND lane = ?")
.get(runId, config.sentinelId, config.node, config.lane) as Record<string, unknown> | null;
}
function findingsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string, limit: number): readonly Record<string, unknown>[] {
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT ?")
.all(runId, limit) as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? AND sentinel_id = ? ORDER BY created_at DESC LIMIT ?")
.all(runId, config.sentinelId, limit) as Record<string, unknown>[];
return rows.map((row) => enrichFindingRowWithStoredDetail(config, db, runId, row));
}
@@ -1259,24 +1312,24 @@ function normalizeCheckLevel(value: string | null): string | null {
}
function globalSeverityCounts(config: WebProbeSentinelServiceConfig, db: Database): Record<string, number> {
const rows = db.query("SELECT finding_id, severity, count FROM findings").all() as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE sentinel_id = ?").all(config.sentinelId) as Record<string, unknown>[];
return checkLevelCounts(config, rows);
}
function severityCountsForRun(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record<string, number> {
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ?").all(runId) as Record<string, unknown>[];
const rows = db.query("SELECT finding_id, severity, count FROM findings WHERE run_id = ? AND sentinel_id = ?").all(runId, config.sentinelId) as Record<string, unknown>[];
return checkLevelCounts(config, rows);
}
function latestRunForFinding(db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
function latestRunForFinding(config: WebProbeSentinelServiceConfig, db: Database, row: Record<string, unknown>): Record<string, unknown> | null {
return db.query(`
SELECT r.*
FROM findings f
JOIN runs r ON r.id = f.run_id
WHERE f.finding_id = ? AND r.scenario_id = ?
WHERE f.finding_id = ? AND r.scenario_id = ? AND r.sentinel_id = ? AND r.node = ? AND r.lane = ? AND f.sentinel_id = ?
ORDER BY f.created_at DESC
LIMIT 1
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id)) as Record<string, unknown> | null;
`).get(stringOrNull(row.finding_id), stringOrNull(row.scenario_id), config.sentinelId, config.node, config.lane, config.sentinelId) as Record<string, unknown> | null;
}
function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>): Record<string, unknown> {
@@ -1284,6 +1337,7 @@ function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<stri
source: "sqlite-index+run-report-metadata",
node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane,
sentinelId: stringOrNull(row.sentinel_id) ?? config.sentinelId,
runId: stringOrNull(row.id),
observerId: stringOrNull(row.observer_id),
stateDir: stringOrNull(row.state_dir),
@@ -1385,13 +1439,15 @@ function severityRankSql(expression: string): string {
return `CASE LOWER(COALESCE(${expression}, '')) WHEN 'critical' THEN 4 WHEN 'red' THEN 4 WHEN 'fatal' THEN 4 WHEN 'error' THEN 3 WHEN 'failed' THEN 3 WHEN 'blocked' THEN 3 WHEN 'warning' THEN 2 WHEN 'warn' THEN 2 WHEN 'amber' THEN 2 WHEN 'yellow' THEN 2 WHEN 'info' THEN 1 WHEN 'notice' THEN 1 ELSE 0 END`;
}
function countWhere(db: Database, where: string): number {
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE ${where}`).get() as { count?: number } | null;
function countWhere(config: WebProbeSentinelServiceConfig, db: Database, where: string): number {
const row = db.query(`SELECT COUNT(*) AS count FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? AND ${where}`)
.get(config.sentinelId, config.node, config.lane) as { count?: number } | null;
return Number(row?.count ?? 0);
}
function sumColumn(db: Database, table: string, column: string): number {
const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table}`).get() as { total?: number } | null;
function sumColumn(config: WebProbeSentinelServiceConfig, db: Database, table: "runs", column: "finding_count"): number {
const row = db.query(`SELECT COALESCE(SUM(${column}), 0) AS total FROM ${table} WHERE sentinel_id = ? AND node = ? AND lane = ?`)
.get(config.sentinelId, config.node, config.lane) as { total?: number } | null;
return Number(row?.total ?? 0);
}
@@ -1428,9 +1484,10 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
const artifactCount = numberOr(input.artifactCount, 0);
const createdAt = stringOrNull(input.createdAt) ?? now;
db.query(`
INSERT INTO runs (id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO runs (id, sentinel_id, scenario_id, node, lane, status, observer_id, state_dir, report_json_sha256, finding_count, artifact_count, maintenance, created_at, updated_at, command_plan_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
sentinel_id = excluded.sentinel_id,
status = excluded.status,
observer_id = excluded.observer_id,
state_dir = excluded.state_dir,
@@ -1438,20 +1495,21 @@ function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, in
finding_count = excluded.finding_count,
artifact_count = excluded.artifact_count,
updated_at = excluded.updated_at
`).run(runId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE status = 'planned' AND scenario_id = ? AND created_at < ?")
.run(now, scenarioId, createdAt);
db.query("DELETE FROM findings WHERE run_id = ?").run(runId);
`).run(runId, config.sentinelId, scenarioId, config.node, config.lane, status, observerId, stateDir, reportJsonSha256, findingCount, artifactCount, thisMaintenanceFlag(input), createdAt, now, JSON.stringify({ source: "recorded-analyze-summary", valuesRedacted: true }));
const supersededPlannedRuns = db.query("UPDATE runs SET status = 'superseded', updated_at = ? WHERE sentinel_id = ? AND node = ? AND lane = ? AND status = 'planned' AND scenario_id = ? AND created_at < ?")
.run(now, config.sentinelId, config.node, config.lane, scenarioId, createdAt);
db.query("DELETE FROM findings WHERE run_id = ? AND sentinel_id = ?").run(runId, config.sentinelId);
for (const item of findings) {
const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding";
const check = checkForFinding(config, { ...item, id: findingId });
const severity = stringOrNull(check.level) ?? normalizeCheckLevel(stringOrNull(item.severity) ?? stringOrNull(item.level)) ?? "unknown";
const summary = stringOrNull(item.summary) ?? stringOrNull(item.message) ?? findingId;
db.query("INSERT INTO findings (run_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(runId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
db.query("INSERT INTO findings (run_id, sentinel_id, finding_id, severity, count, summary, report_json_sha256, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(runId, config.sentinelId, findingId.slice(0, 160), severity.slice(0, 40), numberOr(item.count, 1), summary.slice(0, 500), reportJsonSha256, now);
}
writeMetadata(db, `run.report.${runId}`, {
runId,
sentinelId: config.sentinelId,
scenarioId,
observerId,
stateDir,
@@ -1473,8 +1531,8 @@ function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view
return { ok: false, error: "unsupported-report-view", view, valuesRedacted: true };
}
const row = runId === null
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get() as Record<string, unknown> | null
: db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
? db.query("SELECT * FROM runs WHERE report_json_sha256 IS NOT NULL AND sentinel_id = ? AND node = ? AND lane = ? ORDER BY updated_at DESC LIMIT 1").get(config.sentinelId, config.node, config.lane) as Record<string, unknown> | null
: readRunRow(config, db, runId);
if (row === null) return { ok: false, error: "report-run-missing", runId, view, valuesRedacted: true };
const selectedRunId = stringOrNull(row.id);
if (selectedRunId === null) return { ok: false, error: "report-run-id-missing", view, valuesRedacted: true };