// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-12-p17-monitor-central-persistence. // Responsibility: Build canonical immutable terminal envelopes and submit them with YAML-declared bounded retries. import type { MonitorArtifactLocator, MonitorCentralIngest } from "./monitor-central-persistence"; import { monitorCentralStableJson, normalizeMonitorCentralIngest } from "./monitor-central-persistence"; export interface MonitorIngestRetry { readonly maxAttempts: number; readonly delayMs: number; } export interface MonitorTerminalIngestConfig { readonly endpoint: string; readonly timeoutMs: number; readonly retry: MonitorIngestRetry; } export interface MonitorTerminalIdentity { readonly sentinelId: string; readonly node: string; readonly lane: string; readonly runId: string; } export interface MonitorTerminalArtifact { readonly relativePath: string; readonly kind: string; readonly sha256: string; readonly sizeBytes: number; } export class MonitorIngestFailedError extends Error { readonly code = "monitor-ingest-failed"; constructor(readonly details: Record) { super("Monitor terminal ingest failed after bounded retries"); this.name = "MonitorIngestFailedError"; } } export function buildMonitorTerminalIngest(identity: MonitorTerminalIdentity, report: Record, artifacts: readonly MonitorTerminalArtifact[], locatorOwner: Pick): MonitorCentralIngest { const immutableReport = JSON.parse(monitorCentralStableJson(report)) as Record; if (!identity.runId) throw new MonitorIngestFailedError({ reason: "run_id_missing", valuesRedacted: true }); const updatedAt = stringValue(immutableReport.updatedAt) ?? stringValue(immutableReport.analyzedAt); if (!updatedAt) throw new MonitorIngestFailedError({ reason: "terminal_timestamp_missing", runId: identity.runId, valuesRedacted: true }); for (const artifact of artifacts) if (!/^sha256:[a-f0-9]{64}$/u.test(artifact.sha256) || !Number.isSafeInteger(artifact.sizeBytes) || artifact.sizeBytes < 1) throw new MonitorIngestFailedError({ reason: "terminal_artifact_unverified", runId: identity.runId, relativePath: artifact.relativePath, valuesRedacted: true }); return normalizeMonitorCentralIngest({ ...identity, updatedAt, status: stringValue(immutableReport.status) ?? (immutableReport.ok === true ? "ok" : "blocked"), payload: { report: immutableReport, artifacts: artifacts.map((artifact) => ({ ...artifact })) }, findings: recordArray(immutableReport.findings), timeline: recordArray(immutableReport.timeline), artifactLocators: artifacts.map((artifact) => ({ ...locatorOwner, relativePath: artifact.relativePath, kind: artifact.kind, sha256: artifact.sha256, sizeBytes: artifact.sizeBytes, reachable: true })), }); } export async function submitMonitorTerminalIngest(config: MonitorTerminalIngestConfig, payload: MonitorCentralIngest, fetchImpl: typeof fetch = fetch): Promise> { validateConfig(config); const canonical = normalizeMonitorCentralIngest(payload); const body = monitorCentralStableJson(canonical); let lastError: Record | null = null; for (let attempt = 1; attempt <= config.retry.maxAttempts; attempt += 1) { try { const response = await fetchImpl(config.endpoint, { method: "POST", headers: { "content-type": "application/json" }, body, signal: AbortSignal.timeout(config.timeoutMs) }); const responseBody = await response.json().catch(() => null); if (response.ok && isRecord(responseBody) && responseBody.ok === true) return { ...responseBody, attempts: attempt, payloadHash: canonical.payloadHash, valuesRedacted: true }; lastError = { attempt, httpStatus: response.status, response: responseBody, valuesRedacted: true }; } catch (error) { lastError = { attempt, reason: error instanceof Error ? error.message : String(error), valuesRedacted: true }; } if (attempt < config.retry.maxAttempts && config.retry.delayMs > 0) await sleep(config.retry.delayMs); } throw new MonitorIngestFailedError({ endpoint: config.endpoint, attempts: config.retry.maxAttempts, payloadHash: canonical.payloadHash, lastError, valuesRedacted: true }); } function validateConfig(config: MonitorTerminalIngestConfig): void { if (!/^https?:\/\//u.test(config.endpoint)) throw new Error("monitor ingest endpoint must be an absolute HTTP(S) URL"); if (!Number.isSafeInteger(config.timeoutMs) || config.timeoutMs < 1) throw new Error("monitor ingest timeoutMs must be a positive integer"); if (!Number.isSafeInteger(config.retry.maxAttempts) || config.retry.maxAttempts < 1) throw new Error("monitor ingest retry.maxAttempts must be a positive integer"); if (!Number.isSafeInteger(config.retry.delayMs) || config.retry.delayMs < 0) throw new Error("monitor ingest retry.delayMs must be a non-negative integer"); } function recordArray(value: unknown): readonly Record[] { return Array.isArray(value) ? value.filter(isRecord) : []; } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function sleep(delayMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, delayMs)); } export interface MonitorTerminalWriterConfig { readonly mode: "legacy-sqlite" | "central"; readonly ingest?: MonitorTerminalIngestConfig; } export function resolveMonitorTerminalWriter(runtime: Record): MonitorTerminalWriterConfig { const sqlite = runtime.sqlite; const sqlitePath = sqlite && typeof sqlite === "object" && !Array.isArray(sqlite) ? stringValue((sqlite as Record).path) : undefined; const monitor = runtime.monitor; const writer = monitor && typeof monitor === "object" && !Array.isArray(monitor) ? (monitor as Record).terminalWriter : null; if (!writer || typeof writer !== "object" || Array.isArray(writer)) { if (sqlitePath) return { mode: "legacy-sqlite" }; throw new Error("terminal writer authority is undeclared: sqlite.path or monitor.terminalWriter is required"); } const value = writer as Record; const mode = value.mode; if (mode !== "legacy-sqlite" && mode !== "central") throw new Error("monitor.terminalWriter.mode must be legacy-sqlite or central"); if (mode === "legacy-sqlite") { if (!sqlitePath) throw new Error("sqlite.path is required when mode=legacy-sqlite"); return { mode }; } if (sqlitePath) throw new Error("central terminal writer and sqlite.path are mutually exclusive"); const ingest = value.ingest; if (!ingest || typeof ingest !== "object" || Array.isArray(ingest)) throw new Error("monitor.terminalWriter.ingest is required when mode=central"); const config = ingest as Record; const retry = config.retry; if (!retry || typeof retry !== "object" || Array.isArray(retry)) throw new Error("monitor.terminalWriter.ingest.retry is required when mode=central"); return { mode, ingest: { endpoint: String(config.endpoint ?? ""), timeoutMs: Number(config.timeoutMs), retry: { maxAttempts: Number((retry as Record).maxAttempts), delayMs: Number((retry as Record).delayMs) } } }; }