// 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"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Database } from "bun:sqlite"; 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; export interface WebProbeSentinelServiceConfig { readonly node: string; readonly lane: string; readonly sentinelId: string; readonly plan: WebProbeSentinelConfigPlan; readonly runtime: Record; readonly scenarios: readonly Record[]; readonly reportViews: Record; readonly publicExposure: Record; readonly cicd: Record; readonly stateRoot: string; readonly sqlitePath: string; readonly listenHost: string; readonly servicePort: number; readonly schedulerIntervalMs: number; readonly schedulerHeartbeatStaleSeconds: number; readonly maxConcurrentRuns: number; } export interface WebProbeSentinelServiceOptions { readonly spec: HwlabRuntimeLaneSpec; readonly sentinelId?: string | null; readonly stateRootOverride?: string; readonly portOverride?: number; readonly hostOverride?: string; readonly schedulerEnabled?: boolean; } interface MaintenanceState { readonly active: boolean; readonly reason: string | null; readonly releaseId: string | null; readonly startedAt: string | null; readonly stoppedAt: string | null; readonly quickVerifyPlannedAt: string | null; readonly quickVerifyPlannedRunId: string | null; } interface CommandPlanStep { readonly phase: string; readonly argv: readonly string[]; readonly stdinSource: "none" | "prompt-source"; } export interface WebProbeSentinelService { readonly config: WebProbeSentinelServiceConfig; readonly db: Database; readonly schedulerEnabled: boolean; startScheduler(): void; stopScheduler(): void; close(): void; health(): Record; status(): Record; runs(limit?: number): readonly Record[]; overview(): Record; dashboardRuns(url: URL): Record; runDetail(runId: string): Record; findings(url: URL): Record; runViews(runId: string, view: string | null, url: URL): Record; maintenance(): MaintenanceState; setMaintenance(active: boolean, input: Record): MaintenanceState; planScenarioRun(scenarioId: string, reason: string): Record; recordRun(input: Record): Record; report(view: string, runId: string | null): Record; metrics(): string; dashboardHtml(): string; fetch(request: Request): Promise; } export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit = {}): WebProbeSentinelServiceConfig { 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, reportViews, publicExposure, cicd, stateRoot, sqlitePath: options.stateRootOverride === undefined ? yamlSqlitePath : join(stateRoot, "index.sqlite"), listenHost: options.hostOverride ?? stringAt(runtime, "listenHost"), servicePort: options.portOverride ?? numberAt(runtime, "servicePort"), schedulerIntervalMs: numberAt(runtime, "scheduler.intervalMs"), schedulerHeartbeatStaleSeconds: numberAt(runtime, "scheduler.heartbeatStaleSeconds"), maxConcurrentRuns: numberAt(runtime, "scheduler.maxConcurrentRuns"), }; } export function createWebProbeSentinelService(options: WebProbeSentinelServiceOptions): WebProbeSentinelService { const config = loadWebProbeSentinelServiceConfig(options.spec, options); mkdirSync(config.stateRoot, { recursive: true }); const db = new Database(config.sqlitePath); initializeIndex(db); const restored = markInterruptedRuns(db, nowIso()); const schedulerEnabled = options.schedulerEnabled ?? true; let schedulerTimer: ReturnType | null = null; let schedulerHeartbeatAt = nowIso(); let schedulerLastError: string | null = null; writeMetadata(db, "service.boot", { at: schedulerHeartbeatAt, restoredInterruptedRuns: restored, valuesRedacted: true }); writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "boot" }); const service: WebProbeSentinelService = { config, db, schedulerEnabled, startScheduler() { if (!schedulerEnabled || schedulerTimer !== null) return; schedulerHeartbeatAt = nowIso(); writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "started" }); schedulerTimer = setInterval(() => { try { schedulerHeartbeatAt = nowIso(); writeMetadata(db, "scheduler.heartbeat", { at: schedulerHeartbeatAt, loop: "tick" }); writeMetadata(db, "scheduler.summary", schedulerSummary(config, db)); schedulerLastError = null; } catch (error) { schedulerLastError = error instanceof Error ? error.message : String(error); writeMetadata(db, "scheduler.error", { at: nowIso(), message: schedulerLastError }); } }, config.schedulerIntervalMs); }, stopScheduler() { if (schedulerTimer !== null) clearInterval(schedulerTimer); schedulerTimer = null; writeMetadata(db, "scheduler.heartbeat", { at: nowIso(), loop: "stopped" }); }, close() { this.stopScheduler(); db.close(); }, health() { return serviceHealth(config, db, { schedulerEnabled, schedulerHeartbeatAt, schedulerTimerActive: schedulerTimer !== null, schedulerLastError, }); }, status() { return { ok: true, node: config.node, lane: config.lane, sentinelId: config.sentinelId, status: "observed", configReady: config.plan.ok, scheduler: schedulerSummary(config, db), maintenance: this.maintenance(), runs: runCounts(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[]; }, overview() { return dashboardOverview(config, db, this.health(), this.maintenance()); }, dashboardRuns(url: URL) { return dashboardRunList(config, db, url); }, runDetail(runId: string) { return dashboardRunDetail(config, db, runId); }, findings(url: URL) { return dashboardFindings(config, db, url); }, runViews(runId: string, view: string | null, url: URL) { return dashboardRunViews(config, db, runId, view, url); }, maintenance() { return readMetadata(db, "maintenance") as MaintenanceState | null ?? emptyMaintenance(); }, setMaintenance(active: boolean, input: Record) { const current = this.maintenance(); const next: MaintenanceState = active ? { active: true, reason: stringOrNull(input.reason), releaseId: stringOrNull(input.releaseId), startedAt: nowIso(), stoppedAt: current.stoppedAt, quickVerifyPlannedAt: current.quickVerifyPlannedAt, quickVerifyPlannedRunId: current.quickVerifyPlannedRunId, } : { active: false, reason: current.reason, releaseId: current.releaseId, startedAt: current.startedAt, stoppedAt: nowIso(), quickVerifyPlannedAt: nowIso(), quickVerifyPlannedRunId: null, }; writeMetadata(db, "maintenance", next); if (!active) { const scenarioId = firstEnabledScenarioId(config); if (scenarioId !== null) { const planned = this.planScenarioRun(scenarioId, "maintenance-stop-quick-verify"); const withPlan = { ...next, quickVerifyPlannedRunId: stringOrNull(planned.runId) }; writeMetadata(db, "maintenance", withPlan); return withPlan; } } return next; }, planScenarioRun(scenarioId: string, reason: string) { const scenario = config.scenarios.find((item) => stringAt(item, "id") === scenarioId); if (scenario === undefined) throw new Error(`scenario not found: ${scenarioId}`); 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 })); return { ok: true, runId, scenarioId, status: "planned", commandPlanSha256: sha256Json(commandPlan), valuesRedacted: true }; }, recordRun(input: Record) { return recordRunResult(config, db, input); }, report(view: string, runId: string | null) { return reportRunView(config, db, view, runId); }, metrics() { return renderMetrics(config, db, this.health(), this.maintenance()); }, dashboardHtml() { return renderWebProbeSentinelDashboardHtml(config); }, async fetch(request: Request) { return sentinelFetch(service, request); }, }; service.startScheduler(); return service; } export function startWebProbeSentinelHttpService(service: WebProbeSentinelService): { readonly url: string; readonly stop: () => void } { const server = Bun.serve({ hostname: service.config.listenHost, port: service.config.servicePort, fetch: (request) => service.fetch(request), }); return { url: `http://${service.config.listenHost}:${server.port}`, stop: () => server.stop(true), }; } async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise { const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503); if (request.method === "GET" && url.pathname === "/api/status") return jsonResponse(service.status()); if (request.method === "GET" && url.pathname === "/api/overview") return jsonResponse(service.overview()); if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url)); if (request.method === "GET" && url.pathname === "/api/findings") return jsonResponse(service.findings(url)); const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(url.pathname); if (request.method === "GET" && runViewsMatch !== null) { const view = url.searchParams.get("view"); const result = service.runViews(decodeURIComponent(runViewsMatch[1]), view, url); return jsonResponse(result, result.ok === false ? 404 : 200); } const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(url.pathname); if (request.method === "GET" && runDetailMatch !== null) { const result = service.runDetail(decodeURIComponent(runDetailMatch[1])); return jsonResponse(result, result.ok === false ? 404 : 200); } if (request.method === "GET" && url.pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true }); if (request.method === "POST" && (url.pathname === "/api/maintenance/start" || url.pathname === "/api/maintenance/stop")) { const body = await readJsonBody(request); const active = url.pathname.endsWith("/start"); return jsonResponse({ ok: true, maintenance: service.setMaintenance(active, body), valuesRedacted: true }); } if (request.method === "POST" && url.pathname === "/api/runs/plan") { const body = await readJsonBody(request); return jsonResponse(service.planScenarioRun(stringField(body, "scenarioId"), stringOrNull(body.reason) ?? "manual")); } if (request.method === "POST" && url.pathname === "/api/runs/record") { const body = await readJsonBody(request); return jsonResponse(service.recordRun(body)); } if (request.method === "GET" && url.pathname === "/api/report") { const view = url.searchParams.get("view") ?? stringOrNull(service.config.reportViews.defaultView) ?? "summary"; const runId = url.searchParams.get("run") ?? url.searchParams.get("runId"); const report = service.report(view, runId); return jsonResponse(report, report.ok === false ? 404 : 200); } if (request.method === "GET" && url.pathname === "/metrics") { return new Response(service.metrics(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } }); } if (request.method === "GET" && url.pathname.startsWith("/dashboard/assets/")) { const asset = webProbeSentinelDashboardAssetResponse(url.pathname); if (asset !== null) return asset; } if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) { return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } }); } return jsonResponse({ ok: false, error: "not-found", path: url.pathname, valuesRedacted: true }, 404); } function initializeIndex(db: Database): void { db.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, scenario_id TEXT NOT NULL, node TEXT NOT NULL, lane TEXT NOT NULL, status TEXT NOT NULL, observer_id TEXT, state_dir TEXT, report_json_sha256 TEXT, finding_count INTEGER NOT NULL DEFAULT 0, artifact_count INTEGER NOT NULL DEFAULT 0, maintenance INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, interrupted_at TEXT, command_plan_json TEXT ); CREATE TABLE IF NOT EXISTS findings ( run_id TEXT NOT NULL, finding_id TEXT NOT NULL, severity TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 1, summary TEXT NOT NULL, report_json_sha256 TEXT, created_at TEXT NOT NULL ); `); } 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); return Number(result.changes ?? 0); } function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, scheduler: Record): Record { const checks: Record> = {}; checks.config = { ok: config.plan.ok, status: config.plan.status, conflicts: config.plan.conflicts.length }; checks.pvc = checkWritable(config.stateRoot); checks.sqlite = checkSqlite(db); const heartbeatAt = stringOrNull(scheduler.schedulerHeartbeatAt) ?? stringOrNull(readMetadata(db, "scheduler.heartbeat")?.at); const heartbeatAgeSeconds = heartbeatAt === null ? null : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000)); checks.scheduler = { ok: scheduler.schedulerLastError === null && heartbeatAgeSeconds !== null && heartbeatAgeSeconds <= config.schedulerHeartbeatStaleSeconds, enabled: scheduler.schedulerEnabled === true, active: scheduler.schedulerTimerActive === true, heartbeatAt, heartbeatAgeSeconds, staleAfterSeconds: config.schedulerHeartbeatStaleSeconds, lastError: scheduler.schedulerLastError, }; checks.analyzer = { ok: true, source: "existing observe analyze CLI command", command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir `, }; const ok = Object.values(checks).every((check) => check.ok === true); return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, sentinelId: config.sentinelId, checks, valuesRedacted: true }; } function checkWritable(stateRoot: string): Record { try { mkdirSync(stateRoot, { recursive: true }); const path = join(stateRoot, ".health-write-probe"); writeFileSync(path, `${nowIso()}\n`); return { ok: true, stateRoot, probeFile: path }; } catch (error) { return { ok: false, stateRoot, error: error instanceof Error ? error.message : String(error) }; } } function checkSqlite(db: Database): Record { try { db.query("SELECT 1 AS ok").get(); writeMetadata(db, "sqlite.health", { at: nowIso() }); return { ok: true }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario: Record): readonly CommandPlanStep[] { const targetPath = stringAt(scenario, "observeTargetPath"); const start: CommandPlanStep = { phase: "observe-start", argv: [ "bun", "scripts/cli.ts", "web-probe", "observe", "start", "--node", config.node, "--lane", config.lane, "--target-path", targetPath, "--sample-interval-ms", String(numberAt(scenario, "sampleIntervalMs")), "--screenshot-interval-ms", String(numberAt(scenario, "screenshotIntervalMs")), "--command-timeout-seconds", "55", ], stdinSource: "none", }; const commands = arrayAt(scenario, "commandSequence").map((item) => { const type = stringAt(item, "type"); const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "", "--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 = { phase: "observe-analyze", argv: ["bun", "scripts/cli.ts", "web-probe", "observe", "analyze", ""], stdinSource: "none", }; return [start, ...commands, analyze]; } function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database): Record { return { 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')"), plannedRuns: countWhere(db, "status = 'planned'"), heartbeat: readMetadata(db, "scheduler.heartbeat"), valuesRedacted: true, }; } function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, health: Record, maintenance: MaintenanceState): string { const counts = runCounts(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)); 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{${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{${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{${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')")}`, "# 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")}`, "# 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}`, "# 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{${labels}} ${heartbeatAge}`, ]; return `${lines.join("\n")}\n`; } function readConfigRefTarget(ref: string): unknown { const [file, path] = ref.split("#"); if (file === undefined || path === undefined) throw new Error(`invalid configRef: ${ref}`); const text = readFileSync(rootPath(file), "utf8"); return valueAtPath(Bun.YAML.parse(text) as unknown, path); } function writeMetadata(db: Database, key: string, value: unknown): void { db.query("INSERT INTO metadata (key, value_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at") .run(key, JSON.stringify(value), nowIso()); } function readMetadata(db: Database, key: string): Record | null { const row = db.query("SELECT value_json FROM metadata WHERE key = ?").get(key) as { value_json?: string } | null; if (typeof row?.value_json !== "string") return null; try { const parsed = JSON.parse(row.value_json) as unknown; return record(parsed); } catch { return null; } } function runCounts(db: Database): Record { const rows = db.query("SELECT status, COUNT(*) AS count FROM runs GROUP BY status").all() as { status: string; count: number }[]; return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database, health: Record, maintenance: MaintenanceState): Record { const latestRow = db.query("SELECT * FROM runs ORDER BY updated_at DESC LIMIT 1").get() as Record | null; const latestRun = latestRow === null ? null : dashboardRunSummary(config, db, latestRow); const severityCounts = globalSeverityCounts(db); const latestUpdatedAt = latestRow === null ? null : stringOrNull(latestRow.updated_at); const latestRunAgeSeconds = latestUpdatedAt === null ? null : ageSeconds(latestUpdatedAt); return { ok: health.ok === true, contractVersion: DASHBOARD_CONTRACT_VERSION, status: dashboardOverallStatus(health, latestRun, severityCounts), node: config.node, lane: config.lane, sentinelId: config.sentinelId, publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl), configReady: config.plan.ok, health, scheduler: schedulerSummary(config, db), maintenance, latestRun, runCounts: runCounts(db), severityCounts, freshness: { latestRunUpdatedAt: latestUpdatedAt, latestRunAgeSeconds, schedulerHeartbeatAgeSeconds: numberOr(record(record(health.checks).scheduler).heartbeatAgeSeconds, -1), }, targetValidation: { scenarioId: stringOrNull(record(config.cicd.targetValidation).scenarioId), maxSeconds: numberOr(record(config.cicd.targetValidation).maxSeconds, 120), sourceRef: "config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd.targetValidation", }, traceability: { source: "sqlite-index+run-report-metadata", stateRoot: config.stateRoot, latestRun: latestRow === null ? null : runTraceability(config, latestRow), }, valuesRedacted: true, }; } function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record { const filters = dashboardRunFilters(url); const page = dashboardPage(url, config); const sort = dashboardRunSort(url); const where = runWhereClause(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[]; const visibleRows = rows.slice(0, page.limit); const items = visibleRows.map((row) => dashboardRunSummary(config, db, row)); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, node: config.node, lane: config.lane, sentinelId: config.sentinelId, filters, page: { limit: page.limit, cursor: String(page.offset), nextCursor: rows.length > page.limit ? String(page.offset + visibleRows.length) : null, hasMore: rows.length > page.limit, sort: sort.name, direction: sort.direction, }, items, runs: items, traceability: { source: "sqlite-index", stateRoot: config.stateRoot, }, valuesRedacted: true, }; } function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, runId: string): Record { const row = readRunRow(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(db, runId, dashboardPageSize(config)); const views = record(stored.views); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, sentinelId: config.sentinelId, run: dashboardRunSummary(config, db, row), summary: record(stored.summary), findings, viewsAvailable: Object.keys(views), artifacts: { artifactCount: numberOr(row.artifact_count, 0), reportJsonSha256: stringOrNull(row.report_json_sha256), screenshot: compactArtifactRef(stored.screenshot), publicOrigin: stringOrNull(stored.publicOrigin), }, commands: { 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), valuesRedacted: true, }; } function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database, url: URL): Record { const limit = dashboardPage(url, config).limit; const filters = dashboardFindingFilters(url); const where = findingWhereClause(filters); const rows = db.query(` SELECT f.finding_id, f.severity, r.scenario_id, SUM(f.count) AS count, COUNT(DISTINCT f.run_id) AS run_count, MAX(f.created_at) AS latest_at, MAX(f.summary) AS summary FROM findings f JOIN runs r ON r.id = f.run_id ${where.sql} GROUP BY f.finding_id, f.severity, r.scenario_id ORDER BY ${severityRankSql("f.severity")} DESC, latest_at DESC LIMIT ? `).all(...where.params, limit) as Record[]; const items = rows.map((row) => { const latestRun = latestRunForFinding(db, row); return { code: stringOrNull(row.finding_id), findingId: stringOrNull(row.finding_id), severity: stringOrNull(row.severity), scenarioId: stringOrNull(row.scenario_id), count: numberOr(row.count, 0), runCount: numberOr(row.run_count, 0), latestAt: stringOrNull(row.latest_at), latestRunId: latestRun === null ? null : stringOrNull(latestRun.id), latestReportJsonSha256: latestRun === null ? null : stringOrNull(latestRun.report_json_sha256), summary: stringOrNull(row.summary), traceability: latestRun === null ? null : runTraceability(config, latestRun), valuesRedacted: true, }; }); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, node: config.node, lane: config.lane, sentinelId: config.sentinelId, filters, page: { limit, hasMore: items.length === limit, sort: "severity", direction: "desc", }, groups: items, findings: items, traceability: { source: "sqlite-index-findings", stateRoot: config.stateRoot, }, valuesRedacted: true, }; } function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string | null, url: URL): Record { const row = readRunRow(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); const views = requestedViews.map((item) => dashboardReportView(config, db, runId, item, maxBytes)); return { ok: true, contractVersion: DASHBOARD_CONTRACT_VERSION, sentinelId: config.sentinelId, run: dashboardRunSummary(config, db, row), views, view: view ?? null, redaction: record(config.reportViews.redaction), traceability: runTraceability(config, row), valuesRedacted: true, }; } function dashboardReportView(config: WebProbeSentinelServiceConfig, db: Database, runId: string, view: string, maxBytes: number): Record { const report = reportRunView(config, db, view, runId); const renderedText = typeof report.renderedText === "string" ? report.renderedText : ""; const bounded = boundedText(renderedText, maxBytes); return { ok: report.ok !== false, view, error: stringOrNull(report.error), availableViews: Array.isArray(report.availableViews) ? report.availableViews : undefined, renderedText: bounded.text, renderedTextBytes: bounded.bytes, truncated: bounded.truncated, finalResponse: view === "trace-frame" ? finalResponseBlock(bounded.text) : null, summary: record(report.summary), findingCount: Array.isArray(report.findings) ? report.findings.length : 0, valuesRedacted: true, }; } function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database, row: Record): Record { const id = stringOrNull(row.id); const severityCounts = id === null ? {} : severityCountsForRun(db, id); const maxSeverity = maxSeverityFromCounts(severityCounts); return { id, runId: id, scenario_id: stringOrNull(row.scenario_id), scenarioId: stringOrNull(row.scenario_id), 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), stateDir: stringOrNull(row.state_dir), report_json_sha256: stringOrNull(row.report_json_sha256), reportJsonSha256: stringOrNull(row.report_json_sha256), finding_count: numberOr(row.finding_count, 0), findingCount: numberOr(row.finding_count, 0), artifact_count: numberOr(row.artifact_count, 0), artifactCount: numberOr(row.artifact_count, 0), maintenance: numberOr(row.maintenance, 0) === 1, created_at: stringOrNull(row.created_at), createdAt: stringOrNull(row.created_at), updated_at: stringOrNull(row.updated_at), updatedAt: stringOrNull(row.updated_at), interrupted_at: stringOrNull(row.interrupted_at), interruptedAt: stringOrNull(row.interrupted_at), severityCounts, maxSeverity, traceability: runTraceability(config, row), valuesRedacted: true, }; } function dashboardOverallStatus(health: Record, latestRun: Record | null, severityCounts: Record): string { 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"; } function dashboardRunFilters(url: URL): Record { return { status: optionalSearchParam(url, "status"), scenario: optionalSearchParam(url, "scenario"), severity: optionalSearchParam(url, "severity"), maintenance: maintenanceFilter(url), observerId: optionalSearchParam(url, "observerId"), search: optionalSearchParam(url, "search"), from: validIsoParam(url, "from"), to: validIsoParam(url, "to"), }; } function dashboardFindingFilters(url: URL): Record { return { severity: optionalSearchParam(url, "severity"), code: optionalSearchParam(url, "code"), scenario: optionalSearchParam(url, "scenario"), search: optionalSearchParam(url, "search"), window: optionalSearchParam(url, "window"), since: windowSinceIso(optionalSearchParam(url, "window")) ?? validIsoParam(url, "from"), to: validIsoParam(url, "to"), }; } function runWhereClause(filters: Record): { readonly sql: string; readonly params: readonly (string | number)[] } { const clauses: string[] = []; const params: (string | number)[] = []; const status = stringOrNull(filters.status); if (status !== null) { clauses.push("status = ?"); params.push(status); } const scenario = stringOrNull(filters.scenario); if (scenario !== null) { clauses.push("scenario_id = ?"); params.push(scenario); } const observerId = stringOrNull(filters.observerId); if (observerId !== null) { clauses.push("observer_id = ?"); params.push(observerId); } const severity = stringOrNull(filters.severity); if (severity !== null) { clauses.push("EXISTS (SELECT 1 FROM findings f WHERE f.run_id = runs.id AND f.severity = ?)"); params.push(severity); } if (typeof filters.maintenance === "boolean") { clauses.push("maintenance = ?"); params.push(filters.maintenance ? 1 : 0); } const from = stringOrNull(filters.from); if (from !== null) { clauses.push("updated_at >= ?"); params.push(from); } const to = stringOrNull(filters.to); if (to !== null) { clauses.push("updated_at <= ?"); params.push(to); } const search = stringOrNull(filters.search); if (search !== null) { const pattern = `%${escapeSqlLike(search)}%`; clauses.push("(id LIKE ? ESCAPE '\\' OR scenario_id LIKE ? ESCAPE '\\' OR observer_id LIKE ? ESCAPE '\\' OR state_dir LIKE ? ESCAPE '\\' OR report_json_sha256 LIKE ? ESCAPE '\\')"); params.push(pattern, pattern, pattern, pattern, pattern); } return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params }; } function findingWhereClause(filters: Record): { readonly sql: string; readonly params: readonly (string | number)[] } { const clauses: string[] = []; const params: (string | number)[] = []; const severity = stringOrNull(filters.severity); if (severity !== null) { clauses.push("f.severity = ?"); params.push(severity); } const code = stringOrNull(filters.code); if (code !== null) { clauses.push("f.finding_id = ?"); params.push(code); } const scenario = stringOrNull(filters.scenario); if (scenario !== null) { clauses.push("r.scenario_id = ?"); params.push(scenario); } const since = stringOrNull(filters.since); if (since !== null) { clauses.push("f.created_at >= ?"); params.push(since); } const to = stringOrNull(filters.to); if (to !== null) { clauses.push("f.created_at <= ?"); params.push(to); } const search = stringOrNull(filters.search); if (search !== null) { const pattern = `%${escapeSqlLike(search)}%`; clauses.push("(f.finding_id LIKE ? ESCAPE '\\' OR f.summary LIKE ? ESCAPE '\\')"); params.push(pattern, pattern); } return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, params }; } function dashboardRunSort(url: URL): { readonly name: string; readonly direction: "asc" | "desc"; readonly sql: string } { const requested = optionalSearchParam(url, "sort") ?? "updated"; const direction = optionalSearchParam(url, "direction") === "asc" ? "asc" : "desc"; const column = requested === "created" ? "created_at" : requested === "findings" ? "finding_count" : requested === "severity" ? severityRankSql("(SELECT severity FROM findings f WHERE f.run_id = runs.id ORDER BY " + severityRankSql("f.severity") + " DESC LIMIT 1)") : "updated_at"; return { name: requested, direction, sql: `${column} ${direction.toUpperCase()}, id ${direction.toUpperCase()}` }; } function dashboardPage(url: URL, config: WebProbeSentinelServiceConfig): { readonly limit: number; readonly offset: number } { const limit = Math.min(numberParam(url, "limit", dashboardPageSize(config)), dashboardMaxPageSize(config)); const rawCursor = url.searchParams.get("cursor"); const offset = rawCursor === null ? 0 : Math.max(0, Math.min(100_000, Number.isInteger(Number(rawCursor)) ? Number(rawCursor) : 0)); return { limit, offset }; } function dashboardPageSize(config: WebProbeSentinelServiceConfig): number { return Math.max(1, Math.min(dashboardMaxPageSize(config), numberOr(config.reportViews.pageSize, 20))); } function dashboardMaxPageSize(config: WebProbeSentinelServiceConfig): number { return Math.max(1, Math.min(200, numberOr(config.reportViews.maxPageSize, 100))); } function readRunRow(db: Database, runId: string): Record | null { return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record | null; } function findingsForRun(db: Database, runId: string, limit: number): readonly Record[] { return 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[]; } function globalSeverityCounts(db: Database): Record { const rows = db.query("SELECT severity, SUM(count) AS count FROM findings GROUP BY severity").all() as { severity: string; count: number }[]; return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)])); } function severityCountsForRun(db: Database, runId: string): Record { const rows = db.query("SELECT severity, SUM(count) AS count FROM findings WHERE run_id = ? GROUP BY severity").all(runId) as { severity: string; count: number }[]; return Object.fromEntries(rows.map((row) => [row.severity, Number(row.count)])); } function latestRunForFinding(db: Database, row: Record): Record | null { return db.query(` SELECT r.* FROM findings f JOIN runs r ON r.id = f.run_id WHERE f.finding_id = ? AND f.severity = ? AND r.scenario_id = ? ORDER BY f.created_at DESC LIMIT 1 `).get(stringOrNull(row.finding_id), stringOrNull(row.severity), stringOrNull(row.scenario_id)) as Record | null; } function runTraceability(config: WebProbeSentinelServiceConfig, row: Record): Record { return { source: "sqlite-index+run-report-metadata", node: stringOrNull(row.node) ?? config.node, lane: stringOrNull(row.lane) ?? config.lane, runId: stringOrNull(row.id), observerId: stringOrNull(row.observer_id), stateDir: stringOrNull(row.state_dir), reportJsonSha256: stringOrNull(row.report_json_sha256), stateRoot: config.stateRoot, valuesRedacted: true, }; } function compactArtifactRef(value: unknown): Record | null { const source = record(value); if (Object.keys(source).length === 0) return null; return { path: stringOrNull(source.path) ?? stringOrNull(source.file) ?? stringOrNull(source.screenshotPath), sha256: stringOrNull(source.sha256) ?? stringOrNull(source.hash), byteCount: typeof source.byteCount === "number" ? source.byteCount : null, valuesRedacted: true, }; } function boundedText(value: string, maxBytes: number): { readonly text: string; readonly bytes: number; readonly truncated: boolean } { const bytes = Buffer.byteLength(value, "utf8"); if (bytes <= maxBytes) return { text: value, bytes, truncated: false }; return { text: Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8"), bytes, truncated: true }; } function finalResponseBlock(text: string): Record { const index = text.toLowerCase().lastIndexOf("final response"); const body = index === -1 ? "" : text.slice(index + "final response".length).replace(/^[\s=\-:]+/u, "").trim(); const normalized = body.length === 0 ? "(空内容)" : body; return { label: "Final Response", empty: body.length === 0 || body === "(空内容)", text: normalized, byteCount: Buffer.byteLength(normalized, "utf8"), valuesRedacted: true, }; } function optionalSearchParam(url: URL, key: string): string | null { const raw = url.searchParams.get(key); if (raw === null) return null; const trimmed = raw.trim(); return trimmed.length === 0 ? null : trimmed.slice(0, 200); } function validIsoParam(url: URL, key: string): string | null { const value = optionalSearchParam(url, key); if (value === null) return null; return Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null; } function maintenanceFilter(url: URL): boolean | null { const raw = optionalSearchParam(url, "maintenance"); if (raw === null) return null; if (/^(1|true|yes)$/iu.test(raw)) return true; if (/^(0|false|no)$/iu.test(raw)) return false; return null; } function windowSinceIso(value: string | null): string | null { if (value === null) return null; const match = /^(\d+)(m|h|d)$/iu.exec(value); if (match === null) return null; const amount = Number(match[1]); const unit = match[2].toLowerCase(); const ms = unit === "m" ? amount * 60_000 : unit === "h" ? amount * 3_600_000 : amount * 86_400_000; return new Date(Date.now() - ms).toISOString(); } function escapeSqlLike(value: string): string { return value.replace(/[\\%_]/gu, (char) => `\\${char}`); } function ageSeconds(iso: string): number | null { const parsed = Date.parse(iso); return Number.isFinite(parsed) ? Math.max(0, Math.round((Date.now() - parsed) / 1000)) : null; } function maxSeverityFromCounts(counts: Record): string | null { let best: string | null = null; for (const [severity, count] of Object.entries(counts)) { if (count <= 0) continue; if (best === null || severityRank(severity) > severityRank(best)) best = severity; } return best; } function severityRank(value: string | null): number { const normalized = (value ?? "").toLowerCase(); if (/critical|red|fatal/iu.test(normalized)) return 4; if (/error|failed|blocked/iu.test(normalized)) return 3; if (/warn|amber|yellow/iu.test(normalized)) return 2; if (/info|notice/iu.test(normalized)) return 1; return 0; } 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; 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; return Number(row?.total ?? 0); } function firstEnabledScenarioId(config: WebProbeSentinelServiceConfig): string | null { const scenario = config.scenarios.find((item) => boolAt(item, "enabled")); return scenario === undefined ? null : stringAt(scenario, "id"); } async function readJsonBody(request: Request): Promise> { if ((request.headers.get("content-length") ?? "0") === "0") return {}; const value = await request.json().catch(() => ({})) as unknown; return record(value); } function jsonResponse(value: unknown, status = 200): Response { return new Response(JSON.stringify(value, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8" } }); } function emptyMaintenance(): MaintenanceState { return { active: false, reason: null, releaseId: null, startedAt: null, stoppedAt: null, quickVerifyPlannedAt: null, quickVerifyPlannedRunId: null }; } function recordRunResult(config: WebProbeSentinelServiceConfig, db: Database, input: Record): Record { const now = nowIso(); const runId = stringOrNull(input.runId) ?? `sentinel-run-${Date.now()}-${randomUUID().slice(0, 8)}`; const scenarioId = stringOrNull(input.scenarioId) ?? firstEnabledScenarioId(config); if (scenarioId === null) return { ok: false, error: "scenario-missing", valuesRedacted: true }; const status = stringOrNull(input.status) ?? "analyzed"; const observerId = stringOrNull(input.observerId); const stateDir = stringOrNull(input.stateDir); const reportJsonSha256 = stringOrNull(input.reportJsonSha256); const findings = arrayRecords(input.findings).slice(0, 50); const findingCount = numberOr(input.findingCount, findings.length); 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, observer_id = excluded.observer_id, state_dir = excluded.state_dir, report_json_sha256 = excluded.report_json_sha256, 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 })); db.query("DELETE FROM findings WHERE run_id = ?").run(runId); for (const item of findings) { const findingId = stringOrNull(item.id) ?? stringOrNull(item.kind) ?? stringOrNull(item.code) ?? "finding"; const severity = 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); } writeMetadata(db, `run.report.${runId}`, { runId, scenarioId, observerId, stateDir, reportJsonSha256, summary: record(input.summary), views: record(input.views), publicOrigin: stringOrNull(input.publicOrigin), screenshot: record(input.screenshot), artifactCount, findingCount, valuesRedacted: true, }); return { ok: true, runId, scenarioId, status, reportJsonSha256, findingCount, artifactCount, valuesRedacted: true }; } function reportRunView(config: WebProbeSentinelServiceConfig, db: Database, view: string, runId: string | null): Record { if (!stringArrayAt(config.reportViews, "views").includes(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 | null : db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record | null; 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 }; const stored = readMetadata(db, `run.report.${selectedRunId}`) ?? {}; const findings = db.query("SELECT finding_id, severity, count, summary, report_json_sha256, created_at FROM findings WHERE run_id = ? ORDER BY created_at DESC LIMIT 50") .all(selectedRunId) as Record[]; const views = record(stored.views); const storedView = record(views[view]); const renderedText = typeof storedView.renderedText === "string" ? storedView.renderedText : view === "summary" ? renderStoredSummary(row, stored, findings) : view === "findings" ? renderStoredFindings(row, findings) : null; if (renderedText === null) { return { ok: false, error: "report-view-not-indexed", runId: selectedRunId, view, availableViews: Object.keys(views), valuesRedacted: true }; } return { ok: true, view, run: row, summary: record(stored.summary), findings, renderedText, valuesRedacted: true, }; } function renderStoredSummary(row: Record, stored: Record, findings: readonly Record[]): string { const summary = record(stored.summary); return [ "Web Probe Sentinel Report", "=======================================================", `run=${stringOrNull(row.id) ?? "-"} scenario=${stringOrNull(row.scenario_id) ?? "-"} status=${stringOrNull(row.status) ?? "-"}`, `observer=${stringOrNull(row.observer_id) ?? "-"} stateDir=${stringOrNull(row.state_dir) ?? "-"}`, `report=${stringOrNull(row.report_json_sha256) ?? "-"} artifacts=${String(row.artifact_count ?? 0)} findings=${String(row.finding_count ?? findings.length)}`, `publicOrigin=${stringOrNull(stored.publicOrigin) ?? "-"}`, `analysisWindow=${JSON.stringify(record(summary.analysisWindow))}`, "", "Findings", findings.length === 0 ? "-" : findings.slice(0, 12).map((item) => `${item.severity ?? "-"} ${item.finding_id ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`).join("\n"), ].join("\n"); } function renderStoredFindings(row: Record, findings: readonly Record[]): string { return [ "Web Probe Sentinel Findings", "=======================================================", `run=${stringOrNull(row.id) ?? "-"} report=${stringOrNull(row.report_json_sha256) ?? "-"}`, findings.length === 0 ? "-" : findings.map((item) => `${item.severity ?? "-"} ${item.finding_id ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`).join("\n"), ].join("\n"); } function thisMaintenanceFlag(input: Record): number { return input.maintenance === true ? 1 : 0; } function numberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.map(record) : []; } function checkPath(value: unknown, path: string): unknown { const result = valueAtPath(value, path); if (result === undefined) throw new Error(`required field missing: ${path}`); return result; } function stringAt(value: unknown, path: string): string { const result = checkPath(value, path); if (typeof result !== "string" || result.length === 0) throw new Error(`${path} must be a non-empty string`); return result; } function numberAt(value: unknown, path: string): number { const result = checkPath(value, path); if (typeof result !== "number" || !Number.isFinite(result)) throw new Error(`${path} must be a number`); return result; } function boolAt(value: unknown, path: string): boolean { const result = checkPath(value, path); if (typeof result !== "boolean") throw new Error(`${path} must be a boolean`); return result; } function arrayAt(value: unknown, path: string): Record[] { const result = checkPath(value, path); if (!Array.isArray(result)) throw new Error(`${path} must be an array`); return result.filter(record); } function stringArrayAt(value: unknown, path: string): string[] { const result = checkPath(value, path); if (!Array.isArray(result)) throw new Error(`${path} must be an array`); return result.filter((item): item is string => typeof item === "string" && item.length > 0); } function stringField(value: Record, key: string): string { const found = value[key]; if (typeof found !== "string" || found.length === 0) throw new Error(`${key} must be a non-empty string`); return found; } function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function numberParam(url: URL, key: string, defaultValue: number): number { const raw = url.searchParams.get(key); if (raw === null) return defaultValue; const parsed = Number(raw); return Number.isInteger(parsed) && parsed > 0 && parsed <= 200 ? parsed : defaultValue; } function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function recordTarget(value: unknown): Record { const result = record(value); if (Object.keys(result).length === 0) throw new Error("configRef target must be an object"); return result; } function arrayTarget(value: unknown): Record[] { if (!Array.isArray(value)) throw new Error("configRef target must be an array"); return value.map(recordTarget); } function scenarioArrayTarget(value: unknown): Record[] { 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(".")) { const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment); if (match === null) return undefined; if (match[1] !== undefined) { const obj = record(current); current = obj[match[1]]; } if (match[2] !== undefined) { if (!Array.isArray(current)) return undefined; current = current[Number(match[2])]; } } return current; } function sha256Json(value: unknown): string { return `sha256:${createHash("sha256").update(JSON.stringify(value)).digest("hex")}`; } function nowIso(): string { return new Date().toISOString(); } function metricLabel(value: string): string { return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\n/gu, "\\n"); }