diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 7ec21015..f30e3d60 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -19,6 +19,7 @@ import { IdentityAccessService } from "../services/identity-access-service"; import { OperationsService } from "../services/operations-service"; import { SdlcService } from "../services/sdlc-service"; import { AuditService } from "../services/audit-service"; +import { RollbackService } from "../services/rollback-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -42,6 +43,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const operations = new OperationsService(deps.connection.db); const sdlc = new SdlcService(deps.connection.db); const audit = new AuditService(deps.connection.db); + const rollback = new RollbackService(deps.connection.db); const onboarding = new OnboardingService({ db: deps.connection.db, saveProviderCredential: (input) => providers.saveCredential(input), @@ -159,6 +161,8 @@ 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.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/database/migrations/020-rollback-snapshots.ts b/AgentCompany-New/src/main/database/migrations/020-rollback-snapshots.ts new file mode 100644 index 00000000..2b430ea2 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/020-rollback-snapshots.ts @@ -0,0 +1,25 @@ +export const rollbackSnapshotsSchema = ` + create table if not exists rollback_snapshots ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + migration_report_id text not null references migration_reports(id) on delete cascade, + source_database_path text not null, + source_fingerprint text not null, + source_database_sha256 text not null, + workspace_manifest_json text not null, + workspace_manifest_sha256 text not null, + delta_journal_json text not null, + restore_plan_json text not null, + active integer not null, + created_by_employee_id text references employees(id) on delete set null, + created_at text not null, + activated_at text + ); + + create index if not exists idx_rollback_snapshots_org_created on rollback_snapshots(org_id, created_at); + create index if not exists idx_rollback_snapshots_report on rollback_snapshots(migration_report_id); + create unique index if not exists idx_rollback_snapshots_one_active on rollback_snapshots(org_id) where active = 1; + + insert into schema_migrations(version, name, applied_at) + values (20, 'rollback_snapshots', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index d019601a..a1a632c0 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -18,6 +18,7 @@ import { orgGovernanceSchema } from "./migrations/016-org-governance"; import { operationsControlsSchema } from "./migrations/017-operations-controls"; import { sdlcControlsSchema } from "./migrations/018-sdlc-controls"; import { auditReplaySchema } from "./migrations/019-audit-replay"; +import { rollbackSnapshotsSchema } from "./migrations/020-rollback-snapshots"; interface Migration { version: number; @@ -45,6 +46,7 @@ const MIGRATIONS: Migration[] = [ { version: 17, name: "operations_controls", sql: operationsControlsSchema }, { version: 18, name: "sdlc_controls", sql: sdlcControlsSchema }, { version: 19, name: "audit_replay", sql: auditReplaySchema }, + { version: 20, name: "rollback_snapshots", sql: rollbackSnapshotsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index a3512976..98e94ef0 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -71,6 +71,7 @@ const LEGACY_TABLES = [ "retention_freezes", "search_documents", "audit_replay_runs", + "rollback_snapshots", "audit_events", ]; @@ -226,6 +227,9 @@ const RELATION_CHECKS: RelationCheck[] = [ { table: "audit_replay_runs", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, { table: "audit_replay_runs", column: "thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_thread_id" }, { table: "audit_replay_runs", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, + { table: "rollback_snapshots", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, + { table: "rollback_snapshots", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" }, + { table: "rollback_snapshots", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" }, { table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" }, ]; diff --git a/AgentCompany-New/src/main/services/rollback-service.ts b/AgentCompany-New/src/main/services/rollback-service.ts new file mode 100644 index 00000000..d58d3b6b --- /dev/null +++ b/AgentCompany-New/src/main/services/rollback-service.ts @@ -0,0 +1,250 @@ +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 { createId } from "../../shared/ids"; +import { AuditService } from "./audit-service"; + +interface MigrationReportRow { + id: string; + source_ref: string; + report_json: string; + created_at: string; +} + +interface MigrationReportBody { + sourceFingerprint: string; + rollbackPack: { + sourceDatabasePath: string; + restoreStrategy: string; + sourceFingerprint: string; + }; +} + +interface WorkspaceRow { + id: string; + name: string; + local_path: string; + repo_url: string; + repo_ref: string; + is_primary: number; + updated_at: string; +} + +interface FileRow { + id: string; + name: string; + size_bytes: number; + sha256: string; + storage_path: string; + scan_state: string; + updated_at: string | null; +} + +interface AuditRow { + id: string; + entity_type: string; + entity_id: string; + actor_type: string; + actor_id: string | null; + action: string; + payload_json: string; + created_at: string; +} + +export class RollbackService { + private readonly audit: AuditService; + + constructor(private readonly db: DatabaseSync) { + this.audit = new AuditService(db); + } + + createSnapshot(input: CreateRollbackSnapshotInput): string { + this.requireEmployee(input.orgId, input.createdByEmployeeId); + const report = this.requireReport(input.orgId, input.migrationReportId); + const body = parseMigrationReport(report.report_json); + const sourceDatabasePath = realSourcePath(body.rollbackPack.sourceDatabasePath); + if (sourceDatabasePath !== realSourcePath(report.source_ref)) throw new Error("Rollback source path does not match the migration report."); + if (body.sourceFingerprint !== body.rollbackPack.sourceFingerprint) throw new Error("Rollback pack fingerprint is invalid."); + const sourceDatabaseSha256 = fileSha256(sourceDatabasePath); + const workspaceManifestJson = JSON.stringify(this.workspaceManifest(input.orgId)); + const workspaceManifestSha256 = sha256(workspaceManifestJson); + const deltaJournalJson = JSON.stringify(this.deltaJournal(input.orgId, report.created_at)); + const restorePlanJson = JSON.stringify({ + migrationReportId: report.id, + sourceDatabasePath, + sourceFingerprint: body.sourceFingerprint, + sourceDatabaseSha256, + workspaceManifestSha256, + restoreStrategy: body.rollbackPack.restoreStrategy, + }); + const id = createId("rbs"); + const now = new Date().toISOString(); + this.db.prepare(` + insert into rollback_snapshots( + id, org_id, migration_report_id, source_database_path, source_fingerprint, + source_database_sha256, workspace_manifest_json, workspace_manifest_sha256, + delta_journal_json, restore_plan_json, active, created_by_employee_id, created_at, activated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, null) + `).run( + id, + input.orgId, + report.id, + sourceDatabasePath, + body.sourceFingerprint, + sourceDatabaseSha256, + workspaceManifestJson, + workspaceManifestSha256, + deltaJournalJson, + restorePlanJson, + input.createdByEmployeeId, + now, + ); + this.audit.record({ + orgId: input.orgId, + entityType: "rollback_snapshot", + entityId: id, + actorType: "employee", + actorId: input.createdByEmployeeId, + action: "created", + payload: { migrationReportId: report.id, sourceFingerprint: body.sourceFingerprint }, + }); + return id; + } + + setActive(input: SetRollbackSnapshotActiveInput): void { + this.requireEmployee(input.orgId, input.employeeId); + const snapshot = this.db.prepare("select id from rollback_snapshots where id = ? and org_id = ?") + .get(input.snapshotId, input.orgId) as { id: string } | undefined; + if (!snapshot) throw new Error("Rollback snapshot was not found."); + const now = new Date().toISOString(); + if (input.active) this.db.prepare("update rollback_snapshots set active = 0, activated_at = null where org_id = ?").run(input.orgId); + const result = this.db.prepare("update rollback_snapshots set active = ?, activated_at = ? where id = ? and org_id = ?") + .run(input.active ? 1 : 0, input.active ? now : null, input.snapshotId, input.orgId); + if (result.changes === 0) throw new Error("Rollback snapshot was not found."); + this.audit.record({ + orgId: input.orgId, + entityType: "rollback_snapshot", + entityId: input.snapshotId, + actorType: "employee", + actorId: input.employeeId, + action: input.active ? "activated" : "deactivated", + payload: { active: input.active }, + }); + } + + 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; + if (!report) throw new Error("Migration report was not found."); + return report; + } + + private requireEmployee(orgId: string, employeeId: string): void { + const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId); + if (!employee) throw new Error("Rollback employee was not found."); + } + + private workspaceManifest(orgId: string): { workspaces: unknown[]; files: unknown[] } { + const workspaces = this.db.prepare(` + select id, name, local_path, repo_url, repo_ref, is_primary, updated_at + from workspaces + where org_id = ? + order by id asc + `).all(orgId) as unknown as WorkspaceRow[]; + const files = this.db.prepare(` + select id, name, size_bytes, sha256, storage_path, scan_state, updated_at + from file_objects + where org_id = ? + order by id asc + `).all(orgId) as unknown as FileRow[]; + return { + workspaces: workspaces.map((row) => ({ + id: row.id, + name: row.name, + localPath: row.local_path, + repoUrl: row.repo_url, + repoRef: row.repo_ref, + isPrimary: row.is_primary === 1, + updatedAt: row.updated_at, + })), + files: files.map((row) => ({ + id: row.id, + name: row.name, + sizeBytes: row.size_bytes, + sha256: row.sha256, + storagePath: row.storage_path, + scanState: row.scan_state, + updatedAt: row.updated_at, + })), + }; + } + + private deltaJournal(orgId: string, since: string): unknown[] { + const rows = this.db.prepare(` + select id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at + from audit_events + where org_id = ? and created_at >= ? + order by created_at asc, id asc + `).all(orgId, since) as unknown as AuditRow[]; + return rows.map((row) => ({ + id: row.id, + entityType: row.entity_type, + entityId: row.entity_id, + actorType: row.actor_type, + actorId: row.actor_id, + action: row.action, + payload: parseRecord(row.payload_json), + createdAt: row.created_at, + })); + } +} + +function parseMigrationReport(reportJson: string): MigrationReportBody { + const parsed = parseRecord(reportJson); + const rollbackPack = parsed.rollbackPack; + if (typeof parsed.sourceFingerprint !== "string") throw new Error("Migration report is missing a source fingerprint."); + if (typeof rollbackPack !== "object" || rollbackPack === null || Array.isArray(rollbackPack)) throw new Error("Migration report is missing a rollback pack."); + const pack = rollbackPack as Record; + if (typeof pack.sourceDatabasePath !== "string" || typeof pack.restoreStrategy !== "string" || typeof pack.sourceFingerprint !== "string") { + throw new Error("Migration rollback pack is invalid."); + } + return { + sourceFingerprint: parsed.sourceFingerprint, + rollbackPack: { + sourceDatabasePath: pack.sourceDatabasePath, + restoreStrategy: pack.restoreStrategy, + sourceFingerprint: pack.sourceFingerprint, + }, + }; +} + +function realSourcePath(path: string): string { + if (!existsSync(path)) throw new Error("Rollback source database was not found."); + return realpathSync.native(path); +} + +function fileSha256(path: string): string { + const fd = openSync(path, "r"); + try { + const hash = createHash("sha256"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + for (;;) { + const bytes = readSync(fd, buffer, 0, buffer.length, null); + if (bytes === 0) return hash.digest("hex"); + hash.update(buffer.subarray(0, bytes)); + } + } finally { + closeSync(fd); + } +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function parseRecord(json: string): Record { + const parsed = JSON.parse(json) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JSON record is invalid."); + return parsed as Record; +} diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 51eb0f78..9bd213bd 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -245,7 +245,7 @@ export class SdlcService { const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations"); return [ { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, - { key: "schema_version", passed: schemaVersion >= 19, value: schemaVersion }, + { key: "schema_version", passed: schemaVersion >= 20, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 6b3f5090..81fd7607 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -63,6 +63,8 @@ export class SnapshotService { .map((agent) => ({ ...agent, enabled: toBool(agent.enabled) })); const prBotGates = list(this.db, "pr_bot_gates", "key asc") .map((gate) => ({ ...gate, enabled: toBool(gate.enabled) })); + const rollbackSnapshots = list(this.db, "rollback_snapshots", "created_at desc") + .map((snapshot) => ({ ...snapshot, active: toBool(snapshot.active) })); return { activeOrgId, @@ -99,6 +101,7 @@ export class SnapshotService { browserActions: list(this.db, "browser_actions", "created_at desc"), policyRules: list(this.db, "policy_rules", "created_at asc"), migrationReports: list(this.db, "migration_reports", "created_at desc"), + rollbackSnapshots, complianceExports: list(this.db, "compliance_exports", "created_at desc"), legalHolds, retentionFreezes, diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index b9018d0a..bf06160d 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -38,6 +38,8 @@ const api: DesktopApi = { importFile: (input) => invoke(IPC_CHANNELS.importFile, input), setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input), createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input), + createRollbackSnapshot: (input) => invoke(IPC_CHANNELS.createRollbackSnapshot, input), + setRollbackSnapshotActive: (input) => invoke(IPC_CHANNELS.setRollbackSnapshotActive, 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 a3919b37..9738da04 100644 --- a/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/SettingsPage.tsx @@ -101,6 +101,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const evalRuns = useMemo(() => snapshot.evalRuns.filter((item) => item.orgId === orgId), [snapshot.evalRuns, orgId]); const prBotGates = useMemo(() => snapshot.prBotGates.filter((item) => item.orgId === orgId), [snapshot.prBotGates, orgId]); const auditReplayRuns = useMemo(() => snapshot.auditReplayRuns.filter((item) => item.orgId === orgId), [snapshot.auditReplayRuns, orgId]); + const rollbackSnapshots = useMemo(() => snapshot.rollbackSnapshots.filter((item) => item.orgId === orgId), [snapshot.rollbackSnapshots, orgId]); return (
@@ -198,7 +199,15 @@ export function SettingsPage({ snapshot, actions }: PageProps) { actions={actions} /> )} - {tab === "migration" && } + {tab === "migration" && ( + + )} {tab === "profile" && } {tab === "notifications" && } {tab === "security" && } diff --git a/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/MigrationPanel.tsx index e7e5e912..a6a83e19 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 } from "../../../../shared/domain"; +import type { MigrationReportRecord, RollbackSnapshotRecord } from "../../../../shared/domain"; import type { SnapshotActions } from "../../app/useSnapshot"; import { formatRelative } from "../types"; @@ -15,11 +15,15 @@ interface ParsedReport { export function MigrationPanel({ orgId, + employeeId, reports, + rollbackSnapshots, actions, }: { orgId: string; + employeeId: string; reports: MigrationReportRecord[]; + rollbackSnapshots: RollbackSnapshotRecord[]; actions: SnapshotActions; }) { const [open, setOpen] = useState(false); @@ -47,10 +51,33 @@ export function MigrationPanel({ {reports.length > 0 && (
{reports.map((report, index) => ( - + ))}
)} +
+
+ +
Rollback snapshots
+
+ {rollbackSnapshots.length > 0 ? rollbackSnapshots.map((snapshot, index) => ( + + )) :
0 rollback snapshots
} +
setOpen(false)} onSubmit={submit}> @@ -58,13 +85,25 @@ export function MigrationPanel({ ); } -function ReportRow({ report, last }: { report: MigrationReportRecord; last: boolean }) { +function ReportRow({ + orgId, + employeeId, + report, + actions, + last, +}: { + orgId: string; + employeeId: string; + report: MigrationReportRecord; + actions: SnapshotActions; + last: boolean; +}) { const body = parseReport(report.reportJson); const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0); return (
{report.sourceRef}
@@ -80,6 +119,50 @@ function ReportRow({ report, last }: { report: MigrationReportRecord; last: bool {idCount} ids {body.failures.length > 0 ? {body.failures.length} failures : null}
+ +
+ ); +} + +function RollbackRow({ + orgId, + employeeId, + snapshot, + actions, + last, +}: { + orgId: string; + employeeId: string; + snapshot: RollbackSnapshotRecord; + actions: SnapshotActions; + last: boolean; +}) { + const manifest = JSON.parse(snapshot.workspaceManifestJson) as { workspaces: unknown[]; files: unknown[] }; + return ( +
+
+
{snapshot.sourceDatabasePath}
+
{formatRelative(snapshot.createdAt)}
+
+ {snapshot.sourceDatabaseSha256.slice(0, 12)} + {manifest.workspaces.length} workspaces + {manifest.files.length} files +
); } diff --git a/AgentCompany-New/src/shared/commands.ts b/AgentCompany-New/src/shared/commands.ts index f3e40ad2..0b1fc18d 100644 --- a/AgentCompany-New/src/shared/commands.ts +++ b/AgentCompany-New/src/shared/commands.ts @@ -211,6 +211,19 @@ export interface CreateMigrationReportInput { sourceDatabasePath: string; } +export interface CreateRollbackSnapshotInput { + orgId: string; + migrationReportId: string; + createdByEmployeeId: string; +} + +export interface SetRollbackSnapshotActiveInput { + orgId: string; + snapshotId: string; + active: boolean; + 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 99060408..80a61386 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -16,6 +16,7 @@ import type { CreateOrganizationInput, CreateProviderResponseInput, CreateRetentionFreezeInput, + CreateRollbackSnapshotInput, CreateScheduledPostInput, CreateSocialAccountInput, CreateTaskInput, @@ -38,6 +39,7 @@ import type { SetFeatureFlagInput, SetLegalHoldStateInput, SetPrBotGateInput, + SetRollbackSnapshotActiveInput, SetRetentionFreezeStateInput, SetScheduledPostStateInput, SyncEmployeeIdentityInput, @@ -81,6 +83,8 @@ export interface DesktopApi { importFile(input: ImportFileInput): Promise>; setFilePinned(input: SetFilePinnedInput): Promise>; createMigrationReport(input: CreateMigrationReportInput): Promise>; + createRollbackSnapshot(input: CreateRollbackSnapshotInput): Promise>; + setRollbackSnapshotActive(input: SetRollbackSnapshotActiveInput): Promise>; setFeatureFlag(input: SetFeatureFlagInput): Promise>; runChaosDrill(input: RunChaosDrillInput): Promise>; runSdlcReview(input: RunSdlcReviewInput): Promise>; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index 8750f3b9..6ac50f2d 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -5,6 +5,7 @@ import type { AccessDecisionRecord, EmployeeIdentityRecord, RoleGrantRecord, Ser import type { ApprovalAssignmentRecord, ApprovalRoutingRuleRecord, HiringPolicyRecord, OrgDepartmentRecord } from "./org-governance-domain"; import type { ChaosDrillRecord, ChaosDrillRunRecord, FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from "./operations-domain"; import type { ProviderDataPolicy, ProviderExecutionMode } from "./provider-policy-domain"; +import type { RollbackSnapshotRecord } from "./rollback-domain"; import type { EvalCaseRecord, EvalRunRecord, EvalSuiteRecord, PrBotGateRecord, SdlcReviewAgentRecord, SdlcReviewRunRecord } from "./sdlc-domain"; export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain"; export type { AuditReplayRunRecord } from "./audit-domain"; @@ -13,6 +14,7 @@ export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type A export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain"; export { CHAOS_DRILL_SCENARIO, CHAOS_DRILL_STATE, FEATURE_FLAG_KEY, SLO_SIGNAL_KEY, type ChaosDrillRecord, type ChaosDrillRunRecord, type ChaosDrillScenario, type ChaosDrillState, type FeatureFlagKey, type FeatureFlagRecord, type OperationalEventRecord, type SloSignalKey, type SloTargetRecord } from "./operations-domain"; export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; +export type { RollbackSnapshotRecord } from "./rollback-domain"; export { EVAL_SUITE_KEY, PR_BOT_GATE_KEY, SDLC_REVIEW_AGENT_KEY, SDLC_REVIEW_TARGET_TYPE, SDLC_RUN_STATE, type EvalCaseRecord, type EvalRunRecord, type EvalSuiteKey, type EvalSuiteRecord, type PrBotGateKey, type PrBotGateRecord, type SdlcReviewAgentKey, type SdlcReviewAgentRecord, type SdlcReviewRunRecord, type SdlcReviewTargetType, type SdlcRunState } from "./sdlc-domain"; export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; @@ -474,6 +476,7 @@ export interface AppSnapshot { browserActions: BrowserActionRecord[]; policyRules: PolicyRuleRecord[]; migrationReports: MigrationReportRecord[]; + rollbackSnapshots: RollbackSnapshotRecord[]; complianceExports: ComplianceExportRecord[]; legalHolds: LegalHoldRecord[]; retentionFreezes: RetentionFreezeRecord[]; employeeIdentities: EmployeeIdentityRecord[]; serviceIdentities: ServiceIdentityRecord[]; roleGrants: RoleGrantRecord[]; accessDecisions: AccessDecisionRecord[]; orgDepartments: OrgDepartmentRecord[]; hiringPolicies: HiringPolicyRecord[]; approvalRoutingRules: ApprovalRoutingRuleRecord[]; approvalAssignments: ApprovalAssignmentRecord[]; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index dc967062..5aba717d 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -30,6 +30,8 @@ export const IPC_CHANNELS = { importFile: "agentcompany:import-file", setFilePinned: "agentcompany:set-file-pinned", createMigrationReport: "agentcompany:create-migration-report", + createRollbackSnapshot: "agentcompany:create-rollback-snapshot", + setRollbackSnapshotActive: "agentcompany:set-rollback-snapshot-active", setFeatureFlag: "agentcompany:set-feature-flag", runChaosDrill: "agentcompany:run-chaos-drill", runSdlcReview: "agentcompany:run-sdlc-review", diff --git a/AgentCompany-New/src/shared/rollback-domain.ts b/AgentCompany-New/src/shared/rollback-domain.ts new file mode 100644 index 00000000..84f6b0a4 --- /dev/null +++ b/AgentCompany-New/src/shared/rollback-domain.ts @@ -0,0 +1,16 @@ +export interface RollbackSnapshotRecord { + id: string; + orgId: string; + migrationReportId: string; + sourceDatabasePath: string; + sourceFingerprint: string; + sourceDatabaseSha256: string; + workspaceManifestJson: string; + workspaceManifestSha256: string; + deltaJournalJson: string; + restorePlanJson: string; + active: boolean; + createdByEmployeeId: string | null; + createdAt: string; + activatedAt: string | null; +} diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 7f48ec36..5aa27ec5 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -3,6 +3,7 @@ import { mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { randomUUID } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; test("onboards, hires, and creates work in the Electron shell", async () => { const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`); @@ -133,12 +134,32 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText("Replay runs", { exact: true })).toBeVisible(); await expect(page.getByText(/\d+ events ยท/).first()).toBeVisible(); + const legacyPath = createLegacyDatabase(); + await page.getByRole("button", { name: "Migration" }).click(); + await page.getByRole("button", { name: "Report" }).click(); + await page.getByLabel("Source database path").fill(legacyPath); + await page.locator(".modal").getByRole("button", { name: "Create" }).click(); + await expect(page.getByText("organizations: 1").first()).toBeVisible(); + await page.getByRole("button", { name: "Snapshot" }).click(); + await expect(page.getByText("Rollback snapshots", { exact: true })).toBeVisible(); + await expect(page.getByText(/\d+ workspaces/).first()).toBeVisible(); + await page.getByRole("button", { name: "Set", exact: true }).click(); + await expect(page.getByRole("button", { name: "active", exact: true })).toBeDisabled(); + await verifyPagesDoNotOverflow(page); } finally { await app.close(); } }); +function createLegacyDatabase(): string { + const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`); + const legacy = new DatabaseSync(legacyPath); + legacy.exec("create table organizations(id text primary key); insert into organizations(id) values ('legacy-org');"); + legacy.close(); + return legacyPath; +} + async function verifyPagesDoNotOverflow(page: Page): Promise { for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) { await page.setViewportSize(size); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 3d793222..e1bee669 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -23,7 +23,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/migration-service.test.ts b/AgentCompany-New/tests/unit/migration-service.test.ts index c2058c94..111872c4 100644 --- a/AgentCompany-New/tests/unit/migration-service.test.ts +++ b/AgentCompany-New/tests/unit/migration-service.test.ts @@ -153,6 +153,7 @@ describe("migration service", () => { create table retention_freezes(id text primary key, org_id text, created_by_employee_id text); create table search_documents(entity_id text); create table audit_replay_runs(id text primary key, org_id text, thread_id text, created_by_employee_id text); + create table rollback_snapshots(id text primary key, org_id text, migration_report_id text, created_by_employee_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'); @@ -216,6 +217,7 @@ describe("migration service", () => { insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1'); insert into search_documents(entity_id) values ('task-1'); insert into audit_replay_runs(id, org_id, thread_id, created_by_employee_id) values ('replay-1', 'org-1', 'thread-1', 'emp-1'); + insert into rollback_snapshots(id, org_id, migration_report_id, created_by_employee_id) values ('rollback-1', 'org-1', 'migration-1', 'emp-1'); insert into audit_events(id, org_id) values ('audit-1', 'org-1'); `); let orgId = ""; @@ -244,6 +246,7 @@ describe("migration service", () => { expect(body.tableCounts.provider_usage_windows).toBe(1); expect(body.tableCounts.compliance_exports).toBe(1); expect(body.tableCounts.audit_replay_runs).toBe(1); + expect(body.tableCounts.rollback_snapshots).toBe(1); expect(body.tableCounts.search_documents).toBe(1); expect(body.tableChecksums.audit_events).toHaveLength(64); expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]); @@ -257,6 +260,7 @@ describe("migration service", () => { expect(body.idMaps.board_interventions).toEqual(["bdi-1"]); expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]); expect(body.idMaps.audit_replay_runs).toEqual(["replay-1"]); + expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]); expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/rollback-service.test.ts b/AgentCompany-New/tests/unit/rollback-service.test.ts new file mode 100644 index 00000000..c18f4701 --- /dev/null +++ b/AgentCompany-New/tests/unit/rollback-service.test.ts @@ -0,0 +1,99 @@ +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 { RollbackService } from "../../src/main/services/rollback-service"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-rollback-${randomUUID()}.db`)); +} + +function createLegacyDatabase(): string { + const legacyPath = join(tmpdir(), `agentcompany-legacy-${randomUUID()}.db`); + const legacy = new DatabaseSync(legacyPath); + legacy.exec(` + create table organizations(id text primary key); + insert into organizations(id) values ('legacy-org'); + `); + legacy.close(); + return legacyPath; +} + +describe("rollback service", () => { + test("creates immutable rollback snapshots from migration reports and workspace manifests", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const migrations = new MigrationService(connection.db); + const rollback = new RollbackService(connection.db); + const snapshot = new SnapshotService(connection.db); + const legacyPath = createLegacyDatabase(); + let orgId = ""; + let employeeId = ""; + let reportId = ""; + let rollbackId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + seedWorkspace(connection.db, orgId); + reportId = migrations.createReport({ orgId, sourceDatabasePath: legacyPath }); + rollbackId = rollback.createSnapshot({ orgId, migrationReportId: reportId, createdByEmployeeId: employeeId }); + rollback.setActive({ orgId, snapshotId: rollbackId, active: true, employeeId }); + }); + + const state = snapshot.load(); + const record = state.rollbackSnapshots[0]; + if (!record) throw new Error("Rollback snapshot was not created."); + const workspaceManifest = JSON.parse(record.workspaceManifestJson) as { workspaces: unknown[]; files: unknown[] }; + const deltaJournal = JSON.parse(record.deltaJournalJson) as unknown[]; + const restorePlan = JSON.parse(record.restorePlanJson) as { migrationReportId: string; restoreStrategy: string }; + expect(record).toMatchObject({ orgId, migrationReportId: reportId, active: true, createdByEmployeeId: employeeId }); + expect(record.sourceDatabaseSha256).toHaveLength(64); + expect(record.workspaceManifestSha256).toHaveLength(64); + expect(workspaceManifest.workspaces).toHaveLength(1); + expect(workspaceManifest.files).toHaveLength(1); + expect(deltaJournal.length).toBeGreaterThan(0); + expect(restorePlan).toMatchObject({ migrationReportId: reportId, restoreStrategy: "legacy_snapshot_restore" }); + expect(state.auditEvents.some((event) => event.entityType === "rollback_snapshot" && event.action === "activated")).toBe(true); + connection.close(); + }); +}); + +function seedWorkspace(db: DatabaseSync, orgId: string): void { + const now = new Date().toISOString(); + db.prepare(` + insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, null, 1, ?, ?) + `).run("wsp-rollback", orgId, "Primary", "C:\\repo\\workspace", "https://example.invalid/repo.git", "main", now, now); + db.prepare(` + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, + created_at, pinned, space_id, updated_at, scan_state, scan_labels_json, scanned_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?) + `).run( + "file-rollback", + orgId, + "release.txt", + "document", + 12, + "0".repeat(64), + "artifacts/release.txt", + "system", + null, + now, + now, + "approved", + "[]", + now, + ); +}