Resolve migration reconciliation items
This commit is contained in:
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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user