Add migration verification reports

This commit is contained in:
ZacharyZhang-NY committed 2026-05-20 23:42:53 -04:00
1 parent de63df7daf
commit 49c1dd2888
9 files changed
+117

No files matched your search

@@ -11,6 +11,7 @@ import { ProviderService } from "../services/provider-service";
import { SkillService } from "../services/skill-service";
import { SocialService } from "../services/social-service";
import { FileService } from "../services/file-service";
import { MigrationService } from "../services/migration-service";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -27,6 +28,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const skills = new SkillService(deps.connection.db);
const social = new SocialService(deps.connection.db);
const files = new FileService(deps.connection.db, deps.artifactRoot);
const migrations = new MigrationService(deps.connection.db);
const mutate = <T>(operation: () => T): DesktopResult<AppSnapshot> => {
try {
@@ -87,6 +89,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
ipcMain.handle(IPC_CHANNELS.createMigrationReport, (_event, input) => mutate(() => migrations.createReport(input)));
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close());
@@ -0,0 +1,65 @@
import { existsSync } from "node:fs";
import { DatabaseSync, type StatementSync } from "node:sqlite";
import type { CreateMigrationReportInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
const LEGACY_TABLES = [
"organizations",
"agents",
"tasks",
"approvals",
"chat_messages",
"providers",
"skill_definitions",
"file_objects",
"social_accounts",
];
export class MigrationService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
createReport(input: CreateMigrationReportInput): string {
if (!existsSync(input.sourceDatabasePath)) throw new Error("Source database was not found.");
const source = new DatabaseSync(input.sourceDatabasePath, { readOnly: true });
try {
const availableTables = new Set(
source.prepare("select name from sqlite_master where type = 'table'")
.all()
.map((row) => (row as { name: string }).name),
);
const tableCounts = Object.fromEntries(
LEGACY_TABLES
.filter((table) => availableTables.has(table))
.map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]),
);
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
const reportId = createId("mig");
const now = new Date().toISOString();
this.db.prepare(`
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
values (?, ?, ?, ?, ?)
`).run(reportId, input.orgId, input.sourceDatabasePath, JSON.stringify({ tableCounts, missingTables }), now);
this.audit.record({
orgId: input.orgId,
entityType: "migration_report",
entityId: reportId,
actorType: "system",
actorId: null,
action: "created",
payload: { sourceRef: input.sourceDatabasePath, tableCount: Object.keys(tableCounts).length },
});
return reportId;
} finally {
source.close();
}
}
}
function countRows(statement: StatementSync): number {
return (statement.get() as { count: number }).count;
}
@@ -57,6 +57,7 @@ export class SnapshotService {
skillRuntimeSnapshots: list(this.db, "skill_runtime_snapshots", "created_at desc"),
socialAccounts: list(this.db, "social_accounts", "created_at asc"),
policyRules: list(this.db, "policy_rules", "created_at asc"),
migrationReports: list(this.db, "migration_reports", "created_at desc"),
auditEvents: list<AppSnapshot["auditEvents"][number]>(this.db, "audit_events", "created_at desc").slice(0, 200),
};
}
+1
View File
@@ -23,6 +23,7 @@ const api: DesktopApi = {
createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input),
launchSocialLogin: (input) => invoke(IPC_CHANNELS.launchSocialLogin, input),
importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
+5
View File
@@ -114,6 +114,11 @@ export interface ImportFileInput {
orgId: string;
}
export interface CreateMigrationReportInput {
orgId: string;
sourceDatabasePath: string;
}
export interface NativeDialogResult {
canceled: boolean;
filePaths: string[];
@@ -4,6 +4,7 @@ import type {
BindSkillInput,
CreateAgentInput,
CreateApprovalInput,
CreateMigrationReportInput,
CreateOrganizationInput,
CreateProviderResponseInput,
CreateSocialAccountInput,
@@ -39,6 +40,7 @@ export interface DesktopApi {
createSocialAccount(input: CreateSocialAccountInput): Promise<DesktopResult<AppSnapshot>>;
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
importFile(input: ImportFileInput): Promise<DesktopResult<AppSnapshot>>;
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
windowMinimize(): Promise<void>;
+9
View File
@@ -275,6 +275,14 @@ export interface PolicyRuleRecord {
createdAt: string;
}
export interface MigrationReportRecord {
id: string;
orgId: string;
sourceRef: string;
reportJson: string;
createdAt: string;
}
export interface AppSnapshot {
activeOrgId: string | null;
organizations: OrganizationRecord[];
@@ -297,5 +305,6 @@ export interface AppSnapshot {
skillRuntimeSnapshots: SkillRuntimeSnapshotRecord[];
socialAccounts: SocialAccountRecord[];
policyRules: PolicyRuleRecord[];
migrationReports: MigrationReportRecord[];
auditEvents: AuditEventRecord[];
}
+1
View File
@@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
createSocialAccount: "agentcompany:create-social-account",
launchSocialLogin: "agentcompany:launch-social-login",
importFile: "agentcompany:import-file",
createMigrationReport: "agentcompany:create-migration-report",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",
windowMaximize: "agentcompany:window-maximize",
@@ -2,12 +2,14 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { SnapshotService } from "../../src/main/services/snapshot-service";
import { SkillService } from "../../src/main/services/skill-service";
import { ChatService } from "../../src/main/services/chat-service";
import { MigrationService } from "../../src/main/services/migration-service";
import { RISK_LEVEL, TASK_STATUS } from "../../src/shared/domain";
import { createId } from "../../src/shared/ids";
@@ -159,4 +161,31 @@ describe("domain services", () => {
expect(mention.target_id).toBe("Board");
connection.close();
});
test("creates a read-only migration report from a legacy database", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const migrations = new MigrationService(connection.db);
const snapshot = new SnapshotService(connection.db);
const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`);
const legacy = new DatabaseSync(legacyPath);
legacy.exec("create table agents(id text primary key); insert into agents(id) values ('agent-1');");
legacy.close();
let orgId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
migrations.createReport({ orgId, sourceDatabasePath: legacyPath });
});
const state = snapshot.load();
expect(state.migrationReports).toHaveLength(1);
expect(state.migrationReports[0]?.reportJson).toContain("\"agents\":1");
connection.close();
});
});