From 24d1fe4e3fcabd3b1bb1c8b142370a84ed78c80e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 02:45:06 +0000 Subject: [PATCH] feat: add web sentinel dashboard API contract --- .../src/hwlab-node-web-sentinel-service.ts | 552 +++++++++++++++++- 1 file changed, 551 insertions(+), 1 deletion(-) diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 0f195293..5a78d076 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -1,5 +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. // 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"; @@ -8,6 +10,9 @@ import { rootPath } from "./config"; import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; +const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p7-web-probe-sentinel-dashboard"; +const DASHBOARD_MAX_TEXT_BYTES = 16_000; + export interface WebProbeSentinelServiceConfig { readonly node: string; readonly lane: string; @@ -15,6 +20,8 @@ export interface WebProbeSentinelServiceConfig { 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; @@ -58,6 +65,11 @@ export interface WebProbeSentinelService { 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; @@ -75,6 +87,8 @@ export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, op 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 stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot"); const yamlSqlitePath = stringAt(runtime, "sqlite.path"); return { @@ -84,6 +98,8 @@ export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, op runtime, scenarios, reportViews, + publicExposure, + cicd, stateRoot, sqlitePath: options.stateRootOverride === undefined ? yamlSqlitePath : join(stateRoot, "index.sqlite"), listenHost: options.hostOverride ?? stringAt(runtime, "listenHost"), @@ -162,6 +178,21 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp 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(); }, @@ -244,7 +275,20 @@ async function sentinelFetch(service: WebProbeSentinelService, request: Request) 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/runs") return jsonResponse({ ok: true, runs: service.runs(numberParam(url, "limit", 20)), valuesRedacted: true }); + 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); @@ -478,6 +522,512 @@ function runCounts(db: Database): Record { 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, + 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, + 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, + 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} --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`, + }, + 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, + 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, + 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, + 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);