The legacy application - its src, tests, landing site, promo assets, docs, release tooling, CI workflows, and root configs - leaves the tree; it stays reachable in history. Everything under AgentCompany-New moves up unchanged: same package name, so the production userData/database path is untouched, and e2e/build scripts already resolved paths relative to the project. The developer cred.txt stays gitignored at its new location. Full verify:ci passes from the new root.
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
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,
|
|
};
|
|
});
|
|
}
|