Q136: remove rollback records that marked restores nothing performed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 21:53:26 -04:00
1 parent f8f852c326
commit 6e210301db
20 files changed
+31 -757

No files matched your search

-3
View File
@@ -52,9 +52,6 @@ export const platformContracts = {
openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType<C.OpenFileInput>,
setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType<C.SetFilePinnedInput>,
createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType<C.CreateMigrationReportInput>,
createRollbackSnapshot: z.strictObject({ orgId: id, migrationReportId: id, createdByEmployeeId: id }) satisfies z.ZodType<C.CreateRollbackSnapshotInput>,
setRollbackSnapshotActive: z.strictObject({ orgId: id, snapshotId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType<C.SetRollbackSnapshotActiveInput>,
resolveMigrationReconciliation: z.strictObject({ orgId: id, reconciliationItemId: id, employeeId: id }) satisfies z.ZodType<C.ResolveMigrationReconciliationInput>,
setFeatureFlag: z.strictObject({ orgId: id, flagId: id, enabled: z.boolean() }) satisfies z.ZodType<C.SetFeatureFlagInput>,
createComplianceExport: z.strictObject({
orgId: id, subjectType: z.enum(["employee", "agent"]), subjectId: id, exportType: z.enum(COMPLIANCE_EXPORT_TYPE), requestedByEmployeeId: id,
-5
View File
@@ -19,7 +19,6 @@ import { ComplianceService } from "../services/compliance-service";
import { SearchService } from "../services/search-service";
import { IdentityAccessService } from "../services/identity-access-service";
import { OperationsService } from "../services/operations-service";
import { RollbackService } from "../services/rollback-service";
import { TaskExecutionService } from "../services/task-execution-service";
import { DocumentService } from "../services/document-service";
import { KnowledgeService } from "../services/knowledge-service";
@@ -76,7 +75,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const search = new SearchService(deps.connection.db);
const identityAccess = new IdentityAccessService(deps.connection.db);
const operations = new OperationsService(deps.connection.db);
const rollback = new RollbackService(deps.connection.db);
const documents = new DocumentService(deps.connection.db);
const knowledge = new KnowledgeService(deps.connection.db);
const automation = new AutomationService(deps.connection.db);
@@ -370,9 +368,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
});
handle("createMigrationReport", (input) => mutate(() => migrations.createReport(input)));
handle("createRollbackSnapshot", (input) => mutate(() => rollback.createSnapshot(input)));
handle("setRollbackSnapshotActive", (input) => mutate(() => rollback.setActive(input)));
handle("resolveMigrationReconciliation", (input) => mutate(() => rollback.resolveReconciliationItem(input)));
handle("setFeatureFlag", (input) => mutate(() => operations.setFeatureFlag(input)));
handle("createComplianceExport", (input) => mutate(() => compliance.createExport(input)));
handle("createLegalHold", (input) => mutate(() => compliance.createLegalHold(input)));
@@ -0,0 +1,10 @@
// Rollback snapshots, cutover events and reconciliation items only recorded states; nothing ever restored a database, file or outside action, so reports drop their restore promise too.
export const dropRollbackRecordsSchema = `
drop table if exists migration_reconciliation_items;
drop table if exists migration_cutover_events;
drop table if exists rollback_snapshots;
update migration_reports set report_json = json_remove(report_json, '$.rollbackPack');
insert into schema_migrations(version, name, applied_at)
values (99, 'drop_rollback_records', datetime('now'));
`;
+2
View File
@@ -97,6 +97,7 @@ import { dropPrBotGatesSchema } from "./migrations/095-drop-pr-bot-gates";
import { dropChaosDrillsSchema } from "./migrations/096-drop-chaos-drills";
import { dropDrillOnlySloSchema } from "./migrations/097-drop-drill-only-slo";
import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays";
import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records";
interface Migration {
version: number;
@@ -203,6 +204,7 @@ const MIGRATIONS: Migration[] = [
{ version: 96, name: "drop_chaos_drills", sql: dropChaosDrillsSchema },
{ version: 97, name: "drop_drill_only_slo", sql: dropDrillOnlySloSchema },
{ version: 98, name: "drop_audit_replays", sql: dropAuditReplaysSchema },
{ version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
-4
View File
@@ -200,10 +200,6 @@ export class ComplianceService {
.run(orgId, subjectId);
this.db.prepare("update retention_freezes set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
.run(orgId, subjectId);
this.db.prepare("update rollback_snapshots set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
.run(orgId, subjectId);
this.db.prepare("update migration_reconciliation_items set resolved_by_employee_id = null where org_id = ? and resolved_by_employee_id = ?")
.run(orgId, subjectId);
this.db.prepare("delete from approval_assignments where org_id = ? and assignee_type = 'employee' and assignee_id = ?")
.run(orgId, subjectId);
this.db.prepare("delete from role_grants where org_id = ? and principal_type = 'employee' and principal_id = ?")
@@ -1,103 +0,0 @@
import type { DatabaseSync } from "node:sqlite";
import { createId } from "../../shared/ids";
const REPLAYABLE_ENTITY_TYPES = new Set([
"agent",
"approval",
"browser_action",
"chat_message",
"chat_space",
"file",
"scheduled_post",
"social_account",
"task",
"workspace",
]);
export interface CutoverDeltaEvent {
id: string;
entityType: string;
entityId: string;
actorType: string;
actorId: string | null;
action: string;
payload: Record<string, unknown>;
createdAt: string;
}
export interface RecordCutoverEventInput {
orgId: string;
migrationReportId: string;
rollbackSnapshotId: string | null;
eventType: string;
actorType: string;
actorId: string | null;
payload: Record<string, unknown>;
createdAt?: string;
}
export class MigrationCutoverJournalService {
constructor(private readonly db: DatabaseSync) {}
recordEvent(input: RecordCutoverEventInput): string {
const id = createId("mce");
this.db.prepare(`
insert into migration_cutover_events(
id, org_id, migration_report_id, rollback_snapshot_id, event_type,
actor_type, actor_id, payload_json, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
input.migrationReportId,
input.rollbackSnapshotId,
input.eventType,
input.actorType,
input.actorId,
JSON.stringify(input.payload),
input.createdAt ?? new Date().toISOString(),
);
return id;
}
queueReconciliation(input: {
orgId: string;
migrationReportId: string;
rollbackSnapshotId: string;
events: CutoverDeltaEvent[];
createdAt?: string;
}): number {
let queued = 0;
const createdAt = input.createdAt ?? new Date().toISOString();
const statement = this.db.prepare(`
insert or ignore into migration_reconciliation_items(
id, org_id, migration_report_id, rollback_snapshot_id, source_event_id,
target_type, target_id, reason, state, payload_json, created_at, resolved_at, resolved_by_employee_id
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null)
`);
for (const event of input.events) {
if (!REPLAYABLE_ENTITY_TYPES.has(event.entityType)) continue;
const result = statement.run(
createId("mri"),
input.orgId,
input.migrationReportId,
input.rollbackSnapshotId,
event.id,
event.entityType,
event.entityId,
"manual_delta_replay",
"open",
JSON.stringify({
action: event.action,
actorType: event.actorType,
actorId: event.actorId,
payload: event.payload,
createdAt: event.createdAt,
}),
createdAt,
);
queued += Number(result.changes);
}
return queued;
}
}
+1 -30
View File
@@ -70,9 +70,6 @@ const LEGACY_TABLES = [
"legal_holds",
"retention_freezes",
"search_documents",
"rollback_snapshots",
"migration_cutover_events",
"migration_reconciliation_items",
"audit_events",
];
@@ -98,14 +95,6 @@ interface MigrationReportBody {
missingTables: string[];
failures: MigrationFailure[];
validationSummary: MigrationValidationSummary;
rollbackPack: {
sourceDatabasePath: string;
capturedAt: string;
restoreStrategy: string;
sourceFingerprint: string;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
};
}
const RELATION_CHECKS: RelationCheck[] = [
@@ -238,16 +227,6 @@ const RELATION_CHECKS: RelationCheck[] = [
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "retention_freezes", 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: "migration_cutover_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "migration_cutover_events", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
{ table: "migration_cutover_events", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
{ table: "migration_reconciliation_items", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
{ table: "migration_reconciliation_items", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
{ table: "migration_reconciliation_items", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
{ table: "migration_reconciliation_items", column: "resolved_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_resolver_employee_id" },
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
];
@@ -314,14 +293,6 @@ export class MigrationService {
missingTables,
failures,
validationSummary,
rollbackPack: {
sourceDatabasePath,
capturedAt: now,
restoreStrategy: "legacy_snapshot_restore",
sourceFingerprint,
tableChecksums,
idMaps,
},
};
this.db.prepare(`
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
@@ -446,7 +417,7 @@ function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "rollbackPack" | "validationSummary">): string {
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "validationSummary">): string {
return createHash("sha256").update(JSON.stringify(body)).digest("hex");
}
-369
View File
@@ -1,369 +0,0 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
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";
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 RollbackSnapshotRow {
id: string;
migration_report_id: string;
source_fingerprint: string;
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;
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;
private readonly cutoverJournal: MigrationCutoverJournalService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.cutoverJournal = new MigrationCutoverJournalService(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 },
});
this.cutoverJournal.recordEvent({
orgId: input.orgId,
migrationReportId: report.id,
rollbackSnapshotId: id,
eventType: "rollback_snapshot_created",
actorType: "employee",
actorId: input.createdByEmployeeId,
payload: { sourceFingerprint: body.sourceFingerprint, sourceDatabaseSha256, workspaceManifestSha256 },
createdAt: now,
});
return id;
}
setActive(input: SetRollbackSnapshotActiveInput): void {
this.requireEmployee(input.orgId, input.employeeId);
const snapshot = this.db.prepare(`
select id, migration_report_id, source_fingerprint, delta_journal_json
from rollback_snapshots
where id = ? and org_id = ?
`).get(input.snapshotId, input.orgId) as RollbackSnapshotRow | 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 },
});
const queuedReconciliationItems = input.active
? this.cutoverJournal.queueReconciliation({
orgId: input.orgId,
migrationReportId: snapshot.migration_report_id,
rollbackSnapshotId: snapshot.id,
events: parseDeltaJournal(snapshot.delta_journal_json),
createdAt: now,
})
: 0;
this.cutoverJournal.recordEvent({
orgId: input.orgId,
migrationReportId: snapshot.migration_report_id,
rollbackSnapshotId: snapshot.id,
eventType: input.active ? "rollback_snapshot_activated" : "rollback_snapshot_deactivated",
actorType: "employee",
actorId: input.employeeId,
payload: { active: input.active, sourceFingerprint: snapshot.source_fingerprint, queuedReconciliationItems },
createdAt: now,
});
}
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;
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<string, unknown>;
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<string, unknown> {
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<string, unknown>;
}
function parseDeltaJournal(json: string): CutoverDeltaEvent[] {
const parsed = JSON.parse(json) as unknown;
if (!Array.isArray(parsed)) throw new Error("Rollback delta journal is invalid.");
return parsed.map((item) => {
if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("Rollback delta journal entry is invalid.");
const record = item as Record<string, unknown>;
if (
typeof record.id !== "string"
|| typeof record.entityType !== "string"
|| typeof record.entityId !== "string"
|| typeof record.actorType !== "string"
|| typeof record.action !== "string"
|| typeof record.createdAt !== "string"
) {
throw new Error("Rollback delta journal entry is invalid.");
}
const payload = record.payload;
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("Rollback delta journal entry is invalid.");
return {
id: record.id,
entityType: record.entityType,
entityId: record.entityId,
actorType: record.actorType,
actorId: typeof record.actorId === "string" ? record.actorId : null,
action: record.action,
payload: payload as Record<string, unknown>,
createdAt: record.createdAt,
};
});
}
-5
View File
@@ -79,8 +79,6 @@ export class SnapshotService {
.map((flag) => ({ ...flag, enabled: toBool(flag.enabled) }));
const operationalEvents = list<AppSnapshot["operationalEvents"][number]>(this.db, "operational_events", "created_at desc")
.map((event) => ({ ...event, success: toBool(event.success) }));
const rollbackSnapshots = list<AppSnapshot["rollbackSnapshots"][number]>(this.db, "rollback_snapshots", "created_at desc")
.map((snapshot) => ({ ...snapshot, active: toBool(snapshot.active) }));
return {
activeOrgId,
@@ -139,9 +137,6 @@ export class SnapshotService {
socialAuditRecords: list(this.db, "social_audit_records", "created_at desc"),
policyRules: list(this.db, "policy_rules", "created_at asc"),
migrationReports: list(this.db, "migration_reports", "created_at desc"),
rollbackSnapshots,
migrationCutoverEvents: list(this.db, "migration_cutover_events", "created_at desc"),
migrationReconciliationItems: list(this.db, "migration_reconciliation_items", "created_at desc"),
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
legalHolds,
retentionFreezes,
-3
View File
@@ -76,9 +76,6 @@ const api: DesktopApi = {
pickDirectory: () => invoke(IPC_CHANNELS.pickDirectory),
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),
resolveMigrationReconciliation: (input) => invoke(IPC_CHANNELS.resolveMigrationReconciliation, input),
setFeatureFlag: (input) => invoke(IPC_CHANNELS.setFeatureFlag, input),
createComplianceExport: (input) => invoke(IPC_CHANNELS.createComplianceExport, input),
createLegalHold: (input) => invoke(IPC_CHANNELS.createLegalHold, input),
-19
View File
@@ -10,25 +10,6 @@ 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 ResolveMigrationReconciliationInput {
orgId: string;
reconciliationItemId: string;
employeeId: string;
}
export interface CreateComplianceExportInput {
orgId: string;
subjectType: ComplianceSubjectType;
-6
View File
@@ -51,7 +51,6 @@ import type {
CreateOrganizationInput,
CreateProviderResponseInput,
CreateRetentionFreezeInput,
CreateRollbackSnapshotInput,
CreateScheduledPostInput,
CreateSocialAccountInput,
CreateTaskInput,
@@ -63,7 +62,6 @@ import type {
OpenFileInput,
GrantRoleInput,
LaunchSocialLoginInput,
ResolveMigrationReconciliationInput,
RunTaskInput,
RevokeProviderCredentialInput,
SaveProviderCredentialInput,
@@ -81,7 +79,6 @@ import type {
SetFilePinnedInput,
SetFeatureFlagInput,
SetLegalHoldStateInput,
SetRollbackSnapshotActiveInput,
SetRetentionFreezeStateInput,
SetScheduledPostStateInput,
StreamProviderResponseInput,
@@ -182,9 +179,6 @@ export interface DesktopApi {
pickDirectory(): Promise<DesktopResult<string | null>>;
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
createRollbackSnapshot(input: CreateRollbackSnapshotInput): Promise<DesktopResult<AppSnapshot>>;
setRollbackSnapshotActive(input: SetRollbackSnapshotActiveInput): Promise<DesktopResult<AppSnapshot>>;
resolveMigrationReconciliation(input: ResolveMigrationReconciliationInput): Promise<DesktopResult<AppSnapshot>>;
setFeatureFlag(input: SetFeatureFlagInput): Promise<DesktopResult<AppSnapshot>>;
createComplianceExport(input: CreateComplianceExportInput): Promise<DesktopResult<AppSnapshot>>;
createLegalHold(input: CreateLegalHoldInput): Promise<DesktopResult<AppSnapshot>>;
-1
View File
@@ -9,7 +9,6 @@ export type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./pro
export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain";
export { PROVIDER_REQUEST_STATE, type ProviderRequestRecord, type ProviderRequestState, type ProviderStreamRendererEvent } from "./provider-request-domain";
export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
export type { MigrationCutoverEventRecord, MigrationReconciliationItemRecord, RollbackSnapshotRecord } from "./rollback-domain";
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain";
export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain";
-3
View File
@@ -49,9 +49,6 @@ export const IPC_CHANNELS = {
pickDirectory: "agentcompany:pick-directory",
setFilePinned: "agentcompany:set-file-pinned",
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",
createComplianceExport: "agentcompany:create-compliance-export",
createLegalHold: "agentcompany:create-legal-hold",
-44
View File
@@ -1,44 +0,0 @@
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;
}
export interface MigrationCutoverEventRecord {
id: string;
orgId: string;
migrationReportId: string;
rollbackSnapshotId: string | null;
eventType: string;
actorType: string;
actorId: string | null;
payloadJson: string;
createdAt: string;
}
export interface MigrationReconciliationItemRecord {
id: string;
orgId: string;
migrationReportId: string;
rollbackSnapshotId: string | null;
sourceEventId: string;
targetType: string;
targetId: string;
reason: string;
state: string;
payloadJson: string;
createdAt: string;
resolvedAt: string | null;
resolvedByEmployeeId: string | null;
}
-4
View File
@@ -47,7 +47,6 @@ import type { FeatureFlagRecord, OperationalEventRecord, SloTargetRecord } from
import type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./provider-auth-domain";
import type { ProviderRequestRecord } from "./provider-request-domain";
import type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain";
import type { MigrationCutoverEventRecord, MigrationReconciliationItemRecord, RollbackSnapshotRecord } from "./rollback-domain";
import type {
BrowserActionPolicyRecord,
BrowserActionRecord,
@@ -118,9 +117,6 @@ export interface AppSnapshot {
socialAuditRecords: SocialAuditRecord[];
policyRules: PolicyRuleRecord[];
migrationReports: MigrationReportRecord[];
rollbackSnapshots: RollbackSnapshotRecord[];
migrationCutoverEvents: MigrationCutoverEventRecord[];
migrationReconciliationItems: MigrationReconciliationItemRecord[];
complianceExports: ComplianceExportRecord[];
legalHolds: LegalHoldRecord[];
retentionFreezes: RetentionFreezeRecord[];
+4 -4
View File
@@ -1,4 +1,4 @@
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createHash, randomUUID } from "node:crypto";
@@ -35,7 +35,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, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98]);
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, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]);
connection.close();
});
@@ -592,14 +592,14 @@ describe("domain services", () => {
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
validationSummary: { missingTableCount: number; passed: boolean };
rollbackPack: { sourceDatabasePath: string };
};
expect(report.tableChecksums.agents).toHaveLength(64);
expect(report.idMaps.agents).toEqual(["agent-1"]);
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
expect(report.validationSummary.missingTableCount).toBeGreaterThan(0);
expect(report.validationSummary.passed).toBe(false);
expect(report.rollbackPack.sourceDatabasePath).toBe(realpathSync.native(legacyPath));
// A report validates the source; it promises no restore it cannot perform.
expect(Object.keys(report)).not.toContain("rollbackPack");
connection.close();
});
+1 -22
View File
@@ -45,16 +45,10 @@ interface MigrationReportBody {
relationFailureCount: number;
passed: boolean;
};
rollbackPack: {
sourceDatabasePath: string;
sourceFingerprint: string;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
};
}
describe("migration service", () => {
test("creates idempotent reports with checksums, id maps, rollback pack, and integrity failures", () => {
test("creates idempotent reports with checksums, id maps, and integrity failures", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const migrations = new MigrationService(connection.db);
@@ -87,9 +81,6 @@ describe("migration service", () => {
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" });
expect(body.validationSummary).toMatchObject({
@@ -171,9 +162,6 @@ describe("migration service", () => {
create table legal_holds(id text primary key, org_id text, created_by_employee_id text);
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 rollback_snapshots(id text primary key, org_id text, migration_report_id text, created_by_employee_id text);
create table migration_cutover_events(id text primary key, org_id text, migration_report_id text, rollback_snapshot_id text);
create table migration_reconciliation_items(id text primary key, org_id text, migration_report_id text, rollback_snapshot_id text, resolved_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');
@@ -236,9 +224,6 @@ describe("migration service", () => {
insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1');
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 rollback_snapshots(id, org_id, migration_report_id, created_by_employee_id) values ('rollback-1', 'org-1', 'migration-1', 'emp-1');
insert into migration_cutover_events(id, org_id, migration_report_id, rollback_snapshot_id) values ('cutover-1', 'org-1', 'migration-1', 'rollback-1');
insert into migration_reconciliation_items(id, org_id, migration_report_id, rollback_snapshot_id, resolved_by_employee_id) values ('reconcile-1', 'org-1', 'migration-1', 'rollback-1', 'emp-1');
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
`);
let orgId = "";
@@ -279,9 +264,6 @@ describe("migration service", () => {
expect(body.tableCounts.social_approval_routes).toBe(1);
expect(body.tableCounts.social_audit_records).toBe(1);
expect(body.tableCounts.compliance_exports).toBe(1);
expect(body.tableCounts.rollback_snapshots).toBe(1);
expect(body.tableCounts.migration_cutover_events).toBe(1);
expect(body.tableCounts.migration_reconciliation_items).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"]);
@@ -292,9 +274,6 @@ describe("migration service", () => {
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
expect(body.idMaps.file_handoff_records).toEqual(["handoff-1"]);
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
expect(body.idMaps.migration_cutover_events).toEqual(["cutover-1"]);
expect(body.idMaps.migration_reconciliation_items).toEqual(["reconcile-1"]);
expect(body.idMaps.provider_usage_windows).toEqual(["usage-1"]);
expect(body.idMaps.provider_rate_limit_observations).toEqual(["quota-1"]);
expect(body.idMaps.provider_auth_profiles).toEqual(["authp-1"]);
-132
View File
@@ -1,132 +0,0 @@
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 { AuditService } from "../../src/main/services/audit-service";
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 audit = new AuditService(connection.db);
const legacyPath = createLegacyDatabase();
let orgId = "";
let employeeId = "";
let reportId = "";
let rollbackId = "";
let reconciliationId = "";
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 });
audit.record({
orgId,
entityType: "task",
entityId: "task-cutover",
actorType: "employee",
actorId: employeeId,
action: "created",
payload: { title: "Reconcile release delta" },
});
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();
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.migrationCutoverEvents.map((event) => event.eventType).sort()).toEqual([
"migration_reconciliation_resolved",
"rollback_snapshot_activated",
"rollback_snapshot_created",
]);
expect(state.migrationReconciliationItems).toContainEqual(expect.objectContaining({
orgId,
migrationReportId: reportId,
rollbackSnapshotId: rollbackId,
targetType: "task",
targetId: "task-cutover",
reason: "manual_delta_replay",
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();
});
});
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,
);
}
@@ -23,3 +23,16 @@ describe("SDLC controls", () => {
connection.close();
});
});
describe("rollback records", () => {
test("offer no snapshot, cutover or reconciliation that marks a restore it never performs", () => {
const connection = new DatabaseConnection(join(tmpdir(), `agentcompany-new-rollback-${randomUUID()}.db`));
const tables = connection.db.prepare(`
select name from sqlite_master
where type = 'table' and name in ('rollback_snapshots', 'migration_cutover_events', 'migration_reconciliation_items')
`).all();
expect(tables).toEqual([]);
expect(Object.keys(IPC_CHANNELS).filter((name) => ["createRollbackSnapshot", "setRollbackSnapshotActive", "resolveMigrationReconciliation"].includes(name))).toEqual([]);
connection.close();
});
});