diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 94b4b46c..920ad6f7 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -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))); diff --git a/AgentCompany-New/src/main/services/rollback-service.ts b/AgentCompany-New/src/main/services/rollback-service.ts index 6deb8c6e..944f4afa 100644 --- a/AgentCompany-New/src/main/services/rollback-service.ts +++ b/AgentCompany-New/src/main/services/rollback-service.ts @@ -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; diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index 0676cf5c..cb0e6c68 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -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), diff --git a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx index 88ad0ff2..540d8153 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -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} /> )} diff --git a/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx index a6a83e19..8d21529d 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx @@ -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({ /> )) :
0 rollback snapshots
} +
+
+ +
Reconciliation
+
+ {reconciliationItems.length > 0 ? reconciliationItems.map((item, index) => ( + + )) :
0 reconciliation items
} +
setOpen(false)} onSubmit={submit}> @@ -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 ( +
+
+
{item.targetType} ยท {item.targetId}
+
{formatRelative(item.createdAt)}
+
+
{item.reason}
+ {item.state} + {item.sourceEventId.slice(0, 12)} + +
+ ); +} + function parseReport(reportJson: string): ParsedReport { const parsed = JSON.parse(reportJson) as ParsedReport; if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) { diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index c6d7ba8a..feda51fb 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -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; diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 9c94bea7..ad35a77a 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -31,6 +31,7 @@ import type { LaunchProviderSignInInput, LaunchSocialLoginInput, ResolveAgentSkillsInput, + ResolveMigrationReconciliationInput, RunChaosDrillInput, RunEvalSuiteInput, RunSdlcReviewInput, @@ -98,6 +99,7 @@ export interface DesktopApi { createMigrationReport(input: CreateMigrationReportInput): Promise>; createRollbackSnapshot(input: CreateRollbackSnapshotInput): Promise>; setRollbackSnapshotActive(input: SetRollbackSnapshotActiveInput): Promise>; + resolveMigrationReconciliation(input: ResolveMigrationReconciliationInput): Promise>; setFeatureFlag(input: SetFeatureFlagInput): Promise>; runChaosDrill(input: RunChaosDrillInput): Promise>; runSdlcReview(input: RunSdlcReviewInput): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index 4bc15ae8..0df8e9ad 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -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", diff --git a/AgentCompany-New/tests/unit/rollback-service.test.ts b/AgentCompany-New/tests/unit/rollback-service.test.ts index e831fe47..fd938323 100644 --- a/AgentCompany-New/tests/unit/rollback-service.test.ts +++ b/AgentCompany-New/tests/unit/rollback-service.test.ts @@ -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(); }); });