Add migration verification reports
This commit is contained in:
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user