feat: add monitor terminal ingest migration CLI

This commit is contained in:
AgentRun Codex
2026-07-12 20:41:49 +00:00
parent e46819fed9
commit 8017852670
6 changed files with 381 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-12-p17-monitor-central-persistence.
// Responsibility: Read-only legacy SQLite snapshot export and bounded, explicit one-shot migration verification.
import { createHash } from "node:crypto";
import { closeSync, existsSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { Database } from "bun:sqlite";
import { stableJson } from "./monitor-terminal-ingest";
export const MONITOR_SQLITE_MIGRATION_SCHEMA = "2026-07-12-p17-1";
export interface MonitorSqliteSource {
readonly sentinelId: string;
readonly node: string;
readonly lane: string;
readonly path: string;
readonly runtimeConfigRef: string;
}
export interface MonitorSqliteSnapshot {
readonly schema: string;
readonly createdAt: string;
readonly source: MonitorSqliteSource;
readonly fingerprint: string;
readonly integrity: string;
readonly byteLength: number;
readonly tables: readonly { readonly name: string; readonly sql: string; readonly rowCount: number; readonly rows: readonly Record<string, unknown>[] }[];
readonly artifactLocator: { readonly path: string; readonly sha256: string; readonly byteLength: number };
}
export interface MonitorMigrationReconciliation {
readonly runs: number;
readonly findings: number;
readonly metadata: number;
readonly payloads: number;
readonly locators: number;
readonly latest: number;
}
export function planMonitorSqliteSources(sources: readonly MonitorSqliteSource[]): Record<string, unknown> {
const rows = sources.map((source) => {
const path = resolve(source.path);
const state = existsSync(path) ? statSync(path) : null;
return {
sentinelId: source.sentinelId,
node: source.node,
lane: source.lane,
path,
runtimeConfigRef: source.runtimeConfigRef,
exists: state !== null,
isFile: state?.isFile() ?? false,
byteLength: state?.size ?? null,
mutation: false,
valuesRedacted: true,
};
});
return { ok: true, command: "web-probe sentinel migration plan", sources: rows, sourceCount: rows.length, valuesRedacted: true };
}
export function exportMonitorSqliteSnapshot(source: MonitorSqliteSource, outputPath: string): MonitorSqliteSnapshot {
const path = assertReadOnlySqliteSource(source.path);
const bytes = readFileSync(path);
const fingerprint = `sha256:${sha256(bytes)}`;
const database = new Database(path, { readonly: true });
try {
const integrity = String(database.query("PRAGMA integrity_check").get()?.integrity_check ?? "unknown");
if (integrity !== "ok") throw new Error(`legacy SQLite integrity_check failed: ${integrity}`);
const tables = database.query("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string; sql: string }[];
const snapshot: MonitorSqliteSnapshot = {
schema: MONITOR_SQLITE_MIGRATION_SCHEMA,
createdAt: new Date().toISOString(),
source: { ...source, path },
fingerprint,
integrity,
byteLength: bytes.byteLength,
tables: tables.map((table) => ({
name: table.name,
sql: table.sql,
rowCount: Number(database.query(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}`).get()?.count ?? 0),
rows: database.query(`SELECT * FROM ${quoteIdentifier(table.name)}`).all().map(normalizeRow),
})),
artifactLocator: { path: resolve(outputPath), sha256: "", byteLength: 0 },
};
const locatorSha256 = `sha256:${sha256(Buffer.from(stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: "", byteLength: 0 } })))}`;
const materialized = `${stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: locatorSha256, byteLength: 0 } })}\n`;
writeSnapshot(outputPath, materialized);
const artifact = readFileSync(outputPath);
return { ...snapshot, artifactLocator: { path: resolve(outputPath), sha256: locatorSha256, byteLength: artifact.byteLength } };
} finally {
database.close();
}
}
export function verifyMonitorSqliteSnapshot(snapshot: MonitorSqliteSnapshot): Record<string, unknown> {
const expected = `sha256:${sha256(Buffer.from(stableJson({ ...snapshot, artifactLocator: { ...snapshot.artifactLocator, sha256: "", byteLength: 0 } })))} ` .trim();
const reconciliation = reconcileTables(snapshot.tables);
return {
ok: snapshot.schema === MONITOR_SQLITE_MIGRATION_SCHEMA && snapshot.integrity === "ok" && snapshot.artifactLocator.sha256 === expected,
command: "web-probe sentinel migration verify",
fingerprint: snapshot.fingerprint,
integrity: snapshot.integrity,
reconciliation,
artifactLocator: { ...snapshot.artifactLocator, valid: snapshot.artifactLocator.sha256 === expected },
valuesRedacted: true,
};
}
export function reconcileTables(tables: readonly MonitorSqliteSnapshot["tables"][number][]): MonitorMigrationReconciliation {
const count = (names: readonly string[]) => tables.filter((table) => names.includes(table.name)).reduce((total, table) => total + table.rowCount, 0);
return {
runs: count(["runs", "run"]),
findings: count(["findings", "finding"]),
metadata: count(["metadata", "run_metadata"]),
payloads: count(["payloads", "run_payloads"]),
locators: count(["artifact_locators", "locators"]),
latest: count(["latest", "latest_runs"]),
};
}
function assertReadOnlySqliteSource(value: string): string {
const path = resolve(value);
const state = statSync(path);
if (!state.isFile()) throw new Error(`legacy SQLite source is not a file: ${path}`);
const descriptor = openSync(path, "r");
closeSync(descriptor);
return path;
}
function writeSnapshot(path: string, content: string): void {
const parent = dirname(resolve(path));
if (!existsSync(parent)) throw new Error(`snapshot output directory does not exist: ${parent}`);
writeFileSync(path, content);
}
function quoteIdentifier(value: string): string {
return `"${value.replaceAll("\"", "\"\"")}"`;
}
function normalizeRow(value: unknown): Record<string, unknown> {
const row = value as Record<string, unknown>;
return Object.fromEntries(Object.entries(row).map(([key, item]) => [key, item instanceof Uint8Array ? Buffer.from(item).toString("base64") : item]));
}
function sha256(value: Uint8Array): string {
return createHash("sha256").update(value).digest("hex");
}