From 0b53eec2921452e1035797806dc6d8855c1efcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 14:34:25 -0400 Subject: [PATCH] feat(new): harden migration reporting integrity Amp-Thread-ID: https://ampcode.com/threads/T-019e55df-9bcd-73e6-9935-c1e6238cd273 Co-authored-by: Amp --- .../src/main/services/migration-service.ts | 276 +++++++++++++++++- .../tests/unit/migration-service.test.ts | 196 +++++++++++++ 2 files changed, 456 insertions(+), 16 deletions(-) create mode 100644 AgentCompany-New/tests/unit/migration-service.test.ts diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 32a0c350..ccd61237 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -1,5 +1,6 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { createHash } from "node:crypto"; +import { resolve } from "node:path"; import { DatabaseSync, type StatementSync } from "node:sqlite"; import type { CreateMigrationReportInput } from "../../shared/commands"; import { createId } from "../../shared/ids"; @@ -7,14 +8,148 @@ import { AuditService } from "./audit-service"; const LEGACY_TABLES = [ "organizations", + "employees", "agents", "tasks", + "workspaces", + "goals", + "projects", + "task_runs", "approvals", + "chat_spaces", "chat_messages", + "chat_threads", + "chat_memberships", + "chat_attachments", + "chat_mentions", + "chat_slash_invocations", + "chat_presence", + "chat_read_state", "providers", + "provider_requests", + "credentials", "skill_definitions", + "skill_revisions", + "skill_artifacts", + "skill_tests", + "skill_dependencies", + "skill_bindings", + "skill_runtime_snapshots", + "skill_catalog_tags", "file_objects", "social_accounts", + "social_account_grants", + "browser_actions", + "scheduled_posts", + "policy_rules", + "audit_events", +]; + +interface MigrationFailure { + table: string; + reason: string; + detail: string; +} + +interface RelationCheck { + table: string; + column: string; + referencedTable: string; + referencedColumn: string; + reason: string; +} + +interface MigrationReportBody { + sourceFingerprint: string; + tableCounts: Record; + tableChecksums: Record; + idMaps: Record; + missingTables: string[]; + failures: MigrationFailure[]; + rollbackPack: { + sourceDatabasePath: string; + capturedAt: string; + restoreStrategy: string; + sourceFingerprint: string; + tableChecksums: Record; + idMaps: Record; + }; +} + +const RELATION_CHECKS: RelationCheck[] = [ + { table: "employees", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "agents", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "agents", column: "model_provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_model_provider_id" }, + { table: "agents", column: "manager_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_manager_agent_id" }, + { table: "agents", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" }, + { table: "tasks", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "tasks", column: "assignee_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_assignee_agent_id" }, + { table: "tasks", column: "project_id", referencedTable: "projects", referencedColumn: "id", reason: "dangling_project_id" }, + { table: "tasks", column: "goal_id", referencedTable: "goals", referencedColumn: "id", reason: "dangling_goal_id" }, + { table: "tasks", column: "workspace_id", referencedTable: "workspaces", referencedColumn: "id", reason: "dangling_workspace_id" }, + { table: "workspaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "workspaces", column: "project_id", referencedTable: "projects", referencedColumn: "id", reason: "dangling_project_id" }, + { table: "goals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "goals", column: "owner_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_owner_agent_id" }, + { table: "projects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "projects", column: "goal_id", referencedTable: "goals", referencedColumn: "id", reason: "dangling_goal_id" }, + { table: "projects", column: "lead_agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_lead_agent_id" }, + { table: "task_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "task_runs", column: "task_id", referencedTable: "tasks", referencedColumn: "id", reason: "dangling_task_id" }, + { table: "task_runs", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" }, + { table: "task_runs", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "approvals", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "approvals", column: "decided_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_decider_id" }, + { table: "chat_spaces", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "chat_messages", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "chat_messages", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "chat_messages", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" }, + { table: "chat_threads", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "chat_threads", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "chat_threads", column: "root_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_root_message_id" }, + { table: "chat_memberships", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "chat_attachments", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" }, + { table: "chat_attachments", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" }, + { table: "chat_mentions", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" }, + { table: "chat_slash_invocations", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" }, + { table: "chat_read_state", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "chat_read_state", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" }, + { table: "chat_read_state", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "chat_read_state", column: "last_read_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_last_read_message_id" }, + { table: "providers", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "providers", column: "credential_id", referencedTable: "credentials", referencedColumn: "id", reason: "dangling_credential_id" }, + { table: "provider_requests", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "provider_requests", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" }, + { table: "credentials", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "skill_definitions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "skill_revisions", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, + { table: "skill_artifacts", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" }, + { table: "skill_tests", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" }, + { table: "skill_dependencies", column: "revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" }, + { table: "skill_dependencies", column: "depends_on_revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_depends_on_revision_id" }, + { table: "skill_bindings", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "skill_bindings", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" }, + { table: "skill_bindings", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, + { table: "skill_bindings", column: "pinned_revision_id", referencedTable: "skill_revisions", referencedColumn: "id", reason: "dangling_revision_id" }, + { table: "skill_runtime_snapshots", column: "agent_id", referencedTable: "agents", referencedColumn: "id", reason: "dangling_agent_id" }, + { table: "skill_runtime_snapshots", column: "run_id", referencedTable: "task_runs", referencedColumn: "id", reason: "dangling_run_id" }, + { table: "skill_catalog_tags", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" }, + { table: "file_objects", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "file_objects", column: "space_id", referencedTable: "chat_spaces", referencedColumn: "id", reason: "dangling_space_id" }, + { table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" }, + { table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "social_account_grants", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "social_account_grants", column: "employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_employee_id" }, + { table: "browser_actions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "browser_actions", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "browser_actions", column: "evidence_file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_evidence_file_id" }, + { table: "scheduled_posts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "scheduled_posts", column: "social_account_id", referencedTable: "social_accounts", referencedColumn: "id", reason: "dangling_social_account_id" }, + { table: "scheduled_posts", column: "approval_id", referencedTable: "approvals", referencedColumn: "id", reason: "dangling_approval_id" }, + { table: "scheduled_posts", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, + { table: "policy_rules", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, ]; export class MigrationService { @@ -25,8 +160,11 @@ export class MigrationService { } createReport(input: CreateMigrationReportInput): string { - if (!existsSync(input.sourceDatabasePath)) throw new Error("Source database was not found."); - const source = new DatabaseSync(input.sourceDatabasePath, { readOnly: true }); + this.assertOrg(input.orgId); + const normalizedSourcePath = normalizeSourcePath(input.sourceDatabasePath); + if (!existsSync(normalizedSourcePath)) throw new Error("Source database was not found."); + const sourceDatabasePath = realpathSync.native(normalizedSourcePath); + const source = new DatabaseSync(sourceDatabasePath, { readOnly: true }); try { const availableTables = new Set( source.prepare("select name from sqlite_master where type = 'table'") @@ -36,7 +174,7 @@ export class MigrationService { const tableCounts = Object.fromEntries( LEGACY_TABLES .filter((table) => availableTables.has(table)) - .map((table) => [table, countRows(source.prepare(`select count(*) as count from ${table}`))]), + .map((table) => [table, countRows(source.prepare(`select count(*) as count from ${quoteIdentifier(table)}`))]), ); const tableChecksums = Object.fromEntries( LEGACY_TABLES @@ -49,24 +187,35 @@ export class MigrationService { .map((table) => [table, tableIds(source, table)]), ); const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table)); - const failures = missingTables.map((table) => ({ table, reason: "missing_table" })); + const failures = [ + ...missingTables.map((table) => ({ table, reason: "missing_table", detail: "Required table is absent." })), + ...validateRelations(source, availableTables), + ]; + const sourceFingerprint = fingerprintSource({ tableCounts, tableChecksums, idMaps, failures }); + const existingReportId = this.findExistingReport(input.orgId, sourceDatabasePath, sourceFingerprint); + if (existingReportId) return existingReportId; 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({ + const reportBody: MigrationReportBody = { + sourceFingerprint, tableCounts, tableChecksums, idMaps, missingTables, failures, rollbackPack: { - sourceDatabasePath: input.sourceDatabasePath, + sourceDatabasePath, capturedAt: now, restoreStrategy: "legacy_snapshot_restore", + sourceFingerprint, + tableChecksums, + idMaps, }, - }), now); + }; + this.db.prepare(` + insert into migration_reports(id, org_id, source_ref, report_json, created_at) + values (?, ?, ?, ?, ?) + `).run(reportId, input.orgId, sourceDatabasePath, JSON.stringify(reportBody), now); this.audit.record({ orgId: input.orgId, entityType: "migration_report", @@ -74,13 +223,37 @@ export class MigrationService { actorType: "system", actorId: null, action: "created", - payload: { sourceRef: input.sourceDatabasePath, tableCount: Object.keys(tableCounts).length }, + payload: { sourceRef: sourceDatabasePath, tableCount: Object.keys(tableCounts).length, sourceFingerprint }, }); return reportId; } finally { source.close(); } } + + private assertOrg(orgId: string): void { + const org = this.db.prepare("select id from organizations where id = ?").get(orgId); + if (!org) throw new Error("Organization was not found."); + } + + private findExistingReport(orgId: string, sourceRef: string, sourceFingerprint: string): string | null { + const rows = this.db.prepare(` + select id, report_json from migration_reports + where org_id = ? and source_ref = ? + order by created_at desc + `).all(orgId, sourceRef) as Array<{ id: string; report_json: string }>; + for (const row of rows) { + const parsed = parseExistingReport(row.report_json); + if (parsed.sourceFingerprint === sourceFingerprint) return row.id; + } + return null; + } +} + +function normalizeSourcePath(sourceDatabasePath: string): string { + const trimmed = sourceDatabasePath.trim(); + if (!trimmed) throw new Error("Source database path is required."); + return resolve(trimmed); } function countRows(statement: StatementSync): number { @@ -89,13 +262,84 @@ function countRows(statement: StatementSync): number { function tableChecksum(source: DatabaseSync, table: string): string { const hash = createHash("sha256"); - const rows = source.prepare(`select * from ${table} order by rowid`).all(); + const rows = source.prepare(`select * from ${quoteIdentifier(table)} order by ${orderingColumn(source, table)}`).all(); for (const row of rows) hash.update(JSON.stringify(row)); return hash.digest("hex"); } function tableIds(source: DatabaseSync, table: string): string[] { - const columns = source.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>; - if (!columns.some((column) => column.name === "id")) return []; - return source.prepare(`select id from ${table} order by id`).all().map((row) => String((row as { id: unknown }).id)); + if (!hasColumn(source, table, "id")) return []; + return source.prepare(`select id from ${quoteIdentifier(table)} order by id`).all().map((row) => String((row as { id: unknown }).id)); +} + +function validateRelations(source: DatabaseSync, availableTables: Set): MigrationFailure[] { + const failures: MigrationFailure[] = []; + for (const check of RELATION_CHECKS) { + if (!availableTables.has(check.table) || !availableTables.has(check.referencedTable)) continue; + if (!hasColumn(source, check.table, check.column)) { + failures.push({ table: check.table, reason: "missing_column", detail: check.column }); + continue; + } + if (!hasColumn(source, check.referencedTable, check.referencedColumn)) { + failures.push({ table: check.referencedTable, reason: "missing_column", detail: check.referencedColumn }); + continue; + } + failures.push(...danglingReferences(source, check)); + } + return failures; +} + +function danglingReferences(source: DatabaseSync, check: RelationCheck): MigrationFailure[] { + const table = quoteIdentifier(check.table); + const referencedTable = quoteIdentifier(check.referencedTable); + const column = quoteIdentifier(check.column); + const referencedColumn = quoteIdentifier(check.referencedColumn); + const recordKey = recordKeyExpression(source, check.table); + const rows = source.prepare(` + select ${recordKey} as record_id, child.${column} as missing_ref + from ${table} child + left join ${referencedTable} parent on child.${column} = parent.${referencedColumn} + where child.${column} is not null and parent.${referencedColumn} is null + order by record_id + `).all() as Array<{ record_id: unknown; missing_ref: unknown }>; + return rows.map((row) => ({ + table: check.table, + reason: check.reason, + detail: `${String(row.record_id)} -> ${String(row.missing_ref)}`, + })); +} + +function hasColumn(source: DatabaseSync, table: string, columnName: string): boolean { + const columns = source.prepare(`pragma table_info(${quoteIdentifier(table)})`).all() as Array<{ name: string }>; + return columns.some((column) => column.name === columnName); +} + +function orderingColumn(source: DatabaseSync, table: string): string { + if (hasColumn(source, table, "id")) return quoteIdentifier("id"); + return "rowid"; +} + +function recordKeyExpression(source: DatabaseSync, table: string): string { + if (hasColumn(source, table, "id")) return `child.${quoteIdentifier("id")}`; + return "child.rowid"; +} + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function fingerprintSource(body: Omit): string { + return createHash("sha256").update(JSON.stringify(body)).digest("hex"); +} + +function parseExistingReport(reportJson: string): { sourceFingerprint?: string } { + const parsed = JSON.parse(reportJson) as unknown; + if (!isRecord(parsed)) throw new Error("Existing migration report is invalid."); + const fingerprint = parsed.sourceFingerprint; + if (fingerprint !== undefined && typeof fingerprint !== "string") throw new Error("Existing migration report is invalid."); + return { sourceFingerprint: fingerprint }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; } diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts new file mode 100644 index 00000000..74fe70a6 --- /dev/null +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -0,0 +1,196 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { MigrationService } from "../../src/main/services/migration-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-migration-${randomUUID()}.db`)); +} + +function createOrganization(organization: OrganizationService): string { + return organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); +} + +function createLegacyDatabase(sql: string): string { + const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`); + const legacy = new DatabaseSync(legacyPath); + legacy.exec(sql); + legacy.close(); + return legacyPath; +} + +interface MigrationReportBody { + sourceFingerprint: string; + tableCounts: Record; + tableChecksums: Record; + idMaps: Record; + failures: Array<{ table: string; reason: string; detail: string }>; + rollbackPack: { + sourceDatabasePath: string; + sourceFingerprint: string; + tableChecksums: Record; + idMaps: Record; + }; +} + +describe("migration service", () => { + test("creates idempotent reports with checksums, id maps, rollback pack, and integrity failures", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const migrations = new MigrationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const legacyPath = createLegacyDatabase(` + create table organizations(id text primary key); + insert into organizations(id) values ('org-1'); + create table employees(id text primary key, org_id text); + insert into employees(id, org_id) values ('emp-1', 'org-1'); + create table agents(id text primary key, org_id text); + insert into agents(id, org_id) values ('agent-1', 'org-missing'); + create table tasks(id text primary key, org_id text, assignee_agent_id text); + insert into tasks(id, org_id, assignee_agent_id) values ('task-1', 'org-1', 'agent-missing'); + `); + let orgId = ""; + let firstReportId = ""; + let secondReportId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + firstReportId = migrations.createReport({ orgId, sourceDatabasePath: legacyPath }); + secondReportId = migrations.createReport({ orgId, sourceDatabasePath: legacyPath }); + }); + + const state = snapshot.load(); + const body = JSON.parse(state.migrationReports[0]!.reportJson) as MigrationReportBody; + expect(secondReportId).toBe(firstReportId); + expect(state.migrationReports).toHaveLength(1); + expect(body.sourceFingerprint).toHaveLength(64); + expect(body.tableCounts.organizations).toBe(1); + expect(body.tableChecksums.agents).toHaveLength(64); + expect(body.idMaps.tasks).toEqual(["task-1"]); + expect(body.rollbackPack.sourceFingerprint).toBe(body.sourceFingerprint); + expect(body.rollbackPack.tableChecksums.agents).toBe(body.tableChecksums.agents); + expect(body.rollbackPack.idMaps.tasks).toEqual(["task-1"]); + expect(body.failures).toContainEqual({ table: "agents", reason: "dangling_org_id", detail: "agent-1 -> org-missing" }); + expect(body.failures).toContainEqual({ table: "tasks", reason: "dangling_assignee_agent_id", detail: "task-1 -> agent-missing" }); + connection.close(); + }); + + test("captures rebuilt domain tables and direct relation health", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const migrations = new MigrationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const legacyPath = createLegacyDatabase(` + create table organizations(id text primary key); + create table employees(id text primary key, org_id text); + create table agents(id text primary key, org_id text, model_provider_id text, manager_agent_id text, workspace_id text); + create table tasks(id text primary key, org_id text, assignee_agent_id text, project_id text, goal_id text, workspace_id text); + create table workspaces(id text primary key, org_id text, project_id text); + create table goals(id text primary key, org_id text, owner_agent_id text); + create table projects(id text primary key, org_id text, goal_id text, lead_agent_id text); + create table task_runs(id text primary key, org_id text, task_id text, agent_id text, provider_id text); + create table approvals(id text primary key, org_id text, decided_by_employee_id text); + create table chat_spaces(id text primary key, org_id text); + create table chat_messages(id text primary key, org_id text, space_id text, thread_id text); + create table chat_threads(id text primary key, org_id text, space_id text, root_message_id text); + create table chat_memberships(space_id text, principal_type text, principal_id text); + create table chat_attachments(message_id text, file_id text); + create table chat_mentions(message_id text, target_type text, target_id text); + create table chat_slash_invocations(id text primary key, message_id text); + create table chat_presence(principal_type text, principal_id text); + create table chat_read_state(org_id text, employee_id text, space_id text, last_read_message_id text); + create table providers(id text primary key, org_id text, credential_id text); + create table provider_requests(id text primary key, org_id text, provider_id text); + create table credentials(id text primary key, org_id text); + create table skill_definitions(id text primary key, org_id text); + create table skill_revisions(id text primary key, skill_id text); + create table skill_artifacts(id text primary key, revision_id text); + create table skill_tests(id text primary key, revision_id text); + create table skill_dependencies(revision_id text, depends_on_revision_id text); + create table skill_bindings(id text primary key, org_id text, agent_id text, skill_id text, pinned_revision_id text); + create table skill_runtime_snapshots(id text primary key, agent_id text, run_id text); + create table skill_catalog_tags(skill_id text, tag text, tag_type text); + create table file_objects(id text primary key, org_id text, space_id text); + create table social_accounts(id text primary key, org_id text, authorized_employee_id text); + create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text); + create table browser_actions(id text primary key, org_id text, social_account_id text, evidence_file_id text); + create table scheduled_posts(id text primary key, org_id text, social_account_id text, approval_id text, created_by_employee_id text); + create table policy_rules(id text primary key, org_id text); + create table audit_events(id text primary key, org_id text); + insert into organizations(id) values ('org-1'); + insert into employees(id, org_id) values ('emp-1', 'org-1'); + insert into credentials(id, org_id) values ('cred-1', 'org-1'); + insert into providers(id, org_id, credential_id) values ('prov-1', 'org-1', 'cred-1'); + insert into chat_spaces(id, org_id) values ('space-1', 'org-1'); + insert into file_objects(id, org_id, space_id) values ('file-1', 'org-1', 'space-1'); + insert into workspaces(id, org_id, project_id) values ('wsp-1', 'org-1', 'prj-1'); + insert into agents(id, org_id, model_provider_id, manager_agent_id, workspace_id) values ('agent-1', 'org-1', 'prov-1', null, 'wsp-1'); + insert into goals(id, org_id, owner_agent_id) values ('goal-1', 'org-1', 'agent-1'); + insert into projects(id, org_id, goal_id, lead_agent_id) values ('prj-1', 'org-1', 'goal-1', 'agent-1'); + insert into tasks(id, org_id, assignee_agent_id, project_id, goal_id, workspace_id) values ('task-1', 'org-1', 'agent-1', 'prj-1', 'goal-1', 'wsp-1'); + insert into task_runs(id, org_id, task_id, agent_id, provider_id) values ('run-1', 'org-1', 'task-1', 'agent-1', 'prov-1'); + insert into approvals(id, org_id, decided_by_employee_id) values ('approval-1', 'org-1', 'emp-1'); + insert into chat_threads(id, org_id, space_id, root_message_id) values ('thread-1', 'org-1', 'space-1', null); + insert into chat_messages(id, org_id, space_id, thread_id) values ('msg-1', 'org-1', 'space-1', 'thread-1'); + insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1'); + insert into chat_attachments(message_id, file_id) values ('msg-1', 'file-1'); + insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1'); + insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1'); + insert into chat_presence(principal_type, principal_id) values ('agent', 'agent-1'); + insert into chat_read_state(org_id, employee_id, space_id, last_read_message_id) values ('org-1', 'emp-1', 'space-1', 'msg-1'); + insert into provider_requests(id, org_id, provider_id) values ('req-1', 'org-1', 'prov-1'); + insert into skill_definitions(id, org_id) values ('skill-1', 'org-1'); + insert into skill_revisions(id, skill_id) values ('rev-1', 'skill-1'), ('rev-2', 'skill-1'); + insert into skill_artifacts(id, revision_id) values ('artifact-1', 'rev-1'); + insert into skill_tests(id, revision_id) values ('test-1', 'rev-1'); + insert into skill_dependencies(revision_id, depends_on_revision_id) values ('rev-1', 'rev-2'); + insert into skill_bindings(id, org_id, agent_id, skill_id, pinned_revision_id) values ('binding-1', 'org-1', 'agent-1', 'skill-1', 'rev-1'); + insert into skill_runtime_snapshots(id, agent_id, run_id) values ('snapshot-1', 'agent-1', 'run-1'); + insert into skill_catalog_tags(skill_id, tag, tag_type) values ('skill-1', 'engineering', 'department'); + insert into social_accounts(id, org_id, authorized_employee_id) values ('social-1', 'org-1', 'emp-1'); + insert into social_account_grants(id, org_id, social_account_id, employee_id) values ('grant-1', 'org-1', 'social-1', 'emp-1'); + insert into browser_actions(id, org_id, social_account_id, evidence_file_id) values ('browser-1', 'org-1', 'social-1', 'file-1'); + insert into scheduled_posts(id, org_id, social_account_id, approval_id, created_by_employee_id) values ('post-1', 'org-1', 'social-1', 'approval-1', 'emp-1'); + insert into policy_rules(id, org_id) values ('policy-1', 'org-1'); + insert into audit_events(id, org_id) values ('audit-1', 'org-1'); + `); + let orgId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + migrations.createReport({ orgId, sourceDatabasePath: legacyPath }); + }); + + const body = JSON.parse(snapshot.load().migrationReports[0]!.reportJson) as MigrationReportBody; + expect(body.failures).toEqual([]); + expect(body.tableCounts.credentials).toBe(1); + expect(body.tableCounts.skill_artifacts).toBe(1); + expect(body.tableCounts.chat_read_state).toBe(1); + expect(body.tableCounts.provider_requests).toBe(1); + expect(body.tableChecksums.audit_events).toHaveLength(64); + expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); + connection.close(); + }); + + test("rejects reports for unknown target organizations", () => { + const connection = createConnection(); + const migrations = new MigrationService(connection.db); + const legacyPath = createLegacyDatabase("create table organizations(id text primary key);"); + + expect(() => connection.transaction(() => migrations.createReport({ + orgId: "org_missing", + sourceDatabasePath: legacyPath, + }))).toThrow("Organization was not found."); + connection.close(); + }); +});