Resolve migration reconciliation items

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 22:46:50 -04:00
1 parent 971bdd95e6
commit 16855b696a
9 files changed
+125 -3

No files matched your search

@@ -198,6 +198,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input)));
ipcMain.handle(IPC_CHANNELS.createRollbackSnapshot, (_event, input) => mutate(() => rollback.createSnapshot(input)));
ipcMain.handle(IPC_CHANNELS.setRollbackSnapshotActive, (_event, input) => mutate(() => rollback.setActive(input)));
ipcMain.handle(IPC_CHANNELS.resolveMigrationReconciliation, (_event, input) => mutate(() => rollback.resolveReconciliationItem(input)));
ipcMain.handle(IPC_CHANNELS.setFeatureFlag, (_event, input) => mutate(() => operations.setFeatureFlag(input)));
ipcMain.handle(IPC_CHANNELS.runChaosDrill, (_event, input) => mutate(() => operations.runChaosDrill(input)));
ipcMain.handle(IPC_CHANNELS.runSdlcReview, (_event, input) => mutate(() => sdlc.runReview(input)));
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import type { CreateRollbackSnapshotInput, SetRollbackSnapshotActiveInput } from "../../shared/commands";
import type { CreateRollbackSnapshotInput, ResolveMigrationReconciliationInput, SetRollbackSnapshotActiveInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { MigrationCutoverJournalService, type CutoverDeltaEvent } from "./migration-cutover-journal-service";
@@ -39,6 +39,15 @@ interface RollbackSnapshotRow {
delta_journal_json: string;
}
interface MigrationReconciliationItemRow {
id: string;
migration_report_id: string;
rollback_snapshot_id: string | null;
target_type: string;
target_id: string;
state: string;
}
interface FileRow {
id: string;
name: string;
@@ -175,6 +184,43 @@ export class RollbackService {
});
}
resolveReconciliationItem(input: ResolveMigrationReconciliationInput): void {
this.requireEmployee(input.orgId, input.employeeId);
const item = this.db.prepare(`
select id, migration_report_id, rollback_snapshot_id, target_type, target_id, state
from migration_reconciliation_items
where id = ? and org_id = ?
`).get(input.reconciliationItemId, input.orgId) as MigrationReconciliationItemRow | undefined;
if (!item) throw new Error("Migration reconciliation item was not found.");
if (item.state === "resolved") throw new Error("Migration reconciliation item is already resolved.");
const now = new Date().toISOString();
const result = this.db.prepare(`
update migration_reconciliation_items
set state = ?, resolved_at = ?, resolved_by_employee_id = ?
where id = ? and org_id = ? and state = ?
`).run("resolved", now, input.employeeId, item.id, input.orgId, "open");
if (result.changes === 0) throw new Error("Migration reconciliation item could not be resolved.");
this.audit.record({
orgId: input.orgId,
entityType: "migration_reconciliation_item",
entityId: item.id,
actorType: "employee",
actorId: input.employeeId,
action: "resolved",
payload: { targetType: item.target_type, targetId: item.target_id, rollbackSnapshotId: item.rollback_snapshot_id },
});
this.cutoverJournal.recordEvent({
orgId: input.orgId,
migrationReportId: item.migration_report_id,
rollbackSnapshotId: item.rollback_snapshot_id,
eventType: "migration_reconciliation_resolved",
actorType: "employee",
actorId: input.employeeId,
payload: { reconciliationItemId: item.id, targetType: item.target_type, targetId: item.target_id },
createdAt: now,
});
}
private requireReport(orgId: string, reportId: string): MigrationReportRow {
const report = this.db.prepare("select id, source_ref, report_json, created_at from migration_reports where id = ? and org_id = ?")
.get(reportId, orgId) as MigrationReportRow | undefined;
+1
View File
@@ -46,6 +46,7 @@ const api: DesktopApi = {
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input),
createRollbackSnapshot: (input) => invoke(IPC_CHANNELS.createRollbackSnapshot, input),
setRollbackSnapshotActive: (input) => invoke(IPC_CHANNELS.setRollbackSnapshotActive, input),
resolveMigrationReconciliation: (input) => invoke(IPC_CHANNELS.resolveMigrationReconciliation, input),
setFeatureFlag: (input) => invoke(IPC_CHANNELS.setFeatureFlag, input),
runChaosDrill: (input) => invoke(IPC_CHANNELS.runChaosDrill, input),
runSdlcReview: (input) => invoke(IPC_CHANNELS.runSdlcReview, input),
@@ -209,6 +209,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
employeeId={employee.id}
reports={migrationReports}
rollbackSnapshots={rollbackSnapshots}
reconciliationItems={snapshot.migrationReconciliationItems.filter((item) => item.orgId === orgId)}
actions={actions}
/>
)}
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from "react";
import { Button, Field, Icon, Pill } from "../../shared/ui";
import { Dialog } from "../../shared/Dialog";
import type { MigrationReportRecord, RollbackSnapshotRecord } from "../../../../shared/domain";
import type { MigrationReconciliationItemRecord, MigrationReportRecord, RollbackSnapshotRecord } from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
import { formatRelative } from "../types";
@@ -18,12 +18,14 @@ export function MigrationPanel({
employeeId,
reports,
rollbackSnapshots,
reconciliationItems,
actions,
}: {
orgId: string;
employeeId: string;
reports: MigrationReportRecord[];
rollbackSnapshots: RollbackSnapshotRecord[];
reconciliationItems: MigrationReconciliationItemRecord[];
actions: SnapshotActions;
}) {
const [open, setOpen] = useState(false);
@@ -78,6 +80,22 @@ export function MigrationPanel({
/>
)) : <div style={{ padding: 16 }}><Pill>0 rollback snapshots</Pill></div>}
</section>
<section className="card" style={{ marginTop: 16, padding: 0, overflow: "hidden" }}>
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name="list" size={14} color="var(--text-3)" />
<div style={{ fontSize: 13, fontWeight: 600 }}>Reconciliation</div>
</div>
{reconciliationItems.length > 0 ? reconciliationItems.map((item, index) => (
<ReconciliationRow
key={item.id}
orgId={orgId}
employeeId={employeeId}
item={item}
actions={actions}
last={index === reconciliationItems.length - 1}
/>
)) : <div style={{ padding: 16 }}><Pill>0 reconciliation items</Pill></div>}
</section>
<Dialog title="Migration report" open={open} submitLabel="Create" onClose={() => setOpen(false)} onSubmit={submit}>
<Field label="Source database path"><input className="input" name="sourceDatabasePath" required /></Field>
</Dialog>
@@ -167,6 +185,43 @@ function RollbackRow({
);
}
function ReconciliationRow({
orgId,
employeeId,
item,
actions,
last,
}: {
orgId: string;
employeeId: string;
item: MigrationReconciliationItemRecord;
actions: SnapshotActions;
last: boolean;
}) {
const resolved = item.state === "resolved";
return (
<div
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr) 86px 112px 84px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.targetType} · {item.targetId}</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>{formatRelative(item.createdAt)}</div>
</div>
<div style={{ fontSize: 12, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.reason}</div>
<Pill tone={resolved ? "green" : "amber"}>{item.state}</Pill>
<Pill>{item.sourceEventId.slice(0, 12)}</Pill>
<Button
size="small"
disabled={resolved}
onClick={() => void actions.run(() => window.agentCompany.resolveMigrationReconciliation({ orgId, reconciliationItemId: item.id, employeeId }))}
>
{resolved ? "Resolved" : "Resolve"}
</Button>
</div>
);
}
function parseReport(reportJson: string): ParsedReport {
const parsed = JSON.parse(reportJson) as ParsedReport;
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
+6
View File
@@ -260,6 +260,12 @@ export interface SetRollbackSnapshotActiveInput {
employeeId: string;
}
export interface ResolveMigrationReconciliationInput {
orgId: string;
reconciliationItemId: string;
employeeId: string;
}
export interface SearchInput {
orgId: string;
employeeId: string;
@@ -31,6 +31,7 @@ import type {
LaunchProviderSignInInput,
LaunchSocialLoginInput,
ResolveAgentSkillsInput,
ResolveMigrationReconciliationInput,
RunChaosDrillInput,
RunEvalSuiteInput,
RunSdlcReviewInput,
@@ -98,6 +99,7 @@ export interface DesktopApi {
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
createRollbackSnapshot(input: CreateRollbackSnapshotInput): Promise<DesktopResult<AppSnapshot>>;
setRollbackSnapshotActive(input: SetRollbackSnapshotActiveInput): Promise<DesktopResult<AppSnapshot>>;
resolveMigrationReconciliation(input: ResolveMigrationReconciliationInput): Promise<DesktopResult<AppSnapshot>>;
setFeatureFlag(input: SetFeatureFlagInput): Promise<DesktopResult<AppSnapshot>>;
runChaosDrill(input: RunChaosDrillInput): Promise<DesktopResult<AppSnapshot>>;
runSdlcReview(input: RunSdlcReviewInput): Promise<DesktopResult<AppSnapshot>>;
+1
View File
@@ -38,6 +38,7 @@ export const IPC_CHANNELS = {
createMigrationReport: "agentcompany:create-migration-report",
createRollbackSnapshot: "agentcompany:create-rollback-snapshot",
setRollbackSnapshotActive: "agentcompany:set-rollback-snapshot-active",
resolveMigrationReconciliation: "agentcompany:resolve-migration-reconciliation",
setFeatureFlag: "agentcompany:set-feature-flag",
runChaosDrill: "agentcompany:run-chaos-drill",
runSdlcReview: "agentcompany:run-sdlc-review",
@@ -38,6 +38,7 @@ describe("rollback service", () => {
let employeeId = "";
let reportId = "";
let rollbackId = "";
let reconciliationId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
@@ -60,6 +61,9 @@ describe("rollback service", () => {
});
rollbackId = rollback.createSnapshot({ orgId, migrationReportId: reportId, createdByEmployeeId: employeeId });
rollback.setActive({ orgId, snapshotId: rollbackId, active: true, employeeId });
reconciliationId = (connection.db.prepare("select id from migration_reconciliation_items where org_id = ? limit 1")
.get(orgId) as { id: string }).id;
rollback.resolveReconciliationItem({ orgId, reconciliationItemId: reconciliationId, employeeId });
});
const state = snapshot.load();
@@ -76,6 +80,7 @@ describe("rollback service", () => {
expect(deltaJournal.length).toBeGreaterThan(0);
expect(restorePlan).toMatchObject({ migrationReportId: reportId, restoreStrategy: "legacy_snapshot_restore" });
expect(state.migrationCutoverEvents.map((event) => event.eventType).sort()).toEqual([
"migration_reconciliation_resolved",
"rollback_snapshot_activated",
"rollback_snapshot_created",
]);
@@ -86,9 +91,13 @@ describe("rollback service", () => {
targetType: "task",
targetId: "task-cutover",
reason: "manual_delta_replay",
state: "open",
state: "resolved",
resolvedByEmployeeId: employeeId,
}));
expect(() => connection.transaction(() => rollback.resolveReconciliationItem({ orgId, reconciliationItemId: reconciliationId, employeeId })))
.toThrow("Migration reconciliation item is already resolved.");
expect(state.auditEvents.some((event) => event.entityType === "rollback_snapshot" && event.action === "activated")).toBe(true);
expect(state.auditEvents.some((event) => event.entityType === "migration_reconciliation_item" && event.action === "resolved")).toBe(true);
connection.close();
});
});