87 lines
7.3 KiB
TypeScript
87 lines
7.3 KiB
TypeScript
// 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<string, unknown>) { super("Monitor terminal ingest failed after bounded retries"); this.name = "MonitorIngestFailedError"; }
|
|
}
|
|
|
|
export function buildMonitorTerminalIngest(identity: MonitorTerminalIdentity, report: Record<string, unknown>, artifacts: readonly MonitorTerminalArtifact[], locatorOwner: Pick<MonitorArtifactLocator, "ownerNode" | "ownerLane" | "ownerPvc" | "stateDir">): MonitorCentralIngest {
|
|
const immutableReport = JSON.parse(monitorCentralStableJson(report)) as Record<string, unknown>;
|
|
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<Record<string, unknown>> {
|
|
validateConfig(config);
|
|
const canonical = normalizeMonitorCentralIngest(payload);
|
|
const body = monitorCentralStableJson(canonical);
|
|
let lastError: Record<string, unknown> | 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<string, unknown>[] { 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<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
function sleep(delayMs: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, delayMs)); }
|
|
|
|
export interface MonitorTerminalWriterConfig { readonly mode: "legacy-sqlite" | "central"; readonly ingest?: MonitorTerminalIngestConfig; }
|
|
|
|
export function resolveMonitorTerminalWriter(runtime: Record<string, unknown>): MonitorTerminalWriterConfig {
|
|
const sqlite = runtime.sqlite;
|
|
const sqlitePath = sqlite && typeof sqlite === "object" && !Array.isArray(sqlite) ? stringValue((sqlite as Record<string, unknown>).path) : undefined;
|
|
const monitor = runtime.monitor;
|
|
const writer = monitor && typeof monitor === "object" && !Array.isArray(monitor) ? (monitor as Record<string, unknown>).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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>).maxAttempts), delayMs: Number((retry as Record<string, unknown>).delayMs) } } };
|
|
}
|