From babbf07cb26490e47eb7bff25f7447ae75297834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 21:35:21 -0400 Subject: [PATCH] Add migration validation summary gate --- .../src/main/services/migration-service.ts | 18 +++- .../services/migration-validation-summary.ts | 86 +++++++++++++++++++ .../tests/unit/domain-services.test.ts | 3 + .../tests/unit/migration-service.test.ts | 27 ++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 AgentCompany-New/src/main/services/migration-validation-summary.ts diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 9fba7eeb..6b222f9b 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -6,6 +6,7 @@ import type { CreateMigrationReportInput } from "../../shared/commands"; import { SLO_SIGNAL_KEY } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +import { buildMigrationValidationSummary, type MigrationValidationSummary } from "./migration-validation-summary"; import { OperationsService } from "./operations-service"; const LEGACY_TABLES = [ @@ -103,6 +104,7 @@ interface MigrationReportBody { idMaps: Record; missingTables: string[]; failures: MigrationFailure[]; + validationSummary: MigrationValidationSummary; rollbackPack: { sourceDatabasePath: string; capturedAt: string; @@ -296,11 +298,20 @@ export class MigrationService { .filter((table) => availableTables.has(table)) .map((table) => [table, tableIds(source, table)]), ); + const idMappedTables = LEGACY_TABLES.filter((table) => availableTables.has(table) && hasColumn(source, table, "id")); const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table)); const failures = [ ...missingTables.map((table) => ({ table, reason: "missing_table", detail: "Required table is absent." })), ...validateRelations(source, availableTables), ]; + const validationSummary = buildMigrationValidationSummary({ + requiredTables: LEGACY_TABLES, + tableCounts, + tableChecksums, + idMaps, + idMappedTables, + failures, + }); const sourceFingerprint = fingerprintSource({ tableCounts, tableChecksums, idMaps, failures }); const existingReportId = this.findExistingReport(input.orgId, sourceDatabasePath, sourceFingerprint); if (existingReportId) return existingReportId; @@ -313,6 +324,7 @@ export class MigrationService { idMaps, missingTables, failures, + validationSummary, rollbackPack: { sourceDatabasePath, capturedAt: now, @@ -339,9 +351,9 @@ export class MigrationService { orgId: input.orgId, signalKey: SLO_SIGNAL_KEY.MIGRATION_VALIDATION_SUCCESS, source: "migration_validation", - success: failures.length === 0, + success: validationSummary.passed, durationMs: null, - detail: { reportId, sourceFingerprint, failureCount: failures.length }, + detail: { reportId, sourceFingerprint, validationSummary }, }); return reportId; } finally { @@ -446,7 +458,7 @@ function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } -function fingerprintSource(body: Omit): string { +function fingerprintSource(body: Omit): string { return createHash("sha256").update(JSON.stringify(body)).digest("hex"); } diff --git a/AgentCompany-New/src/main/services/migration-validation-summary.ts b/AgentCompany-New/src/main/services/migration-validation-summary.ts new file mode 100644 index 00000000..6547eb76 --- /dev/null +++ b/AgentCompany-New/src/main/services/migration-validation-summary.ts @@ -0,0 +1,86 @@ +export interface MigrationFailureView { + table: string; + reason: string; + detail: string; +} + +export interface MigrationValidationSummary { + requiredTableCount: number; + presentTableCount: number; + missingTableCount: number; + totalSourceRows: number; + rowsWithIntegrityFailures: number; + rowCoverageBasisPoints: number; + checksumCoverageBasisPoints: number; + idMapCoverageBasisPoints: number; + relationFailureCount: number; + passed: boolean; + thresholds: { + rowCoverageBasisPoints: number; + checksumCoverageBasisPoints: number; + idMapCoverageBasisPoints: number; + }; +} + +const BASIS_POINTS = 10000; +const ROW_COVERAGE_TARGET = 9900; + +export function buildMigrationValidationSummary(input: { + requiredTables: string[]; + tableCounts: Record; + tableChecksums: Record; + idMaps: Record; + idMappedTables: string[]; + failures: MigrationFailureView[]; +}): MigrationValidationSummary { + const presentTableCount = Object.keys(input.tableCounts).length; + const missingTableCount = input.requiredTables.length - presentTableCount; + const totalSourceRows = sum(Object.values(input.tableCounts)); + const rowsWithIntegrityFailures = countFailedRows(input.failures); + const rowCoverageBasisPoints = ratioBasisPoints(Math.max(0, totalSourceRows - rowsWithIntegrityFailures), totalSourceRows); + const checksumCoverageBasisPoints = ratioBasisPoints(Object.keys(input.tableChecksums).length, presentTableCount); + const idMapSourceRows = sum(input.idMappedTables.map((table) => input.tableCounts[table] ?? 0)); + const idMappedRows = sum(input.idMappedTables.map((table) => input.idMaps[table]?.length ?? 0)); + const idMapCoverageBasisPoints = ratioBasisPoints(idMappedRows, idMapSourceRows); + const relationFailureCount = input.failures.filter((failure) => failure.reason !== "missing_table").length; + const passed = missingTableCount === 0 + && rowCoverageBasisPoints >= ROW_COVERAGE_TARGET + && checksumCoverageBasisPoints === BASIS_POINTS + && idMapCoverageBasisPoints === BASIS_POINTS + && relationFailureCount === 0; + return { + requiredTableCount: input.requiredTables.length, + presentTableCount, + missingTableCount, + totalSourceRows, + rowsWithIntegrityFailures, + rowCoverageBasisPoints, + checksumCoverageBasisPoints, + idMapCoverageBasisPoints, + relationFailureCount, + passed, + thresholds: { + rowCoverageBasisPoints: ROW_COVERAGE_TARGET, + checksumCoverageBasisPoints: BASIS_POINTS, + idMapCoverageBasisPoints: BASIS_POINTS, + }, + }; +} + +function countFailedRows(failures: MigrationFailureView[]): number { + const keys = new Set(); + for (const failure of failures) { + if (failure.reason === "missing_table" || failure.reason === "missing_column") continue; + keys.add(`${failure.table}:${failure.detail.split(" -> ")[0]}`); + } + return keys.size; +} + +function ratioBasisPoints(numerator: number, denominator: number): number { + if (denominator === 0) return BASIS_POINTS; + return Math.floor((numerator / denominator) * BASIS_POINTS); +} + +function sum(values: number[]): number { + return values.reduce((total, value) => total + value, 0); +} diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 092b087b..30e2df2a 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -445,11 +445,14 @@ describe("domain services", () => { tableChecksums: Record; idMaps: Record; failures: Array<{ table: string; reason: string }>; + validationSummary: { missingTableCount: number; passed: boolean }; rollbackPack: { sourceDatabasePath: string }; }; expect(report.tableChecksums.agents).toHaveLength(64); expect(report.idMaps.agents).toEqual(["agent-1"]); expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true); + expect(report.validationSummary.missingTableCount).toBeGreaterThan(0); + expect(report.validationSummary.passed).toBe(false); expect(report.rollbackPack.sourceDatabasePath).toBe(legacyPath); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index df5c9083..b13a8e8a 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -35,6 +35,16 @@ interface MigrationReportBody { tableChecksums: Record; idMaps: Record; failures: Array<{ table: string; reason: string; detail: string }>; + validationSummary: { + missingTableCount: number; + totalSourceRows: number; + rowsWithIntegrityFailures: number; + rowCoverageBasisPoints: number; + checksumCoverageBasisPoints: number; + idMapCoverageBasisPoints: number; + relationFailureCount: number; + passed: boolean; + }; rollbackPack: { sourceDatabasePath: string; sourceFingerprint: string; @@ -82,6 +92,15 @@ describe("migration service", () => { expect(body.rollbackPack.idMaps.tasks).toEqual(["task-1"]); expect(body.failures).toContainEqual({ table: "agents", reason: "dangling_org_id", detail: "agent-1 -> org-missing" }); expect(body.failures).toContainEqual({ table: "tasks", reason: "dangling_assignee_agent_id", detail: "task-1 -> agent-missing" }); + expect(body.validationSummary).toMatchObject({ + totalSourceRows: 4, + rowsWithIntegrityFailures: 2, + rowCoverageBasisPoints: 5000, + checksumCoverageBasisPoints: 10000, + idMapCoverageBasisPoints: 10000, + relationFailureCount: 3, + passed: false, + }); connection.close(); }); @@ -243,6 +262,14 @@ describe("migration service", () => { const body = JSON.parse(snapshot.load().migrationReports[0]!.reportJson) as MigrationReportBody; expect(body.failures).toEqual([]); + expect(body.validationSummary).toMatchObject({ + missingTableCount: 0, + rowCoverageBasisPoints: 10000, + checksumCoverageBasisPoints: 10000, + idMapCoverageBasisPoints: 10000, + relationFailureCount: 0, + passed: true, + }); expect(body.tableCounts.credentials).toBe(1); expect(body.tableCounts.employee_identities).toBe(1); expect(body.tableCounts.org_departments).toBe(1);