1390 lines
61 KiB
TypeScript
1390 lines
61 KiB
TypeScript
// 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<string, unknown>;
|
|
readonly scenarios: readonly Record<string, unknown>[];
|
|
readonly reportViews: Record<string, unknown>;
|
|
readonly publicExposure: Record<string, unknown>;
|
|
readonly cicd: Record<string, unknown>;
|
|
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<string, unknown>;
|
|
status(): Record<string, unknown>;
|
|
runs(limit?: number): readonly Record<string, unknown>[];
|
|
overview(): Record<string, unknown>;
|
|
dashboardRuns(url: URL): Record<string, unknown>;
|
|
runDetail(runId: string): Record<string, unknown>;
|
|
findings(url: URL): Record<string, unknown>;
|
|
runViews(runId: string, view: string | null, url: URL): Record<string, unknown>;
|
|
maintenance(): MaintenanceState;
|
|
setMaintenance(active: boolean, input: Record<string, unknown>): MaintenanceState;
|
|
planScenarioRun(scenarioId: string, reason: string): Record<string, unknown>;
|
|
recordRun(input: Record<string, unknown>): Record<string, unknown>;
|
|
report(view: string, runId: string | null): Record<string, unknown>;
|
|
metrics(): string;
|
|
dashboardHtml(): string;
|
|
fetch(request: Request): Promise<Response>;
|
|
}
|
|
|
|
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit<WebProbeSentinelServiceOptions, "spec"> = {}): 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<typeof setInterval> | 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<string, unknown>[];
|
|
},
|
|
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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
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<Response> {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
const checks: Record<string, Record<string, unknown>> = {};
|
|
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 <stateDir>`,
|
|
};
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown>): 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", "<observerId>", "--type", type];
|
|
if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider"));
|
|
if (type === "sendPrompt") argv.push("--text-stdin");
|
|
if (type === "loginAccount" || type === "listSessions" || type === "logout") {
|
|
const accountId = stringOrNull(item.accountId);
|
|
if (accountId !== null) argv.push("--account-id", accountId);
|
|
}
|
|
if (type === "switchSessions") {
|
|
const fromAccountId = stringOrNull(item.fromAccountId);
|
|
const toAccountId = stringOrNull(item.toAccountId);
|
|
if (fromAccountId !== null) argv.push("--from-account-id", fromAccountId);
|
|
if (toAccountId !== null) argv.push("--to-account-id", toAccountId);
|
|
}
|
|
return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep;
|
|
});
|
|
const analyze: CommandPlanStep = {
|
|
phase: "observe-analyze",
|
|
argv: ["bun", "scripts/cli.ts", "web-probe", "observe", "analyze", "<observerId>"],
|
|
stdinSource: "none",
|
|
};
|
|
return [start, ...commands, analyze];
|
|
}
|
|
|
|
function schedulerSummary(config: WebProbeSentinelServiceConfig, db: Database): Record<string, unknown> {
|
|
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<string, unknown>, 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<string, unknown> | 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<string, number> {
|
|
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<string, unknown>, maintenance: MaintenanceState): Record<string, unknown> {
|
|
const latestRow = db.query("SELECT * FROM runs ORDER BY updated_at DESC LIMIT 1").get() as Record<string, unknown> | 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<string, unknown> {
|
|
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<string, unknown>[];
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown>[];
|
|
const items = rows.map((row) => {
|
|
const latestRun = latestRunForFinding(db, row);
|
|
const latestDetail = latestRun === null ? null : storedFindingDetailForRow(db, row, stringOrNull(latestRun.id));
|
|
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),
|
|
rootCause: stringOrNull(latestDetail?.rootCause),
|
|
rootCauseStatus: stringOrNull(latestDetail?.rootCauseStatus),
|
|
rootCauseConfidence: stringOrNull(latestDetail?.rootCauseConfidence),
|
|
nextAction: stringOrNull(latestDetail?.nextAction),
|
|
evidenceSummary: stringOrNull(latestDetail?.evidenceSummary),
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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<string, unknown>, latestRun: Record<string, unknown> | null, severityCounts: Record<string, number>): 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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown>): { 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<string, unknown>): { 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<string, unknown> | null {
|
|
return db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | null;
|
|
}
|
|
|
|
function findingsForRun(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>[];
|
|
return rows.map((row) => enrichFindingRowWithStoredDetail(db, runId, row));
|
|
}
|
|
|
|
function enrichFindingRowWithStoredDetail(db: Database, runId: string, row: Record<string, unknown>): Record<string, unknown> {
|
|
const detail = storedFindingDetailForRow(db, row, runId);
|
|
return {
|
|
...row,
|
|
code: stringOrNull(row.finding_id),
|
|
findingId: stringOrNull(row.finding_id),
|
|
rootCause: stringOrNull(detail?.rootCause),
|
|
rootCauseStatus: stringOrNull(detail?.rootCauseStatus),
|
|
rootCauseConfidence: stringOrNull(detail?.rootCauseConfidence),
|
|
nextAction: stringOrNull(detail?.nextAction),
|
|
evidenceSummary: stringOrNull(detail?.evidenceSummary),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function storedFindingDetailForRow(db: Database, row: Record<string, unknown>, runId: string | null): Record<string, unknown> | null {
|
|
if (runId === null) return null;
|
|
const stored = readMetadata(db, `run.report.${runId}`) ?? {};
|
|
const details = arrayRecords(stored.findings);
|
|
if (details.length === 0) return null;
|
|
const findingId = stringOrNull(row.finding_id) ?? stringOrNull(row.findingId) ?? stringOrNull(row.code);
|
|
const severity = stringOrNull(row.severity);
|
|
return details.find((item) => {
|
|
const itemId = stringOrNull(item.finding_id) ?? stringOrNull(item.findingId) ?? stringOrNull(item.id) ?? stringOrNull(item.code);
|
|
if (itemId !== findingId) return false;
|
|
const itemSeverity = stringOrNull(item.severity) ?? stringOrNull(item.level);
|
|
return severity === null || itemSeverity === null || itemSeverity === severity;
|
|
}) ?? null;
|
|
}
|
|
|
|
function compactStoredFinding(value: unknown): Record<string, unknown> {
|
|
const item = record(value);
|
|
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;
|
|
return {
|
|
id: findingId,
|
|
finding_id: findingId,
|
|
findingId,
|
|
code: findingId,
|
|
severity,
|
|
count: numberOr(item.count, numberOr(item.sampleCount, 1)),
|
|
summary: summary.slice(0, 500),
|
|
rootCause: stringOrNull(item.rootCause),
|
|
rootCauseStatus: stringOrNull(item.rootCauseStatus),
|
|
rootCauseConfidence: stringOrNull(item.rootCauseConfidence),
|
|
nextAction: stringOrNull(item.nextAction),
|
|
evidenceSummary: stringOrNull(item.evidenceSummary) ?? compactFindingEvidenceSummary(item.evidence),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactFindingEvidenceSummary(value: unknown): string | null {
|
|
if (value === null || value === undefined) return null;
|
|
if (typeof value === "string") return value.slice(0, 240);
|
|
const item = record(value);
|
|
const keys = [
|
|
"http404Count",
|
|
"responseErrorCount",
|
|
"requestFailedCount",
|
|
"statuses",
|
|
"afterProjectedSeqs",
|
|
"sinceSeqs",
|
|
"traceIds",
|
|
"maxFallbackRatio",
|
|
"maxFallbackTitleCount",
|
|
"overThresholdSampleCount",
|
|
"majorityFallbackSampleCount",
|
|
];
|
|
const compact: Record<string, unknown> = {};
|
|
for (const key of keys) {
|
|
const raw = item[key];
|
|
if (raw === null || raw === undefined) continue;
|
|
compact[key] = Array.isArray(raw) ? raw.slice(0, 6) : raw;
|
|
}
|
|
const text = Object.keys(compact).length > 0 ? JSON.stringify(compact) : JSON.stringify(item).slice(0, 240);
|
|
return text.length > 240 ? `${text.slice(0, 239)}…` : text;
|
|
}
|
|
|
|
function globalSeverityCounts(db: Database): Record<string, number> {
|
|
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<string, number> {
|
|
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<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 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<string, unknown> | null;
|
|
}
|
|
|
|
function runTraceability(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>): Record<string, unknown> {
|
|
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<string, unknown> | 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<string, unknown> {
|
|
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, number>): 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<Record<string, unknown>> {
|
|
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<string, unknown>): Record<string, unknown> {
|
|
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),
|
|
findings: findings.map(compactStoredFinding),
|
|
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<string, unknown> {
|
|
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<string, unknown> | null
|
|
: db.query("SELECT * FROM runs WHERE id = ?").get(runId) as Record<string, unknown> | 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 = findingsForRun(db, selectedRunId, 50);
|
|
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 formatStoredFindingLine(item: Record<string, unknown>): string {
|
|
const rootCause = stringOrNull(item.rootCause);
|
|
const status = stringOrNull(item.rootCauseStatus);
|
|
const nextAction = stringOrNull(item.nextAction);
|
|
const evidence = stringOrNull(item.evidenceSummary);
|
|
return [
|
|
`${item.severity ?? "-"} ${item.finding_id ?? item.findingId ?? item.code ?? "-"} count=${item.count ?? "-"} ${item.summary ?? ""}`,
|
|
rootCause === null ? null : `rootCause=${rootCause}${status === null ? "" : ` status=${status}`}`,
|
|
evidence === null ? null : `evidence=${evidence}`,
|
|
nextAction === null ? null : `next=${nextAction}`,
|
|
].filter((part) => part !== null).join(" | ");
|
|
}
|
|
|
|
function renderStoredSummary(row: Record<string, unknown>, stored: Record<string, unknown>, findings: readonly Record<string, unknown>[]): 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(formatStoredFindingLine).join("\n"),
|
|
].join("\n");
|
|
}
|
|
|
|
function renderStoredFindings(row: Record<string, unknown>, findings: readonly Record<string, unknown>[]): string {
|
|
return [
|
|
"Web Probe Sentinel Findings",
|
|
"=======================================================",
|
|
`run=${stringOrNull(row.id) ?? "-"} report=${stringOrNull(row.report_json_sha256) ?? "-"}`,
|
|
findings.length === 0 ? "-" : findings.map(formatStoredFindingLine).join("\n"),
|
|
].join("\n");
|
|
}
|
|
|
|
function thisMaintenanceFlag(input: Record<string, unknown>): 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<string, unknown>[] {
|
|
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<string, unknown>[] {
|
|
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<string, unknown>, 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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function recordTarget(value: unknown): Record<string, unknown> {
|
|
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<string, unknown>[] {
|
|
if (!Array.isArray(value)) throw new Error("configRef target must be an array");
|
|
return value.map(recordTarget);
|
|
}
|
|
|
|
function scenarioArrayTarget(value: unknown): Record<string, unknown>[] {
|
|
if (Array.isArray(value)) return value.map(recordTarget);
|
|
return [recordTarget(value)];
|
|
}
|
|
|
|
function valueAtPath(value: unknown, path: string): unknown {
|
|
let current: unknown = value;
|
|
for (const segment of path.split(".")) {
|
|
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");
|
|
}
|